Repository: openstatusHQ/openstatus Branch: main Commit: d106d481b754 Files: 2223 Total size: 11.4 MB Directory structure: gitextract_l155h8jc/ ├── .claude/ │ └── commands/ │ └── ship.md ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── api-preview.yml │ ├── claude-code-review.yml │ ├── claude.yml │ ├── deploy-checker.yml │ ├── deploy-private-location.yml │ ├── deploy-workflows.yml │ ├── deploy.yml │ ├── docker-publish-dev.yml │ ├── docker-publish.yml │ ├── dx.yml │ ├── go-tests.yml │ ├── lint.yml │ ├── migrate.yml │ ├── publish-checker.yml │ ├── synthetic.yml │ ├── test.yml │ └── workflow-preview.yml ├── .gitignore ├── .koyebignore ├── .npmrc ├── .oxlintrc.json ├── .prettierignore ├── .stacked.toml ├── .vscode/ │ └── settings.json ├── CLAUDE.md ├── CONTRIBUTING.MD ├── COOLIFY_DEPLOYMENT.md ├── COOLIFY_ENVIRONMENT_GUIDE.md ├── COOLIFY_SETUP.md ├── DOCKER.md ├── LICENSE ├── README.md ├── SECURITY.md ├── apps/ │ ├── README.md │ ├── checker/ │ │ ├── .gitignore │ │ ├── .golangci.yml │ │ ├── .private.air.toml │ │ ├── .probe.air.toml │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── ca/ │ │ │ └── ca-bundle.crt │ │ ├── checker/ │ │ │ ├── dns.go │ │ │ ├── dns_test.go │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── tcp.go │ │ │ ├── tcp_test.go │ │ │ └── update.go │ │ ├── cmd/ │ │ │ ├── private/ │ │ │ │ └── main.go │ │ │ └── server/ │ │ │ └── main.go │ │ ├── fly.toml │ │ ├── go.mod │ │ ├── go.sum │ │ ├── handlers/ │ │ │ ├── checker.go │ │ │ ├── checker_test.go │ │ │ ├── dns.go │ │ │ ├── dns_test.go │ │ │ ├── handler.go │ │ │ ├── ping.go │ │ │ ├── ping_test.go │ │ │ └── tcp.go │ │ ├── justfile │ │ ├── pkg/ │ │ │ ├── assertions/ │ │ │ │ ├── assertions.go │ │ │ │ └── assertions_test.go │ │ │ ├── job/ │ │ │ │ ├── dns_job.go │ │ │ │ ├── http_job.go │ │ │ │ ├── http_job_test.go │ │ │ │ ├── job.go │ │ │ │ ├── monitors.go │ │ │ │ ├── tcp_job.go │ │ │ │ └── tcp_job_test.go │ │ │ ├── logger/ │ │ │ │ └── logger.go │ │ │ ├── otel/ │ │ │ │ ├── otel.go │ │ │ │ └── otel_test.go │ │ │ ├── scheduler/ │ │ │ │ ├── scheduler.go │ │ │ │ └── scheduler_test.go │ │ │ └── tinybird/ │ │ │ ├── client.go │ │ │ └── client_test.go │ │ ├── private-location.Dockerfile │ │ ├── proto/ │ │ │ └── private_location/ │ │ │ └── v1/ │ │ │ ├── assertions.pb.go │ │ │ ├── dns_monitor.pb.go │ │ │ ├── http_monitor.pb.go │ │ │ ├── private_location.connect.go │ │ │ ├── private_location.pb.go │ │ │ └── tcp_monitor.pb.go │ │ └── request/ │ │ └── request.go │ ├── dashboard/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── components.json │ │ ├── docker-compose.yaml │ │ ├── dofigen.yml │ │ ├── env.ts │ │ ├── instrumentation-client.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── public/ │ │ │ └── fonts/ │ │ │ ├── CommitMono-400-Italic.otf │ │ │ ├── CommitMono-400-Regular.otf │ │ │ ├── CommitMono-700-Italic.otf │ │ │ └── CommitMono-700-Regular.otf │ │ ├── sentry.edge.config.ts │ │ ├── sentry.server.config.ts │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── (dashboard)/ │ │ │ │ │ ├── agents/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── cli/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── invite/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── search-params.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── monitors/ │ │ │ │ │ │ ├── (list)/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── incidents/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── logs/ │ │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ ├── overview/ │ │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ │ │ └── tabs.tsx │ │ │ │ │ │ └── create/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ ├── onboarding/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ ├── overview/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── data-table-status-reports.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── private-locations/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── (list)/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── account/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── billing/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── integrations/ │ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── slack-card.tsx │ │ │ │ │ │ └── tabs.tsx │ │ │ │ │ └── status-pages/ │ │ │ │ │ ├── (list)/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── maintenances/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── nav-actions.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ │ ├── status-reports/ │ │ │ │ │ │ │ ├── [reportId]/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── subscribers/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── tabs.tsx │ │ │ │ │ └── create/ │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ ├── client.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── api/ │ │ │ │ │ ├── auth/ │ │ │ │ │ │ └── [...nextauth]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── trpc/ │ │ │ │ │ ├── edge/ │ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── lambda/ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── global-error.tsx │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ ├── login/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ └── magic-link-form.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── search-params.ts │ │ │ │ ├── metadata.ts │ │ │ │ ├── not-found.tsx │ │ │ │ ├── react-table.d.ts │ │ │ │ └── robots.ts │ │ │ ├── components/ │ │ │ │ ├── chart/ │ │ │ │ │ ├── chart-area-latency.tsx │ │ │ │ │ ├── chart-area-timing-phases.tsx │ │ │ │ │ ├── chart-bar-uptime-light.tsx │ │ │ │ │ ├── chart-bar-uptime.tsx │ │ │ │ │ ├── chart-line-region.tsx │ │ │ │ │ ├── chart-line-regions.tsx │ │ │ │ │ └── chart-tooltip-number.tsx │ │ │ │ ├── common/ │ │ │ │ │ ├── code.tsx │ │ │ │ │ ├── hover-card-timestamp.tsx │ │ │ │ │ ├── icon-cloud-provider.tsx │ │ │ │ │ ├── input-with-addons.tsx │ │ │ │ │ ├── kbd.tsx │ │ │ │ │ ├── link.tsx │ │ │ │ │ ├── note.tsx │ │ │ │ │ └── wheel-picker.tsx │ │ │ │ ├── content/ │ │ │ │ │ ├── action-card.tsx │ │ │ │ │ ├── billing-addons.tsx │ │ │ │ │ ├── billing-overlay.tsx │ │ │ │ │ ├── billing-progress.tsx │ │ │ │ │ ├── block-wrapper.tsx │ │ │ │ │ ├── empty-state.tsx │ │ │ │ │ ├── process-message.tsx │ │ │ │ │ └── section.tsx │ │ │ │ ├── controls-filter/ │ │ │ │ │ └── .gitkeep │ │ │ │ ├── controls-search/ │ │ │ │ │ ├── button-reset.tsx │ │ │ │ │ ├── command-region.tsx │ │ │ │ │ ├── command-tags.tsx │ │ │ │ │ ├── dropdown-interval.tsx │ │ │ │ │ ├── dropdown-percentile.tsx │ │ │ │ │ ├── dropdown-period.tsx │ │ │ │ │ ├── dropdown-status.tsx │ │ │ │ │ ├── dropdown-trigger.tsx │ │ │ │ │ └── popover-date.tsx │ │ │ │ ├── data-table/ │ │ │ │ │ ├── audit-logs/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── wrapper.tsx │ │ │ │ │ ├── billing/ │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ ├── dable-cell-skeleton.tsx │ │ │ │ │ ├── data-table-sheet.tsx │ │ │ │ │ ├── incidents/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── maintenances/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── monitors/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ ├── data-table-action-bar.tsx │ │ │ │ │ │ ├── data-table-row-actions.tsx │ │ │ │ │ │ └── data-table-toolbar.tsx │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── page-components/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── private-locations/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── response-logs/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ ├── data-table-basics.tsx │ │ │ │ │ │ ├── data-table-sheet-test.tsx │ │ │ │ │ │ ├── data-table-sheet.tsx │ │ │ │ │ │ ├── data-table-toolbar.tsx │ │ │ │ │ │ └── regions/ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── api-key/ │ │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ │ ├── invitations/ │ │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ │ └── members/ │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ ├── status-pages/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── status-report-updates/ │ │ │ │ │ │ ├── data-table-row-actions.tsx │ │ │ │ │ │ └── data-table.tsx │ │ │ │ │ ├── status-reports/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── subscribers/ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── data-table-row-actions.tsx │ │ │ │ │ ├── table-cell-badge.tsx │ │ │ │ │ ├── table-cell-boolean.tsx │ │ │ │ │ ├── table-cell-date.tsx │ │ │ │ │ ├── table-cell-link.tsx │ │ │ │ │ ├── table-cell-number.tsx │ │ │ │ │ └── table-cell-unavailable.tsx │ │ │ │ ├── date-picker.tsx │ │ │ │ ├── development-indicator.tsx │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── export-code.tsx │ │ │ │ │ └── upgrade.tsx │ │ │ │ ├── domains/ │ │ │ │ │ ├── domain-configuration.tsx │ │ │ │ │ ├── domain-status-icon.tsx │ │ │ │ │ └── use-domain-status.ts │ │ │ │ ├── dropdowns/ │ │ │ │ │ └── quick-actions.tsx │ │ │ │ ├── forms/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── form-components.tsx │ │ │ │ │ │ ├── form-import.tsx │ │ │ │ │ │ ├── telegram-connection-flow.tsx │ │ │ │ │ │ ├── telegram-form-actions.tsx │ │ │ │ │ │ ├── telegram-manual-input.tsx │ │ │ │ │ │ ├── telegram-qr-connection.tsx │ │ │ │ │ │ ├── telegram-qrcode.tsx │ │ │ │ │ │ └── update.tsx │ │ │ │ │ ├── form-alert-dialog.tsx │ │ │ │ │ ├── form-card.tsx │ │ │ │ │ ├── form-sheet.tsx │ │ │ │ │ ├── maintenance/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ ├── monitor/ │ │ │ │ │ │ ├── form-danger-zone.tsx │ │ │ │ │ │ ├── form-follow-redirect.tsx │ │ │ │ │ │ ├── form-general.tsx │ │ │ │ │ │ ├── form-notifiers.tsx │ │ │ │ │ │ ├── form-otel.tsx │ │ │ │ │ │ ├── form-response-time.tsx │ │ │ │ │ │ ├── form-retry.tsx │ │ │ │ │ │ ├── form-scheduling-regions.tsx │ │ │ │ │ │ ├── form-status-pages.tsx │ │ │ │ │ │ ├── form-tags.tsx │ │ │ │ │ │ ├── form-visibility.tsx │ │ │ │ │ │ └── update.tsx │ │ │ │ │ ├── monitor-tag/ │ │ │ │ │ │ ├── form-monitor-tag.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── form-discord.tsx │ │ │ │ │ │ ├── form-email.tsx │ │ │ │ │ │ ├── form-google-chat.tsx │ │ │ │ │ │ ├── form-grafana-oncall.tsx │ │ │ │ │ │ ├── form-ntfy.tsx │ │ │ │ │ │ ├── form-opsgenie.tsx │ │ │ │ │ │ ├── form-pagerduty.tsx │ │ │ │ │ │ ├── form-slack.tsx │ │ │ │ │ │ ├── form-sms.tsx │ │ │ │ │ │ ├── form-telegram.tsx │ │ │ │ │ │ ├── form-webhook.tsx │ │ │ │ │ │ ├── form-whatsapp.tsx │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ ├── onboarding/ │ │ │ │ │ │ ├── create-monitor.tsx │ │ │ │ │ │ ├── create-page.tsx │ │ │ │ │ │ └── learn-from.tsx │ │ │ │ │ ├── private-location/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── form-api-key.tsx │ │ │ │ │ │ ├── form-members.tsx │ │ │ │ │ │ ├── form-slug.tsx │ │ │ │ │ │ └── form-workspace.tsx │ │ │ │ │ ├── status-page/ │ │ │ │ │ │ ├── form-appearance.tsx │ │ │ │ │ │ ├── form-configuration.tsx │ │ │ │ │ │ ├── form-custom-domain.tsx │ │ │ │ │ │ ├── form-danger-zone.tsx │ │ │ │ │ │ ├── form-general.tsx │ │ │ │ │ │ ├── form-links.tsx │ │ │ │ │ │ ├── form-monitors.tsx │ │ │ │ │ │ ├── form-page-access.tsx │ │ │ │ │ │ └── update.tsx │ │ │ │ │ ├── status-report/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ ├── status-report-update/ │ │ │ │ │ │ ├── form-status-report.tsx │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── sheet.tsx │ │ │ │ │ └── support-contact/ │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ └── form.tsx │ │ │ │ ├── layout/ │ │ │ │ │ └── auth-layout.tsx │ │ │ │ ├── metric/ │ │ │ │ │ ├── global-uptime/ │ │ │ │ │ │ └── section.tsx │ │ │ │ │ └── metric-card.tsx │ │ │ │ ├── nav/ │ │ │ │ │ ├── app-header.tsx │ │ │ │ │ ├── app-sidebar.tsx │ │ │ │ │ ├── nav-banner-checklist.tsx │ │ │ │ │ ├── nav-banner-upgrade.tsx │ │ │ │ │ ├── nav-banner.tsx │ │ │ │ │ ├── nav-breadcrumb.tsx │ │ │ │ │ ├── nav-feedback.tsx │ │ │ │ │ ├── nav-help.tsx │ │ │ │ │ ├── nav-main.tsx │ │ │ │ │ ├── nav-monitors.tsx │ │ │ │ │ ├── nav-overview.tsx │ │ │ │ │ ├── nav-status-pages.tsx │ │ │ │ │ ├── nav-tabs.tsx │ │ │ │ │ ├── nav-user.tsx │ │ │ │ │ ├── sidebar-metadata.tsx │ │ │ │ │ ├── sidebar-right.tsx │ │ │ │ │ └── workspace-switcher.tsx │ │ │ │ ├── popovers/ │ │ │ │ │ ├── popover-quantile.tsx │ │ │ │ │ └── popover-resolution.tsx │ │ │ │ ├── tailwind-indicator.tsx │ │ │ │ ├── theme-provider.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ └── ui/ │ │ │ │ ├── data-table/ │ │ │ │ │ ├── data-table-action-bar.tsx │ │ │ │ │ ├── data-table-column-header.tsx │ │ │ │ │ ├── data-table-faceted-filter.tsx │ │ │ │ │ ├── data-table-pagination.tsx │ │ │ │ │ ├── data-table-skeleton.tsx │ │ │ │ │ ├── data-table-toobar.tsx │ │ │ │ │ ├── data-table-view-options.tsx │ │ │ │ │ └── data-table.tsx │ │ │ │ └── sortable.tsx │ │ │ ├── data/ │ │ │ │ ├── audit-logs.client.ts │ │ │ │ ├── audit-logs.ts │ │ │ │ ├── icons.ts │ │ │ │ ├── incidents.client.ts │ │ │ │ ├── incidents.ts │ │ │ │ ├── invitations.ts │ │ │ │ ├── maintenances.client.ts │ │ │ │ ├── maintenances.ts │ │ │ │ ├── members.ts │ │ │ │ ├── metrics.client.ts │ │ │ │ ├── monitor-tags.ts │ │ │ │ ├── monitors.client.ts │ │ │ │ ├── monitors.ts │ │ │ │ ├── notifications.client.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── page-components.client.ts │ │ │ │ ├── plans.ts │ │ │ │ ├── region-metrics.client.ts │ │ │ │ ├── region-metrics.ts │ │ │ │ ├── regions.ts │ │ │ │ ├── response-logs.ts │ │ │ │ ├── status-codes.ts │ │ │ │ ├── status-pages.client.ts │ │ │ │ ├── status-pages.ts │ │ │ │ ├── status-report-updates.client.ts │ │ │ │ ├── status-reports.client.ts │ │ │ │ ├── status-reports.ts │ │ │ │ └── subscribers.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-feature.ts │ │ │ │ └── use-telegram-connection.ts │ │ │ ├── instrumentation.ts │ │ │ ├── lib/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── providers.ts │ │ │ │ ├── composition.ts │ │ │ │ ├── domains.ts │ │ │ │ ├── formatter.ts │ │ │ │ ├── middleware/ │ │ │ │ │ └── with-invitation.ts │ │ │ │ ├── stripe.ts │ │ │ │ ├── trpc/ │ │ │ │ │ ├── client.tsx │ │ │ │ │ ├── query-client.ts │ │ │ │ │ ├── server.tsx │ │ │ │ │ └── shared.ts │ │ │ │ └── utils.ts │ │ │ ├── next-auth.d.ts │ │ │ ├── proxy.ts │ │ │ └── scripts/ │ │ │ ├── README.md │ │ │ └── export-blog-post-metrics.ts │ │ └── tsconfig.json │ ├── docs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── astro.config.mjs │ │ ├── package.json │ │ ├── public/ │ │ │ ├── fonts/ │ │ │ │ ├── CommitMono-400-Italic.otf │ │ │ │ ├── CommitMono-400-Regular.otf │ │ │ │ ├── CommitMono-700-Italic.otf │ │ │ │ └── CommitMono-700-Regular.otf │ │ │ └── robots.txt │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Footer.astro │ │ │ │ ├── Head.astro │ │ │ │ ├── Hero.astro │ │ │ │ ├── SiteTitle.astro │ │ │ │ ├── Status.astro │ │ │ │ └── utils.ts │ │ │ ├── content/ │ │ │ │ ├── config.ts │ │ │ │ └── docs/ │ │ │ │ ├── 404.md │ │ │ │ ├── concept/ │ │ │ │ │ ├── best-practices-status-page.mdx │ │ │ │ │ ├── getting-started.mdx │ │ │ │ │ ├── latency-vs-response-time.mdx │ │ │ │ │ ├── uptime-calculation-and-values.mdx │ │ │ │ │ ├── uptime-monitoring-as-code.mdx │ │ │ │ │ └── uptime-monitoring.mdx │ │ │ │ ├── guides/ │ │ │ │ │ ├── getting-started.mdx │ │ │ │ │ ├── how-deploy-status-page-cf-pages.mdx │ │ │ │ │ ├── how-to-add-svg-status-badge.mdx │ │ │ │ │ ├── how-to-deploy-probes-cloudflare-containers.mdx │ │ │ │ │ ├── how-to-export-metrics-to-otlp-endpoint.mdx │ │ │ │ │ ├── how-to-monitor-mcp-server.mdx │ │ │ │ │ ├── how-to-run-synthetic-test-github-action.mdx │ │ │ │ │ ├── how-to-use-react-widget.mdx │ │ │ │ │ ├── self-host-status-page-only.mdx │ │ │ │ │ └── self-hosting-openstatus.mdx │ │ │ │ ├── help/ │ │ │ │ │ └── support.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── monitoring/ │ │ │ │ │ └── overview.mdx │ │ │ │ ├── reference/ │ │ │ │ │ ├── cli-reference.mdx │ │ │ │ │ ├── dns-monitor.mdx │ │ │ │ │ ├── http-monitor.mdx │ │ │ │ │ ├── incident.mdx │ │ │ │ │ ├── location.mdx │ │ │ │ │ ├── notification.mdx │ │ │ │ │ ├── page-components.mdx │ │ │ │ │ ├── private-location.mdx │ │ │ │ │ ├── status-page.mdx │ │ │ │ │ ├── status-report.mdx │ │ │ │ │ ├── subscriber.mdx │ │ │ │ │ ├── tcp-monitor.mdx │ │ │ │ │ └── terraform.mdx │ │ │ │ ├── reference.mdx │ │ │ │ ├── sdk/ │ │ │ │ │ └── nodejs/ │ │ │ │ │ ├── authentication.mdx │ │ │ │ │ ├── error-handling.mdx │ │ │ │ │ ├── getting-started.mdx │ │ │ │ │ ├── health-service.mdx │ │ │ │ │ ├── index.mdx │ │ │ │ │ ├── maintenance-service.mdx │ │ │ │ │ ├── monitor-service.mdx │ │ │ │ │ ├── notification-service.mdx │ │ │ │ │ ├── reference.mdx │ │ │ │ │ ├── status-page-service.mdx │ │ │ │ │ ├── status-report-service.mdx │ │ │ │ │ └── typescript-tips.mdx │ │ │ │ └── tutorial/ │ │ │ │ ├── get-started-with-openstatus-cli.mdx │ │ │ │ ├── getting-started.mdx │ │ │ │ ├── how-to-configure-status-page.mdx │ │ │ │ ├── how-to-create-monitor.mdx │ │ │ │ ├── how-to-create-private-location.mdx │ │ │ │ ├── how-to-create-status-page.mdx │ │ │ │ └── how-to-setup-slack-agent.mdx │ │ │ ├── custom.css │ │ │ ├── env.d.ts │ │ │ └── global.css │ │ └── tsconfig.json │ ├── private-location/ │ │ ├── .air.toml │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── .golangci.yml │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── cmd/ │ │ │ └── server/ │ │ │ └── main.go │ │ ├── dofigen.yml │ │ ├── fly.toml │ │ ├── go.mod │ │ ├── go.sum │ │ ├── internal/ │ │ │ ├── database/ │ │ │ │ ├── database.go │ │ │ │ └── models.go │ │ │ ├── logs/ │ │ │ │ ├── logs.go │ │ │ │ └── logs_test.go │ │ │ ├── models/ │ │ │ │ └── assertions.go │ │ │ ├── server/ │ │ │ │ ├── db_testdata │ │ │ │ ├── errors.go │ │ │ │ ├── ingest_common.go │ │ │ │ ├── ingest_dns.go │ │ │ │ ├── ingest_dns_test.go │ │ │ │ ├── ingest_http.go │ │ │ │ ├── ingest_http_test.go │ │ │ │ ├── ingest_tcp.go │ │ │ │ ├── ingest_tcp_test.go │ │ │ │ ├── monitors.go │ │ │ │ ├── monitors_test.go │ │ │ │ ├── routes.go │ │ │ │ ├── server.go │ │ │ │ ├── validation.go │ │ │ │ └── validation_test.go │ │ │ └── tinybird/ │ │ │ ├── client.go │ │ │ └── client_test.go │ │ ├── justfile │ │ └── proto/ │ │ └── private_location/ │ │ └── v1/ │ │ ├── assertions.pb.go │ │ ├── dns_monitor.pb.go │ │ ├── http_monitor.pb.go │ │ ├── private_location.connect.go │ │ ├── private_location.pb.go │ │ └── tcp_monitor.pb.go │ ├── railway-proxy/ │ │ ├── Dockerfile │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── screenshot-service/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── fly.toml │ │ ├── package.json │ │ ├── src/ │ │ │ ├── env.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── server/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── CONNECTRPC_SPEC.md │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── bunfig.toml │ │ ├── docker-compose.yaml │ │ ├── dofigen.yml │ │ ├── env.ts │ │ ├── fly.sh │ │ ├── fly.toml │ │ ├── log/ │ │ │ └── fly.toml │ │ ├── package.json │ │ ├── src/ │ │ │ ├── env.ts │ │ │ ├── index.ts │ │ │ ├── libs/ │ │ │ │ ├── checker/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── clients.ts │ │ │ │ ├── errors/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── openapi-error-responses.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── middlewares/ │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── plan.ts │ │ │ │ │ └── track.ts │ │ │ │ └── test/ │ │ │ │ └── preload.ts │ │ │ ├── routes/ │ │ │ │ ├── public/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── status.test.ts │ │ │ │ │ ├── status.ts │ │ │ │ │ └── unsubscribe.ts │ │ │ │ ├── rpc/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interceptors/ │ │ │ │ │ │ ├── auth.ts │ │ │ │ │ │ ├── error.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── logging.ts │ │ │ │ │ │ └── validation.ts │ │ │ │ │ ├── router.ts │ │ │ │ │ └── services/ │ │ │ │ │ ├── health/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── maintenance/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── maintenance.test.ts │ │ │ │ │ │ ├── converters.ts │ │ │ │ │ │ ├── errors.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── monitor/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── monitor.test.ts │ │ │ │ │ │ ├── converters/ │ │ │ │ │ │ │ ├── assertions.ts │ │ │ │ │ │ │ ├── comparators.ts │ │ │ │ │ │ │ ├── defaults.ts │ │ │ │ │ │ │ ├── enums.ts │ │ │ │ │ │ │ ├── headers.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── monitors.ts │ │ │ │ │ │ │ └── regions.ts │ │ │ │ │ │ ├── errors.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── limits.ts │ │ │ │ │ │ └── validators.ts │ │ │ │ │ ├── notification/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── notification.test.ts │ │ │ │ │ │ ├── converters.ts │ │ │ │ │ │ ├── errors.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── limits.ts │ │ │ │ │ │ └── test-providers.ts │ │ │ │ │ ├── status-page/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ └── status-page.test.ts │ │ │ │ │ │ ├── converters.ts │ │ │ │ │ │ ├── errors.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── limits.ts │ │ │ │ │ └── status-report/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── status-report.test.ts │ │ │ │ │ ├── converters.ts │ │ │ │ │ ├── errors.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── slack/ │ │ │ │ │ ├── agent.test.ts │ │ │ │ │ ├── agent.ts │ │ │ │ │ ├── blocks.test.ts │ │ │ │ │ ├── blocks.ts │ │ │ │ │ ├── confirmation-store.test.ts │ │ │ │ │ ├── confirmation-store.ts │ │ │ │ │ ├── handler.test.ts │ │ │ │ │ ├── handler.ts │ │ │ │ │ ├── index.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interactions.test.ts │ │ │ │ │ ├── interactions.ts │ │ │ │ │ ├── oauth.test.ts │ │ │ │ │ ├── oauth.ts │ │ │ │ │ ├── tools/ │ │ │ │ │ │ ├── add-status-report-update.ts │ │ │ │ │ │ ├── create-maintenance.ts │ │ │ │ │ │ ├── create-status-report.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-maintenances.ts │ │ │ │ │ │ ├── list-status-pages.ts │ │ │ │ │ │ ├── list-status-reports.ts │ │ │ │ │ │ ├── resolve-status-report.ts │ │ │ │ │ │ ├── tools.test.ts │ │ │ │ │ │ └── update-status-report.ts │ │ │ │ │ ├── verify.test.ts │ │ │ │ │ ├── verify.ts │ │ │ │ │ └── workspace-resolver.ts │ │ │ │ └── v1/ │ │ │ │ ├── check/ │ │ │ │ │ ├── http/ │ │ │ │ │ │ ├── post.test.ts │ │ │ │ │ │ ├── post.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── incidents/ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── put.test.ts │ │ │ │ │ ├── put.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── maintenances/ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ ├── put.test.ts │ │ │ │ │ ├── put.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── monitors/ │ │ │ │ │ ├── delete.test.ts │ │ │ │ │ ├── delete.ts │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ ├── post_dns.test.ts │ │ │ │ │ ├── post_dns.ts │ │ │ │ │ ├── post_http.test.ts │ │ │ │ │ ├── post_http.ts │ │ │ │ │ ├── post_tcp.test.ts │ │ │ │ │ ├── post_tcp.ts │ │ │ │ │ ├── put.test.ts │ │ │ │ │ ├── put.ts │ │ │ │ │ ├── put_dns.test.ts │ │ │ │ │ ├── put_dns.ts │ │ │ │ │ ├── put_http.test.ts │ │ │ │ │ ├── put_http.ts │ │ │ │ │ ├── put_tcp.test.ts │ │ │ │ │ ├── put_tcp.ts │ │ │ │ │ ├── results/ │ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ │ └── get.ts │ │ │ │ │ ├── run/ │ │ │ │ │ │ ├── post.test.ts │ │ │ │ │ │ ├── post.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── summary/ │ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ │ ├── get.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ ├── trigger/ │ │ │ │ │ │ ├── post.test.ts │ │ │ │ │ │ ├── post.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── notifications/ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── pageSubscribers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── pages/ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ ├── put.test.ts │ │ │ │ │ ├── put.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── statusReportUpdates/ │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── statusReports/ │ │ │ │ │ ├── delete.test.ts │ │ │ │ │ ├── delete.ts │ │ │ │ │ ├── get.test.ts │ │ │ │ │ ├── get.ts │ │ │ │ │ ├── get_all.test.ts │ │ │ │ │ ├── get_all.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── post.test.ts │ │ │ │ │ ├── post.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── subscriber-filtering.integration.test.ts │ │ │ │ │ └── update/ │ │ │ │ │ ├── post.test.ts │ │ │ │ │ └── post.ts │ │ │ │ ├── utils.ts │ │ │ │ └── whoami/ │ │ │ │ ├── get.test.ts │ │ │ │ ├── get.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── types/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── audit-log.ts │ │ │ ├── not-empty.ts │ │ │ ├── page-component.ts │ │ │ └── random-promise.ts │ │ ├── static/ │ │ │ ├── openapi-v1.json │ │ │ └── openapi.yaml │ │ └── tsconfig.json │ ├── ssh-server/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── banner.txt │ │ ├── fly.toml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── status-page/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── components.json │ │ ├── docker-compose.yaml │ │ ├── dofigen.yml │ │ ├── env.ts │ │ ├── instrumentation-client.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── public/ │ │ │ └── fonts/ │ │ │ ├── CommitMono-400-Italic.otf │ │ │ ├── CommitMono-400-Regular.otf │ │ │ ├── CommitMono-700-Italic.otf │ │ │ └── CommitMono-700-Regular.otf │ │ ├── sentry.edge.config.ts │ │ ├── sentry.server.config.ts │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── (public)/ │ │ │ │ │ ├── client.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── search-params.ts │ │ │ │ ├── (status-page)/ │ │ │ │ │ └── [domain]/ │ │ │ │ │ ├── (auth)/ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── login/ │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ ├── section-magic-link.tsx │ │ │ │ │ │ │ └── section-password.tsx │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── (public)/ │ │ │ │ │ │ ├── badge/ │ │ │ │ │ │ │ ├── route.tsx │ │ │ │ │ │ │ └── v2/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ ├── (list)/ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ │ ├── (view)/ │ │ │ │ │ │ │ │ ├── maintenance/ │ │ │ │ │ │ │ │ │ └── [id]/ │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ └── report/ │ │ │ │ │ │ │ │ └── [id]/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ ├── feed/ │ │ │ │ │ │ │ ├── [type]/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── json/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── manage/ │ │ │ │ │ │ │ ├── [token]/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ ├── monitors/ │ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── search-params.ts │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── unsubscribe/ │ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── verify/ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── api/ │ │ │ │ │ ├── auth/ │ │ │ │ │ │ └── [...nextauth]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── trpc/ │ │ │ │ │ ├── edge/ │ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── lambda/ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── global-error.tsx │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ ├── metadata.ts │ │ │ │ ├── not-found.tsx │ │ │ │ └── react-table.d.ts │ │ │ ├── components/ │ │ │ │ ├── button/ │ │ │ │ │ ├── button-back.tsx │ │ │ │ │ └── button-copy-link.tsx │ │ │ │ ├── chart/ │ │ │ │ │ ├── chart-area-percentiles.tsx │ │ │ │ │ ├── chart-bar-uptime.tsx │ │ │ │ │ ├── chart-legend-badge.tsx │ │ │ │ │ ├── chart-line-region.tsx │ │ │ │ │ ├── chart-line-regions.tsx │ │ │ │ │ └── chart-tooltip-number.tsx │ │ │ │ ├── common/ │ │ │ │ │ ├── kbd.tsx │ │ │ │ │ └── link.tsx │ │ │ │ ├── content/ │ │ │ │ │ ├── empty-state.tsx │ │ │ │ │ ├── metric-card.tsx │ │ │ │ │ ├── process-message.tsx │ │ │ │ │ ├── section.tsx │ │ │ │ │ └── timestamp-hover-card.tsx │ │ │ │ ├── date-picker.tsx │ │ │ │ ├── forms/ │ │ │ │ │ ├── form-card.tsx │ │ │ │ │ ├── form-email.tsx │ │ │ │ │ ├── form-manage-subscription.tsx │ │ │ │ │ ├── form-password.tsx │ │ │ │ │ └── form-subscribe-email.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── discord.tsx │ │ │ │ │ ├── github.tsx │ │ │ │ │ ├── google.tsx │ │ │ │ │ ├── opsgenie.tsx │ │ │ │ │ ├── pagerduty.tsx │ │ │ │ │ └── slack.tsx │ │ │ │ ├── nav/ │ │ │ │ │ ├── footer.tsx │ │ │ │ │ └── header.tsx │ │ │ │ ├── password-wrapper.tsx │ │ │ │ ├── popover/ │ │ │ │ │ └── popover-quantile.tsx │ │ │ │ ├── status-page/ │ │ │ │ │ ├── floating-button.tsx │ │ │ │ │ ├── floating-theme.tsx │ │ │ │ │ ├── messages.ts │ │ │ │ │ ├── status-banner.tsx │ │ │ │ │ ├── status-blank.tsx │ │ │ │ │ ├── status-charts.tsx │ │ │ │ │ ├── status-events.tsx │ │ │ │ │ ├── status-feed.tsx │ │ │ │ │ ├── status-monitor-tabs.tsx │ │ │ │ │ ├── status-monitor.tsx │ │ │ │ │ ├── status-tracker-group.tsx │ │ │ │ │ ├── status-tracker.tsx │ │ │ │ │ ├── status-updates.tsx │ │ │ │ │ ├── status.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── tailwind-indicator.tsx │ │ │ │ ├── themes/ │ │ │ │ │ ├── theme-dropdown.tsx │ │ │ │ │ ├── theme-palette-picker.tsx │ │ │ │ │ ├── theme-provider.tsx │ │ │ │ │ ├── theme-select.tsx │ │ │ │ │ └── theme-sidebar.tsx │ │ │ │ └── ui/ │ │ │ │ └── data-table/ │ │ │ │ ├── data-table-action-bar.tsx │ │ │ │ ├── data-table-column-header.tsx │ │ │ │ ├── data-table-faceted-filter.tsx │ │ │ │ ├── data-table-pagination.tsx │ │ │ │ ├── data-table-skeleton.tsx │ │ │ │ ├── data-table-toobar.tsx │ │ │ │ ├── data-table-view-options.tsx │ │ │ │ └── data-table.tsx │ │ │ ├── data/ │ │ │ │ ├── icons.ts │ │ │ │ ├── incidents.client.ts │ │ │ │ ├── incidents.ts │ │ │ │ ├── invitations.ts │ │ │ │ ├── maintenances.client.ts │ │ │ │ ├── maintenances.ts │ │ │ │ ├── members.ts │ │ │ │ ├── metrics.client.ts │ │ │ │ ├── monitor-tags.ts │ │ │ │ ├── monitors.client.ts │ │ │ │ ├── monitors.ts │ │ │ │ ├── plans.ts │ │ │ │ ├── region-metrics.client.ts │ │ │ │ ├── region-metrics.ts │ │ │ │ ├── region-percentile.ts │ │ │ │ ├── regions.ts │ │ │ │ ├── response-logs.ts │ │ │ │ ├── status-codes.ts │ │ │ │ ├── status-pages.client.ts │ │ │ │ ├── status-pages.ts │ │ │ │ ├── status-report-updates.client.ts │ │ │ │ ├── status-reports.client.ts │ │ │ │ └── status-reports.ts │ │ │ ├── hooks/ │ │ │ │ └── use-pathname-prefix.ts │ │ │ ├── instrumentation.ts │ │ │ ├── lib/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── providers.ts │ │ │ │ ├── base-url.ts │ │ │ │ ├── chart.ts │ │ │ │ ├── composition.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── formatter.ts │ │ │ │ ├── protected.ts │ │ │ │ ├── server-actions.ts │ │ │ │ ├── trpc/ │ │ │ │ │ ├── client.tsx │ │ │ │ │ ├── query-client.ts │ │ │ │ │ ├── server.tsx │ │ │ │ │ └── shared.ts │ │ │ │ └── utils.ts │ │ │ ├── next-auth.d.ts │ │ │ └── proxy.ts │ │ ├── tsconfig.json │ │ └── turbo.json │ ├── web/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── components.json │ │ ├── env.ts │ │ ├── instrumentation-client.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public/ │ │ │ ├── assets/ │ │ │ │ └── posts/ │ │ │ │ ├── global-latency-monitoring-benchmark-hono-hetzner/ │ │ │ │ │ └── hetzner.json │ │ │ │ ├── hono-vercel-fluid-compute/ │ │ │ │ │ ├── hono-cold.json │ │ │ │ │ └── hono-warm.json │ │ │ │ ├── monitoring-latency/ │ │ │ │ │ ├── cloudflare.json │ │ │ │ │ ├── fly.json │ │ │ │ │ ├── koyeb.json │ │ │ │ │ ├── railway.json │ │ │ │ │ └── render.json │ │ │ │ └── monitoring-vercel/ │ │ │ │ ├── vercel-cold.json │ │ │ │ ├── vercel-edge.json │ │ │ │ ├── vercel-roulette.json │ │ │ │ └── vercel-warm.json │ │ │ └── llms.txt │ │ ├── sentry.edge.config.ts │ │ ├── sentry.server.config.ts │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── (landing)/ │ │ │ │ │ ├── (redirect)/ │ │ │ │ │ │ ├── bsky/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── cal/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── discord/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── docs/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── github/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── linkedin/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── schema.json/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── twitter/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── youtube/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── blog/ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── feed.xml/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── changelog/ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── feed.xml/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── compare/ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── content-box.tsx │ │ │ │ │ ├── content-category.tsx │ │ │ │ │ ├── content-list.tsx │ │ │ │ │ ├── content-metadata.tsx │ │ │ │ │ ├── content-pagination.tsx │ │ │ │ │ ├── guides/ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── oss-friends/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── play/ │ │ │ │ │ │ ├── checker/ │ │ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── search-params.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── curl/ │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── severity-matrix/ │ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── uptime-sla/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── status/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ └── use-case/ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── api/ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ └── pagerduty/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── checker/ │ │ │ │ │ │ ├── cron/ │ │ │ │ │ │ │ ├── 10m/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── 1h/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── 1m/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── 30m/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── 30s/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── 5m/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── _cron.ts │ │ │ │ │ │ │ └── _sentry.ts │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ ├── http/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── tcp/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── schema.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── internal/ │ │ │ │ │ │ └── email/ │ │ │ │ │ │ ├── feedback/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── team-invite/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── markdown/ │ │ │ │ │ │ └── [[...path]]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── og/ │ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ │ ├── background.tsx │ │ │ │ │ │ │ ├── basic-layout.tsx │ │ │ │ │ │ │ ├── status-check.tsx │ │ │ │ │ │ │ └── tracker.tsx │ │ │ │ │ │ ├── checker/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── monitor/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── page/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── post/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ ├── route.tsx │ │ │ │ │ │ ├── status/ │ │ │ │ │ │ │ └── route.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── search/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── test/ │ │ │ │ │ │ └── timeout/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── trpc/ │ │ │ │ │ │ ├── edge/ │ │ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── lambda/ │ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── upload/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── webhook/ │ │ │ │ │ └── stripe/ │ │ │ │ │ └── route.ts │ │ │ │ ├── global-error.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── robots.ts │ │ │ │ └── sitemap.ts │ │ │ ├── components/ │ │ │ │ ├── dev-mode-container.tsx │ │ │ │ ├── icon-cloud-provider.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── loading-animation.tsx │ │ │ │ ├── tailwind-indicator.tsx │ │ │ │ └── theme-provider.tsx │ │ │ ├── config/ │ │ │ │ └── socials.ts │ │ │ ├── content/ │ │ │ │ ├── cmdk.tsx │ │ │ │ ├── component-highlighter.tsx │ │ │ │ ├── convert.ts │ │ │ │ ├── copy-button.tsx │ │ │ │ ├── footer-status.tsx │ │ │ │ ├── footer.tsx │ │ │ │ ├── header.tsx │ │ │ │ ├── highlight-text.tsx │ │ │ │ ├── image-zoom.tsx │ │ │ │ ├── latency-chart-table.tsx │ │ │ │ ├── link.tsx │ │ │ │ ├── listing.ts │ │ │ │ ├── logo-with-context-menu.tsx │ │ │ │ ├── mdx-components/ │ │ │ │ │ ├── button-link.tsx │ │ │ │ │ ├── code.tsx │ │ │ │ │ ├── custom-image.tsx │ │ │ │ │ ├── custom-link.tsx │ │ │ │ │ ├── details.tsx │ │ │ │ │ ├── grid.tsx │ │ │ │ │ ├── heading.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── pre.tsx │ │ │ │ │ ├── status-page-example.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ └── tweet.tsx │ │ │ │ ├── mdx.tsx │ │ │ │ ├── nav.tsx │ │ │ │ ├── pages/ │ │ │ │ │ ├── blog/ │ │ │ │ │ │ ├── 2023-year-review.mdx │ │ │ │ │ │ ├── 2026-roadmap.mdx │ │ │ │ │ │ ├── data-table-redesign.mdx │ │ │ │ │ │ ├── deploy-private-locations-raspberry-pi.mdx │ │ │ │ │ │ ├── dynamic-breadcrumb-nextjs.mdx │ │ │ │ │ │ ├── event-analytics-implementation.mdx │ │ │ │ │ │ ├── global-latency-monitoring-benchmark-hono-hetzner.mdx │ │ │ │ │ │ ├── hono-vercel-fluid-compute.mdx │ │ │ │ │ │ ├── how-we-build-our-github-action.mdx │ │ │ │ │ │ ├── introducing-goatstack.mdx │ │ │ │ │ │ ├── introducing-openstatus-cli.mdx │ │ │ │ │ │ ├── introducing-status-page-theme-explorer.mdx │ │ │ │ │ │ ├── live-mode-infinite-query.mdx │ │ │ │ │ │ ├── migrating-from-zod-openapi-to-connectrpc.mdx │ │ │ │ │ │ ├── migration-auth-clerk-to-next-auth.mdx │ │ │ │ │ │ ├── migration-backend-from-vercel-to-fly.mdx │ │ │ │ │ │ ├── migration-planetscale-to-turso.mdx │ │ │ │ │ │ ├── monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx │ │ │ │ │ │ ├── monitoring-latency-vercel-edge-vs-serverless.mdx │ │ │ │ │ │ ├── new-dashboard-we-are-so-back.mdx │ │ │ │ │ │ ├── nobody-should-hand-code-a-data-table.mdx │ │ │ │ │ │ ├── openstatus-infra.mdx │ │ │ │ │ │ ├── openstatus-light-viewer.mdx │ │ │ │ │ │ ├── openstatus-slack-agent.mdx │ │ │ │ │ │ ├── our-new-pricing-explained.mdx │ │ │ │ │ │ ├── our-producthunt-launch-brutal-reality.mdx │ │ │ │ │ │ ├── pricing-update-july-2024.mdx │ │ │ │ │ │ ├── product-strategy-a-reality-check.mdx │ │ │ │ │ │ ├── q1-2024-update.mdx │ │ │ │ │ │ ├── reflecting-1-year-building-openstatus.mdx │ │ │ │ │ │ ├── rss-app-slack-feed.mdx │ │ │ │ │ │ ├── same-pricing-more-monitors.mdx │ │ │ │ │ │ ├── secure-api-with-unkey.mdx │ │ │ │ │ │ ├── shadcn-component-registry.mdx │ │ │ │ │ │ ├── status-page-addons.mdx │ │ │ │ │ │ ├── status-page-components.mdx │ │ │ │ │ │ ├── status-pages-is-politics.mdx │ │ │ │ │ │ ├── telegram-group-qr-integration.mdx │ │ │ │ │ │ ├── the-first-48-hours.mdx │ │ │ │ │ │ └── vision-2025.mdx │ │ │ │ │ ├── changelog/ │ │ │ │ │ │ ├── auto-resolved-incidents.mdx │ │ │ │ │ │ ├── binary-payload.mdx │ │ │ │ │ │ ├── check-run-api.mdx │ │ │ │ │ │ ├── checker-playground.mdx │ │ │ │ │ │ ├── cli-import-apply.mdx │ │ │ │ │ │ ├── cli-improvement.mdx │ │ │ │ │ │ ├── cli-monitor-template.mdx │ │ │ │ │ │ ├── clone-monitor.mdx │ │ │ │ │ │ ├── curl-builder-playground.mdx │ │ │ │ │ │ ├── dark-theme-support.mdx │ │ │ │ │ │ ├── dashboard-metrics-card.mdx │ │ │ │ │ │ ├── dns-monitoring.mdx │ │ │ │ │ │ ├── docker-checker.mdx │ │ │ │ │ │ ├── follow-redirect.mdx │ │ │ │ │ │ ├── github-action.mdx │ │ │ │ │ │ ├── global-speed-checker-skills.mdx │ │ │ │ │ │ ├── golang-monitor-checker.mdx │ │ │ │ │ │ ├── google-chat-notifications.mdx │ │ │ │ │ │ ├── grafana-oncall-integration.mdx │ │ │ │ │ │ ├── grouped-monitors.mdx │ │ │ │ │ │ ├── individual-status-report-page.mdx │ │ │ │ │ │ ├── json-status-page.mdx │ │ │ │ │ │ ├── latency-quantiles.mdx │ │ │ │ │ │ ├── maintenance-status.mdx │ │ │ │ │ │ ├── monitor-external-name.mdx │ │ │ │ │ │ ├── monitor-tags.mdx │ │ │ │ │ │ ├── monitor-threshold.mdx │ │ │ │ │ │ ├── more-regions.mdx │ │ │ │ │ │ ├── multi-cloud-fly-railway-koyeb.mdx │ │ │ │ │ │ ├── multi-region-monitoring.mdx │ │ │ │ │ │ ├── ntfy-sh-integration.mdx │ │ │ │ │ │ ├── openstatus-cli.mdx │ │ │ │ │ │ ├── openstatus-sdk.mdx │ │ │ │ │ │ ├── opentelemetry.mdx │ │ │ │ │ │ ├── pagerduty-integration.mdx │ │ │ │ │ │ ├── password-protected-status-page.mdx │ │ │ │ │ │ ├── play-checker-improvements.mdx │ │ │ │ │ │ ├── private-location.mdx │ │ │ │ │ │ ├── public-monitors.mdx │ │ │ │ │ │ ├── raycast-integration.mdx │ │ │ │ │ │ ├── request-assertions.mdx │ │ │ │ │ │ ├── response-time-charts.mdx │ │ │ │ │ │ ├── screenshot-incident.mdx │ │ │ │ │ │ ├── slack-agent.mdx │ │ │ │ │ │ ├── slack-discord-improvements.mdx │ │ │ │ │ │ ├── slack-discord-notification.mdx │ │ │ │ │ │ ├── sms-notification.mdx │ │ │ │ │ │ ├── status-page-badge-v2.mdx │ │ │ │ │ │ ├── status-page-badge.mdx │ │ │ │ │ │ ├── status-page-colors-and-more.mdx │ │ │ │ │ │ ├── status-page-component-subscription.mdx │ │ │ │ │ │ ├── status-page-email-authentification.mdx │ │ │ │ │ │ ├── status-page-feed.mdx │ │ │ │ │ │ ├── status-page-monitor-order.mdx │ │ │ │ │ │ ├── status-page-monitor-values-visibility.mdx │ │ │ │ │ │ ├── status-page-redesign-beta.mdx │ │ │ │ │ │ ├── status-page-slack-feed-subscribe.mdx │ │ │ │ │ │ ├── status-page-subscribers.mdx │ │ │ │ │ │ ├── status-page-unsubscribe.mdx │ │ │ │ │ │ ├── status-page-white-label.mdx │ │ │ │ │ │ ├── status-report-location-change.mdx │ │ │ │ │ │ ├── status-update-subscriber.mdx │ │ │ │ │ │ ├── status-widget.mdx │ │ │ │ │ │ ├── tcp-monitoring.mdx │ │ │ │ │ │ ├── team-invites.mdx │ │ │ │ │ │ ├── telegram-bot-integration.mdx │ │ │ │ │ │ ├── terraform-provider.mdx │ │ │ │ │ │ ├── webhook-integration.mdx │ │ │ │ │ │ └── whatsapp-notifications.mdx │ │ │ │ │ ├── compare/ │ │ │ │ │ │ ├── atlassian-statuspage.mdx │ │ │ │ │ │ ├── betterstack.mdx │ │ │ │ │ │ ├── checkly.mdx │ │ │ │ │ │ ├── incidentio.mdx │ │ │ │ │ │ ├── instatus.mdx │ │ │ │ │ │ ├── statusio.mdx │ │ │ │ │ │ ├── uptime-kuma.mdx │ │ │ │ │ │ └── uptime-robot.mdx │ │ │ │ │ ├── guides/ │ │ │ │ │ │ ├── api-service-disruption.mdx │ │ │ │ │ │ ├── best-opensource-status-page-2026.mdx │ │ │ │ │ │ ├── boring-is-better-for-status-pages.mdx │ │ │ │ │ │ ├── database-performance-degradation.mdx │ │ │ │ │ │ ├── deployment-rollback.mdx │ │ │ │ │ │ ├── feature-degradation.mdx │ │ │ │ │ │ ├── http-headers.mdx │ │ │ │ │ │ ├── incident-severity-matrix.mdx │ │ │ │ │ │ ├── network-connectivity-issues.mdx │ │ │ │ │ │ ├── public-postmortem-underrated-marketing.mdx │ │ │ │ │ │ ├── public-vs-private-status-pages.mdx │ │ │ │ │ │ ├── scheduled-maintenance.mdx │ │ │ │ │ │ ├── security-incident-response.mdx │ │ │ │ │ │ ├── sla-vs-slo-vs-sli.mdx │ │ │ │ │ │ ├── top-five-atlassian-statuspage-alternatives.mdx │ │ │ │ │ │ ├── why-every-saas-needs-a-status-page.mdx │ │ │ │ │ │ └── why-uptime-percentage-is-misleading.mdx │ │ │ │ │ ├── home.mdx │ │ │ │ │ ├── product/ │ │ │ │ │ │ ├── status-page.mdx │ │ │ │ │ │ └── uptime-monitoring.mdx │ │ │ │ │ ├── tools/ │ │ │ │ │ │ ├── checker-slug.mdx │ │ │ │ │ │ ├── checker.mdx │ │ │ │ │ │ ├── curl.mdx │ │ │ │ │ │ ├── severity-matrix.mdx │ │ │ │ │ │ └── uptime-sla.mdx │ │ │ │ │ ├── unrelated/ │ │ │ │ │ │ ├── about.mdx │ │ │ │ │ │ ├── not-found.mdx │ │ │ │ │ │ ├── pricing.mdx │ │ │ │ │ │ ├── privacy.mdx │ │ │ │ │ │ ├── registry.mdx │ │ │ │ │ │ ├── subprocessors.mdx │ │ │ │ │ │ └── terms.mdx │ │ │ │ │ └── use-case/ │ │ │ │ │ ├── api-providers.mdx │ │ │ │ │ ├── compliance.mdx │ │ │ │ │ ├── crypto.mdx │ │ │ │ │ └── open-source.mdx │ │ │ │ ├── resolve.ts │ │ │ │ ├── shadcn-registry-example.tsx │ │ │ │ ├── simple-chart.tsx │ │ │ │ ├── sub-nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ └── utils.ts │ │ │ ├── data/ │ │ │ │ ├── author.ts │ │ │ │ ├── code-dictionary.ts │ │ │ │ ├── content.ts │ │ │ │ ├── incidents-dictionary.ts │ │ │ │ └── trigger-dictionary.ts │ │ │ ├── env.ts │ │ │ ├── instrumentation.ts │ │ │ ├── lib/ │ │ │ │ ├── checker/ │ │ │ │ │ ├── mock.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── domains.ts │ │ │ │ ├── github.ts │ │ │ │ ├── image-dimensions.ts │ │ │ │ ├── maintenances/ │ │ │ │ │ └── utils.ts │ │ │ │ ├── metadata/ │ │ │ │ │ ├── shared-metadata.ts │ │ │ │ │ └── structured-data.ts │ │ │ │ ├── monitor/ │ │ │ │ │ └── utils.ts │ │ │ │ ├── preferred-settings/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── ratelimit.ts │ │ │ │ ├── stream.ts │ │ │ │ ├── stripe/ │ │ │ │ │ └── client.ts │ │ │ │ ├── tb.ts │ │ │ │ ├── timezone.ts │ │ │ │ ├── toast.tsx │ │ │ │ └── utils.ts │ │ │ ├── public/ │ │ │ │ └── fonts/ │ │ │ │ ├── CommitMono-400-Italic.otf │ │ │ │ ├── CommitMono-400-Regular.otf │ │ │ │ ├── CommitMono-700-Italic.otf │ │ │ │ └── CommitMono-700-Regular.otf │ │ │ ├── react-table.d.ts │ │ │ ├── styles/ │ │ │ │ └── globals.css │ │ │ ├── trpc/ │ │ │ │ ├── client.ts │ │ │ │ ├── query-client.ts │ │ │ │ ├── rq-client.tsx │ │ │ │ ├── rq-server.ts │ │ │ │ ├── server.ts │ │ │ │ └── shared.ts │ │ │ └── types/ │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── turbo.json │ │ └── vercel.json │ └── workflows/ │ ├── .dockerignore │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yaml │ ├── dofigen.yml │ ├── fly.toml │ ├── package.json │ ├── src/ │ │ ├── build-docker.ts │ │ ├── checker/ │ │ │ ├── alerting.test.ts │ │ │ ├── alerting.ts │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── cron/ │ │ │ ├── checker.ts │ │ │ ├── emails.ts │ │ │ ├── index.ts │ │ │ └── monitor.ts │ │ ├── env.ts │ │ ├── incident/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ └── db.ts │ │ ├── scripts/ │ │ │ └── tinybird.ts │ │ └── utils/ │ │ └── audit-log.ts │ ├── start.sh │ └── tsconfig.json ├── biome.jsonc ├── bunfig.toml ├── config.openstatus.yaml ├── coolify-deployment.yaml ├── devbox.json ├── docker-compose-lightweight.yaml ├── docker-compose.github-packages.yaml ├── docker-compose.yaml ├── infra/ │ └── openstatus.yaml ├── knip.ts ├── package.json ├── packages/ │ ├── analytics/ │ │ ├── env.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── events.ts │ │ │ ├── index.ts │ │ │ ├── server.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── api/ │ │ ├── env.ts │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── edge.ts │ │ │ ├── env.ts │ │ │ ├── lambda.ts │ │ │ ├── root.ts │ │ │ ├── router/ │ │ │ │ ├── apiKey.ts │ │ │ │ ├── blob.ts │ │ │ │ ├── checker.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── email/ │ │ │ │ │ └── index.ts │ │ │ │ ├── feedback.ts │ │ │ │ ├── import.test.ts │ │ │ │ ├── import.ts │ │ │ │ ├── incident.ts │ │ │ │ ├── integration.ts │ │ │ │ ├── invitation.ts │ │ │ │ ├── maintenance.test.ts │ │ │ │ ├── maintenance.ts │ │ │ │ ├── member.ts │ │ │ │ ├── monitor.test.ts │ │ │ │ ├── monitor.ts │ │ │ │ ├── monitorTag.ts │ │ │ │ ├── notification.test.ts │ │ │ │ ├── notification.ts │ │ │ │ ├── page.ts │ │ │ │ ├── pageComponent.test.ts │ │ │ │ ├── pageComponent.ts │ │ │ │ ├── pageSubscriber.ts │ │ │ │ ├── privateLocation.test.ts │ │ │ │ ├── privateLocation.ts │ │ │ │ ├── statusPage.e2e.test.ts │ │ │ │ ├── statusPage.ts │ │ │ │ ├── statusPage.unsubscribe.test.ts │ │ │ │ ├── statusPage.utils.test.ts │ │ │ │ ├── statusPage.utils.ts │ │ │ │ ├── statusReport.test.ts │ │ │ │ ├── statusReport.ts │ │ │ │ ├── stripe/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── webhook.ts │ │ │ │ ├── tinybird/ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── user.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── workspace.test.ts │ │ │ │ └── workspace.ts │ │ │ ├── service/ │ │ │ │ ├── apiKey.test.ts │ │ │ │ ├── apiKey.ts │ │ │ │ ├── import.test.ts │ │ │ │ ├── import.ts │ │ │ │ └── telegram-updates.ts │ │ │ ├── test/ │ │ │ │ └── preload.ts │ │ │ └── trpc.ts │ │ └── tsconfig.json │ ├── assertions/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── dictionary.ts │ │ │ ├── index.ts │ │ │ ├── serializing.ts │ │ │ ├── type-guards.ts │ │ │ ├── types.ts │ │ │ └── v1.ts │ │ └── tsconfig.json │ ├── db/ │ │ ├── README.md │ │ ├── drizzle/ │ │ │ ├── 0000_lively_master_chief.sql │ │ │ ├── 0001_brainy_beast.sql │ │ │ ├── 0002_luxuriant_ser_duncan.sql │ │ │ ├── 0003_glamorous_living_mummy.sql │ │ │ ├── 0004_fixed_dakota_north.sql │ │ │ ├── 0005_even_baron_strucker.sql │ │ │ ├── 0006_tired_anita_blake.sql │ │ │ ├── 0007_complex_frog_thor.sql │ │ │ ├── 0008_overjoyed_sunset_bain.sql │ │ │ ├── 0009_small_maximus.sql │ │ │ ├── 0010_lame_songbird.sql │ │ │ ├── 0011_bright_jazinda.sql │ │ │ ├── 0012_tan_magma.sql │ │ │ ├── 0013_tired_paladin.sql │ │ │ ├── 0014_adorable_skaar.sql │ │ │ ├── 0015_bent_sister_grimm.sql │ │ │ ├── 0016_certain_praxagora.sql │ │ │ ├── 0017_loose_maggott.sql │ │ │ ├── 0018_neat_orphan.sql │ │ │ ├── 0019_dashing_malcolm_colcord.sql │ │ │ ├── 0020_flat_bedlam.sql │ │ │ ├── 0021_reflective_nico_minoru.sql │ │ │ ├── 0022_chunky_rockslide.sql │ │ │ ├── 0023_dry_blink.sql │ │ │ ├── 0024_young_proudstar.sql │ │ │ ├── 0025_strong_thunderball.sql │ │ │ ├── 0026_giant_absorbing_man.sql │ │ │ ├── 0027_bizarre_bastion.sql │ │ │ ├── 0028_thin_power_pack.sql │ │ │ ├── 0029_regular_marrow.sql │ │ │ ├── 0030_elite_barracuda.sql │ │ │ ├── 0031_lowly_gabe_jones.sql │ │ │ ├── 0032_hot_swordsman.sql │ │ │ ├── 0033_solid_colossus.sql │ │ │ ├── 0034_serious_shard.sql │ │ │ ├── 0035_open_the_professor.sql │ │ │ ├── 0036_gifted_deathbird.sql │ │ │ ├── 0037_equal_beyonder.sql │ │ │ ├── 0038_foamy_stardust.sql │ │ │ ├── 0039_lonely_jigsaw.sql │ │ │ ├── 0040_narrow_anthem.sql │ │ │ ├── 0041_nasty_jigsaw.sql │ │ │ ├── 0042_great_epoch.sql │ │ │ ├── 0043_low_lily_hollister.sql │ │ │ ├── 0044_illegal_turbo.sql │ │ │ ├── 0045_little_paladin.sql │ │ │ ├── 0046_lucky_tarantula.sql │ │ │ ├── 0047_nifty_roughhouse.sql │ │ │ ├── 0048_neat_tempest.sql │ │ │ ├── 0049_sloppy_inhumans.sql │ │ │ ├── 0050_damp_xorn.sql │ │ │ ├── 0051_fuzzy_red_hulk.sql │ │ │ ├── 0052_illegal_killraven.sql │ │ │ ├── 0053_dark_orphan.sql │ │ │ ├── 0054_bitter_lilandra.sql │ │ │ ├── 0055_spicy_bastion.sql │ │ │ ├── 0056_violet_shotgun.sql │ │ │ ├── 0057_curious_xorn.sql │ │ │ ├── 0058_absent_chameleon.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ ├── 0007_snapshot.json │ │ │ ├── 0008_snapshot.json │ │ │ ├── 0009_snapshot.json │ │ │ ├── 0010_snapshot.json │ │ │ ├── 0011_snapshot.json │ │ │ ├── 0012_snapshot.json │ │ │ ├── 0013_snapshot.json │ │ │ ├── 0014_snapshot.json │ │ │ ├── 0015_snapshot.json │ │ │ ├── 0016_snapshot.json │ │ │ ├── 0017_snapshot.json │ │ │ ├── 0018_snapshot.json │ │ │ ├── 0019_snapshot.json │ │ │ ├── 0020_snapshot.json │ │ │ ├── 0021_snapshot.json │ │ │ ├── 0022_snapshot.json │ │ │ ├── 0023_snapshot.json │ │ │ ├── 0024_snapshot.json │ │ │ ├── 0025_snapshot.json │ │ │ ├── 0026_snapshot.json │ │ │ ├── 0027_snapshot.json │ │ │ ├── 0028_snapshot.json │ │ │ ├── 0029_snapshot.json │ │ │ ├── 0030_snapshot.json │ │ │ ├── 0031_snapshot.json │ │ │ ├── 0032_snapshot.json │ │ │ ├── 0033_snapshot.json │ │ │ ├── 0034_snapshot.json │ │ │ ├── 0035_snapshot.json │ │ │ ├── 0036_snapshot.json │ │ │ ├── 0037_snapshot.json │ │ │ ├── 0038_snapshot.json │ │ │ ├── 0039_snapshot.json │ │ │ ├── 0040_snapshot.json │ │ │ ├── 0041_snapshot.json │ │ │ ├── 0042_snapshot.json │ │ │ ├── 0043_snapshot.json │ │ │ ├── 0044_snapshot.json │ │ │ ├── 0045_snapshot.json │ │ │ ├── 0046_snapshot.json │ │ │ ├── 0047_snapshot.json │ │ │ ├── 0048_snapshot.json │ │ │ ├── 0049_snapshot.json │ │ │ ├── 0050_snapshot.json │ │ │ ├── 0051_snapshot.json │ │ │ ├── 0052_snapshot.json │ │ │ ├── 0053_snapshot.json │ │ │ ├── 0054_snapshot.json │ │ │ ├── 0055_snapshot.json │ │ │ ├── 0056_snapshot.json │ │ │ ├── 0057_snapshot.json │ │ │ ├── 0058_snapshot.json │ │ │ └── _journal.json │ │ ├── drizzle.config.ts │ │ ├── env.mjs │ │ ├── env.ts │ │ ├── package.json │ │ ├── script/ │ │ │ ├── region-migration.test.ts │ │ │ └── region-migration.ts │ │ ├── src/ │ │ │ ├── db.ts │ │ │ ├── index.ts │ │ │ ├── migrate.mts │ │ │ ├── schema/ │ │ │ │ ├── api-keys/ │ │ │ │ │ ├── api_key.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── applications/ │ │ │ │ │ ├── application.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── check/ │ │ │ │ │ ├── check.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── incidents/ │ │ │ │ │ ├── incident.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integration.ts │ │ │ │ ├── invitations/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── invitation.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── maintenances/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── maintenance.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── monitor_groups/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── monitor_group.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── monitor_run/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── monitor_run.ts │ │ │ │ ├── monitor_status/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── monitor_status.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── monitor_tags/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── monitor_tag.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── monitors/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── monitor.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── notifications/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── page_component_groups/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── page_component_groups.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── page_components/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── page_components.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── page_subscribers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── page_subscriber_to_page_component.ts │ │ │ │ │ ├── page_subscribers.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── pages/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── page.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── plan/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── private_locations/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── private_locations.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── shared.ts │ │ │ │ ├── status_reports/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── status_reports.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── users/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── validation.ts │ │ │ │ ├── viewers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── validation.ts │ │ │ │ │ └── viewer.ts │ │ │ │ └── workspaces/ │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ ├── validation.ts │ │ │ │ └── workspace.ts │ │ │ ├── seed.mts │ │ │ ├── sync-db.ts │ │ │ └── utils/ │ │ │ ├── api-key.test.ts │ │ │ ├── api-key.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── emails/ │ │ ├── emails/ │ │ │ ├── _components/ │ │ │ │ ├── footer.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── styles.ts │ │ │ ├── feedback.tsx │ │ │ ├── followup.tsx │ │ │ ├── monitor-alert.tsx │ │ │ ├── monitor-deactivation.tsx │ │ │ ├── monitor-paused.tsx │ │ │ ├── page-subscription.tsx │ │ │ ├── slack-feedback.tsx │ │ │ ├── status-page-magic-link.tsx │ │ │ ├── status-report.tsx │ │ │ ├── subscribe.tsx │ │ │ ├── team-invitation.tsx │ │ │ ├── team-invite-reminder.tsx │ │ │ └── welcome.tsx │ │ ├── hotfix/ │ │ │ ├── monitor-alert.ts │ │ │ ├── monitor-deactivation.ts │ │ │ └── monitor-paused.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── client.integration.test.tsx │ │ │ ├── client.tsx │ │ │ ├── env.ts │ │ │ ├── index.ts │ │ │ ├── send.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── error/ │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── base-error.ts │ │ │ ├── error-code.ts │ │ │ ├── http-error.ts │ │ │ ├── schema-error.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── header-analysis/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── parser/ │ │ │ │ ├── cache-control.ts │ │ │ │ ├── cf-cache-status.ts │ │ │ │ ├── cf-ray.ts │ │ │ │ ├── fly-request-id.ts │ │ │ │ ├── x-vercel-cache.ts │ │ │ │ └── x-vercel-id.ts │ │ │ ├── regions/ │ │ │ │ ├── cloudflare.ts │ │ │ │ ├── fly.ts │ │ │ │ └── vercel.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── icons/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── discord.tsx │ │ │ ├── fly.tsx │ │ │ ├── github.tsx │ │ │ ├── google.tsx │ │ │ ├── grafana.tsx │ │ │ ├── index.tsx │ │ │ ├── koyeb.tsx │ │ │ ├── markdown.tsx │ │ │ ├── opsgenie.tsx │ │ │ ├── pagerduty.tsx │ │ │ ├── railway.tsx │ │ │ ├── slack.tsx │ │ │ ├── statuspage.tsx │ │ │ ├── telegram.tsx │ │ │ └── whatsapp.tsx │ │ └── tsconfig.json │ ├── importers/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── providers/ │ │ │ │ └── statuspage/ │ │ │ │ ├── api-types.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── client.ts │ │ │ │ ├── fixtures.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mapper.test.ts │ │ │ │ ├── mapper.ts │ │ │ │ ├── provider.test.ts │ │ │ │ └── provider.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── notifications/ │ │ ├── base/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── colors.ts │ │ │ │ ├── duration.test.ts │ │ │ │ ├── duration.ts │ │ │ │ ├── incident.test.ts │ │ │ │ ├── incident.ts │ │ │ │ ├── message.ts │ │ │ │ └── timestamp.ts │ │ │ └── tsconfig.json │ │ ├── discord/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── embeds.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── mock.ts │ │ │ └── tsconfig.json │ │ ├── email/ │ │ │ ├── README.md │ │ │ ├── env.ts │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── mock.ts │ │ │ └── tsconfig.json │ │ ├── google-chat/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── mock.ts │ │ │ └── tsconfig.json │ │ ├── grafana-oncall/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── tsconfig.json │ │ ├── ntfy/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── tsconfig.json │ │ ├── opsgenie/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── tsconfig.json │ │ ├── pagerduty/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── env.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema/ │ │ │ │ └── config.ts │ │ │ └── tsconfig.json │ │ ├── slack/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── blocks.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── mock.ts │ │ │ └── tsconfig.json │ │ ├── telegram/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── tsconfig.json │ │ ├── twillio-sms/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── env.ts │ │ │ │ ├── index.test.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── twillio-whatsapp/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── env.ts │ │ │ │ ├── index.test.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ └── webhook/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── tsconfig.json │ ├── proto/ │ │ ├── api/ │ │ │ └── openstatus/ │ │ │ ├── health/ │ │ │ │ └── v1/ │ │ │ │ └── health.proto │ │ │ ├── maintenance/ │ │ │ │ └── v1/ │ │ │ │ ├── maintenance.proto │ │ │ │ └── service.proto │ │ │ ├── monitor/ │ │ │ │ └── v1/ │ │ │ │ ├── assertions.proto │ │ │ │ ├── dns_monitor.proto │ │ │ │ ├── http_monitor.proto │ │ │ │ ├── monitor.proto │ │ │ │ ├── service.proto │ │ │ │ └── tcp_monitor.proto │ │ │ ├── notification/ │ │ │ │ └── v1/ │ │ │ │ ├── notification.proto │ │ │ │ ├── providers.proto │ │ │ │ └── service.proto │ │ │ ├── status_page/ │ │ │ │ └── v1/ │ │ │ │ ├── page_component.proto │ │ │ │ ├── page_subscriber.proto │ │ │ │ ├── service.proto │ │ │ │ └── status_page.proto │ │ │ └── status_report/ │ │ │ └── v1/ │ │ │ ├── service.proto │ │ │ └── status_report.proto │ │ ├── base.openapi.yaml │ │ ├── buf.gen.go.yaml │ │ ├── buf.gen.openapi.yaml │ │ ├── buf.gen.ts.yaml │ │ ├── buf.yaml │ │ ├── gen/ │ │ │ ├── openapi.yaml │ │ │ └── ts/ │ │ │ ├── buf/ │ │ │ │ └── validate/ │ │ │ │ └── validate_pb.ts │ │ │ ├── gnostic/ │ │ │ │ └── openapi/ │ │ │ │ └── v3/ │ │ │ │ ├── annotations_pb.ts │ │ │ │ └── openapiv3_pb.ts │ │ │ ├── index.ts │ │ │ └── openstatus/ │ │ │ ├── health/ │ │ │ │ └── v1/ │ │ │ │ ├── health_pb.ts │ │ │ │ └── index.ts │ │ │ ├── maintenance/ │ │ │ │ └── v1/ │ │ │ │ ├── index.ts │ │ │ │ ├── maintenance_pb.ts │ │ │ │ └── service_pb.ts │ │ │ ├── monitor/ │ │ │ │ └── v1/ │ │ │ │ ├── assertions_pb.ts │ │ │ │ ├── dns_monitor_pb.ts │ │ │ │ ├── http_monitor_pb.ts │ │ │ │ ├── index.ts │ │ │ │ ├── monitor_pb.ts │ │ │ │ ├── service_pb.ts │ │ │ │ └── tcp_monitor_pb.ts │ │ │ ├── notification/ │ │ │ │ └── v1/ │ │ │ │ ├── index.ts │ │ │ │ ├── notification_pb.ts │ │ │ │ ├── providers_pb.ts │ │ │ │ └── service_pb.ts │ │ │ ├── status_page/ │ │ │ │ └── v1/ │ │ │ │ ├── index.ts │ │ │ │ ├── page_component_pb.ts │ │ │ │ ├── page_subscriber_pb.ts │ │ │ │ ├── service_pb.ts │ │ │ │ └── status_page_pb.ts │ │ │ └── status_report/ │ │ │ └── v1/ │ │ │ ├── index.ts │ │ │ ├── service_pb.ts │ │ │ └── status_report_pb.ts │ │ ├── go.mod │ │ ├── go.sum │ │ ├── internal/ │ │ │ └── private_location/ │ │ │ └── v1/ │ │ │ ├── assertions.proto │ │ │ ├── dns_monitor.proto │ │ │ ├── http_monitor.proto │ │ │ ├── private_location.proto │ │ │ └── tcp_monitor.proto │ │ ├── justfile │ │ ├── package.json │ │ ├── plan/ │ │ │ ├── PLAN.md │ │ │ └── api.md │ │ ├── scripts/ │ │ │ └── clean-openapi.ts │ │ └── tsconfig.json │ ├── react/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── styles.css │ │ │ ├── utils.ts │ │ │ └── widget.tsx │ │ ├── tsconfig.json │ │ └── tsup.config.js │ ├── regions/ │ │ ├── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── status-fetcher/ │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── fetch-utils.test.ts │ │ │ ├── fetchers/ │ │ │ │ ├── atlassian.test.ts │ │ │ │ ├── betterstack.test.ts │ │ │ │ ├── custom.test.ts │ │ │ │ ├── edge-cases.test.ts │ │ │ │ ├── html.test.ts │ │ │ │ ├── incidentio.test.ts │ │ │ │ └── instatus.test.ts │ │ │ ├── integration.test.ts │ │ │ └── utils.test.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── test-fetchers.ts │ │ ├── src/ │ │ │ ├── data/ │ │ │ │ ├── directory.ts │ │ │ │ └── index.ts │ │ │ ├── fetch-utils.ts │ │ │ ├── fetchers/ │ │ │ │ ├── atlassian.ts │ │ │ │ ├── betterstack.ts │ │ │ │ ├── custom.ts │ │ │ │ ├── html.ts │ │ │ │ ├── incidentio.ts │ │ │ │ ├── index.ts │ │ │ │ └── instatus.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── subscriptions/ │ │ ├── bunfig.toml │ │ ├── package.json │ │ ├── src/ │ │ │ ├── channels/ │ │ │ │ ├── email.test.ts │ │ │ │ ├── email.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── webhook.test.ts │ │ │ │ └── webhook.ts │ │ │ ├── dispatcher.test.ts │ │ │ ├── dispatcher.ts │ │ │ ├── index.ts │ │ │ ├── service.test.ts │ │ │ ├── service.ts │ │ │ ├── test-preload.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── theme-store/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── dracula.ts │ │ │ ├── github.ts │ │ │ ├── index.ts │ │ │ ├── openstatus.ts │ │ │ ├── supabase.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── tinybird/ │ │ ├── README.md │ │ ├── datasources/ │ │ │ ├── audit_log__v0.datasource │ │ │ ├── check_response.datasource │ │ │ ├── check_response_http.datasource │ │ │ ├── dns_response__v0.datasource │ │ │ ├── external_status.datasource │ │ │ ├── mv__dns_status_45d__v0.datasource │ │ │ ├── mv__http_14d.datasource │ │ │ ├── mv__http_14d__v0.datasource │ │ │ ├── mv__http_14d__v1.datasource │ │ │ ├── mv__http_1d__v0.datasource │ │ │ ├── mv__http_1d__v1.datasource │ │ │ ├── mv__http_30d__v0.datasource │ │ │ ├── mv__http_30d__v1.datasource │ │ │ ├── mv__http_7d__v0.datasource │ │ │ ├── mv__http_7d__v1.datasource │ │ │ ├── mv__http_full_14d__v0.datasource │ │ │ ├── mv__http_full_30d__v0.datasource │ │ │ ├── mv__http_status_14d__v0.datasource │ │ │ ├── mv__http_status_45d__v0.datasource │ │ │ ├── mv__http_status_45d__v1.datasource │ │ │ ├── mv__http_status_7d__v0.datasource │ │ │ ├── mv__http_timing_phases_14d.datasource │ │ │ ├── mv__http_timing_phases_14d__v1.datasource │ │ │ ├── mv__http_uptime_30d__v1.datasource │ │ │ ├── mv__http_uptime_7d__v1.datasource │ │ │ ├── mv__http_workspace_30d__v0.datasource │ │ │ ├── mv__tcp_14d__v0.datasource │ │ │ ├── mv__tcp_14d__v1.datasource │ │ │ ├── mv__tcp_1d__v0.datasource │ │ │ ├── mv__tcp_1d__v1.datasource │ │ │ ├── mv__tcp_30d__v0.datasource │ │ │ ├── mv__tcp_30d__v1.datasource │ │ │ ├── mv__tcp_7d__v0.datasource │ │ │ ├── mv__tcp_7d__v1.datasource │ │ │ ├── mv__tcp_full_14d__v0.datasource │ │ │ ├── mv__tcp_full_30d__v0.datasource │ │ │ ├── mv__tcp_status_45d__v0.datasource │ │ │ ├── mv__tcp_status_45d__v1.datasource │ │ │ ├── mv__tcp_status_7d__v0.datasource │ │ │ ├── mv__tcp_uptime_30d__v1.datasource │ │ │ ├── mv__tcp_uptime_7d__v1.datasource │ │ │ ├── mv__tcp_workspace_30d__v0.datasource │ │ │ ├── mv_http_status_14d.datasource │ │ │ ├── ping_response__v8.datasource │ │ │ ├── tcp_response.datasource │ │ │ └── tcp_response__v0.datasource │ │ ├── endpoints/ │ │ │ ├── endpoint__audit_log.pipe │ │ │ ├── endpoint__audit_log__v1.pipe │ │ │ ├── endpoint__dns_get_14d__v0.pipe │ │ │ ├── endpoint__dns_list_14d__v0.pipe │ │ │ ├── endpoint__dns_metrics_14d__v0.pipe │ │ │ ├── endpoint__dns_metrics_1d__v0.pipe │ │ │ ├── endpoint__dns_metrics_7d__v0.pipe │ │ │ ├── endpoint__dns_metrics_latency_1d_multi__v0.pipe │ │ │ ├── endpoint__dns_metrics_latency_7d__v0.pipe │ │ │ ├── endpoint__dns_metrics_regions_14d__v0.pipe │ │ │ ├── endpoint__dns_status_45d__v0.pipe │ │ │ ├── endpoint__dns_uptime_30d__v0.pipe │ │ │ ├── endpoint__http_get_14d__v0.pipe │ │ │ ├── endpoint__http_get_30d.pipe │ │ │ ├── endpoint__http_list_14d.pipe │ │ │ ├── endpoint__http_list_14d__v1.pipe │ │ │ ├── endpoint__http_list_1d.pipe │ │ │ ├── endpoint__http_list_1d__v1.pipe │ │ │ ├── endpoint__http_list_7d.pipe │ │ │ ├── endpoint__http_list_7d__v1.pipe │ │ │ ├── endpoint__http_metrics_14d.pipe │ │ │ ├── endpoint__http_metrics_14d__v1.pipe │ │ │ ├── endpoint__http_metrics_1d.pipe │ │ │ ├── endpoint__http_metrics_1d__v1.pipe │ │ │ ├── endpoint__http_metrics_7d.pipe │ │ │ ├── endpoint__http_metrics_7d__v1.pipe │ │ │ ├── endpoint__http_metrics_by_interval_14d.pipe │ │ │ ├── endpoint__http_metrics_by_interval_1d.pipe │ │ │ ├── endpoint__http_metrics_by_interval_7d.pipe │ │ │ ├── endpoint__http_metrics_by_region_14d.pipe │ │ │ ├── endpoint__http_metrics_by_region_1d.pipe │ │ │ ├── endpoint__http_metrics_by_region_7d.pipe │ │ │ ├── endpoint__http_metrics_global_1d__v0.pipe │ │ │ ├── endpoint__http_metrics_latency_1d__v1.pipe │ │ │ ├── endpoint__http_metrics_latency_1d_multi__v1.pipe │ │ │ ├── endpoint__http_metrics_latency_7d__v1.pipe │ │ │ ├── endpoint__http_metrics_regions_14d__v0.pipe │ │ │ ├── endpoint__http_metrics_regions_1d__v0.pipe │ │ │ ├── endpoint__http_metrics_regions_7d__v0.pipe │ │ │ ├── endpoint__http_status_14d.pipe │ │ │ ├── endpoint__http_status_45d.pipe │ │ │ ├── endpoint__http_status_45d__v1.pipe │ │ │ ├── endpoint__http_status_7d.pipe │ │ │ ├── endpoint__http_timing_phases_14d__v1.pipe │ │ │ ├── endpoint__http_uptime_30d__v1.pipe │ │ │ ├── endpoint__http_uptime_7d__v1.pipe │ │ │ ├── endpoint__http_workspace_30d__v0.pipe │ │ │ ├── endpoint__stats_global.pipe │ │ │ ├── endpoint__tcp_get_14d__v0.pipe │ │ │ ├── endpoint__tcp_get_30d.pipe │ │ │ ├── endpoint__tcp_list_14d.pipe │ │ │ ├── endpoint__tcp_list_14d__v1.pipe │ │ │ ├── endpoint__tcp_list_1d.pipe │ │ │ ├── endpoint__tcp_list_1d__v1.pipe │ │ │ ├── endpoint__tcp_list_7d.pipe │ │ │ ├── endpoint__tcp_list_7d__v1.pipe │ │ │ ├── endpoint__tcp_metrics_14d.pipe │ │ │ ├── endpoint__tcp_metrics_14d__v1.pipe │ │ │ ├── endpoint__tcp_metrics_1d.pipe │ │ │ ├── endpoint__tcp_metrics_1d__v1.pipe │ │ │ ├── endpoint__tcp_metrics_7d.pipe │ │ │ ├── endpoint__tcp_metrics_7d__v1.pipe │ │ │ ├── endpoint__tcp_metrics_by_interval_14d.pipe │ │ │ ├── endpoint__tcp_metrics_by_interval_1d.pipe │ │ │ ├── endpoint__tcp_metrics_by_interval_7d.pipe │ │ │ ├── endpoint__tcp_metrics_by_region_14d.pipe │ │ │ ├── endpoint__tcp_metrics_by_region_1d.pipe │ │ │ ├── endpoint__tcp_metrics_by_region_7d.pipe │ │ │ ├── endpoint__tcp_metrics_global_1d.pipe │ │ │ ├── endpoint__tcp_metrics_latency_1d__v1.pipe │ │ │ ├── endpoint__tcp_metrics_latency_1d_multi__v1.pipe │ │ │ ├── endpoint__tcp_metrics_latency_7d__v1.pipe │ │ │ ├── endpoint__tcp_status_45d.pipe │ │ │ ├── endpoint__tcp_status_45d__v1.pipe │ │ │ ├── endpoint__tcp_status_7d.pipe │ │ │ ├── endpoint__tcp_uptime_30d__v1.pipe │ │ │ ├── endpoint__tcp_uptime_7d__v1.pipe │ │ │ ├── endpoint__tcp_workspace_30d__v0.pipe │ │ │ ├── endpoint_audit_log.pipe │ │ │ └── endpoint_external_status.pipe │ │ ├── package.json │ │ ├── pipes/ │ │ │ ├── __ttl_45d_count_utc_get.pipe │ │ │ ├── aggregate__dns_status_45d__v1.pipe │ │ │ ├── aggregate__http_14d__v1.pipe │ │ │ ├── aggregate__http_1d__v1.pipe │ │ │ ├── aggregate__http_30d__v1.pipe │ │ │ ├── aggregate__http_7d__v1.pipe │ │ │ ├── aggregate__http_full_14d__v0.pipe │ │ │ ├── aggregate__http_full_30d__v0.pipe │ │ │ ├── aggregate__http_status_14d.pipe │ │ │ ├── aggregate__http_status_45d.pipe │ │ │ ├── aggregate__http_status_45d__v1.pipe │ │ │ ├── aggregate__http_status_7d.pipe │ │ │ ├── aggregate__http_timing_phases_14d.pipe │ │ │ ├── aggregate__http_uptime_30d.pipe │ │ │ ├── aggregate__http_uptime_7d__v1.pipe │ │ │ ├── aggregate__http_workspace_30d__v0.pipe │ │ │ ├── aggregate__tcp_14d.pipe │ │ │ ├── aggregate__tcp_14d__v1.pipe │ │ │ ├── aggregate__tcp_1d.pipe │ │ │ ├── aggregate__tcp_1d__v1.pipe │ │ │ ├── aggregate__tcp_30d.pipe │ │ │ ├── aggregate__tcp_30d__v1.pipe │ │ │ ├── aggregate__tcp_7d.pipe │ │ │ ├── aggregate__tcp_7d__v1.pipe │ │ │ ├── aggregate__tcp_full_14d__v0.pipe │ │ │ ├── aggregate__tcp_full_30d__v0.pipe │ │ │ ├── aggregate__tcp_status_45d.pipe │ │ │ ├── aggregate__tcp_status_45d__v1.pipe │ │ │ ├── aggregate__tcp_status_7d.pipe │ │ │ ├── aggregate__tcp_uptime_30d__v1.pipe │ │ │ ├── aggregate__tcp_uptime_7d__v1.pipe │ │ │ ├── aggregate__tcp_workspace_30d__v0.pipe │ │ │ ├── get_result_for_on_demand_check_http.pipe │ │ │ ├── public_status.pipe │ │ │ ├── response_details.pipe │ │ │ ├── response_graph.pipe │ │ │ ├── response_list.pipe │ │ │ └── single_checks_get.pipe │ │ ├── src/ │ │ │ ├── audit-log/ │ │ │ │ ├── README.md │ │ │ │ ├── action-schema.ts │ │ │ │ ├── action-validation.ts │ │ │ │ ├── base-validation.ts │ │ │ │ ├── client.ts │ │ │ │ ├── examples.ts │ │ │ │ └── index.ts │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── tsconfig.json │ ├── tracker/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── blacklist.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── mock.ts │ │ │ ├── tracker.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── tsconfig/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── ui/ │ │ ├── REGISTRY.md │ │ ├── components.json │ │ ├── package.json │ │ ├── registry.json │ │ ├── scripts/ │ │ │ ├── copy-to-web.mjs │ │ │ └── transform-imports.mjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── blocks/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── status-banner.tsx │ │ │ │ │ ├── status-bar.tsx │ │ │ │ │ ├── status-blank.tsx │ │ │ │ │ ├── status-component-group.tsx │ │ │ │ │ ├── status-component.tsx │ │ │ │ │ ├── status-events.tsx │ │ │ │ │ ├── status-feed.tsx │ │ │ │ │ ├── status-icon.tsx │ │ │ │ │ ├── status-layout.tsx │ │ │ │ │ ├── status-timestamp.tsx │ │ │ │ │ ├── status.types.ts │ │ │ │ │ └── status.utils.ts │ │ │ │ └── ui/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button-group.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── calendar.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── hover-card.tsx │ │ │ │ ├── input-group.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── kbd.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── qr-code.tsx │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── globals.css │ │ │ ├── hooks/ │ │ │ │ ├── use-cookie-state.ts │ │ │ │ ├── use-copy-to-clipboard.ts │ │ │ │ ├── use-debounce-callback.ts │ │ │ │ ├── use-debounce.ts │ │ │ │ ├── use-media-query.ts │ │ │ │ └── use-mobile.ts │ │ │ └── lib/ │ │ │ ├── compose-refs.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── upstash/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── redis/ │ │ │ └── client.ts │ │ └── tsconfig.json │ └── utils/ │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── pnpm-workspace.yaml ├── process-compose.yaml ├── ralph/ │ ├── .gitignore │ ├── README.md │ ├── afk-ralph.sh │ └── ralph-once.sh ├── turbo.json └── utils/ └── api-bruno/ ├── Monitor Summary.bru ├── OpenApi.bru ├── bruno.json ├── checker.bru ├── environments/ │ ├── local.bru │ └── prod.bru ├── incident_update/ │ └── Get Status Report Update.bru └── incidents/ ├── All Status Reports.bru └── Get Status Report.bru ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/commands/ship.md ================================================ --- description: Create a new git branch, commit changes, and create a pull request allowed-tools: Bash(git:*), Bash(gh:*) --- # Current Git State - Branch: !`git branch --show-current` - Status: !`git status --porcelain` - Recent commits: !`git log --oneline -5` # Arguments $ARGUMENTS - optional branch name # Workflow ## 1. Validate - Confirm there are uncommitted changes (from status above). If none, stop. - Confirm current branch. If not on `main`, ask user before proceeding. - Warn if any .env or credential files would be staged. - Run `pnpm format:fix` in the root directory to fix formatting issues ## 2. Create Branch - If `$ARGUMENTS` provided, use it as branch name - Otherwise, generate from task context using conventional prefixes: `feat/`, `fix/`, `chore/`, `refactor/`, `docs/` in kebab-case ```bash git checkout -b ``` ## 3. Commit 1. Run `git diff` to review all changes 2. **Think about context**: What was the user trying to achieve? What problem does this solve? 3. Draft commit message: - Conventional commit format: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:` - **Write at the outcome level, not implementation level** - Bad: "change timeout from 5000 to 10000 in checker/monitor.go" - Good: "increase monitor check timeout to handle slow API responses" 4. Stage relevant files (not .env/credentials) 5. Commit with heredoc format: ```bash git commit -m "$(cat <<'EOF' Co-Authored-By: Claude Opus 4.6 EOF )" ``` 6. Run `git status` to verify success ## 4. Push & Create PR ```bash git push -u origin ``` Create PR with `gh`: - Title from commit message (same high-level framing) - Summary explains **outcomes**, not file changes - Bad: "Added `getMonitorStatus()` to monitor.ts, updated `Monitor` type" - Good: "Status pages now reflect real-time monitor degradation states" ```bash gh pr create --title "" --body "$(cat <<'EOF' ## Summary - <outcome 1> - <outcome 2> 🤖 Generated with [Claude Code](https://claude.com/claude-code) EOF )" ``` Return the PR URL. ## Error Handling If any step fails, report the error and stop. Don't proceed to the next step. ================================================ FILE: .dockerignore ================================================ # Dependencies **/node_modules node_modules **/.pnpm-store .pnpm-store # Build outputs **/.next **/.turbo **/dist **/build **/.nuxt **/.output **/.cache # Environment files **/.env **/.env.* .env .env.* !.env.docker.example # Development files **/.vscode **/.idea **/.DS_Store .DS_Store **/*.log **/npm-debug.log **/yarn-debug.log **/yarn-error.log **/pnpm-debug.log # Testing **/coverage **/.nyc_output **/test-results **/__tests__ **/tests **/*.test.ts **/*.test.tsx **/*.test.js **/*.spec.ts **/*.spec.tsx **/*.spec.js # Git **/.git **/.gitignore **/.gitattributes .git .gitignore # Documentation **/README.md **/CHANGELOG.md **/LICENSE **/*.md !packages/**/README.md # CI/CD **/.github .github **/.gitlab **/.circleci **/.drone.yml **/.travis.yml # Docker **/.dockerignore **/Dockerfile **/docker-compose*.yml **/docker-compose*.yaml .dockerignore docker-compose*.yml docker-compose*.yaml # Database files openstatus.db **/*.db **/*.db-shm **/*.db-wal # Temporary files **/tmp **/temp **/.temp **/.tmp # IDE **/.vscode **/.idea **/.fleet # OS .DS_Store **/Thumbs.db # Other build artifacts **/.contentlayer **/public/build **/.svelte-kit ================================================ FILE: .github/FUNDING.yml ================================================ github: openstatusHQ ================================================ FILE: .github/workflows/api-preview.yml ================================================ name: Fly Preview API server on: workflow_dispatch: inputs: action: description: "Action to perform" required: true type: choice options: - deploy - destroy permissions: contents: read deployments: write env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} FLY_REGION: ams FLY_ORG: openstatus APP_NAME: openstatus-api-preview-${{ github.ref_name }} jobs: deploy: if: ${{ github.event.inputs.action == 'deploy' }} runs-on: depot-ubuntu-24.04-4 timeout-minutes: 15 concurrency: group: api-preview-${{ github.ref_name }} environment: name: api-preview-${{ github.ref_name }} url: https://${{ env.APP_NAME }}.fly.dev steps: - name: Get code uses: actions/checkout@v4 - name: Setup Fly.io CLI uses: superfly/flyctl-actions/setup-flyctl@master - name: Create app if not exists run: flyctl apps create ${{ env.APP_NAME }} --org ${{ env.FLY_ORG }} || true - name: Set secrets run: | flyctl secrets set \ DATABASE_URL="${{ secrets.STAGING_DB_URL }}" \ DATABASE_AUTH_TOKEN="${{ secrets.STAGING_DB_AUTH_TOKEN }}" \ RESEND_API_KEY="${{ secrets.STAGING_RESEND_API_KEY }}" \ UPSTASH_REDIS_REST_URL=test \ UPSTASH_REDIS_REST_TOKEN=test \ GCP_PROJECT_ID=test \ NEXT_PUBLIC_OPENPANEL_CLIENT_ID=test \ OPENPANEL_CLIENT_SECRET=test \ --app ${{ env.APP_NAME }} - name: Deploy to Fly.io run: | flyctl deploy \ --config apps/server/fly.toml \ --app ${{ env.APP_NAME }} \ --region ${{ env.FLY_REGION }} \ --vm-size shared-cpu-1x \ --yes destroy: if: ${{ github.event.inputs.action == 'destroy' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Setup Fly.io CLI uses: superfly/flyctl-actions/setup-flyctl@master - name: Destroy app run: flyctl apps destroy ${{ env.APP_NAME }} --yes || true - name: Clean up GitHub environment uses: strumwolf/delete-deployment-environment@v2 with: token: ${{ secrets.GITHUB_TOKEN }} environment: api-preview-${{ github.ref_name }} ================================================ FILE: .github/workflows/claude-code-review.yml ================================================ name: Claude Code Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] # Optional: Only run on specific file changes # paths: # - "src/**/*.ts" # - "src/**/*.tsx" # - "src/**/*.js" # - "src/**/*.jsx" jobs: claude-review: # Optional: Filter by PR author if: | github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER' runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: read id-token: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | ( github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') ) || ( github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') ) || ( github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'MEMBER' || github.event.review.author_association == 'COLLABORATOR') ) || ( github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && (github.event.issue.author_association == 'OWNER' || github.event.issue.author_association == 'MEMBER' || github.event.issue.author_association == 'COLLABORATOR') ) runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write pull-requests: write issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. # prompt: 'Update the pull request description to include a summary of changes.' # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' ================================================ FILE: .github/workflows/deploy-checker.yml ================================================ name: Fly Deploy Checker on: push: branches: - main paths: - "apps/checker/**" jobs: deploy-checker: name: Deploy Checker runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - working-directory: apps/checker name: Deploy Checker run: | flyctl deploy --remote-only --wait-timeout=500 env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .github/workflows/deploy-private-location.yml ================================================ name: Fly Deploy Private Location on: push: branches: - main paths: - "apps/private-location/**" - "apps/checker/**" jobs: deploy-private-location: name: Deploy Private Location runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - working-directory: apps/private-location name: Deploy Private Location run: | flyctl deploy --remote-only --wait-timeout=500 env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .github/workflows/deploy-workflows.yml ================================================ name: Fly Deploy Workflows on: push: branches: - main paths: - "apps/workflows/**" - "packages/db/**" - "packages/emails/**" - "packages/utils/**" - "packages/tsconfig/**" - "packages/notifications/**" jobs: deploy-workflows: name: Deploy Workflows runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - run: flyctl deploy --config apps/workflows/fly.toml --dockerfile apps/workflows/Dockerfile --remote-only --wait-timeout=500 env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Fly Deploy on: push: branches: - main jobs: deploy: name: Deploy API runs-on: depot-ubuntu-24.04-4 timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: superfly/flyctl-actions/setup-flyctl@master - run: flyctl deploy --config apps/server/fly.toml --dockerfile apps/server/Dockerfile --remote-only --wait-timeout=500 env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .github/workflows/docker-publish-dev.yml ================================================ name: Publish Docker Images (Development) on: workflow_dispatch: env: REGISTRY: ghcr.io IMAGE_NAME: openstatus jobs: build-and-push-dev: runs-on: ubuntu-latest permissions: contents: read id-token: write packages: write strategy: matrix: service: [server, dashboard, workflows, private-location, status-page, checker] include: - service: server context: . dockerfile: apps/server/Dockerfile port: 3001 - service: dashboard context: . dockerfile: apps/dashboard/Dockerfile port: 3002 - service: workflows context: . dockerfile: apps/workflows/Dockerfile port: 3000 - service: private-location context: apps/private-location dockerfile: apps/private-location/Dockerfile port: 8081 - service: status-page context: . dockerfile: apps/status-page/Dockerfile port: 3003 - service: checker context: apps/checker dockerfile: apps/checker/Dockerfile port: 8082 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}-${{ matrix.service }} tags: | type=ref,event=branch,suffix=-${{ matrix.service }} type=ref,event=pr,suffix=-${{ matrix.service }} type=sha,prefix=dev-,suffix=-${{ matrix.service }} type=raw,value=dev-latest,suffix=-${{ matrix.service }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 cache-from: type=gha cache-to: type=gha,mode=max provenance: false test-images: runs-on: ubuntu-latest needs: build-and-push-dev if: github.event_name == 'pull_request' steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Docker Compose run: | sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose - name: Create test compose file run: | cp docker-compose.github-packages.yaml docker-compose.test.yaml # Replace latest tags with dev-latest for testing sed -i 's/:latest/:dev-latest/g' docker-compose.test.yaml - name: Test service startup run: | # Start external services first docker-compose -f docker-compose.test.yaml up -d libsql tinybird-local # Wait for external services to be healthy timeout 120 bash -c 'until docker-compose -f docker-compose.test.yaml ps libsql | grep -q "healthy"; do sleep 5; done' timeout 120 bash -c 'until docker-compose -f docker-compose.test.yaml ps tinybird-local | grep -q "healthy"; do sleep 5; done' # Start one service for basic testing docker-compose -f docker-compose.test.yaml up -d workflows # Wait and check if it's running sleep 30 if ! docker-compose -f docker-compose.test.yaml ps workflows | grep -q "Up"; then echo "Workflows service failed to start" docker-compose -f docker-compose.test.yaml logs workflows exit 1 fi - name: Cleanup if: always() run: | docker-compose -f docker-compose.test.yaml down -v ================================================ FILE: .github/workflows/docker-publish.yml ================================================ name: Publish Docker Images on: push: branches: - main paths: - "apps/server/**" - "apps/dashboard/**" - "apps/workflows/**" - "apps/private-location/**" - "apps/status-page/**" - "apps/checker/**" - "packages/**" - "docker-compose.yaml" workflow_dispatch: inputs: services: description: 'Services to build (comma-separated)' required: false default: 'server,dashboard,workflows,private-location,status-page,checker' type: string concurrency: group: docker-${{ github.ref }} cancel-in-progress: true env: REGISTRY: ghcr.io IMAGE_NAME: openstatus jobs: prepare: runs-on: ubuntu-latest outputs: services: ${{ steps.set-services.outputs.services }} steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 2 - name: Determine services to build id: set-services run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then # Convert comma-separated input to JSON array SERVICES=$(echo "${{ inputs.services }}" | tr ',' '\n' | jq -R . | jq -sc .) else # Detect changed files CHANGED=$(git diff --name-only HEAD~1 HEAD) SERVICES_LIST=() # packages/** changes affect all services if echo "$CHANGED" | grep -q '^packages/'; then SERVICES_LIST=("server" "dashboard" "workflows" "private-location" "status-page" "checker") else for svc in server dashboard workflows private-location status-page checker; do if echo "$CHANGED" | grep -q "^apps/$svc/"; then SERVICES_LIST+=("$svc") fi done fi SERVICES=$(printf '%s\n' "${SERVICES_LIST[@]}" | jq -R . | jq -sc .) fi echo "services=$SERVICES" >> "$GITHUB_OUTPUT" echo "Building services: $SERVICES" build-and-push: runs-on: ubuntu-latest needs: [prepare] if: needs.prepare.outputs.services != '[]' timeout-minutes: 30 permissions: contents: read id-token: write packages: write strategy: fail-fast: false matrix: service: ${{ fromJson(needs.prepare.outputs.services) }} include: - service: server context: . dockerfile: apps/server/Dockerfile - service: dashboard context: . dockerfile: apps/dashboard/Dockerfile - service: workflows context: . dockerfile: apps/workflows/Dockerfile - service: private-location context: apps/private-location dockerfile: apps/private-location/Dockerfile - service: status-page context: . dockerfile: apps/status-page/Dockerfile - service: checker context: apps/checker dockerfile: apps/checker/Dockerfile steps: - name: Checkout repository uses: actions/checkout@v4 - name: Lowercase owner run: echo "OWNER_LC=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_ENV - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.OWNER_LC }}/${{ env.IMAGE_NAME }}-${{ matrix.service }} tags: | type=ref,event=branch type=ref,event=pr type=sha,prefix= type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image id: build uses: docker/build-push-action@v6 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Generate SBOM uses: anchore/sbom-action@v0 with: image: ${{ env.REGISTRY }}/${{ env.OWNER_LC }}/${{ env.IMAGE_NAME }}-${{ matrix.service }}@${{ steps.build.outputs.digest }} format: spdx-json output-file: sbom-${{ matrix.service }}.spdx.json - name: Upload SBOM uses: actions/upload-artifact@v4 with: name: sbom-${{ matrix.service }} path: sbom-${{ matrix.service }}.spdx.json retention-days: 30 ================================================ FILE: .github/workflows/dx.yml ================================================ # https://github.com/kentcdodds/kentcdodds.com/blob/main/.github/workflows/deployment.yml name: DX Check on: push: branches: - "main" pull_request: branches: [main] jobs: dx: name: 🧑‍💻 DX checker runs-on: ubuntu-latest timeout-minutes: 15 services: sqld: image: ghcr.io/tursodatabase/libsql-server:latest ports: - 8080:8080 # env: # SQLD_HTTP_AUTH: "basic:token" env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} DATABASE_URL: http://127.0.0.1:8080 DATABASE_AUTH_TOKEN: "basic:token" steps: - name: ⬇️ Checkout repo uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: 10.26.0 - name: ⎔ Setup node uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: 📥 Download deps run: pnpm install - name: 🔥 Install bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: 🔥 DX task run: pnpm dx ================================================ FILE: .github/workflows/go-tests.yml ================================================ name: Go Tests on: push: branches: - master tags: - '*.*.*' pull_request: branches: - '**' paths: - "apps/checker/**" - "apps/private-location/**" jobs: ci: name: Continuous Integration runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '>=1.25.0' - name: Run test Checker run: go test -timeout 30s -race -count=1 ./... working-directory: apps/checker - name: Run test Private Location run: go test -timeout 30s -race -count=1 ./... working-directory: apps/private-location ================================================ FILE: .github/workflows/lint.yml ================================================ # https://github.com/kentcdodds/kentcdodds.com/blob/main/.github/workflows/deployment.yml name: autofix.ci # needed to securely identify the workflow on: pull_request: concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.ref }} cancel-in-progress: true permissions: contents: read jobs: autofix: name: autofix runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: 10.26.0 - name: ⎔ Setup node uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: 📥 Download deps run: pnpm install - name: 🔬 Lint run: pnpm format - name: Apply fixes uses: autofix-ci/action@dd55f44df8f7cdb7a6bf74c78677eb8acd40cd0a with: commit-message: 'ci: apply automated fixes' ================================================ FILE: .github/workflows/migrate.yml ================================================ name: Migrate DB on: push: branches: - main paths: - "packages/db/drizzle/**" jobs: migrate: name: 🗃️ Migrate DB runs-on: ubuntu-latest env: DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_AUTH_TOKEN: ${{ secrets.DATABASE_AUTH_TOKEN }} steps: - name: ⬇️ Checkout repo uses: actions/checkout@v4 - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: 10.26.0 - name: ⎔ Setup node uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: 🔥 Install bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: 📥 Download deps run: pnpm install - name: 🗃️ Run migrations run: pnpm migrate working-directory: ./packages/db ================================================ FILE: .github/workflows/publish-checker.yml ================================================ name: Publish Checker on: push: branches: - main paths: - "apps/checker/**" env: REGISTRY: ghcr.io IMAGE_NAME: checker jobs: build-checker: runs-on: ubuntu-latest # Permissions to use OIDC token authentication permissions: contents: read id-token: write # Allows pushing to the GitHub Container Registry packages: write steps: - uses: actions/checkout@v3 - uses: depot/setup-action@v1 - name: Log in to the Container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: depot/build-push-action@v1 with: project: 9cknw183m8 context: apps/checker tags: ghcr.io/openstatushq/checker:latest platforms: linux/amd64,linux/arm64 push: true build-private-location: runs-on: ubuntu-latest # Permissions to use OIDC token authentication permissions: contents: read id-token: write # Allows pushing to the GitHub Container Registry packages: write steps: - uses: actions/checkout@v3 - uses: depot/setup-action@v1 - name: Log in to the Container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: depot/build-push-action@v1 with: file: apps/checker/private-location.Dockerfile project: 9cknw183m8 context: apps/checker tags: ghcr.io/openstatushq/private-location:latest platforms: linux/amd64,linux/arm64 push: true ================================================ FILE: .github/workflows/synthetic.yml ================================================ name: Run OpenStatus Synthetics CI on: workflow_run: workflows: ['Fly Deploy'] types: [completed] branches: - main repository_dispatch: types: - 'vercel.deployment.success' branches: - main jobs: synthetic_ci: runs-on: ubuntu-latest name: Run OpenStatus Synthetics CI steps: - name: Checkout uses: actions/checkout@v4 - name: Run OpenStatus Synthetics CI uses: openstatushq/openstatus-github-action@v1 with: api_key: ${{ secrets.OPENSTATUS_API_KEY }} ================================================ FILE: .github/workflows/test.yml ================================================ # https://github.com/kentcdodds/kentcdodds.com/blob/main/.github/workflows/deployment.yml name: Tests on: push: branches: - "main" pull_request: branches: [main] jobs: tests: name: 🧪 Tests runs-on: ubuntu-latest timeout-minutes: 15 services: sqld: image: ghcr.io/tursodatabase/libsql-server:latest ports: - 8080:8080 # env: # SQLD_HTTP_AUTH: "basic:token" env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} DATABASE_URL: http://127.0.0.1:8080 DATABASE_AUTH_TOKEN: "basic:token" steps: - name: ⬇️ Checkout repo uses: actions/checkout@v6 - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: 10.26.0 - name: ⎔ Setup node uses: actions/setup-node@v6 with: node-version: 24 cache: "pnpm" - name: 🔥 Install bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: 📥 Download deps run: pnpm install - name: 🗃️ Run migrations run: pnpm migrate working-directory: ./packages/db - name: 💽 Seed database run: pnpm seed working-directory: ./packages/db - name: 🧪 Tests run: pnpm test ================================================ FILE: .github/workflows/workflow-preview.yml ================================================ name: Fly Preview Workflows on: workflow_dispatch: inputs: action: description: "Action to perform" required: true type: choice options: - deploy - destroy permissions: contents: read deployments: write env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} FLY_REGION: ams FLY_ORG: openstatus APP_NAME: openstatus-workflows-preview-${{ github.ref_name }} jobs: deploy: if: ${{ github.event.inputs.action == 'deploy' }} runs-on: ubuntu-latest timeout-minutes: 15 concurrency: group: workflows-preview-${{ github.ref_name }} environment: name: workflows-preview-${{ github.ref_name }} url: https://${{ env.APP_NAME }}.fly.dev steps: - name: Get code uses: actions/checkout@v4 - name: Setup Fly.io CLI uses: superfly/flyctl-actions/setup-flyctl@master - name: Create app if not exists run: flyctl apps create ${{ env.APP_NAME }} --org ${{ env.FLY_ORG }} || true - name: Set secrets run: | flyctl secrets set \ DATABASE_URL="${{ secrets.STAGING_DB_URL }}" \ DATABASE_AUTH_TOKEN="${{ secrets.STAGING_DB_AUTH_TOKEN }}" \ RESEND_API_KEY="${{ secrets.STAGING_RESEND_API_KEY }}" \ UPSTASH_REDIS_REST_URL=test \ UPSTASH_REDIS_REST_TOKEN=test \ GCP_PROJECT_ID=test \ --app ${{ env.APP_NAME }} - name: Deploy to Fly.io run: | flyctl deploy \ --config apps/workflows/fly.toml \ --app ${{ env.APP_NAME }} \ --region ${{ env.FLY_REGION }} \ --vm-size shared-cpu-1x \ --yes destroy: if: ${{ github.event.inputs.action == 'destroy' }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Setup Fly.io CLI uses: superfly/flyctl-actions/setup-flyctl@master - name: Destroy app run: flyctl apps destroy ${{ env.APP_NAME }} --yes || true - name: Clean up GitHub environment uses: strumwolf/delete-deployment-environment@v2 with: token: ${{ secrets.GITHUB_TOKEN }} environment: workflows-preview-${{ github.ref_name }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules .pnp .pnp.js # testing coverage # next.js .next/ out/ build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env .env.local .env.development.local .env.test.local .env.production.local .env*.local # turbo .turbo # vercel .vercel .venv # packages dist packages/emails/.react-email packages/db/database.db packages/db/openstatus.db packages/db/database.db-wal packages/db/database.db-shm packages/db/openstatus.db-shm packages/db/openstatus.db-wal openstatus.db-wal openstatus.db openstatus.db-shm packages/tinybird/.tinyb apps/web/tsconfig.tsbuildinfo openstatus-test.db openstatus-test.db-shm openstatus-test.db-wal #webstorm .idea .wrangler apps/web/.env.dev packages/db/.env.dev openstatus-dev.db openstatus-dev.db-wal openstatus-dev.db-shm apps/ingest-worker/.production.vars apps/ingest-worker/.dev.vars apps/alerting-engine/tmp/ .env.docker # UI Registry build output apps/web/public/r packages/ui/public/r # For vibing research.md plan.md .zed *.tsbuildinfo .claude/skills/** .agents/skills/** skills-lock.json ================================================ FILE: .koyebignore ================================================ * !.koyebignore !/apps/checker/* ================================================ FILE: .npmrc ================================================ auto-install-peers = true ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "./node_modules/oxlint/configuration_schema.json", "ignorePatterns": ["**/*.test.ts", "**/*_pb.ts"] } ================================================ FILE: .prettierignore ================================================ **/.content-collections ================================================ FILE: .stacked.toml ================================================ mainBranch = "main" draft = true ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" }, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "[dotenv]": { "editor.defaultFormatter": "foxundermoon.shell-format" }, "[dockerfile]": { "editor.defaultFormatter": "ms-azuretools.vscode-docker" }, "[xml]": { "editor.defaultFormatter": "redhat.vscode-xml" }, "[go]": { "editor.defaultFormatter": "golang.go" }, "[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" }, "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], "gopls": { "ui.diagnostic.analyses": { "fieldalignment": true } } } ================================================ FILE: CLAUDE.md ================================================ # Agent.md This file provides a comprehensive overview of the OpenStatus project, its architecture, and development conventions to be used as instructional context for future interactions. ## Project Overview OpenStatus is an open-source synthetic monitoring platform. It allows users to monitor their websites and APIs from multiple locations and receive notifications when they are down or slow. The project is a monorepo managed with pnpm workspaces and Turborepo. It consists of several applications and packages that work together to provide a complete monitoring solution. ### Core Technologies - **Frontend:** - Next.js (with Turbopack) - React - Tailwind CSS - shadcn/ui - tRPC - **Backend:** - Hono (Node.js framework) - Go - **Database:** - Turso (libSQL) - Drizzle ORM - **Data Analytics:** - Tinybird - **Authentication:** - NextAuth.js - **Build System:** - Turborepo ### Architecture The OpenStatus platform is composed of three main applications: - **`apps/dashboard`**: A Next.js application that provides the main user interface for managing monitors, viewing status pages, and configuring notifications. - **`apps/server`**: A Hono-based backend server that provides the API for the dashboard application. - **`apps/checker`**: A Go application responsible for performing the actual monitoring checks from different locations. These applications are supported by a collection of shared packages in the `packages/` directory, which provide common functionality such as database access, UI components, and utility functions. ## Building and Running The project can be run using Docker (recommended) or a manual setup. ### With Docker 1. Copy the example environment file: ```sh cp .env.docker.example .env.docker ``` 2. Start all services: ```sh docker compose up -d ``` 3. Access the applications: - Dashboard: `http://localhost:3002` - Status Pages: `http://localhost:3003` ### Manual Setup 1. Install dependencies: ```sh pnpm install ``` 2. Initialize the development environment: ```sh pnpm dx ``` 3. Run a specific application: ```sh pnpm dev:dashboard pnpm dev:status-page pnpm dev:web ``` ### Running Tests To run the test suite, use the following command: Before running the test you should launch turso dev in a separate terminal: ```sh turso dev ``` Then, seed the database with test data: ```sh cd packages/db pnpm migrate pnpm seed ``` Then run the tests with: ```sh pnpm test ``` ## Development Conventions - **Monorepo:** The project is organized as a monorepo using pnpm workspaces. All applications and packages are located in the `apps/` and `packages/` directories, respectively. - **Build System:** Turborepo is used to manage the build process. The `turbo.json` file defines the build pipeline and dependencies between tasks. - **Linting and Formatting:** The project uses Biome for linting and formatting. The configuration can be found in the `biome.jsonc` file. - **Code Generation:** The project uses `drizzle-kit` for database schema migrations. - **API:** The backend API is built using Hono and tRPC. The API is documented using OpenAPI. ================================================ FILE: CONTRIBUTING.MD ================================================ # Contribution Guidelines Thank you for considering contributing to this project! We appreciate your efforts to make it better. To contribute to this project, please follow these guidelines: ## Table of Contents - [Reporting Issues](#reporting-issues) - [Feature Requests](#feature-requests) - [Submitting Changes](#submitting-changes) - [Coding Conventions](#coding-conventions) - [Documentation](#documentation) ## Reporting Issues If you encounter any problems or bugs while using this project, please report them by opening a GitHub issue. Before creating a new issue, please check if a similar one already exists to avoid duplicates. When reporting issues, provide a clear and concise description of the problem, including steps to reproduce it, expected behavior, and any relevant screenshots or error messages. ## Feature Requests If you have ideas for new features or improvements, you can submit a GitHub issue as well. Clearly describe the feature or improvement you would like to see and provide any additional context or examples that might be helpful. Feature requests help us understand your needs and prioritize the project's development. ## Submitting Changes To contribute code changes, follow these steps: 1. Fork the repository and create a new branch for your changes. 2. Ensure that your code follows the project's coding conventions and style guide. 3. Make commits with clear and descriptive messages. Each commit should have a single logical purpose. 4. Push your branch to your forked repository. 5. Open a pull request (PR) from your branch to the original repository's `main` branch. 6. Provide a detailed description of your changes in the PR, including any related issues or feature requests. A project maintainer will review your PR, provide feedback if necessary, and merge it once it meets the project's standards. ## Coding Conventions Please adhere to the existing coding conventions and style guide used in this project. Consistent coding styles improve code readability and maintainability. If you're unsure about any aspect of the coding conventions, feel free to ask for clarification in your PR. ## Documentation Improvements to documentation are always welcome. If you find any inaccuracies, missing information, or have suggestions for improving the documentation, you can contribute by submitting a PR with your changes. Make sure to clearly explain the purpose of the documentation update and provide relevant examples, if applicable. ================================================ FILE: COOLIFY_DEPLOYMENT.md ================================================ # Coolify Deployment Guide This guide explains how to deploy OpenStatus using Coolify with pre-built Docker images from GitHub Container Registry. ## Prerequisites - Coolify instance (self-hosted or cloud) - GitHub account with access to the repository - Environment variables configured ## Available Docker Images All images are published to `ghcr.io/openstatusHQ/openstatus-*`: - `ghcr.io/openstatusHQ/openstatus-server:latest` - Main API server - `ghcr.io/openstatusHQ/openstatus-dashboard:latest` - Web dashboard - `ghcr.io/openstatusHQ/openstatus-workflows:latest` - Workflow engine - `ghcr.io/openstatusHQ/openstatus-private-location:latest` - Private monitoring agent - `ghcr.io/openstatusHQ/openstatus-status-page:latest` - Public status page - `ghcr.io/openstatusHQ/openstatus-checker:latest` - Monitoring checker service ## Coolify Setup ### 1. Create a New Application 1. In Coolify, click "Add New" → "Application" 2. Choose "Docker" as the application type 3. Select "Public Repository" for the image source ### 2. Configure Each Service #### Server Service - **Image**: `ghcr.io/openstatusHQ/openstatus-server:latest` - **Port**: 3000 - **Environment Variables**: ```yaml DATABASE_URL: http://libsql:8080 PORT: 3000 # Add other required variables from .env.docker ``` #### Dashboard Service - **Image**: `ghcr.io/openstatusHQ/openstatus-dashboard:latest` - **Port**: 3000 - **Environment Variables**: ```yaml DATABASE_URL: http://libsql:8080 PORT: 3000 HOSTNAME: 0.0.0.0 AUTH_TRUST_HOST: true ``` #### Workflows Service - **Image**: `ghcr.io/openstatusHQ/openstatus-workflows:latest` - **Port**: 3000 - **Environment Variables**: ```yaml DATABASE_URL: http://libsql:8080 PORT: 3000 ``` #### Private Location Service - **Image**: `ghcr.io/openstatusHQ/openstatus-private-location:latest` - **Port**: 8080 - **Environment Variables**: ```yaml DB_URL: http://libsql:8080 TINYBIRD_URL: http://tinybird-local:7181 GIN_MODE: release PORT: 8080 ``` #### Checker Service - **Image**: `ghcr.io/openstatusHQ/openstatus-checker:latest` - **Port**: 8080 - **Environment Variables**: ```yaml DATABASE_URL: http://libsql:8080 PORT: 8080 ``` #### Status Page Service - **Image**: `ghcr.io/openstatusHQ/openstatus-status-page:latest` - **Port**: 3000 - **Environment Variables**: ```yaml DATABASE_URL: http://libsql:8080 PORT: 3000 HOSTNAME: 0.0.0.0 AUTH_TRUST_HOST: true ``` ### 3. External Dependencies #### LibSQL Database - **Image**: `ghcr.io/tursodatabase/libsql-server:latest` - **Port**: 8080 - **Environment Variables**: ```yaml SQLD_NODE: primary ``` #### TinyBird (Optional) - **Image**: `tinybirdco/tinybird-local:latest` - **Port**: 7181 - **Environment Variables**: ```yaml COMPATIBILITY_MODE: 1 ``` ### 4. Network Configuration 1. Create a shared network for all services 2. Ensure services can communicate using container names 3. Configure health checks for each service ### 5. Deployment Order Deploy services in this order: 1. LibSQL (database) 2. TinyBird (if used) 3. Workflows 4. Server 5. Private Location 6. Checker 7. Dashboard 8. Status Page ## Environment Variables Create a `.env` file with all required variables. Refer to `.env.docker.example` in the repository for the complete list. ## Health Checks All images include built-in health checks: - **Server/Workflows**: `curl -f http://localhost:3000/ping` - **Dashboard/Status Page**: `curl -f http://localhost:3000/` - **Private Location**: `wget --spider -q http://localhost:8080/health` - **Checker**: `curl -f http://localhost:8080/health` ## Version Management - `latest` tag points to the latest main branch build - Specific commits are tagged with their SHA - Use specific tags for production deployments ## Troubleshooting ### Image Pull Issues Ensure Coolify has access to GitHub Container Registry: 1. Add GitHub token as a registry credential in Coolify 2. Use `GITHUB_TOKEN` with `packages: read` permissions ### Service Communication - Verify all services are on the same network - Check container names match the configuration - Ensure ports are correctly mapped ### Database Connection - Wait for LibSQL to be fully healthy before starting other services - Verify the DATABASE_URL format: `http://libsql:8080` ## Updates Images are automatically built and pushed when: - Code is pushed to the main branch - Manual workflow dispatch is triggered To update in Coolify: 1. Pull new images 2. Redeploy services in the correct order 3. Verify health checks pass ## Support - Check the GitHub Actions workflows for build status - Review container logs in Coolify - Open an issue for deployment problems ================================================ FILE: COOLIFY_ENVIRONMENT_GUIDE.md ================================================ # Coolify Environment Variables Setup Guide This guide explains how to configure environment variables for OpenStatus deployment in Coolify. ## 🚀 Quick Setup ### Step 1: Import the Stack 1. In Coolify dashboard, click **"New Service"** → **"Docker Compose"** 2. Choose **"Import from URL"** and enter: ``` https://raw.githubusercontent.com/openstatusHQ/openstatus/main/coolify-deployment.yaml ``` 3. Click **"Deploy"** ### Step 2: Configure Environment Variables 1. After deployment, click on the **OpenStatus stack** 2. Go to **"Settings"** → **"Environment Variables"** 3. Add the required variables below ## 🔧 Required Environment Variables ### **Core Database & Authentication** ```bash # Database Connection DATABASE_URL=http://libsql:8080 DATABASE_AUTH_TOKEN= # Authentication AUTH_SECRET=your-32-character-secret-here NEXT_PUBLIC_URL=https://your-domain.com SELF_HOST=true ``` ### **Email Service (Required for Login)** ```bash RESEND_API_KEY=re_your_resend_api_key_here ``` ## 🔧 Optional Environment Variables ### **Analytics (TinyBird)** ```bash TINY_BIRD_API_KEY=your_tinybird_api_key TINYBIRD_URL=http://tinybird:7181 ``` ### **OAuth Providers** ```bash # GitHub OAuth AUTH_GITHUB_ID=your_github_oauth_id AUTH_GITHUB_SECRET=your_github_oauth_secret # Google OAuth AUTH_GOOGLE_ID=your_google_oauth_id AUTH_GOOGLE_SECRET=your_google_oauth_secret ``` ### **Redis & Queue (Optional)** ```bash UPSTASH_REDIS_REST_URL=http://localhost:6379 UPSTASH_REDIS_REST_TOKEN=your_redis_token QSTASH_CURRENT_SIGNING_KEY=your_qstash_key QSTASH_NEXT_SIGNING_KEY=your_qstash_key QSTASH_TOKEN=your_qstash_token QSTASH_URL=https://qstash.upstash.io/v1/publish/ ``` ### **Google Cloud (Optional)** ```bash GCP_PROJECT_ID=your_gcp_project_id GCP_LOCATION=your_gcp_location GCP_CLIENT_EMAIL=your_gcp_service_account GCP_PRIVATE_KEY=your_gcp_private_key CRON_SECRET=your_cron_secret ``` ### **API Keys (Optional)** ```bash UNKEY_API_ID=your_unkey_api_id UNKEY_TOKEN=your_unkey_token SUPER_ADMIN_TOKEN=your_super_admin_token FLY_REGION=self-hosted ``` ### **Stripe (Optional)** ```bash STRIPE_SECRET_KEY=sk_test_your_stripe_secret STRIPE_WEBHOOK_SECRET_KEY=whsec_your_webhook_secret NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key ``` ### **Vercel (Optional)** ```bash PROJECT_ID_VERCEL=your_vercel_project_id TEAM_ID_VERCEL=your_vercel_team_id VERCEL_AUTH_BEARER_TOKEN=your_vercel_token BLOB_READ_WRITE_TOKEN=your_vercel_blob_token ``` ### **Observability (Optional)** ```bash NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn SENTRY_AUTH_TOKEN=your_sentry_auth_token NEXT_PUBLIC_OPENPANEL_CLIENT_ID=your_openpanel_client_id OPENPANEL_CLIENT_SECRET=your_openpanel_client_secret PAGERDUTY_APP_ID=your_pagerduty_app_id SLACK_SUPPORT_WEBHOOK_URL=your_slack_webhook_url TELEGRAM_BOT_TOKEN=your_telegram_bot_token ``` ### **External Services (Optional)** ```bash OPENSTATUS_INGEST_URL=https://openstatus-private-location.fly.dev SCREENSHOT_SERVICE_URL=your_screenshot_service_url ``` ### **Development & Testing (Optional)** ```bash TURBO_ENV_MODE=loose PLAYGROUND_UNKEY_API_KEY=your_playground_key WORKSPACES_LOOKBACK_30=true WORKSPACES_HIDE_URL=false ``` ## 🔍 Environment Variable Visibility in Coolify ### **Where to Find Environment Variables** 1. **Stack Settings**: Click on your OpenStatus stack 2. **Environment Tab**: Look for "Environment Variables" section 3. **Add Variables**: Click "Add Variable" for each required variable 4. **Apply Changes**: Save and redeploy the stack ### **Variable Categories in Coolify UI** - **Database**: `DATABASE_URL`, `DATABASE_AUTH_TOKEN` - **Authentication**: `AUTH_SECRET`, `NEXT_PUBLIC_URL`, `SELF_HOST` - **Email**: `RESEND_API_KEY` - **Analytics**: `TINY_BIRD_API_KEY`, `TINYBIRD_URL` - **OAuth**: `AUTH_GITHUB_*`, `AUTH_GOOGLE_*` - **Queue**: `UPSTASH_*`, `QSTASH_*` - **Cloud**: `GCP_*`, `CRON_SECRET` - **API Keys**: `UNKEY_*`, `SUPER_ADMIN_TOKEN`, `FLY_REGION` - **Payments**: `STRIPE_*`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - **Vercel**: `PROJECT_ID_VERCEL`, `TEAM_ID_VERCEL`, `VERCEL_*` - **Observability**: `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_*`, `OPENPANEL_*` - **External**: `OPENSTATUS_INGEST_URL`, `SCREENSHOT_SERVICE_URL` - **Development**: `TURBO_ENV_MODE`, `PLAYGROUND_*`, `WORKSPACES_*` ## 🚨 Troubleshooting ### **Environment Variables Not Working?** 1. **Check Stack Logs**: Look for environment variable errors 2. **Verify Variable Names**: Ensure exact match with this guide 3. **Redeploy**: Sometimes requires full stack restart 4. **Check Coolify Docs**: Some variables may need special handling ### **Common Issues** - **DATABASE_URL**: Must be `http://libsql:8080` (internal container communication) - **AUTH_SECRET**: Must be at least 32 characters - **NEXT_PUBLIC_URL**: Must include protocol (http:// or https://) - **RESEND_API_KEY**: Required for email login functionality ### **Getting Help** - **Coolify Documentation**: Check Coolify's environment variables guide - **OpenStatus Docs**: Visit [OpenStatus Documentation](https://github.com/openstatusHQ/openstatus) - **Community Support**: Open an issue in the GitHub repository ## 🎯 Production Deployment ### **Minimum Required Variables** For a basic working deployment, you only need: ```bash DATABASE_URL=http://libsql:8080 DATABASE_AUTH_TOKEN= AUTH_SECRET=your-32-char-secret NEXT_PUBLIC_URL=https://your-domain.com RESEND_API_KEY=your-resend-key ``` ### **Testing Your Setup** 1. Deploy with minimum variables first 2. Check if services start correctly 3. Add optional variables incrementally 4. Test each feature as you add variables This ensures a smooth, working deployment! 🚀 ================================================ FILE: COOLIFY_SETUP.md ================================================ # Coolify Setup Guide This guide provides step-by-step instructions for deploying OpenStatus on Coolify using pre-built Docker images. ## Quick Start ### Option 1: Import Complete Stack 1. In Coolify dashboard, click **"New Service"** → **"Docker Compose"** 2. Choose **"Import from URL"** and enter: ``` https://raw.githubusercontent.com/openstatusHQ/openstatus/main/coolify-deployment.yaml ``` 3. Configure your environment variables 4. Click **"Deploy"** ### Option 2: Manual Service Setup Create each service individually using the configurations below. ## Environment Setup ### Setup 1. **Copy the example file**: ```bash cp .env.docker.example .env.docker ``` 2. **Edit the file** with your values: ```bash nano .env.docker ``` 3. **Required variables**: - `DATABASE_URL=http://libsql:8080` - `AUTH_SECRET=your-32-char-secret` - `RESEND_API_KEY=your-resend-key` - `NEXT_PUBLIC_URL=http://your-domain:3002` ## Service Configurations ### 1. Database Services #### LibSQL Database - **Image**: `ghcr.io/tursodatabase/libsql-server:latest` - **Name**: `openstatus-libsql` - **Port**: `8080` - **Environment Variables**: ``` SQLD_NODE=primary ``` - **Volumes**: `openstatus-libsql-data:/var/lib/sqld` - **Health Check**: `curl -f http://localhost:8080` #### TinyBird (Optional) - **Image**: `tinybirdco/tinybird-local:latest` - **Name**: `openstatus-tinybird` - **Port**: `7181` - **Environment Variables**: ``` COMPATIBILITY_MODE=1 ``` ### 2. Core Services #### Workflows Engine - **Image**: `ghcr.io/openstatusHQ/openstatus-workflows:latest` - **Name**: `openstatus-workflows` - **Port**: `3000` - **Environment Variables**: ``` DATABASE_URL=http://libsql:8080 PORT=3000 NODE_ENV=production ``` - **Volumes**: `./data:/app/data` - **Depends On**: `openstatus-libsql` #### API Server - **Image**: `ghcr.io/openstatusHQ/openstatus-server:latest` - **Name**: `openstatus-server` - **Port**: `3001` - **Environment Variables**: ``` DATABASE_URL=http://libsql:8080 PORT=3000 NODE_ENV=production ``` - **Depends On**: `openstatus-workflows`, `openstatus-libsql` #### Private Location Agent - **Image**: `ghcr.io/openstatusHQ/openstatus-private-location:latest` - **Name**: `openstatus-private-location` - **Port**: `8081` - **Environment Variables**: ``` DB_URL=http://libsql:8080 TINYBIRD_URL=http://tinybird:7181 GIN_MODE=release PORT=8080 NODE_ENV=production ``` - **Depends On**: `openstatus-server` #### Checker Service - **Image**: `ghcr.io/openstatusHQ/openstatus-checker:latest` - **Name**: `openstatus-checker` - **Port**: `8082` - **Environment Variables**: ``` DATABASE_URL=http://libsql:8080 PORT=8080 NODE_ENV=production ``` - **Depends On**: `openstatus-server` #### Web Dashboard - **Image**: `ghcr.io/openstatusHQ/openstatus-dashboard:latest` - **Name**: `openstatus-dashboard` - **Port**: `3002` - **Environment Variables**: ``` DATABASE_URL=http://libsql:8080 PORT=3000 HOSTNAME=0.0.0.0 AUTH_TRUST_HOST=true NODE_ENV=production ``` - **Depends On**: `openstatus-workflows`, `openstatus-libsql`, `openstatus-server` #### Status Page - **Image**: `ghcr.io/openstatusHQ/openstatus-status-page:latest` - **Name**: `openstatus-status-page` - **Port**: `3003` - **Environment Variables**: ``` DATABASE_URL=http://libsql:8080 PORT=3000 HOSTNAME=0.0.0.0 AUTH_TRUST_HOST=true NODE_ENV=production ``` - **Depends On**: `openstatus-workflows`, `openstatus-libsql`, `openstatus-server` ## Environment Variables Create a `.env.docker` file with the following variables: ```bash # Database DATABASE_URL=http://libsql:8080 # Authentication NEXTAUTH_SECRET=your-secret-key-here NEXTAUTH_URL=http://localhost:3002 # External Services RESEND_API_KEY=your-resend-api-key UPSTASH_REDIS_REST_URL=your-upstash-url UPSTASH_REDIS_REST_TOKEN=your-upstash-token # TinyBird TINYBIRD_TOKEN=your-tinybird-token TINYBIRD_URL=http://tinybird:7181 # Other ENCRYPTION_KEY=your-encryption-key ``` ## Network Configuration 1. **Create Network**: In Coolify, create a network named `openstatus` 2. **Attach Services**: Ensure all services are attached to this network 3. **Service Discovery**: Services can communicate using container names as hostnames ## Deployment Order Deploy services in this sequence: 1. **libsql** (Database) 2. **tinybird** (Optional - Analytics) 3. **workflows** (Workflow Engine) 4. **server** (API Server) 5. **private-location** (Monitoring Agent) 6. **checker** (Health Checker) 7. **dashboard** (Web Interface) 8. **status-page** (Public Status) ## Resource Allocation Recommended resource limits for each service: | Service | Memory Limit | Memory Reservation | |----------|---------------|-------------------| | libsql | 512MB | 256MB | | tinybird | 1GB | 512MB | | workflows | 512MB | 256MB | | server | 512MB | 256MB | | private-location | 256MB | 128MB | | checker | 256MB | 128MB | | dashboard | 512MB | 256MB | | status-page | 512MB | 256MB | ## Health Checks All services include built-in health checks: - **API Services**: `curl -f http://localhost:3000/ping` - **Web Services**: `curl -f http://localhost:3000/` - **Private Location**: `wget --spider -q http://localhost:8080/health` ## Access URLs After deployment, access services at: - **Dashboard**: `http://your-domain:3002` - **API Server**: `http://your-domain:3001` - **Status Page**: `http://your-domain:3003` - **Workflows**: `http://your-domain:3000` ## Troubleshooting ### Common Issues 1. **Service Not Starting** - Check environment variables - Verify network connectivity - Review service logs 2. **Database Connection Failed** - Ensure libsql is healthy first - Check DATABASE_URL format - Verify network configuration 3. **Health Check Failing** - Wait for full startup (30-60 seconds) - Check if required ports are available - Review service dependencies ### Log Locations In Coolify, access logs via: 1. Service → **Logs** tab 2. **Real-time** streaming logs 3. **Historical** log files ### Updates To update services: 1. Pull new images in Coolify 2. Redeploy affected services 3. Verify health checks pass ## Production Considerations 1. **SSL/TLS**: Configure HTTPS in Coolify 2. **Backups**: Enable automated backups for database 3. **Monitoring**: Set up external monitoring 4. **Scaling**: Configure resource limits based on usage 5. **Security**: Update images regularly, use secrets management ## Support - **Documentation**: [OpenStatus Docs](https://docs.openstatus.dev) - **Issues**: [GitHub Issues](https://github.com/openstatusHQ/openstatus/issues) - **Community**: [Discord](https://www.openstatus.dev/discord) ================================================ FILE: DOCKER.md ================================================ # Docker Setup Guide Complete guide for running OpenStatus with Docker ## Quick Start ```bash # 1. Copy environment file cp .env.docker.example .env.docker # 2. Configure required variables (see Configuration section) vim .env.docker # 3. Build and start services (migrations will run automatically) export DOCKER_BUILDKIT=1 docker compose up -d # 4. Check service health docker compose ps # 5. (Optional) Seed database with test data docker run --rm --network openstatus \ -e DATABASE_URL=http://libsql:8080 \ $(docker build -q -f apps/workflows/Dockerfile --target build .) \ sh -c "cd /app/packages/db && bun src/seed.mts" # 6. (Optional) Deploy Tinybird local - requires tb CLI cd packages/tinybird tb --local deploy # 7. Access the application open http://localhost:3002 # Dashboard open http://localhost:3003 # Status Page Theme Explorer # Note: Status pages are accessed via subdomain/slug (e.g., http://localhost:3003/status) ``` ## Cleanup ```bash # Remove stopped containers docker compose down # Remove volumes docker compose down -v # Clean build cache docker builder prune ``` ## Services | Service | Port | Purpose | |---------|------|---------| | workflows | 3000 | Background jobs | | server | 3001 | API backend (tRPC) | | dashboard | 3002 | Admin interface | | status-page | 3003 | Public status pages | | private-location | 8081 | Monitoring agent | | libsql | 8080 | Database (HTTP) | | libsql | 5001 | Database (gRPC) | | tinybird-local | 7181 | Analytics | ## Architecture ``` ┌─────────────┐ ┌─────────────┐ │ Dashboard │────▶│ Server │ │ (Next.js) │ │ (Bun) │ └─────────────┘ └─────────────┘ │ │ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ Status Page │ │ Workflows │ │ (Next.js) │ │ (Bun) │ └─────────────┘ └─────────────┘ │ │ └────────┬───────────┘ ▼ ┌─────────────┐ │ LibSQL │ │ (Database) │ └─────────────┘ ``` ## Database Setup ### Automatic Migrations Migrations run **automatically** when you start the stack with `docker compose up -d`. **Verifying migrations:** ```bash # Check workflows logs for migration output docker compose logs workflows | grep -A 5 "Running database migrations" # Should show: # openstatus-workflows | Running database migrations... # openstatus-workflows | Migrated successfully # openstatus-workflows | Starting workflows service... ``` **Manual migration:** If you need to re-run migrations or troubleshoot: ```bash # Run migrations using workflows container docker compose exec workflows sh -c "cd /app/packages/db && bun src/migrate.mts" # Or restart workflows to trigger migrations again docker compose restart workflows ``` ### Seeding Test Data (Optional) **Note:** Migrations run automatically, but seeding does **not**. You must manually seed the database if you want test data. After migrations complete, seed the database with sample data: ```bash docker run --rm --network openstatus \ -e DATABASE_URL=http://libsql:8080 \ $(docker build -q -f apps/workflows/Dockerfile --target build .) \ sh -c "cd /app/packages/db && bun src/seed.mts" ``` This creates: - 3 workspaces (`love-openstatus`, `test2`, `test3`) - 5 sample monitors and 1 status page with slug `status` - Test user account: `ping@openstatus.dev` - Sample incidents, status reports, and maintenance windows **Verifying seeded data:** ```bash # Check table counts via libsql HTTP API curl -s http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["SELECT COUNT(*) FROM page"]}' | jq -r '.[0].results.rows[0][0]' # Should output: 1 ``` **Accessing Seeded Data:** After seeding, you can access the test data: **Dashboard:** 1. Navigate to http://localhost:3002/login 2. Use magic link authentication with email: `ping@openstatus.dev` 3. Check your console/logs for the magic link (with `SELF_HOST=true` in `.env.docker`) 4. After logging in, you'll see the `love-openstatus` workspace with all seeded monitors and status page **Status Page:** - The seeded status page has slug `status` - Access it via subdomain routing: http://status.localhost:3003 - Or view theme explorer at: http://localhost:3003 **If you use a different email address**, the system will create a new empty workspace for you instead of showing the seeded data. To access seeded data with a different account, you must add your user to the seeded workspace using SQL: ```bash # First, find your user_id curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["SELECT id, email FROM user"]}' # Then add association (replace USER_ID with your id) curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["INSERT INTO users_to_workspaces (user_id, workspace_id, role) VALUES (USER_ID, 1, '\''owner'\'')"]}' ``` ## Tinybird Setup (Optional) Tinybird is used for analytics and monitoring metrics. The application will work without it, but analytics features will be unavailable. If you want to enable analytics, you can: 1. Use Tinybird Cloud and configure `TINY_BIRD_API_KEY` in `.env.docker` 2. Manually configure Tinybird Local (requires additional setup beyond this guide) ## Configuration ### Required Environment Variables Edit `.env.docker` and set: ```bash # Authentication AUTH_SECRET=your-secret-here # Database DATABASE_URL=http://libsql:8080 DATABASE_AUTH_TOKEN=basic:token # Email RESEND_API_KEY=test ``` ### Optional Services Configure these for full functionality: ```bash # Redis UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= # Analytics TINY_BIRD_API_KEY= # OAuth providers AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= ``` See [.env.docker.example](.env.docker.example) for complete list. ## Development Workflow ### Common Commands ```bash # View logs docker compose logs -f [service-name] # Restart service docker compose restart [service-name] # Rebuild after code changes docker compose up -d --build [service-name] # Stop all services docker compose down # Reset database (removes all data) docker compose down -v docker compose up -d # Migrations run automatically on startup ``` ### Authentication **Magic Link**: Set `SELF_HOST=true` in `.env.docker` to enable email-based magic link authentication. This allows users to sign in without configuring OAuth providers. **OAuth Providers**: Configure GitHub/Google OAuth credentials in `.env.docker` and set up callback URLs: - GitHub: `http://localhost:3002/api/auth/callback/github` - Google: `http://localhost:3002/api/auth/callback/google` ### Creating Status Pages **Via Dashboard (Recommended)**: 1. Login to http://localhost:3002 2. Create a workspace 3. Create a status page with a slug 4. Access at http://localhost:3003/[slug] **Via Database (Testing)**: ```bash # Insert test data curl -s http://localhost:8080/v2/pipeline \ -H 'Content-Type: application/json' \ --data-raw '{ "requests":[{ "type":"execute", "stmt":{ "sql":"INSERT INTO workspace (id, slug, name) VALUES (1, '\''test'\'', '\''Test Workspace'\'');" } }] }' ``` ### Resource Limits Add to `docker-compose.yaml`: ```yaml services: dashboard: deploy: resources: limits: cpus: '1.0' memory: 1G reservations: cpus: '0.5' memory: 512M ``` ## Monitoring ### Health Checks All services have automated health checks: ```bash # View health status docker compose ps # Inspect specific service docker inspect openstatus-dashboard --format='{{.State.Health.Status}}' ``` ## Getting Help - **Documentation**: [docs.openstatus.dev](https://docs.openstatus.dev) - **Discord**: [openstatus.dev/discord](https://www.openstatus.dev/discord) - **GitHub Issues**: [github.com/openstatusHQ/openstatus/issues](https://github.com/openstatusHQ/openstatus/issues) ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) <year> <name of author> This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>. ================================================ FILE: README.md ================================================ <p align="center" style="margin-top: 120px"> <h3 align="center">openstatus</h3> <p align="center">The open-source status page and uptime monitoring platform. <br /> <a href="https://www.openstatus.dev"><strong>Learn more »</strong></a> <br /> <br /> <a href="https://docs.openstatus.dev">Documentation</a> · <a href="https://www.openstatus.dev">Website</a> · <a href="https://www.openstatus.dev/discord">Discord</a> </p> <p align="center"> <a href="https://status.openstatus.dev"><img src="https://status.openstatus.dev/badge/v2?variant=outline" alt="openstatus status"></a> </p> <p align="center"> <a href="https://github.com/openstatushq/openstatus/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License"></a> <a href="https://github.com/openstatushq/openstatus/stargazers"><img src="https://img.shields.io/github/stars/openstatushq/openstatus?style=social" alt="GitHub stars"></a> <a href="https://www.openstatus.dev/discord"><img src="https://img.shields.io/discord/1129008226264940625?color=7289da&logo=discord&logoColor=white" alt="Discord"></a> </p> ## About openstatus openstatus is an open-source platform that combines **status pages** and **uptime monitoring** in a single tool. Keep your users informed and your services reliable. Available as a managed service or self-hosted. ### Status pages Beautiful, customizable status pages with custom domains, password protection, maintenance windows, and subscriber notifications via email and RSS. Build trust and keep your users informed during incidents. ### Synthetic monitoring Monitor your servers, websites and APIs from 28 regions across multiple cloud providers globally. Get notified via Slack, Discord, PagerDuty, email, and more when your services are down or slow. ## Recognitions <a href="https://trendshift.io/repositories/1780" target="_blank"><img src="https://trendshift.io/api/badge/repositories/1780" alt="openstatus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://news.ycombinator.com/item?id=37740870"><img alt="Featured on Hacker News" src="https://hackerbadge.now.sh/api?id=37740870" style="width: 250px; height: 55px;" width="250" height="55" /></a> <a href="https://www.producthunt.com/posts/openstatus-2?utm_source=badge-top-post-badge&utm_medium=badge" target="_blank"><img alt="openstatus - #2 Product of the Day on Product Hunt" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=openstatus-2&theme=light&period=daily" style="width: 250px; height: 55px;" width="250" height="55" /></a> ## Getting Started ### With Docker (Recommended) The fastest way to get started for both development and self-hosting: ```sh # 1. Copy environment file cp .env.docker.example .env.docker # 2. Start all services docker compose up -d # 3. Access the application open http://localhost:3002 # Dashboard open http://localhost:3003 # Status Pages ``` Full guide: [DOCKER.md](DOCKER.md) ### Self-Hosting with Coolify We provide pre-built Docker images for easy deployment: ```bash ghcr.io/openstatushq/openstatus-server:latest ghcr.io/openstatushq/openstatus-dashboard:latest ghcr.io/openstatushq/openstatus-workflows:latest ghcr.io/openstatushq/openstatus-private-location:latest ghcr.io/openstatushq/openstatus-status-page:latest ghcr.io/openstatushq/openstatus-checker:latest ``` [Complete Coolify Deployment Guide](./COOLIFY_DEPLOYMENT.md) ### Manual Setup #### Requirements - [Node.js](https://nodejs.org/en/) >= 20.0.0 - [pnpm](https://pnpm.io/) >= 8.6.2 - [Bun](https://bun.sh/) - [Turso CLI](https://docs.turso.tech/quickstart) #### Setup 1. Clone the repository ```sh git clone https://github.com/openstatushq/openstatus.git ``` 2. Install dependencies ```sh pnpm install ``` 3. Initialize the development environment Launch the database in one terminal: ```sh turso dev --db-file openstatus-dev.db ``` In another terminal, run the following command: ```sh pnpm dx ``` 4. Launch whatever app you wish to: ```sh pnpm dev:web pnpm dev:status-page pnpm dev:dashboard ``` The above commands will automatically run the libSQL client on `8080` so you might want to kill the turso command from step 3. 5. See the results: - open [http://localhost:3000](http://localhost:3000) (default port) ## Tech Stack - [Next.js](https://nextjs.org/) - Dashboard - [Hono](https://hono.dev/) - API server - [Go](https://go.dev/) - Checker - [Turso](https://turso.tech/) - Database - [Drizzle](https://orm.drizzle.team/) - ORM - [Tinybird](https://tinybird.co/?ref=openstatus.dev) - Analytics - [Tailwind CSS](https://tailwindcss.com/) - Styling - [shadcn/ui](https://ui.shadcn.com/) - UI components ## Contributing If you want to help us build the best status page and monitoring platform, check our [contributing guidelines](https://github.com/openstatusHQ/openstatus/blob/main/CONTRIBUTING.MD). <a href="https://github.com/openstatushq/openstatus/graphs/contributors"> <img src="https://contrib.rocks/image?repo=openstatushq/openstatus" /> </a> ![openstatus repository activity](https://repobeats.axiom.co/api/embed/180eee159c0128f683a30f15f51ac35bdbd9fa44.svg "Repobeats analytics image") ## Contact Interested in our enterprise plan or need special features? Email us at [ping@openstatus.dev](mailto:ping@openstatus.dev) or book a call. <a href="https://cal.com/team/openstatus/30min"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a> ## License Distributed under the [AGPL-3.0 License](LICENSE). ================================================ FILE: SECURITY.md ================================================ # Reporting Security Issues The openstatus team takes security bugs in opnestatus seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. To report a security issue, please reach out to us via email at [ping@openstatus](mailto:ping@openstatus.dev) or on Discord ## Please do the following: - Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you. - Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data, - Do not reveal the problem to others until it has been resolved, - Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties, - Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation. ## What we promise: - We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date, - If you have followed the instructions above, we will not take any legal action against you in regard to the report, - We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission, - We will keep you informed of the progress towards resolving the problem, - In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and - We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved. ================================================ FILE: apps/README.md ================================================ # Apps All openstatus apps ================================================ FILE: apps/checker/.gitignore ================================================ tmp ================================================ FILE: apps/checker/.golangci.yml ================================================ version: "2" linters: default: fast ================================================ FILE: apps/checker/.private.air.toml ================================================ root = "." testdata_dir = "testdata" tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/private/main.go" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" poll = false poll_interval = 0 post_cmd = [] pre_cmd = [] rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false [color] app = "" build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [misc] clean_on_exit = false [screen] clear_on_rebuild = false keep_scroll = true ================================================ FILE: apps/checker/.probe.air.toml ================================================ root = "." testdata_dir = "testdata" tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/server/main.go" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" poll = false poll_interval = 0 post_cmd = [] pre_cmd = [] rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false [color] app = "" build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [misc] clean_on_exit = false [screen] clear_on_rebuild = false keep_scroll = true ================================================ FILE: apps/checker/Dockerfile ================================================ FROM golang:1.26-alpine as builder WORKDIR /go/src/app COPY ca/ca-bundle.crt /usr/local/share/ca-certificates/ca-bundle.crt RUN apk update \ && apk upgrade --available \ && update-ca-certificates RUN apk add --no-cache tzdata ENV TZ=UTC ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 COPY go.* . RUN go mod download COPY . . RUN go build -trimpath -ldflags "-s -w" -o checker ./cmd/server/main.go FROM scratch WORKDIR /opt/bin COPY --from=builder /usr/local/share/ca-certificates/ca-bundle.crt /etc/ssl/certs/ca-bundle.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /go/src/app/checker/main /opt/bin/checker ENV TZ=UTC ENV USER=1000 ENV GIN_MODE=release CMD [ "/opt/bin/checker" ] ================================================ FILE: apps/checker/README.md ================================================ # OpenStatus Checker The checker service to ping external service. It pings the service and save thedata to the tinybird ## How to run ```bash go run cmd/main.go ``` you can also set the env variable ```fish set CRON_SECRET YOLO set CLOUD_PROVIDER local set TINYBIRD_TOKEN random ``` ## How to build ```bash go build -o checker *.go ``` ## How to run in docker ```bash docker build -t checker . docker run -p 8080:8080 checker ``` ## How to deploy ```bash fly deploy ``` ## Deploy to all region ```bash fly scale count 35 --region ams,arn,atl,bog,bom,bos,cdg,den,dfw,ewr,eze,fra,gdl,gig,gru,hkg,iad,jnb,lax,lhr,mad,mia,nrt,ord,otp,phx,qro,scl,sjc,sea,sin,syd,waw,yul,yyz ``` ## Deploy to your own infra Use our docker image <https://github.com/openstatusHQ/openstatus/pkgs/container/checker> ================================================ FILE: apps/checker/ca/ca-bundle.crt ================================================ -----BEGIN CERTIFICATE----- MIICPDCCAaUCEHC65B0Q2Sk0tjjKewPMur8wDQYJKoZIhvcNAQECBQAwXzELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G CSqGSIb3DQEBAgUAA4GBALtMEivPLCYATxQT3ab7/AoRhIzzKBxnki98tsX63/Do lbwdj2wsqFHMc9ikwFPwTtYmwHYBV4GSXiHx0bH/59AhWM1pF+NEHJwZRDmJXNyc AA9WjQKZ7aKQRUzkuxCkPfAyAw7xzvjoyVGM5mKf5p/AfbdynMk2OmufTqj/ZA1k -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPTCCAaYCEQDNun9W8N/kvFT+IqyzcqpVMA0GCSqGSIb3DQEBAgUAMF8xCzAJ BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE3MDUGA1UECxMuQ2xh c3MgMSBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw05 NjAxMjkwMDAwMDBaFw0yODA4MDEyMzU5NTlaMF8xCzAJBgNVBAYTAlVTMRcwFQYD VQQKEw5WZXJpU2lnbiwgSW5jLjE3MDUGA1UECxMuQ2xhc3MgMSBQdWJsaWMgUHJp bWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCBnzANBgkqhkiG9w0BAQEFAAOB jQAwgYkCgYEA5Rm/baNWYS2ZSHH2Z965jeu3noaACpEO+jglr0aIguVzqKCbJF0N H8xlbgyw0FaEGIeaBpsQoXPftFg5a27B9hXVqKg/qhIGjTGsf7A01480Z4gJzRQR 4k5FVmkfeAKA2txHkSm7NsljXMXg1y2He6G3MrB7MLoqLzGq7qNn2tsCAwEAATAN BgkqhkiG9w0BAQIFAAOBgQBMP7iLxmjf7kMzDl3ppssHhE16M/+SG/Q2rdiVIjZo EWx8QszznC7EBz8UsA9P/5CSdvnivErpj82ggAr3xSnxgiJduLHdgSOjeyUVRjB5 FvjqBUuUfx3CHMjjt/QQQDwTw18fU+hI5Ia0e6E1sHslurjTjqs/OJ0ANACY89Fx lA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPDCCAaUCED9pHoGc8JpK83P/uUii5N0wDQYJKoZIhvcNAQEFBQAwXzELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz cyAxIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAxIFB1YmxpYyBQcmlt YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN ADCBiQKBgQDlGb9to1ZhLZlIcfZn3rmN67eehoAKkQ76OCWvRoiC5XOooJskXQ0f zGVuDLDQVoQYh5oGmxChc9+0WDlrbsH2FdWoqD+qEgaNMax/sDTXjzRniAnNFBHi TkVWaR94AoDa3EeRKbs2yWNcxeDXLYd7obcysHswuiovMaruo2fa2wIDAQABMA0G CSqGSIb3DQEBBQUAA4GBAFgVKTk8d6PaXCUDfGD67gmZPCcQcMgMCeazh88K4hiW NWLMv5sneYlfycQJ9M61Hd8qveXbhpxoJeUwfLaJFf5n0a3hUKw8fGJLj7qE1xIV Gx/KXQ/BUpQqEZnae88MNhPVNdwQGVnqlMEAv3WP2fr9dgTbYruQagPZRjXZ+Hxb -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPDCCAaUCEC0b/EoXjaOR6+f/9YtFvgswDQYJKoZIhvcNAQECBQAwXzELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz cyAyIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 MDEyOTAwMDAwMFoXDTI4MDgwMTIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAyIFB1YmxpYyBQcmlt YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN ADCBiQKBgQC2WoujDWojg4BrzzmH9CETMwZMJaLtVRKXxaeAufqDwSCg+i8VDXyh YGt+eSz6Bg86rvYbb7HS/y8oUl+DfUvEerf4Zh+AVPy3wo5ZShRXRtGak75BkQO7 FYCTXOvnzAhsPz6zSvz/S2wj1VCCJkQZjiPDceoZJEcEnnW/yKYAHwIDAQABMA0G CSqGSIb3DQEBAgUAA4GBAIobK/o5wXTXXtgZZKJYSi034DNHD6zt96rbHuSLBlxg J8pFUs4W7z8GZOeUaHxgMxURaa+dYo2jA1Rrpr7l7gUYYAS/QoD90KioHgE796Nc r6Pc5iaAIzy4RHT3Cq5Ji2F4zCS/iIqnDupzGUH9TQPwiNHleI2lKk/2lw0Xd8rY -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPDCCAaUCEAq6HgBiMui0NiZdH3zNiWYwDQYJKoZIhvcNAQEFBQAwXzELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz cyAyIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAyIFB1YmxpYyBQcmlt YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN ADCBiQKBgQC2WoujDWojg4BrzzmH9CETMwZMJaLtVRKXxaeAufqDwSCg+i8VDXyh YGt+eSz6Bg86rvYbb7HS/y8oUl+DfUvEerf4Zh+AVPy3wo5ZShRXRtGak75BkQO7 FYCTXOvnzAhsPz6zSvz/S2wj1VCCJkQZjiPDceoZJEcEnnW/yKYAHwIDAQABMA0G CSqGSIb3DQEBBQUAA4GBAIDToA+IyeVoW4R7gB+nt+MjWBEc9RTwWBKMi99x2ZAk EXyge8N6GRm9cr0gvwA63/rVeszC42JFi8tJg5jBcGnQnl6CjDVHjk8btB9jAa3k ltax7nosZm4XNq8afjgGhixrTcsnkm54vwDVAcCxB8MJqmSFKPKdc57PYDoKHUpI -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPDCCAaUCEDyRMcsf9tAbDpq40ES/Er4wDQYJKoZIhvcNAQEFBQAwXzELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFz cyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTk2 MDEyOTAwMDAwMFoXDTI4MDgwMjIzNTk1OVowXzELMAkGA1UEBhMCVVMxFzAVBgNV BAoTDlZlcmlTaWduLCBJbmMuMTcwNQYDVQQLEy5DbGFzcyAzIFB1YmxpYyBQcmlt YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIGfMA0GCSqGSIb3DQEBAQUAA4GN ADCBiQKBgQDJXFme8huKARS0EN8EQNvjV69qRUCPhAwL0TPZ2RHP7gJYHyX3KqhE BarsAx94f56TuZoAqiN91qyFomNFx3InzPRMxnVx0jnvT0Lwdd8KkMaOIG+YD/is I19wKTakyYbnsZogy1Olhec9vn2a/iRFM9x2Fe0PonFkTGUugWhFpwIDAQABMA0G CSqGSIb3DQEBBQUAA4GBABByUqkFFBkyCEHwxWsKzH4PIRnN5GfcX6kb5sroc50i 2JhucwNhkcV8sEVAbkSdjbCxlnRhLQ2pRdKkkirWmnWXbj9T/UWZYB2oK0z5XqcJ 2HUw19JlYD1n1khVdWk/kfVIC0dpImmClr7JyDiGSnoscxlIaU5rfGW/D/xwzoiQ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDAjCCAmsCEH3Z/gfPqB63EHln+6eJNMYwDQYJKoZIhvcNAQEFBQAwgcExCzAJ BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh c3MgMyBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgMyBQdWJsaWMg UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5 MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB AQUAA4GNADCBiQKBgQDMXtERXVxp0KvTuWpMmR9ZmDCOFoUgRm1HP9SFIIThbbP4 pO0M8RcPO/mn+SXXwc+EY/J8Y8+iR/LGWzOOZEAEaMGAuWQcRXfH2G71lSk8UOg0 13gfqLptQ5GVj0VXXn7F+8qkBOvqlzdUMG+7AUcyM83cV5tkaWH4mx0ciU9cZwID AQABMA0GCSqGSIb3DQEBBQUAA4GBAFFNzb5cy5gZnBWyATl4Lk0PZ3BwmcYQWpSk U01UbSuvDV1Ai2TT1+7eVmGSX6bEHRBhNtMsJzzoKQm5EWR0zLVznxxIqbxhAe7i F6YM40AIOw7n60RzKprxaZLvcRTDOaxxp5EJb+RxBrO6WVcmeQD2+A2iMzAo1KpY oJ2daZH9 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDAjCCAmsCEDKIjprS9esTR/h/xCA3JfgwDQYJKoZIhvcNAQEFBQAwgcExCzAJ BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh c3MgNCBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgNCBQdWJsaWMg UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5 MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB AQUAA4GNADCBiQKBgQC68OTP+cSuhVS5B1f5j8V/aBH4xBewRNzjMHPVKmIquNDM HO0oW369atyzkSTKQWI8/AIBvxwWMZQFl3Zuoq29YRdsTjCG8FE3KlDHqGKB3FtK qsGgtG7rL+VXxbErQHDbWk2hjh+9Ax/YA9SPTJlxvOKCzFjomDqG04Y48wApHwID AQABMA0GCSqGSIb3DQEBBQUAA4GBAIWMEsGnuVAVess+rLhDityq3RS6iYF+ATwj cSGIL4LcY/oCRaxFWdcqWERbt5+BO5JoPeI3JPV7bI92NZYJqFmduc4jq3TWg/0y cyfYaT5DdPauxYma51N86Xv2S/PBZYPejYqcPIiNOVn8qj8ijaHBZlCBckztImRP T8qAkbYp -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDAjCCAmsCEEzH6qqYPnHTkxD4PTqJkZIwDQYJKoZIhvcNAQEFBQAwgcExCzAJ BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xh c3MgMSBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcy MTowOAYDVQQLEzEoYykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3Jp emVkIHVzZSBvbmx5MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMB4X DTk4MDUxODAwMDAwMFoXDTI4MDgwMTIzNTk1OVowgcExCzAJBgNVBAYTAlVTMRcw FQYDVQQKEw5WZXJpU2lnbiwgSW5jLjE8MDoGA1UECxMzQ2xhc3MgMSBQdWJsaWMg UHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMTowOAYDVQQLEzEo YykgMTk5OCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5 MR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMIGfMA0GCSqGSIb3DQEB AQUAA4GNADCBiQKBgQCq0Lq+Fi24g9TK0g+8djHKlNgdk4xWArzZbxpvUjZudVYK VdPfQ4chEWWKfo+9Id5rMj8bhDSVBZ1BNeuS65bdqlk/AVNtmU/t5eIqWpDBucSm Fc/IReumXY6cPvBkJHalzasab7bYe1FhbqZ/h8jit+U03EGI6glAvnOSPWvndQID AQABMA0GCSqGSIb3DQEBBQUAA4GBAKlPww3HZ74sy9mozS11534Vnjty637rXC0J h9ZrbWB85a7FkCMMXErQr7Fd88e2CtvgFZMN3QO8x3aKtd1Pw5sTdbgBwObJW2ul uIncrKTdcu1OofdPvAbT6shkdHvClUGcZXNY8ZCaPGqxmMnEh7zPRW1F4m4iP/68 DzFc6PLZ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDAzCCAmwCEQC5L2DMiJ+hekYJuFtwbIqvMA0GCSqGSIb3DQEBBQUAMIHBMQsw CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xPDA6BgNVBAsTM0Ns YXNzIDIgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBH MjE6MDgGA1UECxMxKGMpIDE5OTggVmVyaVNpZ24sIEluYy4gLSBGb3IgYXV0aG9y aXplZCB1c2Ugb25seTEfMB0GA1UECxMWVmVyaVNpZ24gVHJ1c3QgTmV0d29yazAe Fw05ODA1MTgwMDAwMDBaFw0yODA4MDEyMzU5NTlaMIHBMQswCQYDVQQGEwJVUzEX MBUGA1UEChMOVmVyaVNpZ24sIEluYy4xPDA6BgNVBAsTM0NsYXNzIDIgUHVibGlj IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMjE6MDgGA1UECxMx KGMpIDE5OTggVmVyaVNpZ24sIEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25s eTEfMB0GA1UECxMWVmVyaVNpZ24gVHJ1c3QgTmV0d29yazCBnzANBgkqhkiG9w0B AQEFAAOBjQAwgYkCgYEAp4gBIXQs5xoD8JjhlzwPIQjxnNuX6Zr8wgQGE75fUsjM HiwSViy4AWkszJkfrbCWrnkE8hM5wXuYuggs6MKEEyyqaekJ9MepAqRCwiNPStjw DqL7MWzJ5m+ZJwf15vRMeJ5t60aG+rmGyVTyssSv1EYcWskVMP8NbPUtDm3Of3cC AwEAATANBgkqhkiG9w0BAQUFAAOBgQByLvl/0fFx+8Se9sVeUYpAmLho+Jscg9ji nb3/7aHmZuovCfTK1+qlK5X2JGCGTUQug6XELaDTrnhpb3LabK4I8GOSN+a7xDAX rXfMSTWqz9iP0b63GJZHc2pUIjRkLbYWm1lbtFFZOrMLFPQS32eg9K0yZF6xRnIn jBJ7xUS0rg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG A1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jv b3QgQ0ExGzAZBgNVBAMTEkdsb2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAw MDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i YWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJHbG9iYWxT aWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDaDuaZ jc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavp xy0Sy6scTHAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp 1Wrjsok6Vjk4bwY8iGlbKk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdG snUOhugZitVtbNV4FpWi6cgKOOvyJBNPc1STE4U6G7weNLWLBYy5d4ux2x8gkasJ U26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrXgzT/LCrBbBlDSgeF59N8 9iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E BTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0B AQUFAAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOz yj1hTdNGCbM+w6DjY1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE 38NflNUVyRRBnMRddWQVDf9VMOyGj/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymP AbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhHhm4qxFYxldBniYUr+WymXUad DKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveCX4XSQRjbgbME HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEGjCCAwICEQDsoKeLbnVqAc/EfMwvlF7XMA0GCSqGSIb3DQEBBQUAMIHKMQsw CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZl cmlTaWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWdu LCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlT aWduIENsYXNzIDQgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQswCQYD VQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlT aWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJ bmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWdu IENsYXNzIDQgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK3LpRFpxlmr8Y+1 GQ9Wzsy1HyDkniYlS+BzZYlZ3tCD5PUPtbut8XzoIfzk6AzufEUiGXaStBO3IFsJ +mGuqPKljYXCKtbeZjbSmwL0qJJgfJxptI8kHtCGUvYynEFYHiK9zUVilQhu0Gbd U6LM8BDcVHOLBKFGMzNcF0C5nk3T875Vg+ixiY5afJqWIpA7iCXy0lOIAgwLePLm NxdLMEYH5IBtptiWLugs+BGzOA1mppvqySNb247i8xOOGlktqgLw7KSHZtzBP/XY ufTsgsbSPZUd5cBPhMnZo0QoBmrXRazwa2rvTl/4EYIeOGM0ZlDUPpNz+jDDZq3/ ky2X7wMCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAj/ola09b5KROJ1WrIhVZPMq1 CtRK26vdoV9TxaBXOcLORyu+OshWv8LZJxA6sQU8wHcxuzrTBXttmhwwjIDLk5Mq g6sFUYICABFna/OIYUdfA5PVWw3g8dShMjWFsjrbsIKr0csKvE+MW8VLADsfKoKm fjaF3H48ZwC15DtS4KjrXRX5xm3wrR0OhbepmnMUWluPQSjA1egtTaRezarZ7c7c 2NU8Qh0XwRJdRTjDOPP8hS6DRkiy1yBfkjaP53kPmF6Z6PDQpLv1U70qzlmwr25/ bLvSHgCwIe34QWKCudiyxLtGUPMxxY8BqHTr9Xgn2uf3ZkPznoM+IKrDNWCRzg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEGjCCAwICEQCbfgZJoz5iudXukEhxKe9XMA0GCSqGSIb3DQEBBQUAMIHKMQsw CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZl cmlTaWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWdu LCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlT aWduIENsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQswCQYD VQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlT aWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJ bmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWdu IENsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMu6nFL8eB8aHm8b N3O9+MlrlBIwT/A2R/XQkQr1F8ilYcEWQE37imGQ5XYgwREGfassbqb1EUGO+i2t KmFZpGcmTNDovFJbcCAEWNF6yaRpvIMXZK0Fi7zQWM6NjPXr8EJJC52XJ2cybuGu kxUccLwgTS8Y3pKI6GyFVxEa6X7jJhFUokWWVYPKMIno3Nij7SqAP395ZVc+FSBm CC+Vk7+qRy+oRpfwEuL+wgorUeZ25rdGt+INpsyow0xZVYnm6FNcHOqd8GIWC6fJ Xwzw3sJ2zq/3avL6QaaiMxTJ5Xpj055iN9WFZZ4O5lMkdBteHRJTW8cs54NJOxWu imi5V5cCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAERSWwauSCPc/L8my/uRan2Te 2yFPhpk0djZX3dAVL8WtfxUfN2JzPtTnX84XA9s1+ivbrmAJXx5fj267Cz3qWhMe DGBvtcC1IyIuBwvLqXTLR7sdwdela8wv0kL9Sd2nic9TutoAWii/gt/4uhMdUIaC /Y4wjylGsB49Ndo4YhYYSq3mtlFs3q9i6wHQHiT+eo8SGhJouPtmmRQURVyu565p F4ErWjfJXir0xuKhXFSbplQAz/DxwceYMBo7Nhbbo27q/a2ywtrvAkcTisDxszGt TxzhT5yvDwyd93gN2PQ1VoDat20Xj50egWTh/sVFuq1ruQp6Tk9LhO5L8X3dEQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEGjCCAwICEQCLW3VWhFSFCwDPrzhIzrGkMA0GCSqGSIb3DQEBBQUAMIHKMQsw CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZl cmlTaWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWdu LCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlT aWduIENsYXNzIDEgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQswCQYD VQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlT aWduIFRydXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJ bmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWdu IENsYXNzIDEgUHVibGljIFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN2E1Lm0+afY8wR4 nN493GwTFtl63SRRZsDHJlkNrAYIwpTRMx/wgzUfbhvI3qpuFU5UJ+/EbRrsC+MO 8ESlV8dAWB6jRx9x7GD2bZTIGDnt/kIYVt/kTEkQeE4BdjVjEjbdZrwBBDajVWjV ojYJrKshJlQGrT/KFOCsyq0GHZXi+J3x4GD/wn91K0zM2v6HmSHquv4+VNfSWXjb PG7PoBMAGrgnoeS+Z5bKoMWznN3JdZ7rMJpfo83ZrngZPyPpXNspva1VyBtUjGP2 6KbqxzcSXKMpHgLZ2x87tNcPVkeBFQRKr4Mn0cVYiMHd9qqnoxjaaKptEVHhv2Vr n5Z20T0CAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAq2aN17O6x5q25lXQBfGfMY1a qtmqRiYPce2lrVNWYgFHKkTp/j90CxObufRNG7LRX7K20ohcs5/Ny9Sn2WCVhDr4 wTcdYcrnsMXlkdpUpqwxga6X3s0IrLjAl4B/bnKk52kTlWUfxJM8/XmPBNQ+T+r3 ns7NZ3xPZQL/kYVUc8f/NveGLezQXk//EZ9yBta4GvFMDSZl4kSAHsef493oCtrs pSCAaWihT37ha88HQfqDjrw43bAuEbFrskLMmrz5SCJ5ShkPshw+IHTZasO+8ih4 E1Z5T21Q6huwtVexN2ZYI/PcD98Kh8TvhgXVOBRgmaNL3gaWcSzy27YfpO8/7g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEGTCCAwECEGFwy0mMX5hFKeewptlQW3owDQYJKoZIhvcNAQEFBQAwgcoxCzAJ BgNVBAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjEfMB0GA1UECxMWVmVy aVNpZ24gVHJ1c3QgTmV0d29yazE6MDgGA1UECxMxKGMpIDE5OTkgVmVyaVNpZ24s IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTFFMEMGA1UEAxM8VmVyaVNp Z24gQ2xhc3MgMiBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 eSAtIEczMB4XDTk5MTAwMTAwMDAwMFoXDTM2MDcxNjIzNTk1OVowgcoxCzAJBgNV BAYTAlVTMRcwFQYDVQQKEw5WZXJpU2lnbiwgSW5jLjEfMB0GA1UECxMWVmVyaVNp Z24gVHJ1c3QgTmV0d29yazE6MDgGA1UECxMxKGMpIDE5OTkgVmVyaVNpZ24sIElu Yy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTFFMEMGA1UEAxM8VmVyaVNpZ24g Q2xhc3MgMiBQdWJsaWMgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt IEczMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArwoNwtUs22e5LeWU J92lvuCwTY+zYVY81nzD9M0+hsuiiOLh2KRpxbXiv8GmR1BeRjmL1Za6tW8UvxDO JxOeBUebMXoT2B/Z0wI3i60sR/COgQanDTAM6/c8DyAd3HJG7qUCyFvDyVZpTMUY wZF7C9UTAJu878NIPkZgIIUq1ZC2zYugzDLdt/1AVbJQHFauzI13TccgTacxdu9o koqQHgiBVrKtaaNS0MscxCM9H5n+TOgWY47GCI72MfbS+uV23bUckqNJzc0BzWjN qWm6o+sdDZykIKbBoMXRRkwXbdKsZj+WjOCE1Db/IlnF+RFgqF8EffIa9iVCYQ/E Srg+iQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQA0JhU8wI1NQ0kdvekhktdmnLfe xbjQ5F1fdiLAJvmEOjr5jLX77GDx6M4EsMjdpwOPMPOY36TmpDHf0xwLRtxyID+u 7gU8pDM/CzmscHhzS5kr3zDCVLCoO1Wh/hYozUK9dG6A2ydEp85EXdQbkJgNHkKU sQAsBNB0owIFImNjzYO1+8FtYmtpdf1dcEG59b98377BMnMiIYtYgXsVkXq642RI sH/7NiXaldDxJBQX3RiAa0YjOVT1jmIJBB2UkKab5iXiQkWquJCtvgiPqQtCGJTP cjnhsUPgKM+351psE2tJs//jGHyJizNdrDPXp/naOlXJWBD5qu9ats9LS98q -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChML RW50cnVzdC5uZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBp bmNvcnAuIGJ5IHJlZi4gKGxpbWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5 IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNVBAMTKkVudHJ1c3QubmV0IENlcnRp ZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQxNzUwNTFaFw0yOTA3 MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3d3d3 LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxp YWIuKTElMCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEG A1UEAxMqRW50cnVzdC5uZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgp MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArU1LqRKGsuqjIAcVFmQq K0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOLGp18EzoOH1u3Hs/lJBQe sYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSrhRSGlVuX MlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVT XTzWnLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/ HoZdenoVve8AjhUiVBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH 4QIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJKoZIhvcNAQEFBQADggEBADub j1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPyT/4xmf3IDExo U8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5b u/8j72gZyxKTJ1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+ bYQLCIt+jerXmCHG8+c8eS9enNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/Er fF6adulZkMV8gzURZVE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIIBhDCeat3PfIwDQYJKoZIhvcNAQEFBQAwdjELMAkGA1UE BhMCQ0gxEjAQBgNVBAoTCVN3aXNzU2lnbjEyMDAGA1UEAxMpU3dpc3NTaWduIENB IChSU0EgSUsgTWF5IDYgMTk5OSAxODowMDo1OCkxHzAdBgkqhkiG9w0BCQEWEGNh QFN3aXNzU2lnbi5jb20wHhcNMDAxMTI2MjMyNzQxWhcNMzExMTI2MjMyNzQxWjB2 MQswCQYDVQQGEwJDSDESMBAGA1UEChMJU3dpc3NTaWduMTIwMAYDVQQDEylTd2lz c1NpZ24gQ0EgKFJTQSBJSyBNYXkgNiAxOTk5IDE4OjAwOjU4KTEfMB0GCSqGSIb3 DQEJARYQY2FAU3dpc3NTaWduLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC AQoCggEBAKw5fjnmNneLQlUCQG8jQLwwfbrOZoUwNX8cbNqhxK03/xUloFVgAt+S Te2RxNXaCAXLBPn5ZST35TLV57aLmbHCtifv3YZqaaQGvjedltIBMJihJhZ+h3LY SKsUb+xEJ3x5ZUf8jP+Q1g57y1s8SnBFWN/ni5NkF1Y1y31VwOi9wiOf/VISL+uu SC4i1CP1Kbz3BDs6Hht1GpRYCbJ/K0bc9oJSpWpT5PGONsGIawqMbJuyoDghsXQ1 pbn2e8K64BSscGZVZTNooSGgNiHmACNJBYXiWVWrwXPF4l6SddmC3Rj0aKXjgECc FkHLDQcsM5JsK2ZLryTDUsQFbxVP2ikCAwEAAaNHMEUwCwYDVR0PBAQDAgEGMAwG A1UdEwQFMAMBAf8wHQYDVR0OBBYEFJbXcc05KtT8iLGKq1N4ae+PR34WMAkGA1Ud IwQCMAAwDQYJKoZIhvcNAQEFBQADggEBAKMy6W8HvZdS1fBpEUzl6Lvw50bgE1Xc HU1JypSBG9mhdcXZo5AlPB4sCvx9Dmfwhyrdsshc0TP2V3Vh6eQqnEF5qB4lVziT Bko9mW6Ot+pPnwsy4SHpx3rw6jCYnOqfUcZjWqqqRrq/3P1waz+Mn4cLMVEg3Xaz qYov/khvSqS0JniwjRlo2H6f/1oVUKZvP+dUhpQepfZrOqMAWZW4otp6FolyQyeU NN6UCRNiUKl5vTijbKwUUwfER/1Vci3M1/O1QCfttQ4vRN4Buc0xqYtGL3cd5WiO vWzyhlTzAI6VUdNkQhhHJSAyTpj6dmXDRzrryoFGa2PjgESxz7XBaSI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDYTCCAkmgAwIBAgIQCgEBAQAAAnwAAAAKAAAAAjANBgkqhkiG9w0BAQUFADA6 MRkwFwYDVQQKExBSU0EgU2VjdXJpdHkgSW5jMR0wGwYDVQQLExRSU0EgU2VjdXJp dHkgMjA0OCBWMzAeFw0wMTAyMjIyMDM5MjNaFw0yNjAyMjIyMDM5MjNaMDoxGTAX BgNVBAoTEFJTQSBTZWN1cml0eSBJbmMxHTAbBgNVBAsTFFJTQSBTZWN1cml0eSAy MDQ4IFYzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt49VcdKA3Xtp eafwGFAyPGJn9gqVB93mG/Oe2dJBVGutn3y+Gc37RqtBaB4Y6lXIL5F4iSj7Jylg /9+PjDvJSZu1pJTOAeo+tWN7fyb9Gd3AIb2E0S1PRsNO3Ng3OTsor8udGuorryGl wSMiuLgbWhOHV4PR8CDn6E8jQrAApX2J6elhc5SYcSa8LWrg903w8bYqODGBDSnh AMFRD0xS+ARaqn1y07iHKrtjEAMqs6FPDVpeRrc9DvV07Jmf+T0kgYim3WBU6JU2 PcYJk5qjEoAAVZkZR73QpXzDuvsf9/UP+Ky5tfQ3mBMY3oVbtwyCO4dvlTlYMNpu AWgXIszACwIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB BjAfBgNVHSMEGDAWgBQHw1EwpKrpRa41JPr/JCwz0LGdjDAdBgNVHQ4EFgQUB8NR MKSq6UWuNST6/yQsM9CxnYwwDQYJKoZIhvcNAQEFBQADggEBAF8+hnZuuDU8TjYc HnmYv/3VEhF5Ug7uMYm83X/50cYVIeiKAVQNOvtUudZj1LGqlk2iQk3UUx+LEN5/ Zb5gEydxiKRz44Rj0aRV4VCT5hsOedBnvEbIvz8XDZXmxpBp3ue0L96VfdASPz0+ f00/FGj1EVDVwfSQpQgdMWD/YIwjVAqv/qFuxdF6Kmh4zx6CCiC0H63lhbJqaHVO rSU3lIW+vaHU6rcMSzyd6BIA8F+sDeGscGNz9395nzIlQnQFgCi/vcEkllgVsRch 6YlL2weIZ/QVrXA+L02FO8K32/6YaCOJ4XQP3vTFhGMpG8zLB8kApKnXwiJPZ9d3 7CAFYd4= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDpDCCAoygAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEc MBoGA1UEChMTQW1lcmljYSBPbmxpbmUgSW5jLjE2MDQGA1UEAxMtQW1lcmljYSBP bmxpbmUgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAxMB4XDTAyMDUyODA2 MDAwMFoXDTM3MTExOTIwNDMwMFowYzELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0Ft ZXJpY2EgT25saW5lIEluYy4xNjA0BgNVBAMTLUFtZXJpY2EgT25saW5lIFJvb3Qg Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMTCCASIwDQYJKoZIhvcNAQEBBQADggEP ADCCAQoCggEBAKgv6KRpBgNHw+kqmP8ZonCaxlCyfqXfaE0bfA+2l2h9LaaLl+lk hsmj76CGv2BlnEtUiMJIxUo5vxTjWVXlGbR0yLQFOVwWpeKVBeASrlmLojNoWBym 1BW32J/X3HGrfpq/m44zDyL9Hy7nBzbvYjnF3cu6JRQj3gzGPTzOggjmZj7aUTsW OqMFf6Dch9Wc/HKpoH145LcxVR5lu9RhsCFg7RAycsWSJR74kEoYeEfffjA3PlAb 2xzTa5qGUwew76wGePiEmf4hjUyAtgyC9mZweRrTT6PP8c9GsEsPPt2IYriMqQko O3rHl+Ee5fSfwMCuJKDIodkP1nsmgmkyPacCAwEAAaNjMGEwDwYDVR0TAQH/BAUw AwEB/zAdBgNVHQ4EFgQUAK3Zo/Z59m50qX8zPYEX10zPM94wHwYDVR0jBBgwFoAU AK3Zo/Z59m50qX8zPYEX10zPM94wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB BQUAA4IBAQB8itEfGDeC4Liwo+1WlchiYZwFos3CYiZhzRAW18y0ZTTQEYqtqKkF Zu90821fnZmv9ov761KyBZiibyrFVL0lvV+uyIbqRizBs73B6UlwGBaXCBOMIOAb LjpHyx7kADCVW/RFo8AasAFOq73AI25jP4BKxQft3OJvx8Fi8eNy1gTIdGcL+oir oQHIb/AUr9KZzVGTfu0uOMe9zkZQPXLjeSWdm4grECDdpbgyn43gKd8hdIaC2y+C MMbHNYaz+ZZfRtsMRf3zUMNvxsNIrUam4SdHCh0Om7bCd39j8uB9Gr784N/Xx6ds sPmuujz9dLQR6FgNgLzTqIA6me11zEZ7 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFpDCCA4ygAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEc MBoGA1UEChMTQW1lcmljYSBPbmxpbmUgSW5jLjE2MDQGA1UEAxMtQW1lcmljYSBP bmxpbmUgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAyMB4XDTAyMDUyODA2 MDAwMFoXDTM3MDkyOTE0MDgwMFowYzELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE0Ft ZXJpY2EgT25saW5lIEluYy4xNjA0BgNVBAMTLUFtZXJpY2EgT25saW5lIFJvb3Qg Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP ADCCAgoCggIBAMxBRR3pPU0Q9oyxQcngXssNt79Hc9PwVU3dxgz6sWYFas14tNwC 206B89enfHG8dWOgXeMHDEjsJcQDIPT/DjsS/5uN4cbVG7RtIuOx238hZK+GvFci KtZHgVdEglZTvYYUAQv8f3SkWq7xuhG1m1hagLQ3eAkzfDJHA1zEpYNI9FdWboE2 JxhP7JsowtS013wMPgwr38oE18aO6lhOqKSlGBxsRZijQdEt0sdtjRnxrXm3gT+9 BoInLRBYBbV4Bbkv2wxrkJB+FFk4u5QkE+XRnRTf04JNRvCAOVIyD+OEsnpD8l7e Xz8d3eOyG6ChKiMDbi4BFYdcpnV1x5dhvt6G3NRI270qv0pV2uh9UPu0gBe4lL8B PeraunzgWGcXuVjgiIZGZ2ydEEdYMtA1fHkqkKJaEBEjNa0vzORKW6fIJ/KD3l67 Xnfn6KVuY8INXWHQjNJsWiEOyiijzirplcdIz5ZvHZIlyMbGwcEMBawmxNJ10uEq Z8A9W6Wa6897GqidFEXlD6CaZd4vKL3Ob5Rmg0gp2OpljK+T2WSfVVcmv2/LNzGZ o2C7HK2JNDJiuEMhBnIMoVxtRsX6Kc8w3onccVvdtjc+31D1uAclJuW8tf48ArO3 +L5DwYcRlJ4jbBeKuIonDFRH8KmzwICMoCfrHRnjB453cMor9H124HhnAgMBAAGj YzBhMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFE1FwWg4u3OpaaEg5+31IqEj FNeeMB8GA1UdIwQYMBaAFE1FwWg4u3OpaaEg5+31IqEjFNeeMA4GA1UdDwEB/wQE AwIBhjANBgkqhkiG9w0BAQUFAAOCAgEAZ2sGuV9FOypLM7PmG2tZTiLMubekJcmn xPBUlgtk87FYT15R/LKXeydlwuXK5w0MJXti4/qftIe3RUavg6WXSIylvfEWK5t2 LHo1YGwRgJfMqZJS5ivmae2p+DYtLHe/YUjRYwu5W1LtGLBDQiKmsXeu3mnFzccc obGlHBD7GL4acN3Bkku+KVqdPzW+5X1R+FXgJXUjhx5c3LqdsKyzadsXg8n33gy8 CNyRnqjQ1xU3c6U1uPx+xURABsPr+CKAXEfOAuMRn0T//ZoyzH1kUQ7rVyZ2OuMe IjzCpjbdGe+n/BLzJsBZMYVMnNjP36TMzCmT/5RtdlwTCJfy7aULTd3oyWgOZtMA DjMSW7yV5TKQqLPGbIOtd+6Lfn6xqavT4fG2wLHqiMDn05DpKJKUe2h7lyoKZy2F AjgQ5ANh1NolNscIWC2hp1GvMApJ9aZphwctREZ2jirlmjvXGKL8nDgQzMY70rUX Om/9riW99XJZZLF0KjhfGEzfz3EEWjbUvy+ZnOjZurGV5gJLIaFb1cFPj65pbVPb AZO1XB4Y3WRayhgoPmMEEf0cjQAPuDffZ4qdZqkCapH/E8ovXYO8h5Ns3CRRFgQl Zvqz2cK6Kb6aSDiCmfS/O0oxGfm/jiEzFMpPVF/7zvuPcX/9XhmgD0uRuMRUvAaw RY8mkaKO/qk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID5jCCAs6gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzELMAkGA1UEBhMCVVMx HTAbBgNVBAoTFEFPTCBUaW1lIFdhcm5lciBJbmMuMRwwGgYDVQQLExNBbWVyaWNh IE9ubGluZSBJbmMuMTcwNQYDVQQDEy5BT0wgVGltZSBXYXJuZXIgUm9vdCBDZXJ0 aWZpY2F0aW9uIEF1dGhvcml0eSAxMB4XDTAyMDUyOTA2MDAwMFoXDTM3MTEyMDE1 MDMwMFowgYMxCzAJBgNVBAYTAlVTMR0wGwYDVQQKExRBT0wgVGltZSBXYXJuZXIg SW5jLjEcMBoGA1UECxMTQW1lcmljYSBPbmxpbmUgSW5jLjE3MDUGA1UEAxMuQU9M IFRpbWUgV2FybmVyIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMTCCASIw DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnej8Mlo2k06AX3dLm/WpcZuS+U 0pPlLYnKhHw/EEMbjIt8hFj4JHxIzyr9wBXZGH6EGhfT257XyuTZ16pYUYfw8ItI TuLCxFlpMGK2MKKMCxGZYTVtfu/FsRkGIBKOQuHfD5YQUqjPnF+VFNivO3ULMSAf RC+iYkGzuxgh28pxPIzstrkNn+9R7017EvILDOGsQI93f7DKeHEMXRZxcKLXwjqF zQ6axOAAsNUl6twr5JQtOJyJQVdkKGUZHLZEtMgxa44Be3ZZJX8VHIQIfHNlIAqh BC4aMqiaILGcLCFZ5/vP7nAtCMpjPiybkxlqpMKX/7eGV4iFbJ4VFitNLLMCAwEA AaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUoTYwFsuGkABFgFOxj8jY PXy+XxIwHwYDVR0jBBgwFoAUoTYwFsuGkABFgFOxj8jYPXy+XxIwDgYDVR0PAQH/ BAQDAgGGMA0GCSqGSIb3DQEBBQUAA4IBAQCKIBilvrMvtKaEAEAwKfq0FHNMeUWn 9nDg6H5kHgqVfGphwu9OH77/yZkfB2FK4V1Mza3u0FIy2VkyvNp5ctZ7CegCgTXT Ct8RHcl5oIBN/lrXVtbtDyqvpxh1MwzqwWEFT2qaifKNuZ8u77BfWgDrvq2g+EQF Z7zLBO+eZMXpyD8Fv8YvBxzDNnGGyjhmSs3WuEvGbKeXO/oTLW4jYYehY0KswsuX n2Fozy1MBJ3XJU8KDk2QixhWqJNIV9xvrr2eZ1d3iVCzvhGbRWeDhhmH05i9CBoW H1iCC+GWaQVLjuyDUTEH1dSf/1l7qG6Fz9NLqUmwX7A5KGgOc90lmt4S -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF5jCCA86gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBgzELMAkGA1UEBhMCVVMx HTAbBgNVBAoTFEFPTCBUaW1lIFdhcm5lciBJbmMuMRwwGgYDVQQLExNBbWVyaWNh IE9ubGluZSBJbmMuMTcwNQYDVQQDEy5BT0wgVGltZSBXYXJuZXIgUm9vdCBDZXJ0 aWZpY2F0aW9uIEF1dGhvcml0eSAyMB4XDTAyMDUyOTA2MDAwMFoXDTM3MDkyODIz NDMwMFowgYMxCzAJBgNVBAYTAlVTMR0wGwYDVQQKExRBT0wgVGltZSBXYXJuZXIg SW5jLjEcMBoGA1UECxMTQW1lcmljYSBPbmxpbmUgSW5jLjE3MDUGA1UEAxMuQU9M IFRpbWUgV2FybmVyIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgMjCCAiIw DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALQ3WggWmRToVbEbJGv8x4vmh6mJ 7ouZzU9AhqS2TcnZsdw8TQ2FTBVsRotSeJ/4I/1n9SQ6aF3Q92RhQVSji6UI0ilb m2BPJoPRYxJWSXakFsKlnUWsi4SVqBax7J/qJBrvuVdcmiQhLE0OcR+mrF1FdAOY xFSMFkpBd4aVdQxHAWZg/BXxD+r1FHjHDtdugRxev17nOirYlxcwfACtCJ0zr7iZ YYCLqJV+FNwSbKTQ2O9ASQI2+W6p1h2WVgSysy0WVoaP2SBXgM1nEG2wTPDaRrbq JS5Gr42whTg0ixQmgiusrpkLjhTXUr2eacOGAgvqdnUxCc4zGSGFQ+aJLZ8lN2fx I2rSAG2X+Z/nKcrdH9cG6rjJuQkhn8g/BsXS6RJGAE57COtCPStIbp1n3UsC5ETz kxmlJ85per5n0/xQpCyrw2u544BMzwVhSyvcG7mm0tCq9Stz+86QNZ8MUhy/XCFh EVsVS6kkUfykXPcXnbDS+gfpj1bkGoxoigTTfFrjnqKhynFbotSg5ymFXQNoKk/S Btc9+cMDLz9l+WceR0DTYw/j1Y75hauXTLPXJuuWCpTehTacyH+BCQJJKg71ZDIM gtG6aoIbs0t0EfOMd9afv9w3pKdVBC/UMejTRrkDfNoSTllkt1ExMVCgyhwn2RAu rda9EGYrw7AiShJbAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE FE9pbQN+nZ8HGEO8txBO1b+pxCAoMB8GA1UdIwQYMBaAFE9pbQN+nZ8HGEO8txBO 1b+pxCAoMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQUFAAOCAgEAO/Ouyugu h4X7ZVnnrREUpVe8WJ8kEle7+z802u6teio0cnAxa8cZmIDJgt43d15Ui47y6mdP yXSEkVYJ1eV6moG2gcKtNuTxVBFT8zRFASbI5Rq8NEQh3q0l/HYWdyGQgJhXnU7q 7C+qPBR7V8F+GBRn7iTGvboVsNIYvbdVgaxTwOjdaRITQrcCtQVBynlQboIOcXKT RuidDV29rs4prWPVVRaAMCf/drr3uNZK49m1+VLQTkCpx+XCMseqdiThawVQ68W/ ClTluUI8JPu3B5wwn3la5uBAUhX0/Kr0VvlEl4ftDmVyXr4m+02kLQgH3thcoNyB M5kYJRF3p+v9WAksmWsbivNSPxpNSGDxoPYzAlOL7SUJuA0t7Zdz7NeWH45gDtoQ my8YJPamTQr5O8t1wswvziRpyQoijlmn94IM19drNZxDAGrElWe6nEXLuA4399xO AU++CrYD062KRffaJ00psUjf5BHklka9bAI+1lHIlRcBFanyqqryvy9lG2/QuRqT 9Y41xICHPpQvZuTpqP9BnHAqTyo5GJUefvthATxRCC4oGKQWDzH9OmwjkyB24f0H hdFbP9IcczLd+rn4jM8Ch3qaluTtT4mNU0OrDhPAARW0eTjb/G49nlG2uBOLZ8/5 fNkiHfZdxRwBL5joeiQYvITX+txyW/fBOmg= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDDDCCAfSgAwIBAgIDAQAgMA0GCSqGSIb3DQEBBQUAMD4xCzAJBgNVBAYTAlBM MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD QTAeFw0wMjA2MTExMDQ2MzlaFw0yNzA2MTExMDQ2MzlaMD4xCzAJBgNVBAYTAlBM MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM6xwS7TT3zNJc4YPk/E jG+AanPIW1H4m9LcuwBcsaD8dQPugfCI7iNS6eYVM42sLQnFdvkrOYCJ5JdLkKWo ePhzQ3ukYbDYWMzhbGZ+nPMJXlVjhNWo7/OxLjBos8Q82KxujZlakE403Daaj4GI ULdtlkIJ89eVgw1BS7Bqa/j8D35in2fE7SZfECYPCE/wpFcozo+47UX2bu4lXapu Ob7kky/ZR6By6/qmW6/KUz/iDsaWVhFu9+lmqSbYf5VT7QqFiLpPKaVCjF62/IUg AKpoC6EahQGcxEZjgoi2IrHu/qpGWX7PNSzVttpd90gzFFS269lvzs2I1qsb2pY7 HVkCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEA uI3O7+cUus/usESSbLQ5PqKEbq24IXfS1HeCh+YgQYHu4vgRt2PRFze+GXYkHAQa TOs9qmdvLdTN/mUxcMUbpgIKumB7bVjCmkn+YzILa+M6wKyrO7Do0wlRjBCDxjTg xSvgGrZgFCdsMneMvLJymM/NzD+5yCRCFNZX/OYmQ6kd5YCQzgNUKD73P9P4Te1q CjqTE5s7FCMTY5w/0YcneeVMUeMBrYVdGjux1XMQpNPyvG5k9VpWkKjHDkx0Dy5x O/fIR/RpbxXyEV6DHpx8Uq79AtoSqFlnGNu8cN2bsWntgM6JQEhqDjXKKWYVIZQs 6GAqm4VKQPNriiTsBhYscw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcjCCA1qgAwIBAgIQH51ZWtcvwgZEpYAIaeNe9jANBgkqhkiG9w0BAQUFADA/ MQswCQYDVQQGEwJUVzEwMC4GA1UECgwnR292ZXJubWVudCBSb290IENlcnRpZmlj YXRpb24gQXV0aG9yaXR5MB4XDTAyMTIwNTEzMjMzM1oXDTMyMTIwNTEzMjMzM1ow PzELMAkGA1UEBhMCVFcxMDAuBgNVBAoMJ0dvdmVybm1lbnQgUm9vdCBDZXJ0aWZp Y2F0aW9uIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB AJoluOzMonWoe/fOW1mKydGGEghU7Jzy50b2iPN86aXfTEc2pBsBHH8eV4qNw8XR IePaJD9IK/ufLqGU5ywck9G/GwGHU5nOp/UKIXZ3/6m3xnOUT0b3EEk3+qhZSV1q gQdW8or5BtD3cCJNtLdBuTK4sfCxw5w/cP1T3YGq2GN49thTbqGsaoQkclSGxtKy yhwOeYHWtXBiCAEuTk8O1RGvqa/lmr/czIdtJuTJV6L7lvnM4T9TjGxMfptTCAts F/tnyMKtsc2AtJfcdgEWFelq16TheEfOhtX7MfP6Mb40qij7cEwdScevLJ1tZqa2 jWR+tSBqnTuBto9AAGdLiYa4zGX+FVPpBMHWXx1E1wovJ5pGfaENda1UhhXcSTvx ls4Pm6Dso3pdvtUqdULle96ltqqvKKyskKw4t9VoNSZ63Pc78/1Fm9G7Q3hub/FC VGqY8A2tl+lSXunVanLeavcbYBT0peS2cWeqH+riTcFCQP5nRhc4L0c/cZyu5SHK YS1tB6iEfC3uUSXxY5Ce/eFXiGvviiNtsea9P63RPZYLhY3Naye7twWb7LuRqQoH EgKXTiCQ8P8NHuJBO9NAOueNXdpm5AKwB1KYXA6OM5zCppX7VRluTI6uSw+9wThN Xo+EHWbNxWCWtFJaBYmOlXqYwZE8lSOyDvR5tMl8wUohAgMBAAGjajBoMB0GA1Ud DgQWBBTMzO/MKWCkO7GStjz6MmKPrCUVOzAMBgNVHRMEBTADAQH/MDkGBGcqBwAE MTAvMC0CAQAwCQYFKw4DAhoFADAHBgVnKgMAAAQUA5vwIhP/lSg209yewDL7MTqK UWUwDQYJKoZIhvcNAQEFBQADggIBAECASvomyc5eMN1PhnR2WPWus4MzeKR6dBcZ TulStbngCnRiqmjKeKBMmo4sIy7VahIkv9Ro04rQ2JyftB8M3jh+Vzj8jeJPXgyf qzvS/3WXy6TjZwj/5cAWtUgBfen5Cv8b5Wppv3ghqMKnI6mGq3ZW6A4M9hPdKmaK ZEk9GhiHkASfQlK3T8v+R0F2Ne//AHY2RTKbxkaFXeIksB7jSJaYV0eUVXoPQbFE JPPB/hprv4j9wabak2BegUqZIJxIZhm1AHlUD7gsL0u8qV1bYH+Mh6XgUmMqvtg7 hUAV/h62ZT/FS9p+tXo1KaMuephgIqP0fSdOLeq0dDzpD6QzDxARvBMB1uUO07+1 EqLhRSPAzAhuYbeJq4PjJB7mXQfnHyA+z2fI56wwbSdLaG5LKlwCCDTb+HbkZ6Mm nD+iMsJKxYEYMRBWqoTvLQr/uB930r+lWKBi5NdLkXWNiYCYfm3LU05er/ayl4WX udpVBrkk7tfGOB5jGxI7leFYrPLfhNVfmS8NVVvmONsuP3LpSIXLuykTjx44Vbnz ssQwmSNOXfJIoRIM3BKQCZBUkQM8R+XVyWXgt0t97EfTsws+rZ7QdAAO671RrcDe LMDDav7v3Aun+kbfYNucpllQdSNpc5Oy+fwC00fmcc4QAu4njIT/rEUNE1yDMuAl pYYsfPQS -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFVjCCBD6gAwIBAgIQ7is969Qh3hSoYqwE893EATANBgkqhkiG9w0BAQUFADCB 8zELMAkGA1UEBhMCRVMxOzA5BgNVBAoTMkFnZW5jaWEgQ2F0YWxhbmEgZGUgQ2Vy dGlmaWNhY2lvIChOSUYgUS0wODAxMTc2LUkpMSgwJgYDVQQLEx9TZXJ2ZWlzIFB1 YmxpY3MgZGUgQ2VydGlmaWNhY2lvMTUwMwYDVQQLEyxWZWdldSBodHRwczovL3d3 dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAoYykwMzE1MDMGA1UECxMsSmVyYXJxdWlh IEVudGl0YXRzIGRlIENlcnRpZmljYWNpbyBDYXRhbGFuZXMxDzANBgNVBAMTBkVD LUFDQzAeFw0wMzAxMDcyMzAwMDBaFw0zMTAxMDcyMjU5NTlaMIHzMQswCQYDVQQG EwJFUzE7MDkGA1UEChMyQWdlbmNpYSBDYXRhbGFuYSBkZSBDZXJ0aWZpY2FjaW8g KE5JRiBRLTA4MDExNzYtSSkxKDAmBgNVBAsTH1NlcnZlaXMgUHVibGljcyBkZSBD ZXJ0aWZpY2FjaW8xNTAzBgNVBAsTLFZlZ2V1IGh0dHBzOi8vd3d3LmNhdGNlcnQu bmV0L3ZlcmFycmVsIChjKTAzMTUwMwYDVQQLEyxKZXJhcnF1aWEgRW50aXRhdHMg ZGUgQ2VydGlmaWNhY2lvIENhdGFsYW5lczEPMA0GA1UEAxMGRUMtQUNDMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsyLHT+KXQpWIR4NA9h0X84NzJB5R 85iKw5K4/0CQBXCHYMkAqbWUZRkiFRfCQ2xmRJoNBD45b6VLeqpjt4pEndljkYRm 4CgPukLjbo73FCeTae6RDqNfDrHrZqJyTxIThmV6PttPB/SnCWDaOkKZx7J/sxaV HMf5NLWUhdWZXqBIoH7nF2W4onW4HvPlQn2v7fOKSGRdghST2MDk/7NQcvJ29rNd QlB50JQ+awwAvthrDk4q7D7SzIKiGGUzE3eeml0aE9jD2z3Il3rucO2n5nzbcc8t lGLfbdb1OL4/pYUKGbio2Al1QnDE6u/LDsg0qBIimAy4E5S2S+zw0JDnJwIDAQAB o4HjMIHgMB0GA1UdEQQWMBSBEmVjX2FjY0BjYXRjZXJ0Lm5ldDAPBgNVHRMBAf8E BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUoMOLRKo3pUW/l4Ba0fF4 opvpXY0wfwYDVR0gBHgwdjB0BgsrBgEEAfV4AQMBCjBlMCwGCCsGAQUFBwIBFiBo dHRwczovL3d3dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbDA1BggrBgEFBQcCAjApGidW ZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAwDQYJKoZIhvcN AQEFBQADggEBAKBIW4IB9k1IuDlVNZyAelOZ1Vr/sXE7zDkJlF7W2u++AVtd0x7Y /X1PzaBB4DSTv8vihpw3kpBWHNzrKQXlxJ7HNd+KDM3FIUPpqojlNcAZQmNaAl6k SBg6hW/cnbw/nZzBh7h6YQjpdwt/cKt63dmXLGQehb+8dJahw3oS7AwaboMMPOhy Rp/7SNVel+axofjk70YllJyJ22k4vuxcDlbHZVHlUIiIv0LVKz3l+bqeLrPK9HOS Agu+TGbrIP65y7WZf+a2E/rKS03Z7lNGBjvGTq2TWoF+bCpLagVFjPIhpDGQh2xl nJ2lYJU6Un/10asIbvPuW/mIPX64b24D5EI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFGTCCBAGgAwIBAgIEPki9xDANBgkqhkiG9w0BAQUFADAxMQswCQYDVQQGEwJE SzEMMAoGA1UEChMDVERDMRQwEgYDVQQDEwtUREMgT0NFUyBDQTAeFw0wMzAyMTEw ODM5MzBaFw0zNzAyMTEwOTA5MzBaMDExCzAJBgNVBAYTAkRLMQwwCgYDVQQKEwNU REMxFDASBgNVBAMTC1REQyBPQ0VTIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEArGL2YSCyz8DGhdfjeebM7fI5kqSXLmSjhFuHnEz9pPPEXyG9VhDr 2y5h7JNp46PMvZnDBfwGuMo2HP6QjklMxFaaL1a8z3sM8W9Hpg1DTeLpHTk0zY0s 2RKY+ePhwUp8hjjEqcRhiNJerxomTdXkoCJHhNlktxmW/OwZ5LKXJk5KTMuPJItU GBxIYXvViGjaXbXqzRowwYCDdlCqT9HU3Tjw7xb04QxQBr/q+3pJoSgrHPb8FTKj dGqPqcNiKXEx5TukYBdedObaE+3pHx8b0bJoc8YQNHVGEBDjkAB2QMuLt0MJIf+r TpPGWOmlgtt3xDqZsXKVSQTwtyv6e1mO3QIDAQABo4ICNzCCAjMwDwYDVR0TAQH/ BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwgewGA1UdIASB5DCB4TCB3gYIKoFQgSkB AQEwgdEwLwYIKwYBBQUHAgEWI2h0dHA6Ly93d3cuY2VydGlmaWthdC5kay9yZXBv c2l0b3J5MIGdBggrBgEFBQcCAjCBkDAKFgNUREMwAwIBARqBgUNlcnRpZmlrYXRl ciBmcmEgZGVubmUgQ0EgdWRzdGVkZXMgdW5kZXIgT0lEIDEuMi4yMDguMTY5LjEu MS4xLiBDZXJ0aWZpY2F0ZXMgZnJvbSB0aGlzIENBIGFyZSBpc3N1ZWQgdW5kZXIg T0lEIDEuMi4yMDguMTY5LjEuMS4xLjARBglghkgBhvhCAQEEBAMCAAcwgYEGA1Ud HwR6MHgwSKBGoESkQjBAMQswCQYDVQQGEwJESzEMMAoGA1UEChMDVERDMRQwEgYD VQQDEwtUREMgT0NFUyBDQTENMAsGA1UEAxMEQ1JMMTAsoCqgKIYmaHR0cDovL2Ny bC5vY2VzLmNlcnRpZmlrYXQuZGsvb2Nlcy5jcmwwKwYDVR0QBCQwIoAPMjAwMzAy MTEwODM5MzBagQ8yMDM3MDIxMTA5MDkzMFowHwYDVR0jBBgwFoAUYLWF7FZkfhIZ J2cdUBVLc647+RIwHQYDVR0OBBYEFGC1hexWZH4SGSdnHVAVS3OuO/kSMB0GCSqG SIb2fQdBAAQQMA4bCFY2LjA6NC4wAwIEkDANBgkqhkiG9w0BAQUFAAOCAQEACrom JkbTc6gJ82sLMJn9iuFXehHTuJTXCRBuo7E4A9G28kNBKWKnctj7fAXmMXAnVBhO inxO5dHKjHiIzxvTkIvmI/gLDjNDfZziChmPyQE+dF10yYscA+UYyAFMP8uXBV2Y caaYb7Z8vTd/vuGTJW1v8AqtFxjhA7wHKcitJuj4YfD9IQl+mo6paH1IYnK9AOoB mbgGglGBTvH1tJFUuSN6AJqfXY3gPGS5GhKSKseCRHI53OI8xthV9RVOyAUO28bQ YqbsFbS1AoLbrIyigfCbmTH1ICCoiGEKB5+U/NDXG8wuF/MEJ3Zn61SD/aSQfgY9 BKNDLdr8C2LqL19iUw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEvTCCA6WgAwIBAgIBADANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJFVTEn MCUGA1UEChMeQUMgQ2FtZXJmaXJtYSBTQSBDSUYgQTgyNzQzMjg3MSMwIQYDVQQL ExpodHRwOi8vd3d3LmNoYW1iZXJzaWduLm9yZzEiMCAGA1UEAxMZQ2hhbWJlcnMg b2YgQ29tbWVyY2UgUm9vdDAeFw0wMzA5MzAxNjEzNDNaFw0zNzA5MzAxNjEzNDRa MH8xCzAJBgNVBAYTAkVVMScwJQYDVQQKEx5BQyBDYW1lcmZpcm1hIFNBIENJRiBB ODI3NDMyODcxIzAhBgNVBAsTGmh0dHA6Ly93d3cuY2hhbWJlcnNpZ24ub3JnMSIw IAYDVQQDExlDaGFtYmVycyBvZiBDb21tZXJjZSBSb290MIIBIDANBgkqhkiG9w0B AQEFAAOCAQ0AMIIBCAKCAQEAtzZV5aVdGDDg2olUkfzIx1L4L1DZ77F1c2VHfRtb unXF/KGIJPov7coISjlUxFF6tdpg6jg8gbLL8bvZkSM/SAFwdakFKq0fcfPJVD0d BmpAPrMMhe5cG3nCYsS4No41XQEMIwRHNaqbYE6gZj3LJgqcQKH0XZi/caulAGgq 7YN6D6IUtdQis4CwPAxaUWktWBiP7Zme8a7ileb2R6jWDA+wWFjbw2Y3npuRVDM3 0pQcakjJyfKl2qUMI/cjDpwyVV5xnIQFUZot/eZOKjRa3spAN2cMVCFVd9oKDMyX roDclDZK9D7ONhMeU+SsTjoF7Nuucpw4i9A5O4kKPnf+dQIBA6OCAUQwggFAMBIG A1UdEwEB/wQIMAYBAf8CAQwwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL2NybC5j aGFtYmVyc2lnbi5vcmcvY2hhbWJlcnNyb290LmNybDAdBgNVHQ4EFgQU45T1sU3p 26EpW1eLTXYGduHRooowDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIA BzAnBgNVHREEIDAegRxjaGFtYmVyc3Jvb3RAY2hhbWJlcnNpZ24ub3JnMCcGA1Ud EgQgMB6BHGNoYW1iZXJzcm9vdEBjaGFtYmVyc2lnbi5vcmcwWAYDVR0gBFEwTzBN BgsrBgEEAYGHLgoDATA+MDwGCCsGAQUFBwIBFjBodHRwOi8vY3BzLmNoYW1iZXJz aWduLm9yZy9jcHMvY2hhbWJlcnNyb290Lmh0bWwwDQYJKoZIhvcNAQEFBQADggEB AAxBl8IahsAifJ/7kPMa0QOx7xP5IV8EnNrJpY0nbJaHkb5BkAFyk+cefV/2icZd p0AJPaxJRUXcLo0waLIJuvvDL8y6C98/d3tGfToSJI6WjzwFCm/SlCgdbQzALogi 1djPHRPH8EjX1wWnz8dHnjs8NMiAT9QUu/wNUPf6s+xCX6ndbcj0dc97wXImsQEc XCz9ek60AcUFV7nnPKoF2YjpB0ZBzu9Bga5Y34OirsrXdx/nADydb47kMgkdTXg0 eDQ8lJsm7U9xxhl6vSAiSFr+S30Dt+dYvsYyTnQeaN2oaFuzPu5ifdmA6Ap1erfu tGWaIZDgqtCYvDi1czyL+Nw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIExTCCA62gAwIBAgIBADANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJFVTEn MCUGA1UEChMeQUMgQ2FtZXJmaXJtYSBTQSBDSUYgQTgyNzQzMjg3MSMwIQYDVQQL ExpodHRwOi8vd3d3LmNoYW1iZXJzaWduLm9yZzEgMB4GA1UEAxMXR2xvYmFsIENo YW1iZXJzaWduIFJvb3QwHhcNMDMwOTMwMTYxNDE4WhcNMzcwOTMwMTYxNDE4WjB9 MQswCQYDVQQGEwJFVTEnMCUGA1UEChMeQUMgQ2FtZXJmaXJtYSBTQSBDSUYgQTgy NzQzMjg3MSMwIQYDVQQLExpodHRwOi8vd3d3LmNoYW1iZXJzaWduLm9yZzEgMB4G A1UEAxMXR2xvYmFsIENoYW1iZXJzaWduIFJvb3QwggEgMA0GCSqGSIb3DQEBAQUA A4IBDQAwggEIAoIBAQCicKLQn0KuWxfH2H3PFIP8T8mhtxOviteePgQKkotgVvq0 Mi+ITaFgCPS3CU6gSS9J1tPfnZdan5QEcOw/Wdm3zGaLmFIoCQLfxS+EjXqXd7/s QJ0lcqu1PzKY+7e3/HKE5TWH+VX6ox8Oby4o3Wmg2UIQxvi1RMLQQ3/bvOSiPGpV eAp3qdjqGTK3L/5cPxvusZjsyq16aUXjlg9V9ubtdepl6DJWk0aJqCWKZQbua795 B9Dxt6/tLE2Su8CoX6dnfQTyFQhwrJLWfQTSM/tMtgsL+xrJxI0DqX5c8lCrEqWh z0hQpe/SyBoT+rB/sYIcd2oPX9wLlY/vQ37mRQklAgEDo4IBUDCCAUwwEgYDVR0T AQH/BAgwBgEB/wIBDDA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3JsLmNoYW1i ZXJzaWduLm9yZy9jaGFtYmVyc2lnbnJvb3QuY3JsMB0GA1UdDgQWBBRDnDafsJ4w TcbOX60Qq+UDpfqpFDAOBgNVHQ8BAf8EBAMCAQYwEQYJYIZIAYb4QgEBBAQDAgAH MCoGA1UdEQQjMCGBH2NoYW1iZXJzaWducm9vdEBjaGFtYmVyc2lnbi5vcmcwKgYD VR0SBCMwIYEfY2hhbWJlcnNpZ25yb290QGNoYW1iZXJzaWduLm9yZzBbBgNVHSAE VDBSMFAGCysGAQQBgYcuCgEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly9jcHMuY2hh bWJlcnNpZ24ub3JnL2Nwcy9jaGFtYmVyc2lnbnJvb3QuaHRtbDANBgkqhkiG9w0B AQUFAAOCAQEAPDtwkfkEVCeR4e3t/mh/YV3lQWVPMvEYBZRqHN4fcNs+ezICNLUM bKGKfKX0j//U2K0X1S0E0T9YgOKBWYi+wONGkyT+kL0mojAt6JcmVzWJdJYY9hXi ryQZVgICsroPFOrGimbBhkVVi76SvpykBMdJPJ7oKXqJ1/6v/2j1pReQvayZzKWG VwlnRtvWFsJG8eSpUPWP0ZIV018+xgBJOm5YstHRJw0lyDL4IBHNfTIzSJRUTN3c ecQwn+uOuFW114hcxWokPbLTBQNRxgfvzBRydD1ucs4YKIxKoHflCStFREest2d/ AYoFWpO+ocH/+OcOZ6RHSXZddZAa9SaP8A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe 3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4 YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2 G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3 smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEPzCCAyegAwIBAgIBATANBgkqhkiG9w0BAQUFADB+MQswCQYDVQQGEwJHQjEb MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEkMCIGA1UEAwwbU2VjdXJlIENlcnRp ZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVow fjELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxJDAiBgNV BAMMG1NlY3VyZSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEB BQADggEPADCCAQoCggEBAMBxM4KK0HDrc4eCQNUd5MvJDkKQ+d40uaG6EfQlhfPM cm3ye5drswfxdySRXyWP9nQ95IDC+DwN879A6vfIUtFyb+/Iq0G4bi4XKpVpDM3S HpR7LZQdqnXXs5jLrLxkU0C8j6ysNstcrbvd4JQX7NFc0L/vpZXJkMWwrPsbQ996 CF23uPJAGysnnlDOXmWCiIxe004MeuoIkbY2qitC++rCoznl2yY4rYsK7hljxxwk 3wN42ubqwUcaCwtGCd0C/N7Lh1/XMGNooa7cMqG6vv5Eq2i2pRcV/b3Vp6ea5EQz 6YiO/O1R65NxTq0B50SOqy3LqP4BSUjwwN3HaNiS/j0CAwEAAaOBxzCBxDAdBgNV HQ4EFgQUPNiTiMLAggnMAZkGkyDpnnAJY08wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud EwEB/wQFMAMBAf8wgYEGA1UdHwR6MHgwO6A5oDeGNWh0dHA6Ly9jcmwuY29tb2Rv Y2EuY29tL1NlY3VyZUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDmgN6A1hjNodHRw Oi8vY3JsLmNvbW9kby5uZXQvU2VjdXJlQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmww DQYJKoZIhvcNAQEFBQADggEBAIcBbSMdflsXfcFhMs+P5/OKlFlm4J4oqF7Tt/Q0 5qo5spcWxYJvMqTpjOev/e/C6LlLqqP05tqNZSH7uoDrJiiFGv45jN5bBAS0VPmj Z55B+glSzAVIqMk/IQQezkhr/IXownuvf7fM+F86/TXGDe+X3EyrEeFryzHRbPtI gKvcnDe4IRRLDXE97IMzbtFuMhbsmMcWi1mmNKsFVy2T96oTy9IT4rcuO81rUBcJ aD61JlfutuC23bkpgHl9j6PwpCikFcSF9CfUa7/lXORlAnZUtOM3ZiTTGWHIUhDl izeauan5Hb/qmZJhlv8BzaFfDbxxvA6sCx1HRR3B7Hzs/Sk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEQzCCAyugAwIBAgIBATANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJHQjEb MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDElMCMGA1UEAwwcVHJ1c3RlZCBDZXJ0 aWZpY2F0ZSBTZXJ2aWNlczAeFw0wNDAxMDEwMDAwMDBaFw0yODEyMzEyMzU5NTla MH8xCzAJBgNVBAYTAkdCMRswGQYDVQQIDBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO BgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoMEUNvbW9kbyBDQSBMaW1pdGVkMSUwIwYD VQQDDBxUcnVzdGVkIENlcnRpZmljYXRlIFNlcnZpY2VzMIIBIjANBgkqhkiG9w0B AQEFAAOCAQ8AMIIBCgKCAQEA33FvNlhTWvI2VFeAxHQIIO0Yfyod5jWaHiWsnOWW fnJSoBVC21ndZHoa0Lh73TkVvFVIxO06AOoxEbrycXQaZ7jPM8yoMa+j49d/vzMt TGo87IvDktJTdyR0nAducPy9C1t2ul/y/9c3S0pgePfw+spwtOpZqqPOSC+pw7IL fhdyFgymBwwbOM/JYrc/oJOlh0Hyt3BAd9i+FHzjqMB6juljatEPmsbS9Is6FARW 1O24zG71++IsWL1/T2sr92AkWCTOJu80kTrV44HQsvAEAtdbtz6SrGsSivnkBbA7 kUlcsutT6vifR4buv5XAwAaf0lteERv0xwQ1KdJVXOTt6wIDAQABo4HJMIHGMB0G A1UdDgQWBBTFe1i97doladL3WRaoszLAeydb9DAOBgNVHQ8BAf8EBAMCAQYwDwYD VR0TAQH/BAUwAwEB/zCBgwYDVR0fBHwwejA8oDqgOIY2aHR0cDovL2NybC5jb21v ZG9jYS5jb20vVHJ1c3RlZENlcnRpZmljYXRlU2VydmljZXMuY3JsMDqgOKA2hjRo dHRwOi8vY3JsLmNvbW9kby5uZXQvVHJ1c3RlZENlcnRpZmljYXRlU2VydmljZXMu Y3JsMA0GCSqGSIb3DQEBBQUAA4IBAQDIk4E7ibSvuIQSTI3S8NtwuleGFTQQuS9/ HrCoiWChisJ3DFBKmwCL2Iv0QeLQg4pKHBQGsKNoBXAxMKdTmw7pSqBYaWcOrp32 pSxBvzwGa+RZzG0Q8ZZvH9/0BAKkn0U+yNj6NkZEUD+Cl5EfKNsYEYwq5GWDVxIS jBc/lDb+XbDABHcTuPQV1T84zJQ6VdCsmPW6AF/ghhmBeC8owH7TzEIK9a5QoNE+ xqFx7D+gIIxmOom0jtTYsU0lR+4viMi14QVFwL4Ucd56/Y57fU0IlqUSc/Atyjcn dBInTMu2l+nZrghtWjlA3QVHdWpaIbOjGM9O9y5Xt5hwXsjEeLBi -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDhDCCAmygAwIBAgIBCTANBgkqhkiG9w0BAQUFADAzMQswCQYDVQQGEwJDTjER MA8GA1UEChMIVW5pVHJ1c3QxETAPBgNVBAMTCFVDQSBSb290MB4XDTA0MDEwMTAw MDAwMFoXDTI5MTIzMTAwMDAwMFowMzELMAkGA1UEBhMCQ04xETAPBgNVBAoTCFVu aVRydXN0MREwDwYDVQQDEwhVQ0EgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEP ADCCAQoCggEBALNdB8qGJn1r4vs4CQ7MgsJqGgCiFV/W6dQBt1YDAVmP9ThpJHbC XivF9iu/r/tB/Q9a/KvXg3BNMJjRnrJ2u5LWu+kQKGkoNkTo8SzXWHwk1n8COvCB a2FgP/Qz3m3l6ihST/ypHWN8C7rqrsRoRuTej8GnsrZYWm0dLNmMOreIy4XU9+gD Xv2yTVDo1h//rgI/i0+WITyb1yXJHT/7mLFZ5PCpO6+zzYUs4mBGzG+OoOvwNMXx QhhgrhLtRnUc5dipllq+3lrWeGeWW5N3UPJuG96WUUqm1ktDdSFmjXfsAoR2XEQQ th1hbOSjIH23jboPkXXHjd+8AmCoKai9PUMCAwEAAaOBojCBnzALBgNVHQ8EBAMC AQYwDAYDVR0TBAUwAwEB/zBjBgNVHSUEXDBaBggrBgEFBQcDAQYIKwYBBQUHAwIG CCsGAQUFBwMDBggrBgEFBQcDBAYIKwYBBQUHAwUGCCsGAQUFBwMGBggrBgEFBQcD BwYIKwYBBQUHAwgGCCsGAQUFBwMJMB0GA1UdDgQWBBTbHzXza0z/QjFkm827Wh4d SBC37jANBgkqhkiG9w0BAQUFAAOCAQEAOGy3iPGt+lg3dNHocN6cJ1nL5BXXoMNg 14iABMUwTD3UGusGXllH5rxmy+AI/Og17GJ9ysDawXiv5UZv+4mCI4/211NmVaDe JRI7cTYWVRJ2+z34VFsxugAG+H1V5ad2g6pcSpemKijfvcZsCyOVjjN/Hl5AHxNU LJzltQ7dFyiuawHTUin1Ih+QOfTcYmjwPIZH7LgFRbu3DJaUxmfLI3HQjnQi1kHr A6i26r7EARK1s11AdgYg1GS4KUYGis4fk5oQ7vuqWrTcL9Ury/bXBYSYBZELhPc9 +tb5evosFeo2gkO3t7jj83EB7UNDogVFwygFBzXjAaU4HoDU18PZ3g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFaDCCA1CgAwIBAgIBATANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJVUzEW MBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEeMBwGA1UEAxMVR2VvVHJ1c3QgVW5pdmVy c2FsIENBMB4XDTA0MDMwNDA1MDAwMFoXDTI5MDMwNDA1MDAwMFowRTELMAkGA1UE BhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xHjAcBgNVBAMTFUdlb1RydXN0 IFVuaXZlcnNhbCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKYV VaCjxuAfjJ0hUNfBvitbtaSeodlyWL0AG0y/YckUHUWCq8YdgNY96xCcOq9tJPi8 cQGeBvV8Xx7BDlXKg5pZMK4ZyzBIle0iN430SppyZj6tlcDgFgDgEB8rMQ7XlFTT QjOgNB0eRXbdT8oYN+yFFXoZCPzVx5zw8qkuEKmS5j1YPakWaDwvdSEYfyh3peFh F7em6fgemdtzbvQKoiFs7tqqhZJmr/Z6a4LauiIINQ/PQvE1+mrufislzDoR5G2v c7J2Ha3QsnhnGqQ5HFELZ1aD/ThdDc7d8Lsrlh/eezJS/R27tQahsiFepdaVaH/w mZ7cRQg+59IJDTWU3YBOU5fXtQlEIGQWFwMCTFMNaN7VqnJNk22CDtucvc+081xd VHppCZbW2xHBjXWotM85yM48vCR85mLK4b19p71XZQvk/iXttmkQ3CgaRr0BHdCX teGYO8A3ZNY9lO4L4fUorgtWv3GLIylBjobFS1J72HGrH4oVpjuDWtdYAVHGTEHZ f9hBZ3KiKN9gg6meyHv8U3NyWfWTehd2Ds735VzZC1U0oqpbtWpU5xPKV+yXbfRe Bi9Fi1jUIxaS5BZuKGNZMN9QAZxjiRqf2xeUgnA3wySemkfWWspOqGmJch+RbNt+ nhutxx9z3SxPGWX9f5NAEC7S8O08ni4oPmkmM8V7AgMBAAGjYzBhMA8GA1UdEwEB /wQFMAMBAf8wHQYDVR0OBBYEFNq7LqqwDLiIJlF0XG0D08DYj3rWMB8GA1UdIwQY MBaAFNq7LqqwDLiIJlF0XG0D08DYj3rWMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG 9w0BAQUFAAOCAgEAMXjmx7XfuJRAyXHEqDXsRh3ChfMoWIawC/yOsjmPRFWrZIRc aanQmjg8+uUfNeVE44B5lGiku8SfPeE0zTBGi1QrlaXv9z+ZhP015s8xxtxqv6fX IwjhmF7DWgh2qaavdy+3YL1ERmrvl/9zlcGO6JP7/TG37FcREUWbMPEaiDnBTzyn ANXH/KttgCJwpQzgXQQpAvvLoJHRfNbDflDVnVi+QTjruXU8FdmbyUqDWcDaU/0z uzYYm4UPFd3uLax2k7nZAY1IEKj79TiG8dsKxr2EoyNB3tZ3b4XUhRxQ4K5RirqN Pnbiucon8l+f725ZDQbYKxek0nxru18UGkiPGkzns0ccjkxFKyDuSN/n3QmOGKja QI2SJhFTYXNd673nxE0pN2HrrDktZy4W1vUAg4WhzH92xH3kt0tm7wNFYGm2DFKW koRepqO1pD4r2czYG0eq8kTaT/kD6PAUyz/zg97QwVTjt+gKN02LIFkDMBmhLMi9 ER/frslKxfMnZmaGrGiR/9nmUxwPi1xpZQomyB40w11Re9epnAahNt3ViZS82eQt DF4JbAiXfKM9fJP/P6EUp8+1Xevb2xzEdt+Iub1FBZUbrvxGakyvSOPOrg/Sfuvm bJxPgWp6ZKy7PtXny3YuxadIwVyQD8vIP/rmMuGNG2+k5o7Y+SlIis5z/iw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFbDCCA1SgAwIBAgIBATANBgkqhkiG9w0BAQUFADBHMQswCQYDVQQGEwJVUzEW MBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEgMB4GA1UEAxMXR2VvVHJ1c3QgVW5pdmVy c2FsIENBIDIwHhcNMDQwMzA0MDUwMDAwWhcNMjkwMzA0MDUwMDAwWjBHMQswCQYD VQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEgMB4GA1UEAxMXR2VvVHJ1 c3QgVW5pdmVyc2FsIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC AQCzVFLByT7y2dyxUxpZKeexw0Uo5dfR7cXFS6GqdHtXr0om/Nj1XqduGdt0DE81 WzILAePb63p3NeqqWuDW6KFXlPCQo3RWlEQwAx5cTiuFJnSCegx2oG9NzkEtoBUG FF+3Qs17j1hhNNwqCPkuwwGmIkQcTAeC5lvO0Ep8BNMZcyfwqph/Lq9O64ceJHdq XbboW0W63MOhBW9Wjo8QJqVJwy7XQYci4E+GymC16qFjwAGXEHm9ADwSbSsVsaxL se4YuU6W3Nx2/zu+z18DwPw76L5GG//aQMJS9/7jOvdqdzXQ2o3rXhhqMcceujwb KNZrVMaqW9eiLBsZzKIC9ptZvTdrhrVtgrrY6slWvKk2WP0+GfPtDCapkzj4T8Fd IgbQl+rhrcZV4IErKIM6+vR7IVEAvlI4zs1meaj0gVbi0IMJR1FbUGrP20gaXT73 y/Zl92zxlfgCOzJWgjl6W70viRu/obTo/3+NjN8D8WBOWBFM66M/ECuDmgFz2ZRt hAAnZqzwcEAJQpKtT5MNYQlRJNiS1QuUYbKHsu3/mjX/hVTK7URDrBs8FmtISgoc QIgfksILAAX/8sgCSqSqqcyZlpwvWOB94b67B9xfBHJcMTTD7F8t4D1kkCLm0ey4 Lt1ZrtmhN79UNdxzMk+MBB4zsslG8dhcyFVQyWi9qLo2CQIDAQABo2MwYTAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR281Xh+qQ2+/CfXGJx7Tz0RzgQKzAfBgNV HSMEGDAWgBR281Xh+qQ2+/CfXGJx7Tz0RzgQKzAOBgNVHQ8BAf8EBAMCAYYwDQYJ KoZIhvcNAQEFBQADggIBAGbBxiPz2eAubl/oz66wsCVNK/g7WJtAJDday6sWSf+z dXkzoS9tcBc0kf5nfo/sm+VegqlVHy/c1FEHEv6sFj4sNcZj/NwQ6w2jqtB8zNHQ L1EuxBRa3ugZ4T7GzKQp5y6EqgYweHZUcyiYWTjgAA1i00J9IZ+uPTqM1fp3DRgr Fg5fNuH8KrUwJM/gYwx7WBr+mbpCErGR9Hxo4sjoryzqyX6uuyo9DRXcNJW2GHSo ag/HtPQTxORb7QrSpJdMKu0vbBKJPfEncKpqA1Ihn0CoZ1Dy81of398j9tx4TuaY T1U6U+Pv8vSfx3zYWK8pIpe44L2RLrB27FcRz+8pRPPphXpgY+RdM4kX2TGq2tbz GDVyz4crL2MjhF2EjD9XoIj8mZEoJmmZ1I+XRL6O1UixpCgp8RW04eWe3fiPpm8m 1wk8OhwRDqZsN/etRIcsKMfYdIKz0G9KV7s1KSegi+ghp4dkNl3M2Basx7InQJJV OCiNUW7dFGdTbHFcJoRNdVq2fmBWqU2t+5sel/MN2dKXVHfaPRK34B7vCAas+YWH 6aLcr34YEoP9VhdBLtUpgn2Z9DH2canPLAEnpQW5qrJITirvn5NSUZU8UnOOVkwX QMAJKOSLakhT2+zNVVXxxvjpoixMptEmX36vWkzaH6byHCx+rgIW0lbQL1dTR+iS -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDkzCCAnugAwIBAgIQFBOWgxRVjOp7Y+X8NId3RDANBgkqhkiG9w0BAQUFADA0 MRMwEQYDVQQDEwpDb21TaWduIENBMRAwDgYDVQQKEwdDb21TaWduMQswCQYDVQQG EwJJTDAeFw0wNDAzMjQxMTMyMThaFw0yOTAzMTkxNTAyMThaMDQxEzARBgNVBAMT CkNvbVNpZ24gQ0ExEDAOBgNVBAoTB0NvbVNpZ24xCzAJBgNVBAYTAklMMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8ORUaSvTx49qROR+WCf4C9DklBKK 8Rs4OC8fMZwG1Cyn3gsqrhqg455qv588x26i+YtkbDqthVVRVKU4VbirgwTyP2Q2 98CNQ0NqZtH3FyrV7zb6MBBC11PN+fozc0yz6YQgitZBJzXkOPqUm7h65HkfM/sb 2CEJKHxNGGleZIp6GZPKfuzzcuc3B1hZKKxC+cX/zT/npfo4sdAMx9lSGlPWgcxC ejVb7Us6eva1jsz/D3zkYDaHL63woSV9/9JLEYhwVKZBqGdTUkJe5DSe5L6j7Kpi Xd3DTKaCQeQzC6zJMw9kglcq/QytNuEMrkvF7zuZ2SOzW120V+x0cAwqTwIDAQAB o4GgMIGdMAwGA1UdEwQFMAMBAf8wPQYDVR0fBDYwNDAyoDCgLoYsaHR0cDovL2Zl ZGlyLmNvbXNpZ24uY28uaWwvY3JsL0NvbVNpZ25DQS5jcmwwDgYDVR0PAQH/BAQD AgGGMB8GA1UdIwQYMBaAFEsBmz5WGmU2dst7l6qSBe4y5ygxMB0GA1UdDgQWBBRL AZs+VhplNnbLe5eqkgXuMucoMTANBgkqhkiG9w0BAQUFAAOCAQEA0Nmlfv4pYEWd foPPbrxHbvUanlR2QnG0PFg/LUAlQvaBnPGJEMgOqnhPOAlXsDzACPw1jvFIUY0M cXS6hMTXcpuEfDhOZAYnKuGntewImbQKDdSFc8gS4TXt8QUxHXOZDOuWyt3T5oWq 8Ir7dcHyCTxlZWTzTNity4hp8+SDtwy9F1qWF8pb/627HOkthIDYIb6FUtnUdLlp hbpN7Sgy6/lhSuTENh4Z3G+EER+V9YMoGKgzkkMn3V0TBEVPh9VGzT2ouvDzuFYk Res3x+F2T3I5GN9+dHLHcy056mDmrRGiVod7w2ia/viMcKjfZTL0pECMocJEAw6U AGegcQCCSA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDqzCCApOgAwIBAgIRAMcoRwmzuGxFjB36JPU2TukwDQYJKoZIhvcNAQEFBQAw PDEbMBkGA1UEAxMSQ29tU2lnbiBTZWN1cmVkIENBMRAwDgYDVQQKEwdDb21TaWdu MQswCQYDVQQGEwJJTDAeFw0wNDAzMjQxMTM3MjBaFw0yOTAzMTYxNTA0NTZaMDwx GzAZBgNVBAMTEkNvbVNpZ24gU2VjdXJlZCBDQTEQMA4GA1UEChMHQ29tU2lnbjEL MAkGA1UEBhMCSUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGtWhf HZQVw6QIVS3joFd67+l0Kru5fFdJGhFeTymHDEjWaueP1H5XJLkGieQcPOqs49oh gHMhCu95mGwfCP+hUH3ymBvJVG8+pSjsIQQPRbsHPaHA+iqYHU4Gk/v1iDurX8sW v+bznkqH7Rnqwp9D5PGBpX8QTz7RSmKtUxvLg/8HZaWSLWapW7ha9B20IZFKF3ue Mv5WJDmyVIRD9YTC2LxBkMyd1mja6YJQqTtoz7VdApRgFrFD2UNd3V2Hbuq7s8lr 9gOUCXDeFhF6K+h2j0kQmHe5Y1yLM5d19guMsqtb3nQgJT/j8xH5h2iGNXHDHYwt 6+UarA9z1YJZQIDTAgMBAAGjgacwgaQwDAYDVR0TBAUwAwEB/zBEBgNVHR8EPTA7 MDmgN6A1hjNodHRwOi8vZmVkaXIuY29tc2lnbi5jby5pbC9jcmwvQ29tU2lnblNl Y3VyZWRDQS5jcmwwDgYDVR0PAQH/BAQDAgGGMB8GA1UdIwQYMBaAFMFL7XC29z58 ADsAj8c+DkWfHl3sMB0GA1UdDgQWBBTBS+1wtvc+fAA7AI/HPg5Fnx5d7DANBgkq hkiG9w0BAQUFAAOCAQEAFs/ukhNQq3sUnjO2QiBq1BW9Cav8cujvR3qQrFHBZE7p iL1DRYHjZiM/EoZNGeQFsOY3wo3aBijJD4mkU6l1P7CW+6tMM1X5eCZGbxs2mPtC dsGCuY7e+0X5YxtiOzkGynd6qDwJz2w2PQ8KRUtpFhpFfTMDZflScZAmlaxMDPWL kz/MdXSFmLr/YnpNH4n+rr2UAJm/EaXc4HnFFgt9AmEd6oX5AhVP51qJThRv4zdL hfXBPGHg/QVBspJ/wx2g0K5SZGBrGMYmnNj1ZOQ2GmKfig8+/21OGVZOIJFsnzQz OjRXUDpvgV4GxvU+fE6OK85lBi5d0ipTdF7Tbieejw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDQzCCAiugAwIBAgIQX/h7KCtU3I1CoxW1aMmt/zANBgkqhkiG9w0BAQUFADA1 MRYwFAYDVQQKEw1DaXNjbyBTeXN0ZW1zMRswGQYDVQQDExJDaXNjbyBSb290IENB IDIwNDgwHhcNMDQwNTE0MjAxNzEyWhcNMjkwNTE0MjAyNTQyWjA1MRYwFAYDVQQK Ew1DaXNjbyBTeXN0ZW1zMRswGQYDVQQDExJDaXNjbyBSb290IENBIDIwNDgwggEg MA0GCSqGSIb3DQEBAQUAA4IBDQAwggEIAoIBAQCwmrmrp68Kd6ficba0ZmKUeIhH xmJVhEAyv8CrLqUccda8bnuoqrpu0hWISEWdovyD0My5jOAmaHBKeN8hF570YQXJ FcjPFto1YYmUQ6iEqDGYeJu5Tm8sUxJszR2tKyS7McQr/4NEb7Y9JHcJ6r8qqB9q VvYgDxFUl4F1pyXOWWqCZe+36ufijXWLbvLdT6ZeYpzPEApk0E5tzivMW/VgpSdH jWn0f84bcN5wGyDWbs2mAag8EtKpP6BrXruOIIt6keO1aO6g58QBdKhTCytKmg9l Eg6CTY5j/e/rmxrbU6YTYK/CfdfHbBcl1HP7R2RQgYCUTOG/rksc35LtLgXfAgED o1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJ/PI FR5umgIJFq0roIlgX9p7L6owEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEF BQADggEBAJ2dhISjQal8dwy3U8pORFBi71R803UXHOjgxkhLtv5MOhmBVrBW7hmW Yqpao2TB9k5UM8Z3/sUcuuVdJcr18JOagxEu5sv4dEX+5wW4q+ffy0vhN4TauYuX cB7w4ovXsNgOnbFp1iqRe6lJT37mjpXYgyc81WhJDtSd9i7rp77rMKSsH0T8lasz Bvt9YAretIpjsJyp8qS5UwGH0GikJ3+r/+n6yUA4iGe0OcaEb1fJU9u6ju7AQ7L4 CYNu/2bPPu8Xs1gYJQk0XuPL1hS27PKSb3TkL4Eq1ZKR4OCXPDJoBYVL0fdX4lId kxpUnwVwwEpxYB5DC2Ae/qPOgRnhCzU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICmDCCAgGgAwIBAgIBDjANBgkqhkiG9w0BAQUFADBLMQswCQYDVQQGEwJVUzEY MBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNFQ0ExFDASBgNVBAMT C0VDQSBSb290IENBMB4XDTA0MDYxNDEwMjAwOVoXDTQwMDYxNDEwMjAwOVowSzEL MAkGA1UEBhMCVVMxGDAWBgNVBAoTD1UuUy4gR292ZXJubWVudDEMMAoGA1UECxMD RUNBMRQwEgYDVQQDEwtFQ0EgUm9vdCBDQTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw gYkCgYEArkr2eXIS6oAKIpDkOlcQZdMGdncoygCEIU+ktqY3of5SVVXU7/it7kJ1 EUzR4ii2vthQtbww9aAnpQxcEmXZk8eEyiGEPy+cCQMllBY+efOtKgjbQNDZ3lB9 19qzUJwBl2BMxslU1XsJQw9SK10lPbQm4asa8E8e5zTUknZBWnECAwEAAaOBizCB iDAfBgNVHSMEGDAWgBT2uAQnDlYW2blj2f2hVGVBoAhILzAdBgNVHQ4EFgQU9rgE Jw5WFtm5Y9n9oVRlQaAISC8wDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB Af8wJQYDVR0gBB4wHDAMBgpghkgBZQMCAQwBMAwGCmCGSAFlAwIBDAIwDQYJKoZI hvcNAQEFBQADgYEAHh0EQY2cZ209aBb5q0wW1ER0dc4OGzsLyqjHfaQ4TEaMmUwL AJRta/c4KVWLiwbODsvgJk+CaWmSL03gRW/ciVb/qDV7qh9Pyd1cOlanZTAnPog2 i82yL3i2fK9DCC84uoxEQbgqK2jx9bIjFTwlAqITk9fGAm5mdT84IEwq1Gw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEh MB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBE YWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3 MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRo ZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3Mg MiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCA PVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6w wdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXi EqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWoriMY avx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZEewo+ YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0OBBYEFNLE sNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h /t2oatTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5 IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmlj YXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD ggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wimPQoZ+YeAEW5p5JYXMP80kWNy OO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKtI3lpjbi2Tc7P TMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mER dEr/VxqHD3VILs9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5Cuf ReYNnyicsbkqWletNw+vHX/bvZ8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzEl MCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMp U3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQw NjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBoMQswCQYDVQQGEwJVUzElMCMGA1UE ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZp ZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqGSIb3 DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf 8MOh2tTYbitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN +lq2cwQlZut3f+dZxkqZJRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0 X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVmepsZGD3/cVE8MC5fvj13c7JdBmzDI1aa K4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSNF4Azbl5KXZnJHoe0nRrA 1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HFMIHCMB0G A1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fR zt0fhvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0 YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBD bGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8w DQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGsafPzWdqbAYcaT1epoXkJKtv3 L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLMPUxA2IGvd56D eruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynp VSJYACPq4xJDKVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEY WQPJIrSPnNVeKtelttQKbfi3QBFGmh95DmK/D5fs4C8fF5Q= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCB gjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEk MCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRY UmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQxMTAxMTcx NDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMxHjAcBgNVBAsTFXd3 dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkgU2Vy dmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB dXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS6 38eMpSe2OAtp87ZOqCwuIR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCP KZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMxfoArtYzAQDsRhtDLooY2YKTVMIJt2W7Q DxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FEzG+gSqmUsE3a56k0enI4 qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqsAxcZZPRa JSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNVi PvryxS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0P BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASs jVy16bYbMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0 eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQEwDQYJKoZIhvcNAQEFBQAD ggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc/Kh4ZzXxHfAR vbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLa IR9NmXmd4c8nnxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSy i6mx5O+aGtA9aZnuqCij4Tyz8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQ O+7ETPTsJ3xCwnR8gooJybQDJbw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDcDCCAligAwIBAgIBBTANBgkqhkiG9w0BAQUFADBbMQswCQYDVQQGEwJVUzEY MBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQwwCgYDVQQLEwNEb0QxDDAKBgNVBAsT A1BLSTEWMBQGA1UEAxMNRG9EIFJvb3QgQ0EgMjAeFw0wNDEyMTMxNTAwMTBaFw0y OTEyMDUxNTAwMTBaMFsxCzAJBgNVBAYTAlVTMRgwFgYDVQQKEw9VLlMuIEdvdmVy bm1lbnQxDDAKBgNVBAsTA0RvRDEMMAoGA1UECxMDUEtJMRYwFAYDVQQDEw1Eb0Qg Um9vdCBDQSAyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCzB9o07 rP8/PNZxvrh0IgfscEEV/KtA4weqwcPYn/7aTDq/P8jYKHtLNgHArEUlw9IOCo+F GGQQPRoTcCpvjtfcjZOzQQ84Ic2tq8I9KgXTVxE3Dc2MUfmT48xGSSGOFLTNyxQ+ OM1yMe6rEvJl6jQuVl3/7mN1y226kTT8nvP0LRy+UMRC31mI/2qz+qhsPctWcXEF lrufgOWARVlnQbDrw61gpIB1BhecDvRD4JkOG/t/9bPMsoGCsf0ywbi+QaRktWA6 WlEwjM7eQSwZR1xJEGS5dKmHQa99brrBuKG/ZTE6BGf5tbuOkooAY7ix5ow4X4P/ UNU7ol1rshDMYwIDAQABoz8wPTAdBgNVHQ4EFgQUSXS7DF66ev4CVO97oMaVxgmA cJYwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD ggEBAJiRjT+JyLv1wGlzKTs1rLqzCHY9cAmS6YREIQF9FHYb7lFsHY0VNy17MWn0 mkS4r0bMNPojywMnGdKDIXUr5+AbmSbchECV6KjSzPZYXGbvP0qXEIIdugqi3VsG K52nZE7rLgE1pLQ/E61V5NVzqGmbEfGY8jEeb0DU+HifjpGgb3AEkGaqBivO4XqS tX3h4NGW56E6LcyxnR8FRO2HmdNNGnA5wQQM5X7Z8a/XIA7xInolpHOZzD+kByeW qKKV7YK5FtOeC4fCwfKI9WLfaN/HvGlR7bFc3FRUKQ8JOZqsA8HbDE2ubwp6Fknx v5HSOJTT9pUst2zJQraNypCNhdk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBe MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 ZC4xKjAoBgNVBAsMIWVQS0kgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe Fw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMxMjdaMF4xCzAJBgNVBAYTAlRXMSMw IQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEqMCgGA1UECwwhZVBL SSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAH SyZbCUNsIZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAh ijHyl3SJCRImHJ7K2RKilTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3X DZoTM1PRYfl61dd4s5oz9wCGzh1NlDivqOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1 TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX12ruOzjjK9SXDrkb5wdJ fzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0OWQqraffA sgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uU WH1+ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLS nT0IFaUQAS2zMnaolQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pH dmX2Os+PYhcZewoozRrSgx4hxyy/vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJip NiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXiZo1jDiVN1Rmy5nk3pyKdVDEC AwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/QkqiMAwGA1UdEwQF MAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGB uvl2ICO1J2B01GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6Yl PwZpVnPDimZI+ymBV3QGypzqKOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkP JXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdVxrsStZf0X4OFunHB2WyBEXYKCrC/ gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEPNXubrjlpC2JgQCA2 j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+rGNm6 5ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUB o2M3IUxExJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS /jQ6fbjpKdx2qcgw+BRxgMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2z Gp1iro2C6pSe3VkQw63d4k3jMdXH7OjysP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTE W9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmODBCEIZ43ygknQW/2xzQ+D hNQ+IIX3Sj0rnP0qCglN6oH4EZw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEvDCCA6SgAwIBAgIQAJCLMk/BkBrOtMM4Cc3P5DANBgkqhkiG9w0BAQUFADB5 MQswCQYDVQQGEwJFUzE2MDQGA1UEChMtQ29uc2VqbyBHZW5lcmFsIGRlIGxhIEFi b2dhY2lhIE5JRjpRLTI4NjMwMDZJMTIwMAYDVQQDEylBdXRvcmlkYWQgZGUgQ2Vy dGlmaWNhY2lvbiBkZSBsYSBBYm9nYWNpYTAeFw0wNTA2MTMyMjAwMDBaFw0zMDA2 MTMyMjAwMDBaMHkxCzAJBgNVBAYTAkVTMTYwNAYDVQQKEy1Db25zZWpvIEdlbmVy YWwgZGUgbGEgQWJvZ2FjaWEgTklGOlEtMjg2MzAwNkkxMjAwBgNVBAMTKUF1dG9y aWRhZCBkZSBDZXJ0aWZpY2FjaW9uIGRlIGxhIEFib2dhY2lhMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtLJX7oXwI+gN+7KAhPEQZ6uy+UnfXN5b5I8p GVPJ1egcUGthAoyH8I88wUWSC6yZocYahdY9rX4mph24PbKzPorFCjLTS5HvSXV+ Vvf+oAhiRivO6vJRn2DeMsjtGqfPdVzrPcC9mkilhpTOWFAU6mrhmvSMZZXhYBUl lRL2uniLssDt5myXJFod5HRDyjjENZRYjvWKsGg8KCxElgm/CVtyCudnPJC5VDh0 VLttLWpDyLzvCawfI+hSVl41F18ru17NZVKlFHw7sqrp3Se1NyM7Bg0se4262m9m F4anttceB10ebBmXyOUjc3jRrvkeuqGuSSLtZXEff/dadESNQwIDAQABo4IBPjCC ATowNwYDVR0RBDAwLoERYWNAYWNhYm9nYWNpYS5vcmeGGWh0dHA6Ly93d3cuYWNh Ym9nYWNpYS5vcmcwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwEQYJ YIZIAYb4QgEBBAQDAgAHMB0GA1UdDgQWBBT8iEyObQShIJDT+Byas2cEX3mAxjCB qwYDVR0gBIGjMIGgMIGdBgsrBgEEAYGBFQoBATCBjTApBggrBgEFBQcCARYdaHR0 cDovL3d3dy5hY2Fib2dhY2lhLm9yZy9kb2MwYAYIKwYBBQUHAgIwVBpSQ29uc3Vs dGUgbGEgZGVjbGFyYWNpb24gZGUgcHJhY3RpY2FzIGRlIGNlcnRpZmljYWNpb24g ZW4gaHR0cDovL3d3dy5hY2Fib2dhY2lhLm9yZzANBgkqhkiG9w0BAQUFAAOCAQEA mKf6ObVzESZ/vIk/tGslMzEKhjhryR4VlxTg0kwthfQ8dJuNKBH7zA4muYCDFtH5 Rpi2RgeOZoVtcMC6TIDzpPDVN1Qrr2aEcnP5SC8JzuGFAcqP4IfeoJfQlLQNtU0O ZyzIYMQylMBBgQeNur+p6AxAmkJ4BV2B62Ic5E8UCj0LPh/p9M197kW7vN5d85iX JnvGEyn4K38a1Or6sm4gntoX6qGSvTfpDru7kdUl9mBdhSFQW/9UXfVLO7TDKRFY AvYl5OGCgruijeeRJF5AkZ5HB4wzV9RiMVF2dYVDbwmrEaUlKbnY/1+l9z/rZTsd 74blFiLVHsoyaX1+BdcwJw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEezCCA2OgAwIBAgIQNxkY5lNUfBq1uMtZWts1tzANBgkqhkiG9w0BAQUFADCB rjELMAkGA1UEBhMCREUxIDAeBgNVBAgTF0JhZGVuLVd1ZXJ0dGVtYmVyZyAoQlcp MRIwEAYDVQQHEwlTdHV0dGdhcnQxKTAnBgNVBAoTIERldXRzY2hlciBTcGFya2Fz c2VuIFZlcmxhZyBHbWJIMT4wPAYDVQQDEzVTLVRSVVNUIEF1dGhlbnRpY2F0aW9u IGFuZCBFbmNyeXB0aW9uIFJvb3QgQ0EgMjAwNTpQTjAeFw0wNTA2MjIwMDAwMDBa Fw0zMDA2MjEyMzU5NTlaMIGuMQswCQYDVQQGEwJERTEgMB4GA1UECBMXQmFkZW4t V3VlcnR0ZW1iZXJnIChCVykxEjAQBgNVBAcTCVN0dXR0Z2FydDEpMCcGA1UEChMg RGV1dHNjaGVyIFNwYXJrYXNzZW4gVmVybGFnIEdtYkgxPjA8BgNVBAMTNVMtVFJV U1QgQXV0aGVudGljYXRpb24gYW5kIEVuY3J5cHRpb24gUm9vdCBDQSAyMDA1OlBO MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2bVKwdMz6tNGs9HiTNL1 toPQb9UY6ZOvJ44TzbUlNlA0EmQpoVXhOmCTnijJ4/Ob4QSwI7+Vio5bG0F/WsPo TUzVJBY+h0jUJ67m91MduwwA7z5hca2/OnpYH5Q9XIHV1W/fuJvS9eXLg3KSwlOy ggLrra1fFi2SU3bxibYs9cEv4KdKb6AwajLrmnQDaHgTncovmwsdvs91DSaXm8f1 XgqfeN+zvOyauu9VjxuapgdjKRdZYgkqeQd3peDRF2npW932kKvimAoA0SVtnteF hy+S8dF2g08LOlk3KC8zpxdQ1iALCvQm+Z845y2kuJuJja2tyWp9iRe79n+Ag3rm 7QIDAQABo4GSMIGPMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEG MCkGA1UdEQQiMCCkHjAcMRowGAYDVQQDExFTVFJvbmxpbmUxLTIwNDgtNTAdBgNV HQ4EFgQUD8oeXHngovMpttKFswtKtWXsa1IwHwYDVR0jBBgwFoAUD8oeXHngovMp ttKFswtKtWXsa1IwDQYJKoZIhvcNAQEFBQADggEBAK8B8O0ZPCjoTVy7pWMciDMD pwCHpB8gq9Yc4wYfl35UvbfRssnV2oDsF9eK9XvCAPbpEW+EoFolMeKJ+aQAPzFo LtU96G7m1R08P7K9n3frndOMusDXtk3sU5wPBG7qNWdX4wple5A64U8+wwCSersF iXOMy6ZNwPv2AtawB6MDwidAnwzkhYItr5pCHdDHjfhA7p0GVxzZotiAFP7hYy0y h9WUUpY6RsZxlj33mA6ykaqP2vROJAA5VeitF7nTNCtKqUDMFypVZUF0Qn71wK/I k63yGFs9iQzbRzkk+OBM8h+wPQrKBU6JIRrjKpms/H+h8Q8bHz2eBIPdltkdOpQ= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIE5zCCA8+gAwIBAgIBADANBgkqhkiG9w0BAQUFADCBjTELMAkGA1UEBhMCQ0Ex EDAOBgNVBAgTB09udGFyaW8xEDAOBgNVBAcTB1Rvcm9udG8xHTAbBgNVBAoTFEVj aG93b3J4IENvcnBvcmF0aW9uMR8wHQYDVQQLExZDZXJ0aWZpY2F0aW9uIFNlcnZp Y2VzMRowGAYDVQQDExFFY2hvd29yeCBSb290IENBMjAeFw0wNTEwMDYxMDQ5MTNa Fw0zMDEwMDcxMDQ5MTNaMIGNMQswCQYDVQQGEwJDQTEQMA4GA1UECBMHT250YXJp bzEQMA4GA1UEBxMHVG9yb250bzEdMBsGA1UEChMURWNob3dvcnggQ29ycG9yYXRp b24xHzAdBgNVBAsTFkNlcnRpZmljYXRpb24gU2VydmljZXMxGjAYBgNVBAMTEUVj aG93b3J4IFJvb3QgQ0EyMIIBIDANBgkqhkiG9w0BAQEFAAOCAQ0AMIIBCAKCAQEA utU/5BkV15UBf+s+JQruKQxr77s3rjp/RpOtmhHILIiO5gsEWP8MMrfrVEiidjI6 Qh6ans0KAWc2Dw0/j4qKAQzOSyAZgjcdypNTBZ7muv212DA2Pu41rXqwMrlBrVi/ KTghfdLlNRu6JrC5y8HarrnRFSKF1Thbzz921kLDRoCi+FVs5eVuK5LvIfkhNAqA byrTgO3T9zfZgk8upmEkANPDL1+8y7dGPB/d6lk0I5mv8PESKX02TlvwgRSIiTHR k8++iOPLBWlGp7ZfqTEXkPUZhgrQQvxcrwCUo6mk8TqgxCDP5FgPoHFiPLef5szP ZLBJDWp7GLyE1PmkQI6WiwIBA6OCAVAwggFMMA8GA1UdEwEB/wQFMAMBAf8wCwYD VR0PBAQDAgEGMB0GA1UdDgQWBBQ74YEboKs/OyGC1eISrq5QqxSlEzCBugYDVR0j BIGyMIGvgBQ74YEboKs/OyGC1eISrq5QqxSlE6GBk6SBkDCBjTELMAkGA1UEBhMC Q0ExEDAOBgNVBAgTB09udGFyaW8xEDAOBgNVBAcTB1Rvcm9udG8xHTAbBgNVBAoT FEVjaG93b3J4IENvcnBvcmF0aW9uMR8wHQYDVQQLExZDZXJ0aWZpY2F0aW9uIFNl cnZpY2VzMRowGAYDVQQDExFFY2hvd29yeCBSb290IENBMoIBADBQBgNVHSAESTBH MEUGCysGAQQB+REKAQMBMDYwNAYIKwYBBQUHAgEWKGh0dHA6Ly93d3cuZWNob3dv cnguY29tL2NhL3Jvb3QyL2Nwcy5wZGYwDQYJKoZIhvcNAQEFBQADggEBAG+nrPi/ 0RpfEzrj02C6JGPUar4nbjIhcY6N7DWNeqBoUulBSIH/PYGNHYx7/lnJefiixPGE 7TQ5xPgElxb9bK8zoAApO7U33OubqZ7M7DlHnFeCoOoIAZnG1kuwKwD5CXKB2a74 HzcqNnFW0IsBFCYqrVh/rQgJOzDA8POGbH0DeD0xjwBBooAolkKT+7ZItJF1Pb56 QpDL9G+16F7GkmnKlAIYT3QTS3yFGYChnJcd+6txUPhKi9sSOOmAIaKHnkH9Scz+ A2cSi4A3wUYXVatuVNHpRb2lygfH3SuCX9MU8Ure3zBlSU1LALtMqI4JmcQmQpIq zIzvO2jHyu9PQqo= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID8TCCAtmgAwIBAgIQQT1yx/RrH4FDffHSKFTfmjANBgkqhkiG9w0BAQUFADCB ijELMAkGA1UEBhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxGzAZBgNVBAsTEkNvcHly aWdodCAoYykgMjAwNTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQSBDQTAeFw0w NTEyMTExNjAzNDRaFw0zNzEyMTExNjA5NTFaMIGKMQswCQYDVQQGEwJDSDEQMA4G A1UEChMHV0lTZUtleTEbMBkGA1UECxMSQ29weXJpZ2h0IChjKSAyMDA1MSIwIAYD VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBX SVNlS2V5IEdsb2JhbCBSb290IEdBIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEAy0+zAJs9Nt350UlqaxBJH+zYK7LG+DKBKUOVTJoZIyEVRd7jyBxR VVuuk+g3/ytr6dTqvirdqFEr12bDYVxgAsj1znJ7O7jyTmUIms2kahnBAbtzptf2 w93NvKSLtZlhuAGio9RN1AU9ka34tAhxZK9w8RxrfvbDd50kc3vkDIzh2TbhmYsF mQvtRTEJysIA2/dyoJaqlYfQjse2YXMNdmaM3Bu0Y6Kff5MTMPGhJ9vZ/yxViJGg 4E8HsChWjBgbl0SOid3gF27nKu+POQoxhILYQBRJLnpB5Kf+42TMwVlxSywhp1t9 4B3RLoGbw9ho972WG6xwsRYUC9tguSYBBQIDAQABo1EwTzALBgNVHQ8EBAMCAYYw DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUswN+rja8sHnR3JQmthG+IbJphpQw EAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBAEuh/wuHbrP5wUOx SPMowB0uyQlB+pQAHKSkq0lPjz0e701vvbyk9vImMMkQyh2I+3QZH4VFvbBsUfk2 ftv1TDI6QU9bR8/oCy22xBmddMVHxjtqD6wU2zz0c5ypBd8A3HR4+vg1YFkCExh8 vPtNsCBtQ7tgMHpnM1zFmdH4LTlSc/uMqpclXHLZCB6rTjzjgTGfA6b7wP4piFXa hNVQA7bihKOmNqoROgHhGEvWRGizPflTdISzRpFGlgC3gCy24eMQ4tui5yiPAZZi Fj4A4xylNoEYokxSdsARo27mHbrjWr42U8U+dY+GaSlYU7Wcu2+fXMUY7N0v4ZjJ /L7fCg0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEqjCCA5KgAwIBAgIOLmoAAQACH9dSISwRXDswDQYJKoZIhvcNAQEFBQAwdjEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxIjAgBgNV BAsTGVRDIFRydXN0Q2VudGVyIENsYXNzIDIgQ0ExJTAjBgNVBAMTHFRDIFRydXN0 Q2VudGVyIENsYXNzIDIgQ0EgSUkwHhcNMDYwMTEyMTQzODQzWhcNMjUxMjMxMjI1 OTU5WjB2MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIgR21i SDEiMCAGA1UECxMZVEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMiBDQTElMCMGA1UEAxMc VEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMiBDQSBJSTCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBAKuAh5uO8MN8h9foJIIRszzdQ2Lu+MNF2ujhoF/RKrLqk2jf tMjWQ+nEdVl//OEd+DFwIxuInie5e/060smp6RQvkL4DUsFJzfb95AhmC1eKokKg uNV/aVyQMrKXDcpK3EY+AlWJU+MaWss2xgdW94zPEfRMuzBwBJWl9jmM/XOBCH2J XjIeIqkiRUuwZi4wzJ9l/fzLganx4Duvo4bRierERXlQXa7pIXSSTYtZgo+U4+lK 8edJsBTj9WLL1XK9H7nSn6DNqPoByNkN39r8R52zyFTfSUrxIan+GE7uSNQZu+99 5OKdy1u2bv/jzVrndIIFuoAlOMvkaZ6vQaoahPUCAwEAAaOCATQwggEwMA8GA1Ud EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTjq1RMgKHbVkO3 kUrL84J6E1wIqzCB7QYDVR0fBIHlMIHiMIHfoIHcoIHZhjVodHRwOi8vd3d3LnRy dXN0Y2VudGVyLmRlL2NybC92Mi90Y19jbGFzc18yX2NhX0lJLmNybIaBn2xkYXA6 Ly93d3cudHJ1c3RjZW50ZXIuZGUvQ049VEMlMjBUcnVzdENlbnRlciUyMENsYXNz JTIwMiUyMENBJTIwSUksTz1UQyUyMFRydXN0Q2VudGVyJTIwR21iSCxPVT1yb290 Y2VydHMsREM9dHJ1c3RjZW50ZXIsREM9ZGU/Y2VydGlmaWNhdGVSZXZvY2F0aW9u TGlzdD9iYXNlPzANBgkqhkiG9w0BAQUFAAOCAQEAjNfffu4bgBCzg/XbEeprS6iS GNn3Bzn1LL4GdXpoUxUc6krtXvwjshOg0wn/9vYua0Fxec3ibf2uWWuFHbhOIprt ZjluS5TmVfwLG4t3wVMTZonZKNaL80VKY7f9ewthXbhtvsPcW3nS7Yblok2+XnR8 au0WOB9/WIFaGusyiC2y8zl3gK9etmF1KdsjTYjKUCjLhdLTEKJZbtOTVAB6okaV hgWcqRmY5TFyDADiZ9lA4CQze28suVyrZZ0srHbqNZn1l7kPJOzHdiEoZa5X6AeI dUpWoNIFOqTmjZKILPPy4cHGYdtBxceb9w4aUUXCYWvcZCcXjFq32nQozZfkvQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEqjCCA5KgAwIBAgIOSkcAAQAC5aBd1j8AUb8wDQYJKoZIhvcNAQEFBQAwdjEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxIjAgBgNV BAsTGVRDIFRydXN0Q2VudGVyIENsYXNzIDMgQ0ExJTAjBgNVBAMTHFRDIFRydXN0 Q2VudGVyIENsYXNzIDMgQ0EgSUkwHhcNMDYwMTEyMTQ0MTU3WhcNMjUxMjMxMjI1 OTU5WjB2MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIgR21i SDEiMCAGA1UECxMZVEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMyBDQTElMCMGA1UEAxMc VEMgVHJ1c3RDZW50ZXIgQ2xhc3MgMyBDQSBJSTCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBALTgu1G7OVyLBMVMeRwjhjEQY0NVJz/GRcekPewJDRoeIMJW Ht4bNwcwIi9v8Qbxq63WyKthoy9DxLCyLfzDlml7forkzMA5EpBCYMnMNWju2l+Q Vl/NHE1bWEnrDgFPZPosPIlY2C8u4rBo6SI7dYnWRBpl8huXJh0obazovVkdKyT2 1oQDZogkAHhg8fir/gKya/si+zXmFtGt9i4S5Po1auUZuV3bOx4a+9P/FRQI2Alq ukWdFHlgfa9Aigdzs5OW03Q0jTo3Kd5c7PXuLjHCINy+8U9/I1LZW+Jk2ZyqBwi1 Rb3R0DHBq1SfqdLDYmAD8bs5SpJKPQq5ncWg/jcCAwEAAaOCATQwggEwMA8GA1Ud EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTUovyfs8PYA9NX XAek0CSnwPIA1DCB7QYDVR0fBIHlMIHiMIHfoIHcoIHZhjVodHRwOi8vd3d3LnRy dXN0Y2VudGVyLmRlL2NybC92Mi90Y19jbGFzc18zX2NhX0lJLmNybIaBn2xkYXA6 Ly93d3cudHJ1c3RjZW50ZXIuZGUvQ049VEMlMjBUcnVzdENlbnRlciUyMENsYXNz JTIwMyUyMENBJTIwSUksTz1UQyUyMFRydXN0Q2VudGVyJTIwR21iSCxPVT1yb290 Y2VydHMsREM9dHJ1c3RjZW50ZXIsREM9ZGU/Y2VydGlmaWNhdGVSZXZvY2F0aW9u TGlzdD9iYXNlPzANBgkqhkiG9w0BAQUFAAOCAQEANmDkcPcGIEPZIxpC8vijsrlN irTzwppVMXzEO2eatN9NDoqTSheLG43KieHPOh6sHfGcMrSOWXaiQYUlN6AT0PV8 TtXqluJucsG7Kv5sbviRmEb8yRtXW+rIGjs/sFGYPAfaLFkB2otE6OF0/ado3VS6 g0bsyEa1+K+XwDsJHI/OcpY9M1ZwvJbL2NV9IJqDnxrcOfHFcqMRA/07QlIp2+gB 95tejNaNhk4Z+rwcvsUhpYeeeC422wlxo3I0+GzjBgnyXlal092Y+tTmBvTwtiBj S+opvaqCZh77gaqnN60TGOaSw4HBM7uIHqHn4rS9MWwOUT1v+5ZWgOI2F9Hc5A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFvzCCA6egAwIBAgIQANKFcP2up9ZfEYQVxjG1yzANBgkqhkiG9w0BAQUFADBd MQswCQYDVQQGEwJFUzEoMCYGA1UECgwfRElSRUNDSU9OIEdFTkVSQUwgREUgTEEg UE9MSUNJQTENMAsGA1UECwwERE5JRTEVMBMGA1UEAwwMQUMgUkFJWiBETklFMB4X DTA2MDIxNjEwMzcyNVoXDTM2MDIwODIyNTk1OVowXTELMAkGA1UEBhMCRVMxKDAm BgNVBAoMH0RJUkVDQ0lPTiBHRU5FUkFMIERFIExBIFBPTElDSUExDTALBgNVBAsM BEROSUUxFTATBgNVBAMMDEFDIFJBSVogRE5JRTCCAiIwDQYJKoZIhvcNAQEBBQAD ggIPADCCAgoCggIBAIAArQzDoyAHo2P/9zSgze5qVAgXXbEBFafmuV+Kcf8Mwh3q N/Pek3/WBU2EstXXHAz0xJFwQA5ayJikgOgNM8AH87f1rKE4esBmVCT8UswwKvLD xKEsdr/BwL+C8ZvwaHoTQMiXvBwlBwgKt5bvzClU4OZlLeqyLrEJaRJOMNXY+LwA gC9Nkw/NLlcbM7ufME7Epct5p/viNBi2IJ4bn12nyTqtRWSzGM4REpxtHlVFKISc V2dN+cvii49YCdQ5/8g20jjiDGV/FQ59wQfdqSLfkQDEbHE0dNw56upPRGl/WNtY ClJxK+ypHVB0M/kpavr+mfTnzEVFbcpaJaIS487XOAU58BoJ9XZZzmJvejQNLNG8 BBLsPVPI+tACy849IbXF4DkzZc85U8mbRvmdM/NZgAhBvm9LoPpKzqR2HIXir68U nWWs93+X5DNJpq++zis38S7BcwWcnGBMnTANl1SegWK75+Av9xQHFKl3kenckZWO 04iQM0dvccMUafqmLQEeG+rTLuJ/C9zP5yLw8UGjAZLlgNO+qWKoVYgLNDTs3CEV qu/WIl6J9VGSEypvgBbZsQ3ZLvgQuML+UkUznB04fNwVaTRzv6AsuxF7lM34Ny1v Pe+DWsYem3RJj9nCjb4WdlDIWtElFvb2zIycWjCeZb7QmkiT1/poDXUxh/n3AgMB AAGjezB5MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQW BBSORfSfc8X/LxsF2wFHYBsDioG3ujA3BgNVHSAEMDAuMCwGBFUdIAAwJDAiBggr BgEFBQcCARYWaHR0cDovL3d3dy5kbmllLmVzL2RwYzANBgkqhkiG9w0BAQUFAAOC AgEAdeVzyVFRL4sZoIfp/642Nqb8QR/jHtdxYBnGb5oCML1ica1z/pEtTuQmQESp rngmIzFp3Jpzlh5JUQvg78G4Q+9xnO5Bt8VQHzKEniKG8fcfj9mtK07alyiXu5aa Gvix2XoE81SZEhmWFYBnOf8CX3r8VUJQWua5ov+4qGIeFM3ZP76jZUjFO9c3zg36 KJDav/njUUclfUrTZ02HqmK8Xux6gER8958KvWVXlMryEWbWUn/kOnB1BM07l9Q2 cvdRVr809dJB4bTaqEP+axJJErRdzyJClowIIyaMshBOXapT7gEvdeW5ohEzxNdq /fgOym6C2ee7WSNOtfkRHS9rI/V7ESDqQRKQMkbbMTupwVtzaDpGG4z+l7dWuWGZ zE7wg/o38d4cnRxxiwOTw8Rzgi6omB1kopqM91QITc/qgcv1WwmZY691jJb4eTXV 3OtBgXk4hF5v8W9idtuRzlqFYDkdW+IqL0Ml28J6JNMVsKLxjKB9a0gJE/+iTGaK 7HBSCVOMMMy41bok3DCZPqFet9+BrOw3vk6bJ1jefqGbVH8Gti/kMlD95xC7qM3a GBvUY2Y96lFxOfScPt9a9NrHTCbti7UhujR5AnNhENqYMahgy34Hp9C3BUOJW82F JtmwUa/3jFKqEqdY35KbZ/Kd8ub0aTH0Fufed1se3ZoFAa0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHxDCCBaygAwIBAgIIGq+SbI+Tr2AwDQYJKoZIhvcNAQEFBQAwgZUxgZIwCQYD VQQGDAJCRzAVBgNVBAoMDkluZm9Ob3RhcnkgUExDMBUGCgmSJomT8ixkARkWB3Jv b3QtY2EwGgYDVQQDDBNJbmZvTm90YXJ5IENTUCBSb290MBoGA1UECwwTSW5mb05v dGFyeSBDU1AgUm9vdDAfBgkqhkiG9w0BCQEWEmNzcEBpbmZvbm90YXJ5LmNvbTAi GA8yMDA2MDMwNjE3MzMwNVoYDzIwMjYwMzA2MTczMzA1WjCBlTGBkjAJBgNVBAYM AkJHMBUGA1UECgwOSW5mb05vdGFyeSBQTEMwFQYKCZImiZPyLGQBGRYHcm9vdC1j YTAaBgNVBAMME0luZm9Ob3RhcnkgQ1NQIFJvb3QwGgYDVQQLDBNJbmZvTm90YXJ5 IENTUCBSb290MB8GCSqGSIb3DQEJARYSY3NwQGluZm9ub3RhcnkuY29tMIICIjAN BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnM2kXh+kfgiCT4B2wSeMRxZgn3Os yZF/LRBsO5RJnEIzX5TxgyEJkfeStw84RYuUB0qr/j82NVvR1VI4QkboR8dKUDPI 5OBztpCauqOIONMrQsBu36ITF/JPuefyId1l+qb9UsJgsstPN72vJ45pazJAU1n1 8jF2iAZtaJ8wvLeBHI5nY8MFZfcz9lmpq7OKRwsUE8c23SL7+EQ0NUEowIG6TrVx E68DqEkKLqSbYXdrHSTSpfEt0Udegv9Ig7AVmEfedvtPTsC/VElmvE8B0Xw6Zf7F rHBbxRUbcqV9pls7F8O+wz9dEn9nLXZHDIu/FiO1g3LNgM3ouq9eaDM41JDtbtLp VgQkofm+bF7KniMgkiuaDUtN46JIj77tqg6Mvqk4cLkQfzJdgCdaA+DURzMlWnap g9mYQO1/dQLv7mGRrDE+gNM0S/DCTjUwSV63Keh9IbD/KnmcNDEZZjMgYRovsILV sWdTkA2dKpcobrdmPxp/psSaaJbmZlt6N99adxXg2eN8s7LQ1WjbPh1YH47KmHRj v9R9Nmju0C+kKvKIL3fDEj+NqCUsBGlYpsu0OfGI0Kbels5zQkbTo/nx2I6KnW6K wFJPRbdw/hx7PQ2W3PmQlm3GGKFCwrbc2SMfl5ObDxn5sLc/JXS9glPsDS9t1j9S L9h20CW90Ln1vQ8CAwEAAaOCAhAwggIMMA4GA1UdDwEB/wQEAwIBBjBEBggrBgEF BQcBAQQ4MDYwNAYIKwYBBQUHMAGGKGh0dHA6Ly9vY3NwLmluZm9ub3RhcnkuY29t L3Jlc3BvbmRlci5jZ2kwVgYIKwYBBQUHAQsESjBIMEYGCCsGAQUFBzAFhjpsZGFw Oi8vbGRhcC5pbmZvbm90YXJ5LmNvbS9kYz1yb290LWNhLGRjPWluZm9ub3Rhcnks ZGM9Y29tMIGqBgNVHSAEgaIwgZ8wbwYJKwYBBAGBrQABMGIwOgYIKwYBBQUHAgEW Lmh0dHA6Ly9yZXBvc2l0b3J5LmluZm9ub3RhcnkuY29tL2Nwcy9xY3BzLmh0bWww JAYIKwYBBQUHAgIwGBoWSW5mb05vdGFyeSBDU1AgUm9vdCBDQTAsBgkrBgEEAYGt AAAwHzAdBggrBgEFBQcCARYRaHR0cDovL3d3dy5jcmMuYmcwDwYDVR0TAQH/BAUw AwEB/zB/BgNVHREEeDB2pHQwcjFwMAsGA1UEEQwEMTAwMDAMBgNVBAcMBVNvZmlh MBMGA1UEFAwMKzM1OTI5ODc1NzE3MBsGBlUECmQBAQwRMTMxMjc2ODI3OkJVTFNU QVQwIQYJKoZIhvcNAQkIDBQxNiBJdmFuIFZhc292IFN0cmVldDAdBgNVHQ4EFgQU 3dROZ0M/0+pi6NqJbo47bgu7lZ8wDQYJKoZIhvcNAQEFBQADggIBABib/A3B+HGs 1MwUtScJwVhKNEDmm2XK4PGLUj2Wfoke3qgV+t2ULoPGNl0bIak2Dlw9SYgMUyFd H21JNm+cUOvbZM+Juq9erRREh2LvMHzAlt9wOcs7Ue4r/AgFh1bNMyXggBrgpucN Q0wAI0NWog4ZVOKN0Q0WuVpvm3flHKmDiyjx4TJ2X0ewmjbqsm/dhjFY/gZpsMNg pvvYKNQI3fFuThq9zbesviKHFkmxOADVjEMp5ylrKxJiWapD8LRyiDjWAl8f2iSS jY18/B99OJBYCx8ctxy7NictWhzHMd20K529R6ExtwkR4s1vp270uKMpj/Ngv6cd 4E+XJSUKMEnH/no5RNTTZe+IXf/Z7lM5vDpEqDE7JZd7mr/R3Wvi1xJq+zK70diO 85azj5DqeFjBCtulJb6KAx/lktK8f6Ry5OtWeqn6fLjxoL8m/ko0zyWqZMS7e+0Y 5Uwsb0Fl7eC7PSx1VW/kFQbFS2yT9g9VzJN1hWEmA9LMlct6ECAnVnq/dVjc9Q2W 2UoHhNgimxDR1gZuFgcDs69546AXurhDCEKdPDsnzHwR1H4x82ZRAhFyOqPpcr2V XaEVf0cTOZWyta728WF0MHjHZgHTsnCXCMkNJPREKnCmnS8SZAZuio0g437a8VS/ DRSsKb59f3+5a0UCUWcVWaIOISfJgmcx -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID3TCCAsWgAwIBAgIOHaIAAQAC7LdggHiNtgYwDQYJKoZIhvcNAQEFBQAweTEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxJDAiBgNV BAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEmMCQGA1UEAxMdVEMgVHJ1 c3RDZW50ZXIgVW5pdmVyc2FsIENBIEkwHhcNMDYwMzIyMTU1NDI4WhcNMjUxMjMx MjI1OTU5WjB5MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIg R21iSDEkMCIGA1UECxMbVEMgVHJ1c3RDZW50ZXIgVW5pdmVyc2FsIENBMSYwJAYD VQQDEx1UQyBUcnVzdENlbnRlciBVbml2ZXJzYWwgQ0EgSTCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKR3I5ZEr5D0MacQ9CaHnPM42Q9e3s9B6DGtxnSR JJZ4Hgmgm5qVSkr1YnwCqMqs+1oEdjneX/H5s7/zA1hV0qq34wQi0fiU2iIIAI3T fCZdzHd55yx4Oagmcw6iXSVphU9VDprvxrlE4Vc93x9UIuVvZaozhDrzznq+VZeu jRIPFDPiUHDDSYcTvFHe15gSWu86gzOSBnWLknwSaHtwag+1m7Z3W0hZneTvWq3z wZ7U10VOylY0Ibw+F1tvdwxIAUMpsN0/lm7mlaoMwCC2/T42J5zjXM9OgdwZu5GQ fezmlwQek8wiSdeXhrYTCjxDI3d+8NzmzSQfO4ObNDqDNOMCAwEAAaNjMGEwHwYD VR0jBBgwFoAUkqR1LKSevoFE63n8isWVpesQdXMwDwYDVR0TAQH/BAUwAwEB/zAO BgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFJKkdSyknr6BROt5/IrFlaXrEHVzMA0G CSqGSIb3DQEBBQUAA4IBAQAo0uCG1eb4e/CX3CJrO5UUVg8RMKWaTzqwOuAGy2X1 7caXJ/4l8lfmXpWMPmRgFVp/Lw0BxbFg/UU1z/CyvwbZ71q+s2IhtNerNXxTPqYn 8aEt2hojnczd7Dwtnic0XQ/CNnm8yUpiLe1r2X1BQ3y2qsrtYbE3ghUJGooWMNjs ydZHcnhLEEYUjl8Or+zHL6sQ17bxbuyGssLoDZJz3KL0Dzq/YSMQiZxIQG5wALPT ujdEWBF6AmqI8Dc08BnprNRlc/ZpjGSUOnmFKbAWKwyCPwacx/0QK54PLLae4xW/ 2TYcuiUaUj0a7CIMHOCkoj3w6DnPgcB77V0fb8XQC9eY -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF3zCCA8egAwIBAgIOGTMAAQACKBqaBLzyVUUwDQYJKoZIhvcNAQEFBQAwejEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxJDAiBgNV BAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEnMCUGA1UEAxMeVEMgVHJ1 c3RDZW50ZXIgVW5pdmVyc2FsIENBIElJMB4XDTA2MDMyMjE1NTgzNFoXDTMwMTIz MTIyNTk1OVowejELMAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVy IEdtYkgxJDAiBgNVBAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEnMCUG A1UEAxMeVEMgVHJ1c3RDZW50ZXIgVW5pdmVyc2FsIENBIElJMIICIjANBgkqhkiG 9w0BAQEFAAOCAg8AMIICCgKCAgEAi9R3azRs5TbYalxeOO781R15Azt7g2JEgk6I 7d6D/+7MUGIFBZWZdpj2ufJf2AaRksL2LWYXH/1TA+iojWOpbuHWG4y8mLOLO9Tk Lsp9hUkmW3m4GotAnn+7yT9jLM/RWny6KCJBElpN+Rd3/IX9wkngKhh/6aAsnPlE /AxoOUL1JwW+jhV6YJ3wO8c85j4WvK923mq3ouGrRkXrjGV90ZfzlxElq1nroCLZ gt2Y7X7i+qBhCkoy3iwX921E6oFHWZdXNwM53V6CItQzuPomCba8OYgvURVOm8M7 3xOCiN1LNPIz1pDp81PcNXzAw9l8eLPNcD+NauCjgUjkKa1juPD8KGQ7mbN9/pqd iPaZIgiRRxaJNXhdd6HPv0nh/SSUK2k2e+gc5iqQilvVOzRZQtxtz7sPQRxVzfUN Wy4WIibvYR6X/OJTyM9bo8ep8boOhhLLE8oVx+zkNo3aXBM9ZdIOXXB03L+PemrB Lg/Txl4PK1lszGFs/sBhTtnmT0ayWuIZFHCE+CAA7QGnl37DvRJckiMXoKUdRRcV I5qSCLUiiI3cKyTr4LEXaNOvYb3ZhXj2jbp4yjeNY77nrB/fpUcJucglMVRGURFV DYlcjdrSGC1z8rjVJ/VIIjfRYvd7Dcg4i6FKsPzQ8eu3hmPn4A5zf/1yUbXpfeJV BWR4Z38CAwEAAaNjMGEwHwYDVR0jBBgwFoAUzdeQoW6jv9sw1toyJZAM5jkegGUw DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFM3XkKFu o7/bMNbaMiWQDOY5HoBlMA0GCSqGSIb3DQEBBQUAA4ICAQB+FojoEw42zG4qhQc4 xlaJeuNHIWZMUAgxWlHQ/KZeFHXeTDvs8e3MfhEHSmHu6rOOOqQzxu2KQmZP8Tx7 yaUFQZmx7Cxb7tyW0ohTS3g0uW7muw/FeqZ8Dhjfbw90TNGp8aHp2FRkzF6WeKJW GsFzshXGVwXf2vdIJIqOf2qp+U3pPmrOYCx9LZAI9mOPFdAtnIz/8f38DBZQVhT7 upeG7rRJA1TuG1l/MDoCgoYhrv7wFfLfToPmmcW6NfcgkIw47XXP4S73BDD7Ua2O giRAyn0pXdXZ92Vk/KqfdLh9kl3ShCngE+qK99CrxK7vFcXCifJ7tjtJmGHzTnKR N4xJkunI7Cqg90lufA0kxmts8jgvynAF5X/fxisrgIDV2m/LQLvYG/AkyRDIRAJ+ LtOYqqIN8SvQ2vqOHP9U6OFKbt2o1ni1N6WsZNUUI8cOpevhCTjXwHxgpV2Yj4wC 1dxWqPNNWKkL1HxkdAEy8t8PSoqpAqKiHYR3wvHMl700GXRd4nQ+dSf3r7/ufA5t VIimVuImrTESPB5BeW0X6hNeH/Vcn0lZo7Ivo0LD+qh+v6WfSMlgYmIK371F3uNC tVGW/cT1Gpm4UqJEzS1hjBWPgdVdotSQPYxuQGHDWV3Y2eH2dEcieXR92sqjbzcV NvAsGnE8EXbfXRo+VGN4a2V+Hw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDtjCCAp6gAwIBAgIOBcAAAQACQdAGCk3OdRAwDQYJKoZIhvcNAQEFBQAwdjEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxIjAgBgNV BAsTGVRDIFRydXN0Q2VudGVyIENsYXNzIDQgQ0ExJTAjBgNVBAMTHFRDIFRydXN0 Q2VudGVyIENsYXNzIDQgQ0EgSUkwHhcNMDYwMzIzMTQxMDIzWhcNMjUxMjMxMjI1 OTU5WjB2MQswCQYDVQQGEwJERTEcMBoGA1UEChMTVEMgVHJ1c3RDZW50ZXIgR21i SDEiMCAGA1UECxMZVEMgVHJ1c3RDZW50ZXIgQ2xhc3MgNCBDQTElMCMGA1UEAxMc VEMgVHJ1c3RDZW50ZXIgQ2xhc3MgNCBDQSBJSTCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBALXNTJytrlG7fEjFDSmGehSt2VA9CXIgDRS2Y8b+WJ7gIV7z jyIZ3E6RIM1viCmis8GsKnK6i1S4QF/yqvhDhsIwXMynXX/GCEnkDjkvjhjWkd0j FnmA22xIHbzB3ygQY9GB493fL3l1oht48pQB5hBiecugfQLANIJ7x8CtHUzXapZ2 W78mhEj9h/aECqqSB5lIPGG8ToVYx5ct/YFKocabEvVCUNFkPologiJw3fX64yhC L04y87OjNopq1mJcrPoBbbTgci6VaLTxkwzGioLSHVPqfOA/QrcSWrjN2qUGZ8uh d32llvCSHmcOHUJG5vnt+0dTf1cERh9GX8eu4I8CAwEAAaNCMEAwDwYDVR0TAQH/ BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFB/quz4lGwa9pd1iBX7G TFq/6A9DMA0GCSqGSIb3DQEBBQUAA4IBAQBYpCubTPfkpJKknGWYGWIi/HIy6QRd xMRwLVpG3kxHiiW5ot3u6hKvSI3vK2fbO8w0mCr3CEf/Iq978fTr4jgCMxh1KBue dmWsiANy8jhHHYz1nwqIUxAUu4DlDLNdjRfuHhkcho0UZ3iMksseIUn3f9MYv5x5 +F0IebWqak2SNmy8eesOPXmK2PajVnBd3ttPedJ60pVchidlvqDTB4FAVd0Qy+BL iILAkH0457+W4Ze6mqtCD9Of2J4VMxHL94J59bXAQVaS4d9VA61Iz9PyLrHHLVZM ZHQqMc7cdalUR6SnQnIJ5+ECpkeyBM1CE+FhDOB4OiIgohxgQoaH96Xm -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0 MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+ +FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1 XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3 R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93 d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0 YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7 R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX UKqK1drk/NAJBzewdXUh -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFbjCCA1agAwIBAgIPQupbClERJnzYJ3S3339xMA0GCSqGSIb3DQEBBQUAMDMx CzAJBgNVBAYTAlBUMQ0wCwYDVQQKDARTQ0VFMRUwEwYDVQQDDAxFQ1JhaXpFc3Rh ZG8wHhcNMDYwNjIzMTM0MTI3WhcNMzAwNjIzMTM0MTI3WjAzMQswCQYDVQQGEwJQ VDENMAsGA1UECgwEU0NFRTEVMBMGA1UEAwwMRUNSYWl6RXN0YWRvMIICIjANBgkq hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2++iQ27Iqf1u19+sopKEochZoAyaU/7v rswZDXKKpMIzI+/nBnLqbUs6QVIPyUgOLee6ZO6iOkxjXGYpi9+piMW96PH3jkv8 ATxEEjkqcKLA28Wi31/HS8ao3D1hfEpYwUQyk95wmaEjJlY/o+HqXzBG2Hj1MKOW CYmwPfGGkwW2EmoYjfClZDsrh2RePReOC27mmMyXODggjHBaaSu9ZY3NN1lcbNFy dFkGTsi3Add3v/BIhqizGl1B1DcXERBfSm6NdcUDQH0hrgDw2/yfbDpmpN/3yt+A ZlrZ2H8UoiYZ9K4LIeDKPgXdFth+WdqhsGnDnTQT+mVJOYfudi+NvTwnGQNOrQ4L KyzGLnETNSlX6XDcG1HqzZfxlY2yhvomBi+AGpXxmDvu9uWGpc4bAeX06TPKD1VE X2iKLMdbZijdlkuDnV4dfhjV/rJg+5pRaMOWjB9oS1BSCzbmMSfk1ykMG9obL+EE U7jUeUmwO4FeCIgid+IpwK5yqqu0clK9bLv1unjZnLggbzCNSp0y+fQB5mJ5mEJA BXpvHCo/tfvfzRhAjuUQxDlbVvE8VwWr0jlNP/iLI8druUCx4v7/sxwKaR+bjA+0 H+AK3kj9jV+PmfUBdgU2XY7cM45RbhHiQf3Mt40qXz6S5fKx4KQj4qK3xo0YmylK 0UZ/9GQgGN0CAwEAAaN/MH0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AQYwHQYDVR0OBBYEFHF/Nd71d3FtHRKc4ZCkuvCpg4+AMDsGA1UdIAQ0MDIwMAYE VR0gADAoMCYGCCsGAQUFBwIBFhpodHRwOi8vd3d3LmVjZWUuZ292LnB0L2RwYzAN BgkqhkiG9w0BAQUFAAOCAgEAjK2ccqW1Z3ZnOIfpOoz+nVk1vpDxAwCgWNiY0b/8 /PNQ3LRl1dq68IwufA3mCZFfTaP2XXicWF1qcJSjr9svAMkDQGvfUQMWGYwrvJk2 9sCtkhgTjKftHdLfA5AF7LCTmJv3TVoT+Oeb9zZ23nwm+BE4T0lOs3MfXydb4Z4y HvbAmBvZICxclo2GyQtF15Ktir3qV6KjVrYgPOyyxzl+sID+vVErKrTDcmnD+Ucu bv+ch+3cdcsQiOC0zi4OUx0L6G4eQkzQvjl4dckU3ieRc6rsaoDw8BeWYk++BMvi p+VdD5NFy1lIJhPe3bH1CtoWsagdj35YG7fVCd6Ia86EPqi+UmLK0qGhx8s8FuB2 VjA/5g9rBnf+ZJ1aanN87t4h6ZpJlze2hH+ikT5F+9daBsWHNdy6SEyGAQhHNrY4 UJURmXPRN0kK+kJPLxBU00GQ+sjcuxHcDcx9fJvcDpFxhk248hWaKzgXEaHynqhs nOPOruLmS4vyigY7B3cCEe6D6p1mhsrwYqnVV4OkFfFFFP4adX+lD9xSdFl1Cvj7 VUGpXI0xRN3NlE4z0RtBqtvXoTzwxUhtRUE1tXmD5vlN8VY4179AIvsggOMcwllG B2MCYQA7m1C7Q8Ow6QqauHb0R2FVZHBPN9mcEaMTsuHdQEK7mNegBovmaFdLDjho f7o= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYT AlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBD QTAeFw0wNjA3MDQxNzIwMDRaFw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJP MREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTCC ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7IJUqOtdu0KBuqV5Do 0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHHrfAQ UySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5d RdY4zTW2ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQ OA7+j0xbm0bqQfWwCHTD0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwv JoIQ4uNllAoEwF73XVv4EOLQunpL+943AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08C AwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAcYwHQYDVR0O BBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IBAQA+0hyJ LjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecY MnQ8SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ 44gx+FkagQnIl6Z0x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6I Jd1hJyMctTEHBDa0GpC9oHRxUIltvBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNw i/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7NzTogVZ96edhBiIL5VaZVDADlN 9u6wWk5JRFRYX0KD -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIH/jCCBeagAwIBAgIBADANBgkqhkiG9w0BAQUFADCB1DELMAkGA1UEBhMCQVQx DzANBgNVBAcTBlZpZW5uYTEQMA4GA1UECBMHQXVzdHJpYTE6MDgGA1UEChMxQVJH RSBEQVRFTiAtIEF1c3RyaWFuIFNvY2lldHkgZm9yIERhdGEgUHJvdGVjdGlvbjEq MCgGA1UECxMhR0xPQkFMVFJVU1QgQ2VydGlmaWNhdGlvbiBTZXJ2aWNlMRQwEgYD VQQDEwtHTE9CQUxUUlVTVDEkMCIGCSqGSIb3DQEJARYVaW5mb0BnbG9iYWx0cnVz dC5pbmZvMB4XDTA2MDgwNzE0MTIzNVoXDTM2MDkxODE0MTIzNVowgdQxCzAJBgNV BAYTAkFUMQ8wDQYDVQQHEwZWaWVubmExEDAOBgNVBAgTB0F1c3RyaWExOjA4BgNV BAoTMUFSR0UgREFURU4gLSBBdXN0cmlhbiBTb2NpZXR5IGZvciBEYXRhIFByb3Rl Y3Rpb24xKjAoBgNVBAsTIUdMT0JBTFRSVVNUIENlcnRpZmljYXRpb24gU2Vydmlj ZTEUMBIGA1UEAxMLR0xPQkFMVFJVU1QxJDAiBgkqhkiG9w0BCQEWFWluZm9AZ2xv YmFsdHJ1c3QuaW5mbzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANIS R+xfmOgNhhVJxN3snvFszVG2+5VPi8SQPVMzsdMTxUjipb/19AOED5x4cfaSl/Fb WXUYPycLUS9caMeh6wDz9pU9acN+wqzECjZyelum0PcBeyjHKscyYO5ZuNcLJ92z RQUre2Snc1zokwKXaOz8hNue1NWBR8acwKyXyxnqh6UKo7h1JOdQJw2rFvlWXbGB ARZ98+nhJPMIIbm6rF2ex0h5f2rK3zl3BG0bbjrNf85cSKwSPFnyas+ASOH2AGd4 IOD9tWR7F5ez5SfdRWubYZkGvvLnnqRtiztrDIHutG+hvhoSQUuerQ75RrRa0QMA lBbAwPOs+3y8lsAp2PkzFomjDh2V2QPUIQzdVghJZciNqyEfVLuZvPFEW3sAGP0q GVjSBcnZKTYl/nfua1lUTwgUopkJRVetB94i/IccoO+ged0KfcB/NegMZk3jtWoW WXFb85CwUl6RAseoucIEb55PtAAt7AjsrkBu8CknIjm2zaCGELoLNex7Wg22ecP6 x63B++vtK4QN6t7565pZM2zBKxKMuD7FNiM4GtZ3k5DWd3VqWBkXoRWObnYOo3Ph XJVJ28EPlBTF1WIbmas41Wdu0qkZ4Vo6h2pIP5GW48bFJ2tXdDGY9j5xce1+3rBN LPPuj9t7aNcQRCmt7KtQWVKabGpyFE0WFFH3134fAgMBAAGjggHXMIIB0zAdBgNV HQ4EFgQUwAHV4HgfL3Q64+vAIVKmBO4my6QwggEBBgNVHSMEgfkwgfaAFMAB1eB4 Hy90OuPrwCFSpgTuJsukoYHapIHXMIHUMQswCQYDVQQGEwJBVDEPMA0GA1UEBxMG Vmllbm5hMRAwDgYDVQQIEwdBdXN0cmlhMTowOAYDVQQKEzFBUkdFIERBVEVOIC0g QXVzdHJpYW4gU29jaWV0eSBmb3IgRGF0YSBQcm90ZWN0aW9uMSowKAYDVQQLEyFH TE9CQUxUUlVTVCBDZXJ0aWZpY2F0aW9uIFNlcnZpY2UxFDASBgNVBAMTC0dMT0JB TFRSVVNUMSQwIgYJKoZIhvcNAQkBFhVpbmZvQGdsb2JhbHRydXN0LmluZm+CAQAw DwYDVR0TAQH/BAUwAwEB/zALBgNVHQ8EBAMCAcYwEQYDVR0gBAowCDAGBgRVHSAA MD0GA1UdEQQ2MDSBFWluZm9AZ2xvYmFsdHJ1c3QuaW5mb4YbaHR0cDovL3d3dy5n bG9iYWx0cnVzdC5pbmZvMD0GA1UdEgQ2MDSBFWluZm9AZ2xvYmFsdHJ1c3QuaW5m b4YbaHR0cDovL3d3dy5nbG9iYWx0cnVzdC5pbmZvMA0GCSqGSIb3DQEBBQUAA4IC AQAVO4iDXg7ePvA+XdwtoUr6KKXWB6UkSM6eeeh5mlwkjlhyFEGFx0XuPChpOEmu Io27jAVtrmW7h7l+djsoY2rWbzMwiH5VBbq5FQOYHWLSzsAPbhyaNO7krx9i0ey0 ec/PaZKKWP3Bx3YLXM1SNEhr5Qt/yTIS35gKFtkzVhaP30M/170/xR7FrSGshyya 5BwfhQOsi8e3M2JJwfiqK05dhz52Uq5ZfjHhfLpSi1iQ14BGCzQ23u8RyVwiRsI8 p39iBG/fPkiO6gs+CKwYGlLW8fbUYi8DuZrWPFN/VSbGNSshdLCJkFTkAYhcnIUq mmVeS1fygBzsZzSaRtwCdv5yN3IJsfAjj1izAn3ueA65PXMSLVWfF2Ovrtiuc7bH UGqFwdt9+5RZcMbDB2xWxbAH/E59kx25J8CwldXnfAW89w8Ks/RuFVdJG7UUAKQw K1r0Vli/djSiPf4BJvDduG3wpOe8IPZRCPbjN4lXNvb3L/7NuGS96tem0P94737h HB5Ufg80GYEQc9LjeAYXttJR+zV4dtp3gzdBPi1GqH6G3lb0ypCetK2wHkUYPDSI Aofo8DaR6/LntdIEuS64XY0dmi4LFhnNdqSr+9Hio6LchH176lDq9bIEO4lSOrLD GU+5JrG8vCyy4YGms2G19EVgLyx1xcgtiEsmu3DuO38BLQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHyTCCBbGgAwIBAgIBATANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJJTDEW MBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwg Q2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNh dGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0NjM2WhcNMzYwOTE3MTk0NjM2WjB9 MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMi U2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3Rh cnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA A4ICDwAwggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZk pMyONvg45iPwbm2xPN1yo4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rf OQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/C Ji/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/deMotHweXMAEtcnn6RtYT Kqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt2PZE4XNi HzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMM Av+Z6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w +2OqqGwaVLRcJXrJosmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+ Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3 Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVcUjyJthkqcwEKDwOzEmDyei+B 26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT37uMdBNSSwID AQABo4ICUjCCAk4wDAYDVR0TBAUwAwEB/zALBgNVHQ8EBAMCAa4wHQYDVR0OBBYE FE4L7xqkQFulF2mHMMo0aEPQQa7yMGQGA1UdHwRdMFswLKAqoCiGJmh0dHA6Ly9j ZXJ0LnN0YXJ0Y29tLm9yZy9zZnNjYS1jcmwuY3JsMCugKaAnhiVodHRwOi8vY3Js LnN0YXJ0Y29tLm9yZy9zZnNjYS1jcmwuY3JsMIIBXQYDVR0gBIIBVDCCAVAwggFM BgsrBgEEAYG1NwEBATCCATswLwYIKwYBBQUHAgEWI2h0dHA6Ly9jZXJ0LnN0YXJ0 Y29tLm9yZy9wb2xpY3kucGRmMDUGCCsGAQUFBwIBFilodHRwOi8vY2VydC5zdGFy dGNvbS5vcmcvaW50ZXJtZWRpYXRlLnBkZjCB0AYIKwYBBQUHAgIwgcMwJxYgU3Rh cnQgQ29tbWVyY2lhbCAoU3RhcnRDb20pIEx0ZC4wAwIBARqBl0xpbWl0ZWQgTGlh YmlsaXR5LCByZWFkIHRoZSBzZWN0aW9uICpMZWdhbCBMaW1pdGF0aW9ucyogb2Yg dGhlIFN0YXJ0Q29tIENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFBvbGljeSBhdmFp bGFibGUgYXQgaHR0cDovL2NlcnQuc3RhcnRjb20ub3JnL3BvbGljeS5wZGYwEQYJ YIZIAYb4QgEBBAQDAgAHMDgGCWCGSAGG+EIBDQQrFilTdGFydENvbSBGcmVlIFNT TCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTANBgkqhkiG9w0BAQUFAAOCAgEAFmyZ 9GYMNPXQhV59CuzaEE44HF7fpiUFS5Eyweg78T3dRAlbB0mKKctmArexmvclmAk8 jhvh3TaHK0u7aNM5Zj2gJsfyOZEdUauCe37Vzlrk4gNXcGmXCPleWKYK34wGmkUW FjgKXlf2Ysd6AgXmvB618p70qSmD+LIU424oh0TDkBreOKk8rENNZEXO3SipXPJz ewT4F+irsfMuXGRuczE6Eri8sxHkfY+BUZo7jYn0TZNmezwD7dOaHZrzZVD1oNB1 ny+v8OqCQ5j4aZyJecRDjkZy42Q2Eq/3JR44iZB3fsNrarnDy0RLrHiQi+fHLB5L EUTINFInzQpdn4XBidUaePKVEFMy3YCEZnXZtWgo+2EuvoSoOMCZEoalHmdkrQYu L6lwhceWD3yJZfWOQ1QOq92lgDmUYMA0yZZwLKMS9R9Ie70cfmu3nZD0Ijuu+Pwq yvqCUqDvr0tVk+vBtfAii6w0TiYiBKGHLHVKt+V9E9e4DGTANtLJL4YSjCMJwRuC O3NJo2pXh5Tl1njFmUNj403gdy3hZZlyaQQaRwnmDwFWJPsfvw55qVguucQJAX6V um0ABj6y6koQOdjQK/W/7HW/lwLFCRsI3FU34oH7N4RDYiDK51ZLZer+bMEkkySh NOsF/5oirpt9P/FlUQqmMGqz9IgcgA38corog14= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHhzCCBW+gAwIBAgIBLTANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJJTDEW MBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwg Q2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNh dGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0NjM3WhcNMzYwOTE3MTk0NjM2WjB9 MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMi U2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMgU3Rh cnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA A4ICDwAwggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZk pMyONvg45iPwbm2xPN1yo4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rf OQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/C Ji/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/deMotHweXMAEtcnn6RtYT Kqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt2PZE4XNi HzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMM Av+Z6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w +2OqqGwaVLRcJXrJosmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+ Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3 Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVcUjyJthkqcwEKDwOzEmDyei+B 26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT37uMdBNSSwID AQABo4ICEDCCAgwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD VR0OBBYEFE4L7xqkQFulF2mHMMo0aEPQQa7yMB8GA1UdIwQYMBaAFE4L7xqkQFul F2mHMMo0aEPQQa7yMIIBWgYDVR0gBIIBUTCCAU0wggFJBgsrBgEEAYG1NwEBATCC ATgwLgYIKwYBBQUHAgEWImh0dHA6Ly93d3cuc3RhcnRzc2wuY29tL3BvbGljeS5w ZGYwNAYIKwYBBQUHAgEWKGh0dHA6Ly93d3cuc3RhcnRzc2wuY29tL2ludGVybWVk aWF0ZS5wZGYwgc8GCCsGAQUFBwICMIHCMCcWIFN0YXJ0IENvbW1lcmNpYWwgKFN0 YXJ0Q29tKSBMdGQuMAMCAQEagZZMaW1pdGVkIExpYWJpbGl0eSwgcmVhZCB0aGUg c2VjdGlvbiAqTGVnYWwgTGltaXRhdGlvbnMqIG9mIHRoZSBTdGFydENvbSBDZXJ0 aWZpY2F0aW9uIEF1dGhvcml0eSBQb2xpY3kgYXZhaWxhYmxlIGF0IGh0dHA6Ly93 d3cuc3RhcnRzc2wuY29tL3BvbGljeS5wZGYwEQYJYIZIAYb4QgEBBAQDAgAHMDgG CWCGSAGG+EIBDQQrFilTdGFydENvbSBGcmVlIFNTTCBDZXJ0aWZpY2F0aW9uIEF1 dGhvcml0eTANBgkqhkiG9w0BAQsFAAOCAgEAjo/n3JR5fPGFf59Jb2vKXfuM/gTF wWLRfUKKvFO3lANmMD+x5wqnUCBVJX92ehQN6wQOQOY+2IirByeDqXWmN3PH/UvS Ta0XQMhGvjt/UfzDtgUx3M2FIk5xt/JxXrAaxrqTi3iSSoX4eA+D/i+tLPfkpLst 0OcNOrg+zvZ49q5HJMqjNTbOx8aHmNrs++myziebiMMEofYLWWivydsQD032ZGNc pRJvkrKTlMeIFw6Ttn5ii5B/q06f/ON1FE8qMt9bDeD1e5MNq6HPh+GlBEXoPBKl CcWw0bdT82AUuoVpaiF8H3VhFyAXe2w7QSlc4axa0c2Mm+tgHRns9+Ww2vl5GKVF P0lDV9LdJNUso/2RjSe15esUBppMeyG7Oq0wBhjA2MFrLH9ZXF2RsXAiV+uKa0hK 1Q8p7MZAwC+ITGgBF3f0JBlPvfrhsiAhS90a2Cl9qrjeVOwhVYBsHvUwyKMQ5bLm KhQxw4UtjJixhlpPiVktucf3HMiKf8CdBUrmQk9io20ppB+Fq9vlgcitKj1MXVuE JnHEhV5xJMqlG2zYYdMa4FTbzrqpMrUi9nNBCV24F10OD5mQ1kfabwo6YigUZ4LZ 8dCAWZvLMdibD4x3TrVoivJs9iQOLWxwxXPR3hTQcY+203sC9uO41Alua551hDnm fyWl8kgAwKQB2j8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2ln biBHb2xkIENBIC0gRzIwHhcNMDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBF MQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dpc3NTaWduIEFHMR8wHQYDVQQDExZT d2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC CgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUqt2/8 76LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+ bbqBHH5CjCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c 6bM8K8vzARO/Ws/BtQpgvd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqE emA8atufK+ze3gE/bk3lUIbLtK/tREDFylqM2tIrfKjuvqblCqoOpd8FUrdVxyJd MmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvRAiTysybUa9oEVeXBCsdt MDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuendjIj3o02y MszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69y FGkOpeUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPi aG59je883WX0XaxR7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxM gI93e2CaHt+28kgeDrpOVG2Y4OGiGqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCB qTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUWyV7 lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64OfPAeGZe6Drn 8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe6 45R88a7A3hfm5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczO UYrHUDFu4Up+GC9pWbY9ZIEr44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5 O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOfMke6UiI0HTJ6CVanfCU2qT1L2sCC bwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6mGu6uLftIdxf+u+yv GPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxpmo/a 77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCC hdiDyyJkvC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid3 92qgQmwLOM7XdVAyksLfKzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEpp Ld6leNcG2mqeSz53OiATIgHQv2ieY2BrNU0LbbqhPcCT4H8js1WtciVORvnSFu+w ZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6LqjviOvrv1vA+ACOzB2+htt Qc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFvTCCA6WgAwIBAgIITxvUL1S7L0swDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UE BhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWdu IFNpbHZlciBDQSAtIEcyMB4XDTA2MTAyNTA4MzI0NloXDTM2MTAyNTA4MzI0Nlow RzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMY U3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A MIICCgKCAgEAxPGHf9N4Mfc4yfjDmUO8x/e8N+dOcbpLj6VzHVxumK4DV644N0Mv Fz0fyM5oEMF4rhkDKxD6LHmD9ui5aLlV8gREpzn5/ASLHvGiTSf5YXu6t+WiE7br YT7QbNHm+/pe7R20nqA1W6GSy/BJkv6FCgU+5tkL4k+73JU3/JHpMjUi0R86TieF nbAVlDLaYQ1HTWBCrpJH6INaUFjpiou5XaHc3ZlKHzZnu0jkg7Y360g6rw9njxcH 6ATK72oxh9TAtvmUcXtnZLi2kUpCe2UuMGoM9ZDulebyzYLs2aFK7PayS+VFheZt eJMELpyCbTapxDFkH4aDCyr0NQp4yVXPQbBH6TCfmb5hqAaEuSh6XzjZG6k4sIN/ c8HDO0gqgg8hm7jMqDXDhBuDsz6+pJVpATqJAHgE2cn0mRmrVn5bi4Y5FZGkECwJ MoBgs5PAKrYYC51+jUnyEEp/+dVGLxmSo5mnJqy7jDzmDrxHB9xzUfFwZC8I+bRH HTBsROopN4WSaGa8gzj+ezku01DwH/teYLappvonQfGbGHLy9YR0SslnxFSuSGTf jNFusB3hB48IHpmccelM2KX3RxIfdNFRnobzwqIjQAtz20um53MGjMGg6cFZrEb6 5i/4z3GcRm25xBWNOHkDRUjvxF3XCO6HOSKGsg0PWEP3calILv3q1h8CAwEAAaOB rDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU F6DNweRBtjpbO8tFnb0cwpj6hlgwHwYDVR0jBBgwFoAUF6DNweRBtjpbO8tFnb0c wpj6hlgwRgYDVR0gBD8wPTA7BglghXQBWQEDAQEwLjAsBggrBgEFBQcCARYgaHR0 cDovL3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIB AHPGgeAn0i0P4JUw4ppBf1AsX19iYamGamkYDHRJ1l2E6kFSGG9YrVBWIGrGvShp WJHckRE1qTodvBqlYJ7YH39FkWnZfrt4csEGDyrOj4VwYaygzQu4OSlWhDJOhrs9 xCrZ1x9y7v5RoSJBsXECYxqCsGKrXlcSH9/L3XWgwF15kIwb4FDm3jH+mHtwX6WQ 2K34ArZv02DdQEsixT2tOnqfGhpHkXkzuoLcMmkDlm4fS/Bx/uNncqCxv1yL5PqZ IseEuRuNI5c/7SXgz2W79WEE790eslpBIlqhn10s6FvJbakMDHiqYMZWjwFaDGi8 aRl5xB9+lwW/xekkUV7U1UtT7dkjWjYDZaPBA61BMPNGG4WQr2W11bHkFlt4dR2X em1ZqSqPe97Dh4kQmUlzeMg9vVE1dCrV8X5pGyq7O70luJpaPXJhkGaH7gzWTdQR dAtq/gsD/KNVV4n+SsuuWxcFyPKNIzFTONItaj+CuY0IavdeQXRuwxF+B6wpYJE/ OMpXEA29MC/HpeZBoNquBYeaoKRlbEwJDIm6uNO5wJOKMPqN5ZprFQFOZ6raYlY+ hAhm0sQ2fac+EPyI4NSA5QC9qvNOBqN6avlicuMJT+ubDgEj8Z+7fNzcbBGXJbLy tGMU0gYqZ4yD9c7qB9iaah7s5Aq7KkzrCWA5zspi2C5u -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFwTCCA6mgAwIBAgIITrIAZwwDXU8wDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE BhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEjMCEGA1UEAxMaU3dpc3NTaWdu IFBsYXRpbnVtIENBIC0gRzIwHhcNMDYxMDI1MDgzNjAwWhcNMzYxMDI1MDgzNjAw WjBJMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dpc3NTaWduIEFHMSMwIQYDVQQD ExpTd2lzc1NpZ24gUGxhdGludW0gQ0EgLSBHMjCCAiIwDQYJKoZIhvcNAQEBBQAD ggIPADCCAgoCggIBAMrfogLi2vj8Bxax3mCq3pZcZB/HL37PZ/pEQtZ2Y5Wu669y IIpFR4ZieIbWIDkm9K6j/SPnpZy1IiEZtzeTIsBQnIJ71NUERFzLtMKfkr4k2Htn IuJpX+UFeNSH2XFwMyVTtIc7KZAoNppVRDBopIOXfw0enHb/FZ1glwCNioUD7IC+ 6ixuEFGSzH7VozPY1kneWCqv9hbrS3uQMpe5up1Y8fhXSQQeol0GcN1x2/ndi5ob jM89o03Oy3z2u5yg+gnOI2Ky6Q0f4nIoj5+saCB9bzuohTEJfwvH6GXp43gOCWcw izSC+13gzJ2BbWLuCB4ELE6b7P6pT1/9aXjvCR+htL/68++QHkwFix7qepF6w9fl +zC8bBsQWJj3Gl/QKTIDE0ZNYWqFTFJ0LwYfexHihJfGmfNtf9dng34TaNhxKFrY zt3oEBSa/m0jh26OWnA81Y0JAKeqvLAxN23IhBQeW71FYyBrS3SMvds6DsHPWhaP pZjydomyExI7C3d3rLvlPClKknLKYRorXkzig3R3+jVIeoVNjZpTxN94ypeRSCtF KwH3HBqi7Ri6Cr2D+m+8jVeTO9TUps4e8aCxzqv9KyiaTxvXw3LbpMS/XUz13XuW ae5ogObnmLo2t/5u7Su9IPhlGdpVCX4l3P5hYnL5fhgC72O00Puv5TtjjGePAgMB AAGjgawwgakwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFFCvzAeHFUdvOMW0ZdHelarp35zMMB8GA1UdIwQYMBaAFFCvzAeHFUdvOMW0 ZdHelarp35zMMEYGA1UdIAQ/MD0wOwYJYIV0AVkBAQEBMC4wLAYIKwYBBQUHAgEW IGh0dHA6Ly9yZXBvc2l0b3J5LnN3aXNzc2lnbi5jb20vMA0GCSqGSIb3DQEBBQUA A4ICAQAIhab1Fgz8RBrBY+D5VUYI/HAcQiiWjrfFwUF1TglxeeVtlspLpYhg0DB0 uMoI3LQwnkAHFmtllXcBrqS3NQuB2nEVqXQXOHtYyvkv+8Bldo1bAbl93oI9ZLi+ FHSjClTTLJUYFzX1UWs/j6KWYTl4a0vlpqD4U99REJNi54Av4tHgvI42Rncz7Lj7 jposiU0xEQ8mngS7twSNC/K5/FqdOxa3L8iYq/6KUFkuozv8KV2LwUvJ4ooTHbG/ u0IdUt1O2BReEMYxB+9xJ/cbOQncguqLs5WGXv312l0xpuAxtpTmREl0xRbl9x8D YSjFyMsSoEJL+WuICI20MhjzdZ/EfwBPBZWcoxcCw7NTm6ogOSkrZvqdr16zktK1 puEa+S1BaYEUtLS17Yk9zvupnTVCRLEcFHOBzyoBNZox1S2PbYTfgE1X4z/FhHXa icYwu+uPyyIIoK6q8QNsOktNCaUOcsZWayFCTiMlFGiudgp8DAdwZPmaL/YFOSbG DI8Zf0NebvRbFS/bYV3mZy8/CJT5YLSYMdp08YSTcU1f+2BY0fvEwW2JorsgH51x kcsymxM9Pn2SUjWskpSi0xjCfMfqr3YFFt1nJ8J+HAciIfNAChs0B0QTwoRqjt8Z Wr9/6x3iGjjRXK9HkmuAtTClyY3YqzGBH9/CZjfTk6mFhnll0g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO 0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj 7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS 8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB /zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ 3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR 3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa /FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7 sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIE0zCCA7ugAwIBAgIQGNrRniZ96LtKIVjNzGs7SjANBgkqhkiG9w0BAQUFADCB yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJp U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxW ZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0 aG9yaXR5IC0gRzUwHhcNMDYxMTA4MDAwMDAwWhcNMzYwNzE2MjM1OTU5WjCByjEL MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2ln biwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJp U2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9y aXR5IC0gRzUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvJAgIKXo1 nmAMqudLO07cfLw8RRy7K+D+KQL5VwijZIUVJ/XxrcgxiV0i6CqqpkKzj/i5Vbex t0uz/o9+B1fs70PbZmIVYc9gDaTY3vjgw2IIPVQT60nKWVSFJuUrjxuf6/WhkcIz SdhDY2pSS9KP6HBRTdGJaXvHcPaz3BJ023tdS1bTlr8Vd6Gw9KIl8q8ckmcY5fQG BO+QueQA5N06tRn/Arr0PO7gi+s3i+z016zy9vA9r911kTMZHRxAy3QkGSGT2RT+ rCpSx4/VBEnkjWNHiDxpg8v+R70rfk/Fla4OndTRQ8Bnc+MUCH7lP59zuDMKz10/ NIeWiu5T6CUVAgMBAAGjgbIwga8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E BAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2UvZ2lmMCEwHzAH BgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVy aXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFH/TZafC3ey78DAJ80M5+gKv MzEzMA0GCSqGSIb3DQEBBQUAA4IBAQCTJEowX2LP2BqYLz3q3JktvXf2pXkiOOzE p6B4Eq1iDkVwZMXnl2YtmAl+X6/WzChl8gGqCBpH3vn5fJJaCGkgDdk+bW48DW7Y 5gaRQBi5+MHt39tBquCWIMnNZBU4gcmU7qKEKQsTb47bDN0lAtukixlE0kF6BWlK WE9gyn6CagsCqiUXObXbf+eEZSqVir2G3l6BFoMtEMze/aiCKm0oHw0LxOXnGiYZ 4fQRbxC1lfznQgUy286dUV4otp6F01vvpX1FQHKOtw5rDgb7MzVIcbidJ4vEZV8N hnacRHr2lVz2XTIIM6RUthg/aFzyQkqFOFSDX9HoLPKsEdao7WNq -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe +o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep +OkuE6N36B9K -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEIDCCAwigAwIBAgIQNE7VVyDV7exJ9C/ON9srbTANBgkqhkiG9w0BAQUFADCB qTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMf Q2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIw MDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxHzAdBgNV BAMTFnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwHhcNMDYxMTE3MDAwMDAwWhcNMzYw NzE2MjM1OTU5WjCBqTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5j LjEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYG A1UECxMvKGMpIDIwMDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl IG9ubHkxHzAdBgNVBAMTFnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsoPD7gFnUnMekz52hWXMJEEUMDSxuaPFs W0hoSVk3/AszGcJ3f8wQLZU0HObrTQmnHNK4yZc2AreJ1CRfBsDMRJSUjQJib+ta 3RGNKJpchJAQeg29dGYvajig4tVUROsdB58Hum/u6f1OCyn1PoSgAfGcq/gcfomk 6KHYcWUNo1F77rzSImANuVud37r8UVsLr5iy6S7pBOhih94ryNdOwUxkHt3Ph1i6 Sk/KaAcdHJ1KxtUvkcx8cXIcxcBn6zL9yZJclNqFwJu/U30rCfSMnZEfl2pSy94J NqR32HuHUETVPm4pafs5SSYeCaWAe0At6+gnhcn+Yf1+5nyXHdWdAgMBAAGjQjBA MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBR7W0XP r87Lev0xkhpqtvNG61dIUDANBgkqhkiG9w0BAQUFAAOCAQEAeRHAS7ORtvzw6WfU DW5FvlXok9LOAz/t2iWwHVfLHjp2oEzsUHboZHIMpKnxuIvW1oeEuzLlQRHAd9mz YJ3rG9XRbkREqaYB7FViHXe4XI5ISXycO1cRrK1zN44veFyQaEfZYGDm/Ac9IiAX xPcW6cTYcvnIc3zfFi8VqT79aie2oetaupgf1eNNZAqdE8hhuvU5HIe6uL17In/2 /qxAeeWsEG89jxt5dovEN7MhGITlNgDrYyCZuen+MwS7QcjBAvlEYyCegc5C09Y/ LHbTY5xZ3Y+m4Q6gLkH3LpVHz7z9M/P2C2F+fpErgUfCJzDupxBdN49cOSvkBPB7 jVaMaA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv b3QgQ0EgMjAeFw0wNjExMjQxODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNV BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W YWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCa GMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6XJxg Fyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55J WpzmM+Yklvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bB rrcCaoF6qUWD4gXmuVbBlDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp +ARz8un+XJiM9XOva7R+zdRcAitMOeGylZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1 ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt66/3FsvbzSUr5R/7mp/i Ucw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1JdxnwQ5hYIiz PtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og /zOhD7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UH oycR7hYQe7xFSkyyBNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuI yV77zGHcizN300QyNQliBJIWENieJ0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1Ud EwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBQahGK8SEwzJQTU7tD2 A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGUa6FJpEcwRTEL MAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2f BluornFdLwUvZ+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzn g/iN/Ae42l9NLmeyhP3ZRPx3UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2Bl fF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodmVjB3pjd4M1IQWK4/YY7yarHvGH5K WWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK+JDSV6IZUaUtl0Ha B0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrWIozc hLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPR TUIZ3Ph1WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWD mbA4CD/pXvk1B+TJYm5Xf6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0Z ohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y 4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8VCLAAVBpQ570su9t+Oza 8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0x GTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJv b3QgQ0EgMzAeFw0wNjExMjQxOTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNV BAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMRswGQYDVQQDExJRdW9W YWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDM V0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNggDhoB 4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUr H556VOijKTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd 8lyyBTNvijbO0BNO/79KDDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9Cabwv vWhDFlaJKjdhkf2mrk7AyxRllDdLkgbvBNDInIjbC3uBr7E9KsRlOni27tyAsdLT mZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwpp5ijJUMv7/FfJuGITfhe btfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8nT8KKdjc T5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDt WAEXMJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZ c6tsgLjoC2SToJyMGf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A 4iLItLRkT9a6fUg+qGkM17uGcclzuD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYD VR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHTBgkrBgEEAb5YAAMwgcUwgZMG CCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmljYXRlIGNvbnN0 aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVu dC4wLQYIKwYBBQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2Nw czALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4G A1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4ywLQoUmkRzBFMQswCQYDVQQGEwJC TTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UEAxMSUXVvVmFkaXMg Um9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZVqyM0 7ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSem d1o417+shvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd +LJ2w/w4E6oM3kJpK27zPOuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B 4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadN t54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp8kokUvd0/bpO5qgdAm6x DYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBCbjPsMZ57 k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6s zHXug/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0j Wy10QJLZYxkNc91pvGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeT mJlglFwjz1onl14LBQaTNx47aTbrqZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK 4SVhM7JZG+Ju1zdXtg2pEto= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDfDCCAmSgAwIBAgIQGKy1av1pthU6Y2yv2vrEoTANBgkqhkiG9w0BAQUFADBY MQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjExMC8GA1UEAxMo R2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjEx MjcwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMFgxCzAJBgNVBAYTAlVTMRYwFAYDVQQK Ew1HZW9UcnVzdCBJbmMuMTEwLwYDVQQDEyhHZW9UcnVzdCBQcmltYXJ5IENlcnRp ZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC AQEAvrgVe//UfH1nrYNke8hCUy3f9oQIIGHWAVlqnEQRr+92/ZV+zmEwu3qDXwK9 AWbK7hWNb6EwnL2hhZ6UOvNWiAAxz9juapYC2e0DjPt1befquFUWBRaa9OBesYjA ZIVcFU2Ix7e64HXprQU9nceJSOC7KMgD4TCTZF5SwFlwIjVXiIrxlQqD17wxcwE0 7e9GceBrAqg1cmuXm2bgyxx5X9gaBGgeRwLmnWDiNpcB3841kt++Z8dtd1k7j53W kBWUvEI0EME5+bEnPn7WinXFsq+W06Lem+SYvn3h6YGttm/81w7a4DSwDRp35+MI mO9Y+pyEtzavwt+s0vQQBnBxNQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQULNVQQZcVi/CPNmFbSvtr2ZnJM5IwDQYJ KoZIhvcNAQEFBQADggEBAFpwfyzdtzRP9YZRqSa+S7iq8XEN3GHHoOo0Hnp3DwQ1 6CePbJC/kRYkRj5KTs4rFtULUh38H2eiAkUxT87z+gOneZ1TatnaYzr4gNfTmeGl 4b7UVXGYNTq+k+qurUKykG/g/CFNNWMziUnWm07Kx+dOCQD32sfvmWKZd7aVIl6K oKv0uHiYyjgZmclynnjNS6yvGaBzEi38wkG6gZHaFloxt/m0cYASSJlyc1pZU8Fj UjPtp8nSOQJw+uCxQmYpqptR7TBUIhRf2asdweSU8Pj1K/fqynhG1riR/aYNKxoU AT6A8EKglQdebc3MS6RFjasS6LPeWuWgfOgPIh1a6Vk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMC VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0 Lm5ldC9DUFMgaXMgaW5jb3Jwb3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMW KGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsGA1UEAxMkRW50cnVzdCBSb290IENl cnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0MloXDTI2MTEyNzIw NTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMTkw NwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSBy ZWZlcmVuY2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNV BAMTJEVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJ KoZIhvcNAQEBBQADggEPADCCAQoCggEBALaVtkNC+sZtKm9I35RMOVcF7sN5EUFo Nu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYszA9u3g3s+IIRe7bJWKKf4 4LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOwwCj0Yzfv9 KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGI rb68j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi 94DkZfs0Nw4pgHBNrziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOB sDCBrTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAi gA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1MzQyWjAfBgNVHSMEGDAWgBRo kORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DHhmak8fdLQ/uE vW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9t O1KzKtvn1ISMY/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6Zua AGAT/3B+XxFNSRuzFVJ7yVTav52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP 9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTSW3iDVuycNsMm4hH2Z0kdkquM++v/ eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0tHuu2guQOHXvgR1m 0vdXcDazv/wor3ElhVsT/h5/WrQ8 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGZjCCBE6gAwIBAgIPB35Sk3vgFeNX8GmMy+wMMA0GCSqGSIb3DQEBBQUAMHsx CzAJBgNVBAYTAkNPMUcwRQYDVQQKDD5Tb2NpZWRhZCBDYW1lcmFsIGRlIENlcnRp ZmljYWNpw7NuIERpZ2l0YWwgLSBDZXJ0aWPDoW1hcmEgUy5BLjEjMCEGA1UEAwwa QUMgUmHDrXogQ2VydGljw6FtYXJhIFMuQS4wHhcNMDYxMTI3MjA0NjI5WhcNMzAw NDAyMjE0MjAyWjB7MQswCQYDVQQGEwJDTzFHMEUGA1UECgw+U29jaWVkYWQgQ2Ft ZXJhbCBkZSBDZXJ0aWZpY2FjacOzbiBEaWdpdGFsIC0gQ2VydGljw6FtYXJhIFMu QS4xIzAhBgNVBAMMGkFDIFJhw616IENlcnRpY8OhbWFyYSBTLkEuMIICIjANBgkq hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAq2uJo1PMSCMI+8PPUZYILrgIem08kBeG qentLhM0R7LQcNzJPNCNyu5LF6vQhbCnIwTLqKL85XXbQMpiiY9QngE9JlsYhBzL fDe3fezTf3MZsGqy2IiKLUV0qPezuMDU2s0iiXRNWhU5cxh0T7XrmafBHoi0wpOQ Y5fzp6cSsgkiBzPZkc0OnB8OIMfuuzONj8LSWKdf/WU34ojC2I+GdV75LaeHM/J4 Ny+LvB2GNzmxlPLYvEqcgxhaBvzz1NS6jBUJJfD5to0EfhcSM2tXSExP2yYe68yQ 54v5aHxwD6Mq0Do43zeX4lvegGHTgNiRg0JaTASJaBE8rF9ogEHMYELODVoqDA+b MMCm8Ibbq0nXl21Ii/kDwFJnmxL3wvIumGVC2daa49AZMQyth9VXAnow6IYm+48j ilSH5L887uvDdUhfHjlvgWJsxS3EF1QZtzeNnDeRyPYL1epjb4OsOMLzP96a++Ej YfDIJss2yKHzMI+ko6Kh3VOz3vCaMh+DkXkwwakfU5tTohVTP92dsxA7SH2JD/zt A/X7JWR1DhcZDY8AFmd5ekD8LVkH2ZD6mq093ICK5lw1omdMEWux+IBkAC1vImHF rEsm5VoQgpukg3s0956JkSCXjrdCx2bD0Omk1vUgjcTDlaxECp1bczwmPS9KvqfJ pxAe+59QafMCAwEAAaOB5jCB4zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE AwIBBjAdBgNVHQ4EFgQU0QnQ6dfOeXRU+Tows/RtLAMDG2gwgaAGA1UdIASBmDCB lTCBkgYEVR0gADCBiTArBggrBgEFBQcCARYfaHR0cDovL3d3dy5jZXJ0aWNhbWFy YS5jb20vZHBjLzBaBggrBgEFBQcCAjBOGkxMaW1pdGFjaW9uZXMgZGUgZ2FyYW50 7WFzIGRlIGVzdGUgY2VydGlmaWNhZG8gc2UgcHVlZGVuIGVuY29udHJhciBlbiBs YSBEUEMuMA0GCSqGSIb3DQEBBQUAA4ICAQBclLW4RZFNjmEfAygPU3zmpFmps4p6 xbD/CHwso3EcIRNnoZUSQDWDg4902zNc8El2CoFS3UnUmjIz75uny3XlesuXEpBc unvFm9+7OSPI/5jOCk0iAUgHforA1SBClETvv3eiiWdIG0ADBaGJ7M9i4z0ldma/ Jre7Ir5v/zlXdLp6yQGVwZVR6Kss+LGGIOk/yzVb0hfpKv6DExdA7ohiZVvVO2Dp ezy4ydV/NgIlqmjCMRW3MGXrfx1IebHPOeJCgBbT9ZMj/EyXyVo3bHwi2ErN0o42 gzmRkBDI8ck1fj+404HGIGQatlDCIaR43NAvO2STdPCWkPHv+wlaNECW8DYSwaN0 jJN+Qd53i+yG2dIPPy3RzECiiWZIHiCznCNZc6lEc7wkeZBWN7PGKX6jD/EpOe9+ XCgycDWs2rjIdWb8m0w5R44bb5tNAlQiM+9hup4phO9OSzNHdpdqy35f/RWmnkJD W2ZaiogN9xa5P1FlK2Zqi9E4UqLWRhH6/JocdJ6PlwsCT2TG9WjTSy3/pDceiz+/ RL5hRqGEPQgnTIEgd4kI6mdAXmwIUV80WoyWaM3X94nCHNMyAK9Sy9NgWyo6R35r MDOhYil/SrnhLecUIw4OGEfhefwVVdCx/CVxY3UzHCMrr1zZ7Ud3YA47Dx7SwNxk BYn8eNZcLCZDqQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCB gTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNV BAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjEyMDEwMDAw MDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3Jl YXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01P RE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0 aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3 UcEbVASY06m/weaKXTuH+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI 2GqGd0S7WWaXUF601CxwRM/aN5VCaTwwxHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8 Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV4EajcNxo2f8ESIl33rXp +2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA1KGzqSX+ DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5O nKVIrLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW /zAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6g PKA6hjhodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9u QXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOCAQEAPpiem/Yb6dc5t3iuHXIY SdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CPOGEIqB6BCsAv IC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4 zJVSk/BwJVmcIGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5dd BA6+C4OmF4O5MBKgxTMVBbkN+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IB ZQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID5jCCAs6gAwIBAgIQV8szb8JcFuZHFhfjkDFo4DANBgkqhkiG9w0BAQUFADBi MQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3Jp dHkwHhcNMDYxMjAxMDAwMDAwWhcNMjkxMjMxMjM1OTU5WjBiMQswCQYDVQQGEwJV UzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydO ZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwz c7MEL7xxjOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPP OCwGJgl6cvf6UDL4wpPTaaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rl mGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXTcrA/vGp97Eh/jcOrqnErU2lBUzS1sLnF BgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc/Qzpf14Dl847ABSHJ3A4 qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMBAAGjgZcw gZQwHQYDVR0OBBYEFCEwyfsA106Y2oeqKtCnLrFAMadMMA4GA1UdDwEB/wQEAwIB BjAPBgNVHRMBAf8EBTADAQH/MFIGA1UdHwRLMEkwR6BFoEOGQWh0dHA6Ly9jcmwu bmV0c29sc3NsLmNvbS9OZXR3b3JrU29sdXRpb25zQ2VydGlmaWNhdGVBdXRob3Jp dHkuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQC7rkvnt1frf6ott3NHhWrB5KUd5Oc8 6fRZZXe1eltajSU24HqXLjjAV2CDmAaDn7l2em5Q4LqILPxFzBiwmZVRDuwduIj/ h1AcgsLj4DKAv6ALR8jDMe+ZZzKATxcheQxpXN5eNK4CtSbqUN9/GGUsyfJj4akH /nxxH2szJGoeBfcFaMBqEssuXmHLrijTfsK0ZpEmXzwuJF/LWA/rKOyvEZbz3Htv wKeI8lN3s2Berq4o2jUsbzRF0ybh3uxbTydrFny9RAQYgrOJeRcQcT16ohZO9QHN pGxlaKFJdlxDydi8NmdspZS11My5vWo1ViHe2MPr+8ukYEywVaCge1ey -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGKjCCBBKgAwIBAgIQZgej0p0pVhgO4V5ZmLGEVTANBgkqhkiG9w0BAQUFADB0 MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlrYXZp bW8gY2VudHJhczEgMB4GA1UECxMXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAU BgNVBAMTDVNTQyBSb290IENBIEEwHhcNMDYxMjI3MTIxODUyWhcNMjYxMjI4MTIw NTA0WjB0MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRp ZmlrYXZpbW8gY2VudHJhczEgMB4GA1UECxMXQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkxFjAUBgNVBAMTDVNTQyBSb290IENBIEEwggIiMA0GCSqGSIb3DQEBAQUAA4IC DwAwggIKAoICAQC66k++hMAZJIohqUyZffcM1aVRkqhl44mjC2bnQvh50g+DI3u3 psEk1jXW2OUBynCxFtZHbr4QbH7pUG529+Xkgw941aBz9Y3RmR+URCOWxu5yWvna XTyRr2zol+iGXfeei/rErGZP5HI/O92eTjXSEx99u0RL9FOs1hTXQDm6wD/8hSDT xADQ59hHmQR5h4ZAsqxeyXUgwwkUrwSOpqKtKleIZaHMKL42yR8lD8NrIoQ5d046 A8Bq2z66tome5NcumrdDAT/52qyprOR3M4ftCzndx8GtDVmDMNE2BFi0ZE7m/wjo QrGAq/iY//MphhYRJE4Joc8wf7xesApqoXFr9ZoSayVtdwKiRl75aS/7OxiVX45c l5RgXh1xqEG0Xc9aemfj1Eo1HzfgdhYDO/RRnJgUKUmIDELQLW2pp0AmOnkAMDvA u0SYrSTO0ZbciXiB9lpbQrx04YfTZchH5jayzMFvwMfcgCVSPDGQ3cnIUKh6u3bg 7xOUzgR+arZOd/mD0G/4OtAKQ8q6ELb/PB2UYJSEbfWlyX1MCn4vj2/93S17Sunv NNu7fv8Mbzf6+cPMyS/R6Sw9KqxsJjvQCV7EgCeL3WHw55VRQ8QN5jHQeNbBxsJm AdHjzMfTHhUFNtuUmuxSw5HHL7H0A/cHrNNLkatWPNCu/V9tLdMAEc+TvQIDAQAB o4G3MIG0MA8GA1UdEwEB/wQFMAMBAf8wPQYDVR0gBDYwNDAyBgsrBgEEAYGvZQEC ADAjMCEGCCsGAQUFBwIBFhVodHRwOi8vd3d3LnNzYy5sdC9jcHMwMwYDVR0fBCww KjAooCagJIYiaHR0cDovL2NybC5zc2MubHQvcm9vdC1hL2NhY3JsLmNybDAOBgNV HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMy/3qeQd2JqHXhpLgo4m3dRUwPwMA0GCSqG SIb3DQEBBQUAA4ICAQA+r8ioxzNP8G6aQ+HysFdS4ZyeBl9C1vH9yotRP+HHZWlP dBlQis8Yk0mNoBywOz2OSJPZ6AV+xAmxD1KKa5dv1448gADQQXOtPcNEB3Fqj2J+ BdhTYHKxAekAYqoN2NhJwrR9DVuzlyk2mbmn0UuYa0S8shKOdmR1TA3Nwi6zWPx6 T1WzWX9d4C8wM8+IG2npTYqQnpC5MTrzogW8/vndUI0OlBmdfo2qFX4PUpMl5IEO li0cAxwwgxGWQqmYpJ1fyalcO0lowoRtmdr2/qLy3DdejXrlpVfKI0uTXZIqVYSz lrMemJRJfGw83J4dtqvDrAnFnd4311TEnK0/sNZpAeUQhn25gYNunGZOlQWSkDGH JrLakXS9hORxaOR2AOB2czRHhpVluluQom0FKXhg64b5Ek3oCFakzIyiVkrOgPQU YSLlqx06QTuE14J4BS+sHSNoq3J5hc1G5nqngloo0BU9HduMmFDO+69YO9OproA7 FgB2J9Vw6QmNNpQJf+PvYBBRysZVcGarUW/zUU8SVq7719kN4PqrEN5qgayFdy2s emN7RuE32ldurWX8IQSZhQHPIzoyxe1am9WhggR3EUWOpER9wsvLpw/oErrybrqP MzAb3Sn48EKjbkKlbvpWpalQg9EFZhaLLfvmktHmbAvVWiltK89519naT/Botg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGKzCCBBOgAwIBAgIRAL6SgxjzVYp4o2dZHGkkCT8wDQYJKoZIhvcNAQEFBQAw dDELMAkGA1UEBhMCTFQxKzApBgNVBAoTIlNrYWl0bWVuaW5pbyBzZXJ0aWZpa2F2 aW1vIGNlbnRyYXMxIDAeBgNVBAsTF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYw FAYDVQQDEw1TU0MgUm9vdCBDQSBCMB4XDTA2MTIyNzEyMjI1MFoXDTI2MTIyNTEy MDgyNlowdDELMAkGA1UEBhMCTFQxKzApBgNVBAoTIlNrYWl0bWVuaW5pbyBzZXJ0 aWZpa2F2aW1vIGNlbnRyYXMxIDAeBgNVBAsTF0NlcnRpZmljYXRpb24gQXV0aG9y aXR5MRYwFAYDVQQDEw1TU0MgUm9vdCBDQSBCMIICIjANBgkqhkiG9w0BAQEFAAOC Ag8AMIICCgKCAgEAwfNV9UdRTlUXZY2wskEooUrRn0v2c/8+0slNWT/kt8efBl3Y PKOIhOBzXf0F6seO16QEauufvUP9FJJGuMW6qu1g7OzKkI0KcqlBm9SdvLBsohEf ZMvnHdRFZw4Ja+V47PE/BFTzmpnHWdHSeaekGrB8Sfwch1ReeAbV3R3MhaBCeNXQ sIrq6PGhnlbv08F9h6zn2mhPGdZv4JOtSVxzFMFGap33WEDZV1hObDf0ciME+NtK sN7xQZYSQKEVi2e4XnhWy3/kvsBJaJG4RwiTgcG1GzEG04B70UWhzww9YfOS+PGw FQ74LjBbAKNJ923+7ty/iM/wfVc+r8DRiut80m0xVfqEjXNq2nCAxPTCz5COMJrh xjVyAQjmP+ZmAKPy+JIdvFLsj/bc9wrvvBCH+YQYjF4fA7j/NS8BauXwW2J847N/ M6qU105RgbXoV3iPIpapDIlUPrbu2XNfZPRE4fFqGP9SlsQcv4mXpMOnyn4Ybhbc E4y71bUlCYav9i9FlCowwRSUNfZdyiWVnLFYibi1YIXJxr4UGaM++VaFq8ps1pl5 okoUb8M62OdmUQrpHP7MaeY0bPSB232iEfhMxIcFFj3rl3Q/buycubYnjCTfLbOv 3RNhdo//8kzgCBkwMiQyXDaAF+6Gyd8vUeJWroOS8LO92Ic6LJ7E3GmZ+csCAwEA AaOBtzCBtDAPBgNVHRMBAf8EBTADAQH/MD0GA1UdIAQ2MDQwMgYLKwYBBAGBr2UB AgAwIzAhBggrBgEFBQcCARYVaHR0cDovL3d3dy5zc2MubHQvY3BzMDMGA1UdHwQs MCowKKAmoCSGImh0dHA6Ly9jcmwuc3NjLmx0L3Jvb3QtYi9jYWNybC5jcmwwDgYD VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBScA/Co0phyaK7y7eBP4oUOsiVOzzANBgkq hkiG9w0BAQUFAAOCAgEArFy8L/yuASSmED6sqOGnJ5mNyojBHT2R9qJ+pfGYQf+q YfgJvs0aJWF0tMOvQloJD5EBvkiV9Mp3XguDzoSdz0D9gCy942Y1Crix+mDa5dhU tUuXuqIawyBpjbRGc1yqv717/xowNFhA+StgC3lE+feilgtrUnvwK0s70ouga5M9 yVdjimvMUBOPd6hRvhpMLUxdDJBbjvPvUCBtgeZRSavE59ddCCtR/D1GEufRpXbF UyQFyarTjljF84p0kjLt8C/dq63p0jWPdCPjmQDiizDkw0Ku8Lvp4ggbSnAtffjS mieRQnB1egh+vi8cfzc9qIvcRnL16G82aPpujSCd1PUHcb+9J0K5cyjW7Em0BYVP aEj2q5TfDqNGFGDCMSA76y5b3tWhLG3lUvqBX5eIyWO9AezjzWsKNcLJOOMO81gb fdqQbbf1yFhWna4B35GdrVWCAwwRdASRhsd8k4zzJ/vFJFdui9kbmJ2IMfCvd7gN tMzP9gpvEpvsCStTiexE4KFpi6h0hnQYUuDSv6ChZSG5CIN686T1+F43JUeZpl3X Ilrbk2cX2xDjjNESkUeKlaVHoQP4Sy4hxZBisH8no9sVfzh/bH9OBcUDtC3fRV91 LB3xX6a19hc5Qen4ZcIeWBHKfI7itbqSD2e3j+uZ1DH7cntamF+SlMcE6jD2uxo= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGKjCCBBKgAwIBAgIQNLkSn6zHklVCXN5X/+PABTANBgkqhkiG9w0BAQUFADB0 MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlrYXZp bW8gY2VudHJhczEgMB4GA1UECxMXQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAU BgNVBAMTDVNTQyBSb290IENBIEMwHhcNMDYxMjI3MTIyNjMwWhcNMjYxMjIyMTIx MTMwWjB0MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRp ZmlrYXZpbW8gY2VudHJhczEgMB4GA1UECxMXQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkxFjAUBgNVBAMTDVNTQyBSb290IENBIEMwggIiMA0GCSqGSIb3DQEBAQUAA4IC DwAwggIKAoICAQChRSL6jMypbwSz9GgyFmkRT3nfQ71RYHAamN14eJaYYvdwS4Go 4B0EifSP627p8P+B2C59rxcg8SLv8D9FR0C0y7K4ID8+SmhQ/5oG15fFt4oWLnHS R3NdGGUv7zkz6LZVryatAoDpY9chcAc+zL5ficD4zh0lbsP8f1Y5YdGOwiZ653gC ClndVSOw+DWn4qvzqy/XtYsKKnJUK215vPLZ6UP5z/GOZhL3l1kq2deU3PiUs0Wj rxYts4DKPc7opscKlHT8N5rpPww3FiBDyUdwu4yF/JiJKcuHGX4ZUxCJgHWuE/G/ pF0wBSl8qPe2XgcwFYiuTRWgys3X/6ujBlcPp+OJaRzWGtHUJ9+Wxjhcr3f+FatE QX3TmLuoIBivi23UWsLYlo1I9QcxfmH0YZtSgUCOSicEsgfTAhCU8/vdsXtwuLTI gfUAB6aNiAVNxI+WztS2wMFmjCqsaErJRtwN5i6oeSh9d0NwFn4cGjqmeU8TQImx MrsJRhENdLwn5djtLfpQKdwlypcQ56miYS46iaZEYb5PXpIJ7dwupu9Tu2El2Cel FEYphSYA2Pn5BdV7FjFCQwUXkZxKYEAkbbVtenn7nJpjw5hp5XdiIypRiQ9ssv3D ytj0GkOU0H0L4Vg+Gsh0hJv3rIKuUUWS0gZZ4bPB3qUfkyJ52M3EeWAjlQIDAQAB o4G3MIG0MA8GA1UdEwEB/wQFMAMBAf8wPQYDVR0gBDYwNDAyBgsrBgEEAYGvZQEC ADAjMCEGCCsGAQUFBwIBFhVodHRwOi8vd3d3LnNzYy5sdC9jcHMwMwYDVR0fBCww KjAooCagJIYiaHR0cDovL2NybC5zc2MubHQvcm9vdC1jL2NhY3JsLmNybDAOBgNV HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFIgHc/bxvFIaWh09kWLtXaydC+W3MA0GCSqG SIb3DQEBBQUAA4ICAQAfkHFQmNXZNNKWhBjCrEYCIBzLObG3rwWk11jzkVF9joEn nOiSseccnzqLEFJzTMLHQh3Q694qyiJRfYx0ehr8vKTzc8hmI8QuQxBH4IppV+4v 8gBSsDCSqtbUFcVXy2B69A6N/h4JY3SP4P6+UNkBOVa6UEz240Wau1J23n6d+43C VDE+x7E8Pt/jT/3dmyRpfO3ocbZCBscfxV/7IHXbwf3pbKIqkNSG/c0N/+AFilhh PZ/EmS/t23zEDZiYVZx0ohde26oR5DcMJP8gZ9El25qJoGWIMZEEcV8glFgzNh0y 3m/XZwipoDv926RQJZYeqV+JF6WXmVGVadvE8Y/0bzArWfOsdYczfQbd4cFr1sTJ XnBEemrHnHc7Fv7+db6fLNHAA+4ReXXsqVsceoW1KFAgqRod5nuMMxj/we3IdmUf HfBMO6fb6s1W2JRXP+BIqX+MM0u99AxlFICC9DV32AQQcM4PbMFZy5mtge7ePUjQ eogvQJPXnLp5hBiAdd/QWt9Rdz5YiWl1RzHkahZwVATsvVx5U2PS4l69TSXaEbYP quksrvXRqY0CVsv8sCTqjLpw/zLQt8YEKmPVykaR1ZlyCQdeKAOrEhwls2w6WWW0 dG0tLRlyb/3nmBGHHnMjvzXxm7bD2cw7UHxy6M9ewJjMLgP9Hy/KdFyxHNHsaQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDdzCCAl+gAwIBAgIQJAZiXxG2TMzanpxs6gQCaDANBgkqhkiG9w0BAQUFADBF MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPVHJ1c3RpcyBMaW1pdGVkMRwwGgYDVQQL ExNUcnVzdGlzIEVWUyBSb290IENBMB4XDTA3MDEwOTEyMDAwMVoXDTI3MDEwOTEx NTYwMFowRTELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1RydXN0aXMgTGltaXRlZDEc MBoGA1UECxMTVHJ1c3RpcyBFVlMgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQAD ggEPADCCAQoCggEBANJclMPP3EkLfN8EXAvqebvg0DdbjA7XzTkY3oyXuLQ0EG1n rcSzDu031KW/+kPl2nAnhkef4umygbm+LVu5sSiAMEHfuJSpIKZ+qqzKcEyjEgzP YGKBUnpJgwvIIiBzoJ01PJZv7c49JsJPSMqigqeVBqn2JjL+8sH9I1hfcTv4x9fd TDMfoNbBz3M3q5X+nZ3s7IXD4gm7iVf5lFYqVGKJzARobmSKT7GFe2zUpL0mCvUn /BM+XWozCmc5Yoz+FP2qJNpP3rK18neQoZHvKXcdcZVZtsDjgbj0/4nueqf7Wf7J 1I1u39apn/wLNbXcbqSZefOIMGv6SqlrOMhhS0MCAwEAAaNjMGEwDwYDVR0TAQH/ BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHktc5bOsn1acw6HVikx xSjy7aVfMB8GA1UdIwQYMBaAFHktc5bOsn1acw6HVikxxSjy7aVfMA0GCSqGSIb3 DQEBBQUAA4IBAQBDyG/fm8wJr9w4Jh1I+9aWc6Bm3xriwOad0i+JHOg08iI0kfQV ny69tHKq1+08n61Rn38ehWYlakJpgGA3Vka6PFaraoCAmLXKeljbBQjH9geBv6A1 Nck3kr8R8hdBTz9B7RPTrxk9cYp+sGTIO+f6kxNk/lbD3ivZ61fNgtCMnVJSvGET NT9vn2j2afduGs8LOEpDtUAFM9KmKxo2D9OPsfd7Ph3SoG3YQ6orP2kLAH4a5E5v ym0xkf90gfz74kiTdlyLjMPcoQggC1tlNvx2+yM/0dqXwdNakKO/RZvXW4EIpFL8 eH4YLi1x/drvDYFGifBS8gxQaffO1QF8sn8o -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHEzCCBPugAwIBAgIPLeQK4ZvRwqpM9ACsgTX5MA0GCSqGSIb3DQEBBQUAMIGk MQswCQYDVQQGEwJFUzFKMEgGA1UECgxBQ29sZWdpbyBkZSBSZWdpc3RyYWRvcmVz IGRlIGxhIFByb3BpZWRhZCB5IE1lcmNhbnRpbGVzIGRlIEVzcGHDsWExGzAZBgNV BAsMEkNlcnRpZmljYWRvIFByb3BpbzEsMCoGA1UEAwwjUmVnaXN0cmFkb3JlcyBk ZSBFc3Bhw7FhIC0gQ0EgUmHDrXowHhcNMDcwMTA5MTcwMDM5WhcNMzEwMTA5MTcw MDM5WjCBpDELMAkGA1UEBhMCRVMxSjBIBgNVBAoMQUNvbGVnaW8gZGUgUmVnaXN0 cmFkb3JlcyBkZSBsYSBQcm9waWVkYWQgeSBNZXJjYW50aWxlcyBkZSBFc3Bhw7Fh MRswGQYDVQQLDBJDZXJ0aWZpY2FkbyBQcm9waW8xLDAqBgNVBAMMI1JlZ2lzdHJh ZG9yZXMgZGUgRXNwYcOxYSAtIENBIFJhw616MIICIjANBgkqhkiG9w0BAQEFAAOC Ag8AMIICCgKCAgEArFAbDpLOuHwVavjkD518fHx25AsmOlEGzSiz7Q8+2ZF7zPyH g0L3e7BduHpn/jQhYr+5KcPeWvED8uvy4hLCZWR2p/XmyzGjaPJ5651UxVL/nz2D Yw7mvx0oAn38I/REk6OpQ5zY6CUaIDX1tbDO61Ur+tlesKFEK+UALCQPN38yNISy yBVvivXy6C73Q44CuDKbgBpTHQGZSGt081pwSqTo9wLRupGja4e+EF5+VLlYsgr2 OwrjDjjzgF33QY74jza5g5sRTOELscWTijOyv5u2nkS3H/4qgSg5fM/UrzVlrmde jSHfAGARK9Q85CdQn5O3BfHSDhTcKYKW8SqiG0MFcLPQXB4DQVX+FjjFUk2TtbQ8 diJNqSusFcSpS3S5pSPYzStIweLvzd74SrDfoOPuhjW/W3KUb7JGSupKU64x5pG1 dJhFmqR97HEq5ZBRNkP5SdTXKAYDsf15h9YG+Kyh+b8UeA3LI0vNuy4y9H28abu2 NX55z71Lcn5hqyp+QMcM5bKQtUwM1lcHfJfM+dl323vnjBN+zH4YT0xLI46uGsfq Xx+mF904tk/eCm5SUFmsbc3WMRm9JOmgWM/Z1LJDeT9f1m+qZchG8tLVfvkuQxjC mORo38HTX0UvadEd7pEkSNLrAA7CEEvSnb2jTRejN5qv75cxgdqJsWF6Y6cCAwEA AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1Ud DgQWBBQbjVkcs7dYYmRmrOLkpPaiGRL25TCB9wYDVR0gBIHvMIHsMIHpBgRVHSAA MIHgMDwGCCsGAQUFBwIBFjBodHRwOi8vcGtpLnJlZ2lzdHJhZG9yZXMub3JnL25v cm1hdGl2YS9pbmRleC5odG0wgZ8GCCsGAQUFBwICMIGSGoGPQ2VydGlmaWNhZG8g c3VqZXRvIGEgbGEgRGVjbGFyYWNp824gZGUgUHLhY3RpY2FzIGRlIENlcnRpZmlj YWNp824gZGVsIENvbGVnaW8gZGUgUmVnaXN0cmFkb3JlcyBkZSBsYSBQcm9waWVk YWQgeSBNZXJjYW50aWxlcyBkZSBFc3Bh8WEgKKkgMjAwNikwDQYJKoZIhvcNAQEF BQADggIBAD8f1iwZdkCSnCbmnlgGEj0Swis63uXYiXdAH8ZRqnSJlsXGw53x+rxp E6AGdRcmifxlOY1zeevPd6e71UgmeTGRMCeYQaUX4F9cG1oqfLqtFmUAUX2H3rq6 Y9ZjtDXg104ZRX6/UWlIbz6IblJVg/CLxEz0CtQRIa4pYOhbi5/4wuy3dj+AwnQu R3hiUZ7bjPWtX4UF6P2ae71waAuTwjB+EvRLT3TiiY+5Q3QP1oReet5wVKQTNl9k ftMEDv7dGW8kU5Xt6ckO1Kbxk6FbCeOi0ldOPhrOfazE91PQzaiS7aTJlyJm+Mai 8nXlEX4vdRKW949vzwflyswHPvU8i+28fDJgPuMP1BGDNA12hmS9M5dOcO32IDhf mmnHwE8WyoWCjwG2uhNe0PHt6SjdKr0ljtD6EwwWD3efdik0cGzreUud70408EW7 JSx1kkRfp5vEqtKzby68YeuGAUzZerl1Z4sDS8czUnieBcDtj3R4HRIjtjL8UVBe Ld5QvhA8ju8IhfU6+vLe59hMOuUS6/Q2dJhaUoqUGmapbkU+FCuNNAiq7wUTYRKQ hGgNEVosr3mecJSfxWTLzHj2U1zg1w2xPuMWC/Om7DRCPnUQhKXYvbHj6mHmJJzC gdoe2G/8eC0W40QtwNI9Xn2g0lbUYDdx/kyOZZzWO9o23NgzZ9AB -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIJmzCCB4OgAwIBAgIBATANBgkqhkiG9w0BAQUFADCCAR4xPjA8BgNVBAMTNUF1 dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIFJhaXogZGVsIEVzdGFkbyBWZW5lem9s YW5vMQswCQYDVQQGEwJWRTEQMA4GA1UEBxMHQ2FyYWNhczEZMBcGA1UECBMQRGlz dHJpdG8gQ2FwaXRhbDE2MDQGA1UEChMtU2lzdGVtYSBOYWNpb25hbCBkZSBDZXJ0 aWZpY2FjaW9uIEVsZWN0cm9uaWNhMUMwQQYDVQQLEzpTdXBlcmludGVuZGVuY2lh IGRlIFNlcnZpY2lvcyBkZSBDZXJ0aWZpY2FjaW9uIEVsZWN0cm9uaWNhMSUwIwYJ KoZIhvcNAQkBFhZhY3JhaXpAc3VzY2VydGUuZ29iLnZlMB4XDTA3MDIxNjE1MzU1 MVoXDTI3MDIxMTIzNTk1OVowggEeMT4wPAYDVQQDEzVBdXRvcmlkYWQgZGUgQ2Vy dGlmaWNhY2lvbiBSYWl6IGRlbCBFc3RhZG8gVmVuZXpvbGFubzELMAkGA1UEBhMC VkUxEDAOBgNVBAcTB0NhcmFjYXMxGTAXBgNVBAgTEERpc3RyaXRvIENhcGl0YWwx NjA0BgNVBAoTLVNpc3RlbWEgTmFjaW9uYWwgZGUgQ2VydGlmaWNhY2lvbiBFbGVj dHJvbmljYTFDMEEGA1UECxM6U3VwZXJpbnRlbmRlbmNpYSBkZSBTZXJ2aWNpb3Mg ZGUgQ2VydGlmaWNhY2lvbiBFbGVjdHJvbmljYTElMCMGCSqGSIb3DQEJARYWYWNy YWl6QHN1c2NlcnRlLmdvYi52ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC ggIBALcok9KOeQsz+FEa+MXGdAJVJN63wozmjcrg6uCuKguU9VhnC1UzxQjFsUze rnpGVwX2QYVnA0NJxyzm9fWMSkimcynnpO85uHeFyk8M1DT7WBR8REn50eK9MqVo 8tNXAS80lUxxGdm7dbKY4iL9TL8megLnfNBNSUUaLeq11d1NL47W/uW9+hAzWlu6 aPt3cc/Fpd01XMlGL/K0w9NB5Tv9KQWDerAH6QWIKjMkmxmeQ5USojV55hztS1gP snlcPWk+5oPC9H/MkZxTPn8JK9ATXcOpFMAwNn9jgJL7BMljYzV/cZFHS03aurrz fnb+hI3leMTpCzlnbFAR/eUSN2JIyu/blsHu3S5aXQiDVxNb+q7NCMqACeza38Zd 6ONTyaD8gvAV6JR9rY6wB3SqKWr5Nef0wMn9/EJoGhfTli5SIjYmfjYKWj5gzrDU +vM3gHnlFix6hiskajdswgLEoK+PG7onW2ar6CQpay/U68FcDsn2jIDHhxAIaZIS K6FoecIYvZX6P8SlemDBMxuMaepXR9dFHM9hpyCaqzXbume4bscS8paLWQwMduil oQjOEP0Ocl7Fnuk4w2Kvek+aL69s0ykp6yPoGs0y03S83FmLfwtIt4rT5LfUYQv9 3dDBluLOt++Elw3A3HbajirVPI4lzsLFlirwUXqm/Wf7Gy6PAgMBAAGjggLeMIIC 2jASBgNVHRMBAf8ECDAGAQH/AgECMDcGA1UdEgQwMC6CD3N1c2NlcnRlLmdvYi52 ZaAbBgVghl4CAqASDBBSSUYtRy0yMDAwNDAzNi0wMB0GA1UdDgQWBBRmDZwMrrrR SkMD7hObbfHS1HLVmjCCAVAGA1UdIwSCAUcwggFDgBRmDZwMrrrRSkMD7hObbfHS 1HLVmqGCASakggEiMIIBHjE+MDwGA1UEAxM1QXV0b3JpZGFkIGRlIENlcnRpZmlj YWNpb24gUmFpeiBkZWwgRXN0YWRvIFZlbmV6b2xhbm8xCzAJBgNVBAYTAlZFMRAw DgYDVQQHEwdDYXJhY2FzMRkwFwYDVQQIExBEaXN0cml0byBDYXBpdGFsMTYwNAYD VQQKEy1TaXN0ZW1hIE5hY2lvbmFsIGRlIENlcnRpZmljYWNpb24gRWxlY3Ryb25p Y2ExQzBBBgNVBAsTOlN1cGVyaW50ZW5kZW5jaWEgZGUgU2VydmljaW9zIGRlIENl cnRpZmljYWNpb24gRWxlY3Ryb25pY2ExJTAjBgkqhkiG9w0BCQEWFmFjcmFpekBz dXNjZXJ0ZS5nb2IudmWCAQEwDgYDVR0PAQH/BAQDAgEGMDcGA1UdEQQwMC6CD3N1 c2NlcnRlLmdvYi52ZaAbBgVghl4CAqASDBBSSUYtRy0yMDAwNDAzNi0wMFQGA1Ud HwRNMEswJKAioCCGHmh0dHA6Ly93d3cuc3VzY2VydGUuZ29iLnZlL2xjcjAjoCGg H4YdbGRhcDovL2FjcmFpei5zdXNjZXJ0ZS5nb2IudmUwNwYIKwYBBQUHAQEEKzAp MCcGCCsGAQUFBzABhhtodHRwOi8vb2NzcC5zdXNjZXJ0ZS5nb2IudmUwQAYDVR0g BDkwNzA1BgVghl4BAjAsMCoGCCsGAQUFBwIBFh5odHRwOi8vd3d3LnN1c2NlcnRl LmdvYi52ZS9kcGMwDQYJKoZIhvcNAQEFBQADggIBAIIZ7DHkEaEoHIGrJR44YAjG 9wyGXUMOpagwfBUyBmrhUc2sARNuBhmQJkhYGUUnLwDuDZFx7Y3FwjcZoEYzls1n KJM689/pTskFl4gk6xZnRVl8imf2j8P1jWBVzQ+B2AFuuIE0VVHxkya577LkieqR 5AcTbV+93DRdvy/tsgpNaEUdKQmIgZTb+HbzEUxJHNLJSyqctDuTAZi66gQGG/im kSu4raQHHdvcK8XmUoMwwzdhG/vKv6sAfvKTS+lAlZA73lZx8n/0A9wGz8fpEd0A dhhUDH3SAxyETKkrtNp2dsv0E2jbEvC6piAUoYvaJcGhZMMxq4dmAxzzwGFhilxR xDwv4RYJjxV9xHlRmHzViwVI1/NB7Ob8d5bIDc7w417eSIuel//xAIC8ufVzPsoM /12n3mheMLinbec52N0/Wi/gZKbVANl0e/1vWbPd6okO/ou7QE/PGk4aHwq8rA+U 72NM6WATAicV+rZkR0/qlDVkgfWeIg/Spl5/kqrzAHHwT3YQCNEFZGnPy6sVqPbX DQnG50JaARYKLm8z3akalf8gjY5UIJ3PHb39JIqpIKRwU84Q/1RIsqJo9HELd3zM rtcHFBfTfa7dx3DPYo30r4mE7LNT9gZ5f9+Ct8eOAvbQ3WoubQGG5r55+c7FZAU2 EHgFy96xE/FAndEXR872 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIG/zCCBOegAwIBAgICcRkwDQYJKoZIhvcNAQEFBQAwWjELMAkGA1UEBhMCRlIx EzARBgNVBAoTCkNlcnRldXJvcGUxFzAVBgNVBAsTDjAwMDIgNDM0MjAyMTgwMR0w GwYDVQQDExRDZXJ0ZXVyb3BlIFJvb3QgQ0EgMjAeFw0wNzAzMjcyMjAwMDBaFw0z NzAzMjcyMzAwMDBaMFoxCzAJBgNVBAYTAkZSMRMwEQYDVQQKEwpDZXJ0ZXVyb3Bl MRcwFQYDVQQLEw4wMDAyIDQzNDIwMjE4MDEdMBsGA1UEAxMUQ2VydGV1cm9wZSBS b290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDD/Fa1KwaL 7Z5Gz8MAeRyAOaLKyhsQbSH5xx8KrPOteYKXnsaxaIhScTjEqkxHb3f95x/3lPZy V59EPGtf0NnOIijNcCMBFCJQEA4ae0sb9IZXj+ovaUC6RXoCQFpfNduguZ4/8D91 zTpFkRNVw0gp87fXFPIDhqPJsFd7PdkqrF7h35U6hcYFTDGi2i2xAI6vUVeewYtF TSkHi6Dl5d8xDH8GbGFPa+IjMsHljCsN2JYGcLMmJ8rPs6gjAMASJIG/rEQ9F5iD iM4JkDcuooAZSdmgCBeGmWrdHkCf0gLns5hWR3YXqk6h19vqpLrVUmdpcy6gJ1Rz rIvQu/BhWCaoankYwQznfFbMz83XBoYiB15zuNDmDCU1YroExPEALM6dSJ1btPbR YphDd1ercv4zgBAqMRvbGVApkqyB4AhpX+ZOPl6tXEh5nsVdsJeRF54W3wf6auGr vCV8OADh1th6nPzc1yIAUmeol7tsDWeZlxC4eThnaGGIKW6Uv1IHiDbC8i/GRmoh HvGa6Luf7bYms4anMEqbMGO85OhCVkQnPFqhDn3OqsMbXmjscz8/s/vEhSwEFfus CjhmMxmVA0vKtAR9534PDZhWPthXX7eZvnoUrcWn25QOBZ4lq7Kr+QmVeKoHi2wF HO5agGHo3742+7PjI9w9jHVm76PkVdCa7wIDAQABo4IBzTCCAckwDwYDVR0TAQH/ BAUwAwEB/zARBgNVHQ4ECgQIS8lOuWexmDUwUwYDVR0gBEwwSjBIBgcqgXoBaQQB MD0wOwYIKwYBBQUHAgEWL2h0dHA6Ly93d3cuY2VydGV1cm9wZS5mci9yZWZlcmVu Y2UvcGMtcm9vdDIucGRmMAsGA1UdDwQEAwIBBjCCAT8GA1UdHwSCATYwggEyMDKg MKAuhixodHRwOi8vd3d3LmNlcnRldXJvcGUuZnIvcmVmZXJlbmNlL3Jvb3QyLmNy bDB9oHugeYZ3bGRhcDovL2xjcjEuY2VydGV1cm9wZS5mci9jbj1DZXJ0ZXVyb3Bl JTIwUm9vdCUyMENBJTIwMixvdT0wMDAyJTIwNDM0MjAyMTgwLG89Q2VydGV1cm9w ZSxjPUZSP2NlcnRpZmljYXRlUmV2b2NhdGlvbkxpc3QwfaB7oHmGd2xkYXA6Ly9s Y3IyLmNlcnRldXJvcGUuZnIvY249Q2VydGV1cm9wZSUyMFJvb3QlMjBDQSUyMDIs b3U9MDAwMiUyMDQzNDIwMjE4MCxvPUNlcnRldXJvcGUsYz1GUj9jZXJ0aWZpY2F0 ZVJldm9jYXRpb25MaXN0MA0GCSqGSIb3DQEBBQUAA4ICAQAbRJZgJFo+a6rezdPY W1LAS/pRJePuzbyMPtO1Hfb8QIOsfuXXBkMtbCdz/r/apIIiUW7+jAymEVJgaAZe M0z6SPhbSCHWDJu+OLnhwEwToVPvIjlu7kZQZQsaHwV+d9nOJc30r8Z8nYyXbGod 9mTtlOHOXe9AHZbLcdVKrXlYOUVNq28HuzN8rj6l6cco2mignlcnZu99l+5pqELr c6pLsVnGjTecqcBGUG+MSVPV5S3hok3L51u/pbs8rFLOGZNkwxCaeUKrqPuEg8JG X7sozA5pT3xfuzxn5g2WHoRMXiAVWzlD5YsrgiSJo6D3EGXTyYnapMFFfYlZkOtB no7QxAlgX5ctIW0EphGBMEyTwlhguGvWeqDlsRGfYrgwcUand2RmOkJZH1VjR9cd oDSOgXJiSNmXrqHxvkDioDF/awDZxwLQaQIO8c4eLaSd78yBO2Oe91Qbzr7ECleb zbFr4qfgqx4eg9jAUhyqOlFGktCf2yHfaagLFU1e5In8W1NIeWutYZ8e5bixMrLb fehHatii4GX1zlYXoBKQuvBLLQEaqWnSp+fHrDSbbaKQwYYmSrIvvftvaGtVu8Vj OMF3YGMtrQycPKqYskOj1EbcDdw2HzIuaLp8ZSFBl5aQZxTWpC/9IT9//CJ7KjVY 9Ubxkw7Z7eA6Jn9uLo+YuE/UmQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDVTCCAj2gAwIBAgIESTMAATANBgkqhkiG9w0BAQUFADAyMQswCQYDVQQGEwJD TjEOMAwGA1UEChMFQ05OSUMxEzARBgNVBAMTCkNOTklDIFJPT1QwHhcNMDcwNDE2 MDcwOTE0WhcNMjcwNDE2MDcwOTE0WjAyMQswCQYDVQQGEwJDTjEOMAwGA1UEChMF Q05OSUMxEzARBgNVBAMTCkNOTklDIFJPT1QwggEiMA0GCSqGSIb3DQEBAQUAA4IB DwAwggEKAoIBAQDTNfc/c3et6FtzF8LRb+1VvG7q6KR5smzDo+/hn7E7SIX1mlwh IhAsxYLO2uOabjfhhyzcuQxauohV3/2q2x8x6gHx3zkBwRP9SFIhxFXf2tizVHa6 dLG3fdfA6PZZxU3Iva0fFNrfWEQlMhkqx35+jq44sDB7R3IJMfAw28Mbdim7aXZO V/kbZKKTVrdvmW7bCgScEeOAH8tjlBAKqeFkgjH5jCftppkA9nCTGPihNIaj3XrC GHn2emU1z5DrvTOTn1OrczvmmzQgLx3vqR1jGqCA2wMv+SYahtKNu6m+UjqHZ0gN v7Sg2Ca+I19zN38m5pIEo3/PIKe38zrKy5nLAgMBAAGjczBxMBEGCWCGSAGG+EIB AQQEAwIABzAfBgNVHSMEGDAWgBRl8jGtKvf33VKWCscCwQ7vptU7ETAPBgNVHRMB Af8EBTADAQH/MAsGA1UdDwQEAwIB/jAdBgNVHQ4EFgQUZfIxrSr3991SlgrHAsEO 76bVOxEwDQYJKoZIhvcNAQEFBQADggEBAEs17szkrr/Dbq2flTtLP1se31cpolnK OOK5Gv+e5m4y3R6u6jW39ZORTtpC4cMXYFDy0VwmuYK36m3knITnA3kXr5g9lNvH ugDnuL8BV8F3RTIMO/G0HAiw/VGgod2aHRM2mm23xzy54cXZF/qD1T0VoDy7Hgvi yJA/qIYM/PmLXoXLT1tLYhFHxUV8BS9BsZ4QaRuZluBVeftOhpm4lNqGOGqTo+fL buXf6iFViZx9fX+Y9QCJ7uOEwFyWtcVG6kbghVW2G8kS1sHNzYDzAgE8yGnLRUhj 2JTQ7IUOO04RZfSCjKY9ri4ilAnIXOo8gV0WKgOXFlUJ24pBgp5mmxE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBgMQswCQYDVQQGEwJKUDEl MCMGA1UEChMcU0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEqMCgGA1UECxMh U2VjdXJpdHkgQ29tbXVuaWNhdGlvbiBFViBSb290Q0ExMB4XDTA3MDYwNjAyMTIz MloXDTM3MDYwNjAyMTIzMlowYDELMAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09N IFRydXN0IFN5c3RlbXMgQ08uLExURC4xKjAoBgNVBAsTIVNlY3VyaXR5IENvbW11 bmljYXRpb24gRVYgUm9vdENBMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC ggEBALx/7FebJOD+nLpCeamIivqA4PUHKUPqjgo0No0c+qe1OXj/l3X3L+SqawSE RMqm4miO/VVQYg+kcQ7OBzgtQoVQrTyWb4vVog7P3kmJPdZkLjjlHmy1V4qe70gO zXppFodEtZDkBp2uoQSXWHnvIEqCa4wiv+wfD+mEce3xDuS4GBPMVjZd0ZoeUWs5 bmB2iDQL87PRsJ3KYeJkHcFGB7hj3R4zZbOOCVVSPbW9/wfrrWFVGCypaZhKqkDF MxRldAD5kd6vA0jFQFTcD4SQaCDFkpbcLuUCRarAX1T4bepJz11sS6/vmsJWXMY1 VkJqMF/Cq/biPT+zyRGPMUzXn0kCAwEAAaNCMEAwHQYDVR0OBBYEFDVK9U2vP9eC OKyrcWUXdYydVZPmMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0G CSqGSIb3DQEBBQUAA4IBAQCoh+ns+EBnXcPBZsdAS5f8hxOQWsTvoMpfi7ent/HW tWS3irO4G8za+6xmiEHO6Pzk2x6Ipu0nUBsCMCRGef4Eh3CXQHPRwMFXGZpppSeZ q51ihPZRwSzJIxXYKLerJRO1RuGGAv8mjMSIkh1W/hln8lXkgKNrnKt34VFxDSDb EJrbvXZ5B3eZKK2aXtqxT0QsNY6llsf9g/BYxnnWmHyojf6GPgcWkuF75x3sM3Z+ Qi5KhfmRiWiEA4Glm5q+4zfFVKtWOxgtQaQM+ELbmaDgcm+7XeEWT1MKZPlO9L9O VL14bIjqv5wTJMJwaaJ/D8g8rQjJsJhAoyrniIPtd490 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNV BAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4X DTA3MDYyOTE1MTMwNVoXDTI3MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQ BgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwIQ2VydGlnbmEwggEiMA0GCSqGSIb3 DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7qXOEm7RFHYeGifBZ4 QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyHGxny gQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbw zBfsV1/pogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q 130yGLMLLGq/jj8UEYkgDncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2 JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKfIrjxwo1p3Po6WAbfAgMBAAGjgbwwgbkw DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQtCRZvgHyUtVF9lo53BEw ZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJBgNVBAYT AkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzj AQ/JSP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG 9w0BAQUFAAOCAQEAhQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8h bV6lUmPOEvjvKtpv6zf+EwLHyzs+ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFnc fca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1kluPBS1xp81HlDQwY9qcEQCYsuu HWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY1gkIl2PlwS6w t0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDoTCCAomgAwIBAgIQKTZHquOKrIZKI1byyrdhrzANBgkqhkiG9w0BAQUFADBO MQswCQYDVQQGEwJ1czEYMBYGA1UEChMPVS5TLiBHb3Zlcm5tZW50MQ0wCwYDVQQL EwRGQkNBMRYwFAYDVQQDEw1Db21tb24gUG9saWN5MB4XDTA3MTAxNTE1NTgwMFoX DTI3MTAxNTE2MDgwMFowTjELMAkGA1UEBhMCdXMxGDAWBgNVBAoTD1UuUy4gR292 ZXJubWVudDENMAsGA1UECxMERkJDQTEWMBQGA1UEAxMNQ29tbW9uIFBvbGljeTCC ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJeNvTMn5K1b+3i9L0dHbsd4 6ZOcpN7JHP0vGzk4rEcXwH53KQA7Ax9oD81Npe53uCxiazH2+nIJfTApBnznfKM9 hBiKHa4skqgf6F5PjY7rPxr4nApnnbBnTfAu0DDew5SwoM8uCjR/VAnTNr2kSVdS c+md/uRIeUYbW40y5KVIZPMiDZKdCBW/YDyD90ciJSKtKXG3d+8XyaK2lF7IMJCk FEhcVlcLQUwF1CpMP64Sm1kRdXAHImktLNMxzJJ+zM2kfpRHqpwJCPZLr1LoakCR xVW9QLHIbVeGlRfmH3O+Ry4+i0wXubklHKVSFzYIWcBCvgortFZRPBtVyYyQd+sC AwEAAaN7MHkwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFC9Yl9ipBZilVh/72at17wI8NjTHMBIGCSsGAQQBgjcVAQQFAgMBAAEwIwYJ KwYBBAGCNxUCBBYEFHa3YJbdFFYprHWF03BjwbxHhhyLMA0GCSqGSIb3DQEBBQUA A4IBAQBgrvNIFkBypgiIybxHLCRLXaCRc+1leJDwZ5B6pb8KrbYq+Zln34PFdx80 CTj5fp5B4Ehg/uKqXYeI6oj9XEWyyWrafaStsU+/HA2fHprA1RRzOCuKeEBuMPdi 4c2Z/FFpZ2wR3bgQo2jeJqVW/TZsN5hs++58PGxrcD/3SDcJjwtCga1GRrgLgwb0 Gzigf0/NC++DiYeXHIowZ9z9VKEDfgHLhUyxCynDvux84T8PCVI8L6eaSP436REG WOE2QYrEtr+O3c5Ks7wawM36GpnScZv6z7zyxFSjiDV2zBssRm8MtNHDYXaSdBHq S4CNHIkRi+xb/xfJSPzn4AYR4oRe -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAw NzEUMBIGA1UECgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJv b3QgQ0EgdjEwHhcNMDcxMDE4MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYD VQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwWVGVsaWFTb25lcmEgUm9vdCBDQSB2 MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+6yfwIaPzaSZVfp3F VRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA3GV1 7CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+X Z75Ljo1kB1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+ /jXh7VB7qTCNGdMJjmhnXb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs 81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxHoLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkm dtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3F0fUTPHSiXk+TT2YqGHe Oh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJoWjiUIMu sDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4 pgd7gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fs slESl1MpWtTwEhDcTwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQ arMCpgKIv7NHfirZ1fpoeDVNAgMBAAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYD VR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qWDNXr+nuqF+gTEjANBgkqhkiG 9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNmzqjMDfz1mgbl dxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx 0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1Tj TQpgcmLNkQfWpb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBed Y2gea+zDTYa4EzAvXUYNR0PVG6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7 Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpcc41teyWRyu5FrgZLAMzTsVlQ2jqI OylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOTJsjrDNYmiLbAJM+7 vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2qReW t88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcn HL/EVlP6Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVx SK236thZiNSQvxaz2emsWWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDhDCCAwqgAwIBAgIQL4D+I4wOIg9IZxIokYesszAKBggqhkjOPQQDAzCByjEL MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZW ZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2ln biwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJp U2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9y aXR5IC0gRzQwHhcNMDcxMTA1MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCByjELMAkG A1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJp U2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2lnbiwg SW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2ln biBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 IC0gRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASnVnp8Utpkmw4tXNherJI9/gHm GUo9FANL+mAnINmDiWn6VMaaGF5VKmTeBvaNSjutEDxlPZCIBIngMGGzrl0Bp3ve fLK+ymVhAIau2o970ImtTR1ZmkGxvEeA3J5iw/mjgbIwga8wDwYDVR0TAQH/BAUw AwEB/zAOBgNVHQ8BAf8EBAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJ aW1hZ2UvZ2lmMCEwHzAHBgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYj aHR0cDovL2xvZ28udmVyaXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFLMW kf3upm7ktS5Jj4d4gYDs5bG1MAoGCCqGSM49BAMDA2gAMGUCMGYhDBgmYFo4e1ZC 4Kf8NoRRkSAsdk1DPcQdhCPQrNZ8NQbOzWm9kA3bbEhCHQ6qQgIxAJw9SDkjOVga FRJZap7v1VmyHVIsmXHNxynfGyphe3HR3vPA5Q06Sqotp9iGKt0uEA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICrjCCAjWgAwIBAgIQPLL0SAoA4v7rJDteYD7DazAKBggqhkjOPQQDAzCBmDEL MAkGA1UEBhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChj KSAyMDA3IEdlb1RydXN0IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2 MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 eSAtIEcyMB4XDTA3MTEwNTAwMDAwMFoXDTM4MDExODIzNTk1OVowgZgxCzAJBgNV BAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykgMjAw NyBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNV BAMTLUdlb1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBH MjB2MBAGByqGSM49AgEGBSuBBAAiA2IABBWx6P0DFUPlrOuHNxFi79KDNlJ9RVcL So17VDs6bl8VAsBQps8lL33KSLjHUGMcKiEIfJo22Av+0SbFWDEwKCXzXV2juLal tJLtbCyf691DiaI8S0iRHVDsJt/WYC69IaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBVfNVdRVfslsq0DafwBo/q+EVXVMAoG CCqGSM49BAMDA2cAMGQCMGSWWaboCd6LuvpaiIjwH5HTRqjySkwCY/tsXzjbLkGT qQ7mndwxHLKgpxgceeHHNgIwOlavmnRs9vuD4DPTCF+hnMJbn0bWtsuRBmOiBucz rD6ogRLQy7rQkgu2npaqBA+K -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICiDCCAg2gAwIBAgIQNfwmXNmET8k9Jj1Xm67XVjAKBggqhkjOPQQDAzCBhDEL MAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjE4MDYGA1UECxMvKGMp IDIwMDcgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxJDAi BgNVBAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EgLSBHMjAeFw0wNzExMDUwMDAw MDBaFw0zODAxMTgyMzU5NTlaMIGEMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMdGhh d3RlLCBJbmMuMTgwNgYDVQQLEy8oYykgMjAwNyB0aGF3dGUsIEluYy4gLSBGb3Ig YXV0aG9yaXplZCB1c2Ugb25seTEkMCIGA1UEAxMbdGhhd3RlIFByaW1hcnkgUm9v dCBDQSAtIEcyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEotWcgnuVnfFSeIf+iha/ BebfowJPDQfGAFG6DAJSLSKkQjnE/o/qycG+1E3/n3qe4rF8mq2nhglzh9HnmuN6 papu+7qzcMBniKI11KOasf2twu8x+qi58/sIxpHR+ymVo0IwQDAPBgNVHRMBAf8E BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUmtgAMADna3+FGO6Lts6K DPgR4bswCgYIKoZIzj0EAwMDaQAwZgIxAN344FdHW6fmCsO99YCKlzUNG4k8VIZ3 KMqh9HneteY4sPBlcIx/AlTCv//YoT7ZzwIxAMSNlPzcU9LcnXgWHxUzI1NS41ox XZ3Krr0TKUQNJ1uo52icEvdYPy5yAlejj6EULg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF8DCCA9igAwIBAgIPBuhGJy8fCo/RhFzjafbVMA0GCSqGSIb3DQEBBQUAMDgx CzAJBgNVBAYTAkVTMRQwEgYDVQQKDAtJWkVOUEUgUy5BLjETMBEGA1UEAwwKSXpl bnBlLmNvbTAeFw0wNzEyMTMxMzA4MjdaFw0zNzEyMTMwODI3MjVaMDgxCzAJBgNV BAYTAkVTMRQwEgYDVQQKDAtJWkVOUEUgUy5BLjETMBEGA1UEAwwKSXplbnBlLmNv bTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMnTesoPHqynhugWZWqx whtFMnGV2f4QW8yv56V5AY+Jw8ryVXH3d753lPNypCxE2J6SmxQ6oeckkAoKVo7F 2CaU4dlI4S0+2gpy3aOZFdqBoof0e24md4lYrdbrDLJBenNubdt6eEHpCIgSfocu ZhFjbFT7PJ1ywLwu/8K33Q124zrX97RovqL144FuwUZvXY3gTcZUVYkaMzEKsVe5 o4qYw+w7NMWVQWl+dcI8IMVhulFHoCCQk6GQS/NOfIVFVJrRBSZBsLVNHTO+xAPI JXzBcNs79AktVCdIrC/hxKw+yMuSTFM5NyPs0wH54AlETU1kwOENWocivK0bo/4m tRXzp/yEGensoYi0RGmEg/OJ0XQGqcwL1sLeJ4VQJsoXuMl6h1YsGgEebL4TrRCs tST1OJGh1kva8bvS3ke18byB9llrzxlT6Y0Vy0rLqW9E5RtBz+GGp8rQap+8TI0G M1qiheWQNaBiXBZO8OOi+gMatCxxs1gs3nsL2xoP694hHwZ3BgOwye+Z/MC5TwuG KP7Suerj2qXDR2kS4Nvw9hmL7Xtw1wLW7YcYKCwEJEx35EiKGsY7mtQPyvp10gFA Wo15v4vPS8+qFsGV5K1Mij4XkdSxYuWC5YAEpAN+jb/af6IPl08M0w3719Hlcn4c yHf/W5oPt64FRuXxqBbsR6QXAgMBAAGjgfYwgfMwgbAGA1UdEQSBqDCBpYEPaW5m b0BpemVucGUuY29tpIGRMIGOMUcwRQYDVQQKDD5JWkVOUEUgUy5BLiAtIENJRiBB MDEzMzcyNjAtUk1lcmMuVml0b3JpYS1HYXN0ZWl6IFQxMDU1IEY2MiBTODFDMEEG A1UECQw6QXZkYSBkZWwgTWVkaXRlcnJhbmVvIEV0b3JiaWRlYSAxNCAtIDAxMDEw IFZpdG9yaWEtR2FzdGVpejAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB BjAdBgNVHQ4EFgQUHRxlDqjyJXu0kc/ksbHmvVV0bAUwDQYJKoZIhvcNAQEFBQAD ggIBAMeBRm8hGE+gBe/n1bqXUKJg7aWSFBpSm/nxiEqg3Hh10dUflU7F57dp5iL0 +CmoKom+z892j+Mxc50m0xwbRxYpB2iEitL7sRskPtKYGCwkjq/2e+pEFhsqxPqg l+nqbFik73WrAGLRne0TNtsiC7bw0fRue0aHwp28vb5CO7dz0JoqPLRbEhYArxk5 ja2DUBzIgU+9Ag89njWW7u/kwgN8KRwCfr00J16vU9adF79XbOnQgxCvv11N75B7 XSus7Op9ACYXzAJcY9cZGKfsK8eKPlgOiofmg59OsjQerFQJTx0CCzl+gQgVuaBp E8gyK+OtbBPWg50jLbJtooiGfqgNASYJQNntKE6MkyQP2/EeTXp6WuKlWPHcj1+Z ggwuz7LdmMySlD/5CbOlliVbN/UShUHiGUzGigjB3Bh6Dx4/glmimj4/+eAJn/3B kUtdyXvWton83x18hqrNA/ILUpLxYm9/h+qrdslsUMIZgq+qHfUgKGgu1fxkN0/P pUTEvnK0jHS0bKf68r10OEMr3q/53NjgnZ/cPcqlY0S/kqJPTIAcuxrDmkoEVU3K 7iYLHL8CxWTTnn7S05EcS6L1HOUXHA0MUqORH5zwIe0ClG+poEnK6EOMxPQ02nwi o8ZmPrgbBYhdurz3vOXcFD2nhqi2WVIhA16L4wTtSyoeo09Q -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4 MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6 ZW5wZS5jb20wHhcNMDcxMjEzMTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYD VQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5j b20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ03rKDx6sp4boFmVq scIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAKClaO xdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6H LmYRY2xU+zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFX uaOKmMPsOzTFlUFpfnXCPCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQD yCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxTOTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+ JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbKF7jJeodWLBoBHmy+E60Q rLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK0GqfvEyN BjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8L hij+0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIB QFqNeb+Lz0vPqhbBleStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+ HMh3/1uaD7euBUbl8agW7EekFwIDAQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2lu Zm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+SVpFTlBFIFMuQS4gLSBDSUYg QTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBGNjIgUzgxQzBB BgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AQYwHQYDVR0OBBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUA A4ICAQB4pgwWSp9MiDrAyw6lFn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWb laQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbgakEyrkgPH7UIBzg/YsfqikuFgba56 awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8qhT/AQKM6WfxZSzwo JNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Csg1lw LDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCT VyvehQP5aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGk LhObNA5me0mrZJfQRsN5nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJb UjWumDqtujWTI6cfSN01RpiyEGjkpTHCClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/ QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZoQ0iy2+tzJOeRf1SktoA+ naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1ZWrOZyGls QyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkjCCA3qgAwIBAgIBCDANBgkqhkiG9w0BAQUFADA6MQswCQYDVQQGEwJDTjER MA8GA1UEChMIVW5pVHJ1c3QxGDAWBgNVBAMTD1VDQSBHbG9iYWwgUm9vdDAeFw0w ODAxMDEwMDAwMDBaFw0zNzEyMzEwMDAwMDBaMDoxCzAJBgNVBAYTAkNOMREwDwYD VQQKEwhVbmlUcnVzdDEYMBYGA1UEAxMPVUNBIEdsb2JhbCBSb290MIICIjANBgkq hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2rPlBlA/9nP3xDK/RqUlYjOHsGj+p9+I A2N9Apb964fJ7uIIu527u+RBj8cwiQ9tJMAEbBSUgU2gDXRm8/CFr/hkGd656YGT 0CiFmUdCSiw8OCdKzP/5bBnXtfPvm65bNAbXj6ITBpyKhELVs6OQaG2BkO5NhOxM cE4t3iQ5zhkAQ5N4+QiGHUPR9HK8BcBn+sBR0smFBySuOR56zUHSNqth6iur8CBV mTxtLRwuLnWW2HKX4AzKaXPudSsVCeCObbvaE/9GqOgADKwHLx25urnRoPeZnnRc GQVmMc8+KlL+b5/zub35wYH1N9ouTIElXfbZlJrTNYsgKDdfUet9Ysepk9H50DTL qScmLCiQkjtVY7cXDlRzq6987DqrcDOsIfsiJrOGrCOp139tywgg8q9A9f9ER3Hd J90TKKHqdjn5EKCgTUCkJ7JZFStsLSS3JGN490MYeg9NEePorIdCjedYcaSrbqLA l3y74xNLytu7awj5abQEctXDRrl36v+6++nwOgw19o8PrgaEFt2UVdTvyie3AzzF HCYq9TyopZWbhvGKiWf4xwxmse1Bv4KmAGg6IjTuHuvlb4l0T2qqaqhXZ1LUIGHB zlPL/SR/XybfoQhplqCe/klD4tPq2sTxiDEhbhzhzfN1DiBEFsx9c3Q1RSw7gdQg 7LYJjD5IskkCAwEAAaOBojCBnzALBgNVHQ8EBAMCAQYwDAYDVR0TBAUwAwEB/zBj BgNVHSUEXDBaBggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUFBwMDBggrBgEFBQcD BAYIKwYBBQUHAwUGCCsGAQUFBwMGBggrBgEFBQcDBwYIKwYBBQUHAwgGCCsGAQUF BwMJMB0GA1UdDgQWBBTZw9P4gJJnzF3SOqLXcaK0xDiALTANBgkqhkiG9w0BAQUF AAOCAgEA0Ih5ygiq9ws0oE4Jwul+NUiJcIQjL1HDKy9e21NrW3UIKlS6Mg7VxnGF sZdJgPaE0PC6t3GUyHlrpsVE6EKirSUtVy/m1jEp+hmJVCl+t35HNmktbjK81HXa QnO4TuWDQHOyXd/URHOmYgvbqm4FjMh/Rk85hZCdvBtUKayl1/7lWFZXbSyZoUkh 1WHGjGHhdSTBAd0tGzbDLxLMC9Z4i3WA6UG5iLHKPKkWxk4V43I29tSgQYWvimVw TbVEEFDs7d9t5tnGwBLxSzovc+k8qe4bqi81pZufTcU0hF8mFGmzI7GJchT46U1R IgP/SobEHOh7eQrbRyWBfvw0hKxZuFhD5D1DCVR0wtD92e9uWfdyYJl2b/Unp7uD pEqB7CmB9HdL4UISVdSGKhK28FWbAS7d9qjjGcPORy/AeGEYWsdl/J1GW1fcfA67 loMQfFUYCQSu0feLKj6g5lDWMDbX54s4U+xJRODPpN/xU3uLWrb2EZBL1nXz/gLz Ka/wI3J9FO2pXd96gZ6bkiL8HvgBRUGXx2sBYb4zaPKgZYRmvOAqpGjTcezHCN6j w8k2SjTxF+KAryAhk5Qe5hXTVGLxtTgv48y5ZwSpuuXu+RBuyy5+E6+SFP7zJ3N7 OPxzbbm5iPZujAv1/P8JDrMtXnt145Ik4ubhWD5LKAN1axibRww= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTEL MAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE BxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMT IkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwMzA2MDAw MDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdy ZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09N T0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlv biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSR FtSrYpn1PlILBs5BAH+X4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0J cfRK9ChQtP6IHG4/bC8vCVlbpVsLM5niwz2J+Wos77LTBumjQjBAMB0GA1UdDgQW BBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VGFAkK+qDm fQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdv GDeAU/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEKjCCAxKgAwIBAgIQYAGXt0an6rS0mtZLL/eQ+zANBgkqhkiG9w0BAQsFADCB rjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMf Q2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIw MDggdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxJDAiBgNV BAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EgLSBHMzAeFw0wODA0MDIwMDAwMDBa Fw0zNzEyMDEyMzU5NTlaMIGuMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMdGhhd3Rl LCBJbmMuMSgwJgYDVQQLEx9DZXJ0aWZpY2F0aW9uIFNlcnZpY2VzIERpdmlzaW9u MTgwNgYDVQQLEy8oYykgMjAwOCB0aGF3dGUsIEluYy4gLSBGb3IgYXV0aG9yaXpl ZCB1c2Ugb25seTEkMCIGA1UEAxMbdGhhd3RlIFByaW1hcnkgUm9vdCBDQSAtIEcz MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsr8nLPvb2FvdeHsbnndm gcs+vHyu86YnmjSjaDFxODNi5PNxZnmxqWWjpYvVj2AtP0LMqmsywCPLLEHd5N/8 YZzic7IilRFDGF/Eth9XbAoFWCLINkw6fKXRz4aviKdEAhN0cXMKQlkC+BsUa0Lf b1+6a4KinVvnSr0eAXLbS3ToO39/fR8EtCab4LRarEc9VbjXsCZSKAExQGbY2SS9 9irY7CFJXJv2eul/VTV+lmuNk5Mny5K76qxAwJ/C+IDPXfRa3M50hqY+bAtTyr2S zhkGcuYMXDhpxwTWvGzOW/b3aJzcJRVIiKHpqfiYnODz1TEoYRFsZ5aNOZnLwkUk OQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNV HQ4EFgQUrWyqlGCc7eT/+j4KdCtjA/e2Wb8wDQYJKoZIhvcNAQELBQADggEBABpA 2JVlrAmSicY59BDlqQ5mU1143vokkbvnRFHfxhY0Cu9qRFHqKweKA3rD6z8KLFIW oCtDuSWQP3CpMyVtRRooOyfPqsMpQhvfO0zAMzRbQYi/aytlryjvsvXDqmbOe1bu t8jLZ8HJnBoYuMTDSQPxYA5QzUbF83d597YV4Djbxy8ooAw/dyZ02SUS2jHaGh7c KUGRIjxpp7sC8rZcJwOJ9Abqm+RyguOhCcHpABnTPtRwa7pxpqpYrvS76Wy274fM m7v/OeZWYdMKp8RcTGB7BXcmer/YB1IsYvdwY9k5vG8cwnncdimvzsUsZAReiDZu MdRAGmI0Nj81Aa6sY6A= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEuTCCA6GgAwIBAgIQQBrEZCGzEyEDDrvkEhrFHTANBgkqhkiG9w0BAQsFADCB vTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwOCBWZXJp U2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVzZSBvbmx5MTgwNgYDVQQDEy9W ZXJpU2lnbiBVbml2ZXJzYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAe Fw0wODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIG9MQswCQYDVQQGEwJVUzEX MBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0 IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAyMDA4IFZlcmlTaWduLCBJbmMuIC0gRm9y IGF1dGhvcml6ZWQgdXNlIG9ubHkxODA2BgNVBAMTL1ZlcmlTaWduIFVuaXZlcnNh bCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEF AAOCAQ8AMIIBCgKCAQEAx2E3XrEBNNti1xWb/1hajCMj1mCOkdeQmIN65lgZOIzF 9uVkhbSicfvtvbnazU0AtMgtc6XHaXGVHzk8skQHnOgO+k1KxCHfKWGPMiJhgsWH H26MfF8WIFFE0XBPV+rjHOPMee5Y2A7Cs0WTwCznmhcrewA3ekEzeOEz4vMQGn+H LL729fdC4uW/h2KJXwBL38Xd5HVEMkE6HnFuacsLdUYI0crSK5XQz/u5QGtkjFdN /BMReYTtXlT2NJ8IAfMQJQYXStrxHXpma5hgZqTZ79IugvHw7wnqRMkVauIDbjPT rJ9VAMf2CGqUuV/c4DPxhGD5WycRtPwW8rtWaoAljQIDAQABo4GyMIGvMA8GA1Ud EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMG0GCCsGAQUFBwEMBGEwX6FdoFsw WTBXMFUWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFI/l0xqGrI2Oa8PPgGrUSBgs exkuMCUWI2h0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMB0GA1Ud DgQWBBS2d/ppSEefUxLVwuoHMnYH0ZcHGTANBgkqhkiG9w0BAQsFAAOCAQEASvj4 sAPmLGd75JR3Y8xuTPl9Dg3cyLk1uXBPY/ok+myDjEedO2Pzmvl2MpWRsXe8rJq+ seQxIcaBlVZaDrHC1LGmWazxY8u4TB1ZkErvkBYoH1quEPuBUDgMbMzxPcP1Y+Oz 4yHJJDnp/RVmRvQbEdBNc6N9Rvk97ahfYtTxP/jgdFcrGJ2BtMQo2pSXpXDrrB2+ BxHw1dvd5Yzw1TKwg+ZX4o+/vqGqvz0dtdQ46tewXDpPaj+PwGZsY6rp2aQW9IHR lRQOfc2VNNnSj3BzgXucfr2YYdhFh5iQxeuGMMY1v/D/w1WIg0vvBZIGcfK4mJO3 7M2CYfE45k+XmCpajQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID/jCCAuagAwIBAgIQFaxulBmyeUtB9iepwxgPHzANBgkqhkiG9w0BAQsFADCB mDELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsT MChjKSAyMDA4IEdlb1RydXN0IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25s eTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBDZXJ0aWZpY2F0aW9uIEF1dGhv cml0eSAtIEczMB4XDTA4MDQwMjAwMDAwMFoXDTM3MTIwMTIzNTk1OVowgZgxCzAJ BgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykg MjAwOCBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0 BgNVBAMTLUdlb1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANziXmJYHTNXOTIz +uvLh4yn1ErdBojqZI4xmKU4kB6Yzy5jK/BGvESyiaHAKAxJcCGVn2TAppMSAmUm hsalifD614SgcK9PGpc/BkTVyetyEH3kMSj7HGHmKAdEc5IiaacDiGydY8hS2pgn 5whMcD60yRLBxWeDXTPzAxHsatBT4tG6NmCUgLthY2xbF37fQJQeqw3CIShwiP/W JmxsYAQlTlV+fe+/lEjetx3dcI0FX4ilm/LC7urRQEFtYjgdVgbFA0dRIBn8exAL DmKudlW/X3e+PkkBUz2YJQN2JFodtNuJ6nnltrM7P7pMKEF/BqxqjsHQ9gUdfeZC huOl1UcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw HQYDVR0OBBYEFMR5yo6hTgMdHNxr2zFblD4/MH8tMA0GCSqGSIb3DQEBCwUAA4IB AQAtxRPPVoB7eni9n64smefv2t+UXglpp+duaIy9cr5HqQ6XErhK8WTTOd8lNNTB zU6B8A8ExCSzNJbGpqow32hhc9f5joWJ7w5elShKKiePEI4ufIbEAp7aDHdlDkQN kv39sxY2+hENHYwOB4lqKVb3cvTdFZx3NWZXqxNT2I7BQMXXExZacse3aQHEerGD AWh9jUGhlBjBJVz88P6DAod8DQ3PLghcSkANPuyBYeYk28rgDi0Hsj5W3I31QYUH SJsMC8tJP33st/3LjWeJGqvtux6jAAgIFyqCXDFdRootD4abdNlF+9RAsXqqaC2G spki4cErx5z481+oghLrGREt -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFtTCCA52gAwIBAgIIYY3HhjsBggUwDQYJKoZIhvcNAQEFBQAwRDEWMBQGA1UE AwwNQUNFRElDT00gUm9vdDEMMAoGA1UECwwDUEtJMQ8wDQYDVQQKDAZFRElDT00x CzAJBgNVBAYTAkVTMB4XDTA4MDQxODE2MjQyMloXDTI4MDQxMzE2MjQyMlowRDEW MBQGA1UEAwwNQUNFRElDT00gUm9vdDEMMAoGA1UECwwDUEtJMQ8wDQYDVQQKDAZF RElDT00xCzAJBgNVBAYTAkVTMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC AgEA/5KV4WgGdrQsyFhIyv2AVClVYyT/kGWbEHV7w2rbYgIB8hiGtXxaOLHkWLn7 09gtn70yN78sFW2+tfQh0hOR2QetAQXW8713zl9CgQr5auODAKgrLlUTY4HKRxx7 XBZXehuDYAQ6PmXDzQHe3qTWDLqO3tkE7hdWIpuPY/1NFgu3e3eM+SW10W2ZEi5P Grjm6gSSrj0RuVFCPYewMYWveVqc/udOXpJPQ/yrOq2lEiZmueIM15jO1FillUAK t0SdE3QrwqXrIhWYENiLxQSfHY9g5QYbm8+5eaA9oiM/Qj9r+hwDezCNzmzAv+Yb X79nuIQZ1RXve8uQNjFiybwCq0Zfm/4aaJQ0PZCOrfbkHQl/Sog4P75n/TSW9R28 MHTLOO7VbKvU/PQAtwBbhTIWdjPp2KOZnQUAqhbm84F9b32qhm2tFXTTxKJxqvQU fecyuB+81fFOvW8XAjnXDpVCOscAPukmYxHqC9FK/xidstd7LzrZlvvoHpKuE1XI 2Sf23EgbsCTBheN3nZqk8wwRHQ3ItBTutYJXCb8gWH8vIiPYcMt5bMlL8qkqyPyH K9caUPgn6C9D4zq92Fdx/c6mUlv53U3t5fZvie27k5x2IXXwkkwp9y+cAS7+UEae ZAwUswdbxcJzbPEHXEUkFDWug/FqTYl6+rPYLWbwNof1K1MCAwEAAaOBqjCBpzAP BgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKaz4SsrSbbXc6GqlPUB53NlTKxQ MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUprPhKytJttdzoaqU9QHnc2VMrFAw RAYDVR0gBD0wOzA5BgRVHSAAMDEwLwYIKwYBBQUHAgEWI2h0dHA6Ly9hY2VkaWNv bS5lZGljb21ncm91cC5jb20vZG9jMA0GCSqGSIb3DQEBBQUAA4ICAQDOLAtSUWIm fQwng4/F9tqgaHtPkl7qpHMyEVNEskTLnewPeUKzEKbHDZ3Ltvo/Onzqv4hTGzz3 gvoFNTPhNahXwOf9jU8/kzJPeGYDdwdY6ZXIfj7QeQCM8htRM5u8lOk6e25SLTKe I6RF+7YuE7CLGLHdztUdp0J/Vb77W7tH1PwkzQSulgUV1qzOMPPKC8W64iLgpq0i 5ALudBF/TP94HTXa5gI06xgSYXcGCRZj6hitoocf8seACQl1ThCojz2GuHURwCRi ipZ7SkXp7FnFvmuD5uHorLUwHv4FB4D54SMNUI8FmP8sX+g7tq3PgbUhh8oIKiMn MCArz+2UW6yyetLHKKGKC5tNSixthT8Jcjxn4tncB7rrZXtaAWPWkFtPF2Y9fwsZ o5NjEFIqnxQWWOLcpfShFosOkYuByptZ+thrkQdlVV9SH686+5DdaaVbnG0OLLb6 zqylfDJKZ0DcMDQj3dcEI2bw/FWAp/tmGYI1Z2JwOV5vx+qQQEQIHriy1tvuWacN GHk0vFQYXlPKNFHtRQrmjseCNj6nOGOpMCwXEGCSn1WHElkQwg9naRHMTh5+Spqt r0CodaxWkHS4oJyleW/c6RrIaQXpuvoDs3zk4E7Czp3otkYNbn5XOmeUwssfnHdK Z05phkOTOPu220+DkdRgfks+KzgHVZhepA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEfjCCA2agAwIBAgIBADANBgkqhkiG9w0BAQUFADCBzzELMAkGA1UEBhMCVVMx EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOjA4BgNVBAsTMWh0dHA6Ly9j ZXJ0aWZpY2F0ZXMuc3RhcmZpZWxkdGVjaC5jb20vcmVwb3NpdG9yeS8xNjA0BgNV BAMTLVN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 eTAeFw0wODA2MDIwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIHPMQswCQYDVQQGEwJV UzEQMA4GA1UECBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTElMCMGA1UE ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjE6MDgGA1UECxMxaHR0cDov L2NlcnRpZmljYXRlcy5zdGFyZmllbGR0ZWNoLmNvbS9yZXBvc2l0b3J5LzE2MDQG A1UEAxMtU3RhcmZpZWxkIFNlcnZpY2VzIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9y aXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8sxWKk3mFjdal+pt NTjREJvbuNypBAmVMy4JxQB7GnhCj8j0BY7+0miDHk6ZzRfbRz5Q84nS59yY+wX4 qtZj9FRNwXEDsB8bdrMaNDBz8SgyYIP9tJzXttIiN3wZqjveExBpblwG02+j8mZa dkJIr4DRVFk91LnU2+25qzmZ9O5iq+F4cnvYOI1AtszcEgBwQ4Vp2Bjjyldyn7Tf P/wiqEJS9XdbmfBWLSZwFjYSwieeV6Z80CPxedyjk1goOD2frTZD7jf7+PlDrchW 8pQSXkLrc7gTDcum1Ya5qihqVAOhPw8p6wkA6D9eon8XPaEr+L7QdR2khOOrF2UG UgCvsQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd BgNVHQ4EFgQUtMZ/GkPMm3VdL8RL8ouYEOnxURAwHwYDVR0jBBgwFoAUtMZ/GkPM m3VdL8RL8ouYEOnxURAwDQYJKoZIhvcNAQEFBQADggEBAKyAu8QlBQtYpOR+KX6v vDvsLcBELvmR4NI7MieQLfaACVzCq2Uk2jgQRsRJ0v2aqyhId4jG6W/RR5HVNU8U CahbQAcdfHFWy4lC1L9hwCL3Lt+r83JDi0DolOuwJtrRE9Or0DYtLjqVs3cuFTkY DGm6qoDt8VNOM5toBOKgMC7X0V3UpmadhObnuzyJuzad/BepPVUrivubxEyE/9/S vmkbdLCo9uqwnLIpdIFMaDqaf3MlOfUT4GaRadRXS7furUXgLMOI076USYkf/3DV W205E7Ady5jmZ2MNY/b7w9dhcoOIP3B+U8meiVTWT399cbmu8WCLd2Ds+L/6aqOc ASI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIKbzCCCFegAwIBAgIQAldiBmp1YIdPkAS/ocgoQTANBgkqhkiG9w0BAQUFADCB gzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAk1OMRQwEgYDVQQHEwtNaW5uZWFwb2xp czExMC8GA1UEChMoT3BlbiBBY2Nlc3MgVGVjaG5vbG9neSBJbnRlcm5hdGlvbmFs IEluYzEeMBwGA1UEAxMVT0FUSSBXZWJDQVJFUyBSb290IENBMB4XDTA4MDYwMzE5 MjgzMVoXDTM4MDYwMzE5MzYwMFowgYMxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJN TjEUMBIGA1UEBxMLTWlubmVhcG9saXMxMTAvBgNVBAoTKE9wZW4gQWNjZXNzIFRl Y2hub2xvZ3kgSW50ZXJuYXRpb25hbCBJbmMxHjAcBgNVBAMTFU9BVEkgV2ViQ0FS RVMgUm9vdCBDQTCCAiAwDQYJKoZIhvcNAQEBBQADggINADCCAggCggIBAN54mUOu XmEeLdJ1ePU+LDZCisx8tt8Xd2FWp8zjOoAhgbJu0Ge1z6Whdr4oDRJWg6qWuySB O2v5wQOwi7QHBPmZ0D+0iv7A5RIqlb8VLwreFwFrVcq06LOyk+bjTLwHEXg9//sz dry4MryeFgPc0f1q3VTLJ+BL1DlpkPC6giIPZ3Ula8NiNveYkQTK/xJ0Xsuptndj 8RvkRE6GNtpraC+QXaE1mFylUopwukNeXN8t8TL4rPP27ZLDYmO3VkjHYR4StyGr uN1rZJDQR3AAt2jOlr1PQuULm3pNWbkcpK7vZ7WUtkibP4sESeb8KeP28TmdWkog FOAbwVhDGW26nSJshsu6Gf9YoFZE8W9RW1gL93t3f/ss0Qi6FX506OpnNCm4W5O7 pjDphJGXsCoHqduptYia3JPZZeYbcMzNRY5WkdVbG/PfajXiyIY+reWNegsodA/A fBJoyP2UtohJrFZXAOsMP+VRo5zqNhH9StbyCiDRYBM4w2CsuGdxJeHdBHn2EL9E xfJt0DyV2r3ju40JnaMgdpS1DxGORjM6XpW3hsTj5MgD25yy2ET73j6wZqFADYJJ CRa7eAPmnWeRLOOA6yv3dC+BSPvKJEsEEasZUGYFIsjynOxaWyQyK4ntp6FxtlMO Ofv0rt4Z8+XfAr2k9Ta35j8aCTKtHeMg2ACPAgEDo4IE3TCCBNkwCwYDVR0PBAQD AgFGMBMGCSsGAQQBgjcUAgQGHgQAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFOUNZHGdyVLpwJsqaTPAk3zzgfXfMHAGA1UdHwRpMGcwZaBjoGGGMWh0dHA6 Ly9jZXJ0cy5vYXRpY2VydHMuY29tL3JlcG9zaXRvcnkvT0FUSUNBMi5jcmyGLGh0 dHA6Ly9jZXJ0cy5vYXRpLm5ldC9yZXBvc2l0b3J5L09BVElDQTIuY3JsMBAGCSsG AQQBgjcVAQQDAgEAMIIDdQYDVR0gBIIDbDCCA2gwggNkBggqhkiG/GYLATCCA1Yw ggNSBggrBgEFBQcCAjCCA0QeggNAAEYAbwByACAAbQBvAHIAZQAgAGkAbgBmAG8A cgBtAGEAdABpAG8AbgAgAHIAZQBnAGEAcgBkAGkAbgBnACAATwBBAFQASQAgAGMA ZQByAHQAaQBmAGkAYwBhAHQAZQBzACAAYQBuAGQAIAB0AGgAZQAgAE8AQQBUAEkA IAB3AGUAYgBDAEEAUgBFAFMAIABTAHkAcwB0AGUAbQAsACAAcABsAGUAYQBzAGUA IABzAGUAZQAgAHQAaABlACAATwBBAFQASQAgAEMAZQByAHQAaQBmAGkAYwBhAHQA aQBvAG4AIABQAHIAYQBjAHQAaQBjAGUAIABTAHQAYQB0AGUAbQBlAG4AdAAgACgA QwBQAFMAKQAgAGEAdAAgAHQAaABlACAAZgBvAGwAbABvAHcAaQBuAGcAIABsAG8A YwBhAHQAaQBvAG4AOgAgAGgAdAB0AHAAOgAvAC8AdwB3AHcALgBvAGEAdABpAGMA ZQByAHQAcwAuAGMAbwBtAC8AcgBlAHAAbwBzAGkAdABvAHIAeQAuACAAIABJAGYA IAB5AG8AdQAgAGgAYQB2AGUAIABzAHAAZQBjAGkAZgBpAGMAIABxAHUAZQBzAHQA aQBvAG4AcwAgAHQAaABhAHQAIABjAGEAbgBuAG8AdAAgAGIAZQAgAGEAbgBzAHcA ZQByAGUAZAAgAGIAeQAgAHQAaABlACAATwBBAFQASQAgAEMAUABTACAAbwByACAA dwBvAHUAbABkACAAbABpAGsAZQAgAE8AQQBUAEkAIAB3AGUAYgBDAEEAUgBFAFMA IABwAHIAbwBkAHUAYwB0ACAAaQBuAGYAbwByAG0AYQB0AGkAbwBuACwAIABwAGwA ZQBhAHMAZQAgAGUALQBtAGEAaQBsACAAeQBvAHUAcgAgAHIAZQBxAHUAZQBzAHQA cwAgAHQAbwAgAE8AQQBUAEkAIABhAHQAIAB0AGgAZQAgAGYAbwBsAGwAbwB3AGkA bgBnACAAYQBkAGQAcgBlAHMAcwA6ACAAQwB1AHMAdABvAG0AZQByAF8AUwBlAHIA dgBpAGMAZQBAAG8AYQB0AGkAYwBlAHIAdABzAC4AYwBvAG0ALjCBhwYIKwYBBQUH AQEEezB5MD0GCCsGAQUFBzAChjFodHRwOi8vY2VydHMub2F0aWNlcnRzLmNvbS9y ZXBvc2l0b3J5L09BVElDQTIuY3J0MDgGCCsGAQUFBzAChixodHRwOi8vY2VydHMu b2F0aS5uZXQvcmVwb3NpdG9yeS9PQVRJQ0EyLmNydDANBgkqhkiG9w0BAQUFAAOC AgEAsFcVBnu/4QCC+58H4Fb0rIQ1nIF1aHhRUNpweD+7Ndc8dmlPRQFtHS2vQrAz bv+cCvup0fyp2o+lS0qHLSKksuD0Fw4EuOsOQnMH79S6j0IS0w4tu21UyQHJP03W 7gxCVonaYjcLoUh9bMSxx6tEYsumPPRloH3f82BixYr4ifXbIYZTnefIME/bJXE5 LYTxKXghVpnWX0hJuzO4yc884ysVakReOglgPsDSIBZ2vGbyWwMZP0q2np7dohpY PnPvt2l7e5AHOZpnM7tWkrr+rp1iS1VhLpYfxlSVLWW+SRgR9/f9tsYGoTIPdW8W 4SRiyA5vOvKVgPGp+6B9TdWiQx+FYNZceSvMNM+hd+/m085zhbTYZ4mZvG/LDgcn LnVRiX/BO98NA7+IF+a8+pQMqBmww9GqgKgZ2bZE0pUrVyJbyC2uDtAIraJ7NADg lv+SyjnNwMPSzLn0N8NWpNemGoAebDNyzVb7X+Xd3DBb7rhMs99asJEk4o0cMQ8p swcghdZ2yj66d4v49VCFDU82cWtVEglAOwMVOP7ll3hLKB24gLuOsvrgsh3CeIkp s44M7ABfTke1ncvcTcLIdcg+UEbYfN+GyvVxKpQKbVdveOry1+XjV1R3W2KX1+yR zkJz3pBKv4IcldkZSND8mycZ+4nz5hATRNkCu8VfY29lmzE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGGjCCBAKgAwIBAgIQAMoieQgjKvD1griF02Pd8TANBgkqhkiG9w0BAQUFADB/ MQswCQYDVQQGEwJVWTErMCkGA1UECgwiQURNSU5JU1RSQUNJT04gTkFDSU9OQUwg REUgQ09SUkVPUzEfMB0GA1UECwwWU0VSVklDSU9TIEVMRUNUUk9OSUNPUzEiMCAG A1UEAwwZQ29ycmVvIFVydWd1YXlvIC0gUm9vdCBDQTAeFw0wODA3MTQxNjUyMTVa Fw0zMDEyMzEwMjU5NTlaMH8xCzAJBgNVBAYTAlVZMSswKQYDVQQKDCJBRE1JTklT VFJBQ0lPTiBOQUNJT05BTCBERSBDT1JSRU9TMR8wHQYDVQQLDBZTRVJWSUNJT1Mg RUxFQ1RST05JQ09TMSIwIAYDVQQDDBlDb3JyZW8gVXJ1Z3VheW8gLSBSb290IENB MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsT3SpyVwl4N4DtcyyWYO eCPkKhvsw+9ThYO7ys9+6lOZbSdVyNE4IUBuSU3DPfYJKwYZQ6mYyQFO9KqAMAdV 8/W3fZm3c4XVHGVWbA0ymwgONGEqQAmEN8Nm7Q1MnAx4QDrs7avMpITydTGVQKiq u5O1d5hs8sjgIVoj5EKnk8ioHTjOpBpAQL88k5CbX9aUwSJbRtfFABXVj8b33guv bosFj1uAlQ6jvZPMkPJ940h+ss0HPRvtFJB08900H3zkA1nxLc3go6A7IS5crqwI BlAVMTXuX/kfDTSlgG5ick/jIbo4QF1f22gqXDTGCDv2fC6ojcS3pq3Zm78ZQQ5I OQlmbg00AcW7BxEjpNr+YJYoR9yPZ5sTr315DnjNwIwvuyEs/HQWHt7AMp36eDqG uj7JeAoA0eTgyRLiW9zru4CaMjWr8DDDDkiEL40ICvYsjE0ygEVVCNvNDai/CHq4 52hdmpSJlbz8mo64fzrYbNX0GKxp4qTBC7Mfo4Kf84o8hUA4CfrCBT7hnIn6wwVs CI9dUfR/u8TzbAG9PU/EGYs52crM6XmIBFWrbbjaFkVlORUFGPsLLHMB7ZRS5X0M ATsJoE3xPQiBZjQ2F0TwZ/Nb8gW2IZhY2fShN9lv5u9WxPu/VmICrDAwtgLW0hb8 TuqHQ5poXYijkUYoK785FRUCAwEAAaOBkTCBjjAPBgNVHRMBAf8EBTADAQH/MA4G A1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUfbtp64hh4UDPRyNkAIaiZmvchJUwTAYD VR0gBEUwQzBBBgRVHSAAMDkwNwYIKwYBBQUHAgEWK2h0dHA6Ly93d3cuY29ycmVv LmNvbS51eS9jb3JyZW9jZXJ0L2Nwcy5wZGYwDQYJKoZIhvcNAQEFBQADggIBAFbf E4m+YrcOgSFzpNQ3yu23L5V014n4S0eB7mftuCnfIaD8VGdnyFcsW6EKdXghIcqg qN9rnNk2Ao24AcFvjntsyaSyxUapykwCgfqje509SObKQGbSRJ124FW5ppyn0UPY 9aC0nfj35aamQvMCMllGcisU7F5l1VGBeM6qL42WiXlq+w/IW8+0rpC2X+N8Ymy3 pv+QgbWYkXMSMK/H6IECaHMpu1h1PbfWQ9WuTfJCufDf2jEAE9rhs7YGi1v9yZi4 ohPRuo/BihqeD/+CvgSC5SuTPh61ogwbxhqwc4l2g7yOO7sXbRTDi759FSa1qZwX elB6LevpmZSumBC97ipdXdaONFusHodga5jHh4/TnLJoBUkH+akxZpz+v6dZ6Czw NtTyqBmCwJ6nOfmxmDSjH/rNyRkteN63/WLwk6P+AFvWCuTzfnyXKOEF7AU0RRP/ KRNhiidP27jSkiEntYh3Z6h+zyQ8hwgEM3OPC7aG+M/vsqYkHguRkQBQFjIS2Akl 2mNO3dst1+cEa+NjH6n+qQFjxMpMFGiDvAWsWRb7bqEHb7tLvm2YSHYle0oRllQI rKnzN6uDw9HNgZjA5UA1uJ+R52/mSyAWilN7rDrRmDVU0NS/rn6aSx7pdaMlsDvn Zb9PlfQdvcS6yU2BUcI/WtkS9CEb1pXqPZD+qZPi -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHTzCCBTegAwIBAgIJAKPaQn6ksa7aMA0GCSqGSIb3DQEBBQUAMIGuMQswCQYD VQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0 IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3 MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xKTAnBgNVBAMTIENoYW1iZXJz IG9mIENvbW1lcmNlIFJvb3QgLSAyMDA4MB4XDTA4MDgwMTEyMjk1MFoXDTM4MDcz MTEyMjk1MFowga4xCzAJBgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNlZSBj dXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2FkZHJlc3MpMRIw EAYDVQQFEwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENhbWVyZmlybWEgUy5BLjEp MCcGA1UEAxMgQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDgwggIiMA0G CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCvAMtwNyuAWko6bHiUfaN/Gh/2NdW9 28sNRHI+JrKQUrpjOyhYb6WzbZSm891kDFX29ufyIiKAXuFixrYp4YFs8r/lfTJq VKAyGVn+H4vXPWCGhSRv4xGzdz4gljUha7MI2XAuZPeEklPWDrCQiorjh40G072Q DuKZoRuGDtqaCrsLYVAGUvGef3bsyw/QHg3PmTA9HMRFEFis1tPo1+XqxQEHd9ZR 5gN/ikilTWh1uem8nk4ZcfUyS5xtYBkL+8ydddy/Js2Pk3g5eXNeJQ7KXOt3EgfL ZEFHcpOrUMPrCXZkNNI5t3YRCQ12RcSprj1qr7V9ZS+UWBDsXHyvfuK2GNnQm05a Sd+pZgvMPMZ4fKecHePOjlO+Bd5gD2vlGts/4+EhySnB8esHnFIbAURRPHsl18Tl UlRdJQfKFiC4reRB7noI/plvg6aRArBsNlVq5331lubKgdaX8ZSD6e2wsWsSaR6s +12pxZjptFtYer49okQ6Y1nUCyXeG0+95QGezdIp1Z8XGQpvvwyQ0wlf2eOKNcx5 Wk0ZN5K3xMGtr/R5JJqyAQuxr1yW84Ay+1w9mPGgP0revq+ULtlVmhduYJ1jbLhj ya6BXBg14JC7vjxPNyK5fuvPnnchpj04gftI2jE9K+OJ9dC1vX7gUMQSibMjmhAx hduub+84Mxh2EQIDAQABo4IBbDCCAWgwEgYDVR0TAQH/BAgwBgEB/wIBDDAdBgNV HQ4EFgQU+SSsD7K1+HnA+mCIG8TZTQKeFxkwgeMGA1UdIwSB2zCB2IAU+SSsD7K1 +HnA+mCIG8TZTQKeFxmhgbSkgbEwga4xCzAJBgNVBAYTAkVVMUMwQQYDVQQHEzpN YWRyaWQgKHNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29t L2FkZHJlc3MpMRIwEAYDVQQFEwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENhbWVy ZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdCAt IDIwMDiCCQCj2kJ+pLGu2jAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRV HSAAMCowKAYIKwYBBQUHAgEWHGh0dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20w DQYJKoZIhvcNAQEFBQADggIBAJASryI1wqM58C7e6bXpeHxIvj99RZJe6dqxGfwW PJ+0W2aeaufDuV2I6A+tzyMP3iU6XsxPpcG1Lawk0lgH3qLPaYRgM+gQDROpI9CF 5Y57pp49chNyM/WqfcZjHwj0/gF/JM8rLFQJ3uIrbZLGOU8W6jx+ekbURWpGqOt1 glanq6B8aBMz9p0w8G8nOSQjKpD9kCk18pPfNKXG9/jvjA9iSnyu0/VU+I22mlaH FoI6M6taIgj3grrqLuBHmrS1RaMFO9ncLkVAO+rcf+g769HsJtg1pDDFOqxXnrN2 pSB7+R5KBWIBpih1YJeSDW4+TTdDDZIVnBgizVGZoCkaPF+KMjNbMMeJL0eYD6MD xvbxrN8y8NmBGuScvfaAFPDRLLmF9dijscilIeUcE5fuDr3fKanvNFNb0+RqE4QG tjICxFKuItLcsiFCGtpA8CnJ7AoMXOLQusxI0zcKzBIKinmwPQN/aUv0NCB9szTq jktk9T79syNnFQ0EuPAtwQlRPLJsFfClI9eDdOTlLsn+mCdCxqvGnrDQWzilm1De fhiYtUU79nm06PcaewaD+9CL2rvHvRirCG88gGtAPxkZumWK5r7VXNM21+9AUiRg OGcEMeyP84LG3rlV8zsxkVrctQgVrXYlCg17LofiDKYGvCYQbTed7N14jHyAxfDZ d0jQ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHSTCCBTGgAwIBAgIJAMnN0+nVfSPOMA0GCSqGSIb3DQEBBQUAMIGsMQswCQYD VQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0 IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3 MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xJzAlBgNVBAMTHkdsb2JhbCBD aGFtYmVyc2lnbiBSb290IC0gMjAwODAeFw0wODA4MDExMjMxNDBaFw0zODA3MzEx MjMxNDBaMIGsMQswCQYDVQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUgY3Vy cmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAG A1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xJzAl BgNVBAMTHkdsb2JhbCBDaGFtYmVyc2lnbiBSb290IC0gMjAwODCCAiIwDQYJKoZI hvcNAQEBBQADggIPADCCAgoCggIBAMDfVtPkOpt2RbQT2//BthmLN0EYlVJH6xed KYiONWwGMi5HYvNJBL99RDaxccy9Wglz1dmFRP+RVyXfXjaOcNFccUMd2drvXNL7 G706tcuto8xEpw2uIRU/uXpbknXYpBI4iRmKt4DS4jJvVpyR1ogQC7N0ZJJ0YPP2 zxhPYLIj0Mc7zmFLmY/CDNBAspjcDahOo7kKrmCgrUVSY7pmvWjg+b4aqIG7HkF4 ddPB/gBVsIdU6CeQNR1MM62X/JcumIS/LMmjv9GYERTtY/jKmIhYF5ntRQOXfjyG HoiMvvKRhI9lNNgATH23MRdaKXoKGCQwoze1eqkBfSbW+Q6OWfH9GzO1KTsXO0G2 Id3UwD2ln58fQ1DJu7xsepeY7s2MH/ucUa6LcL0nn3HAa6x9kGbo1106DbDVwo3V yJ2dwW3Q0L9R5OP4wzg2rtandeavhENdk5IMagfeOx2YItaswTXbo6Al/3K1dh3e beksZixShNBFks4c5eUzHdwHU1SjqoI7mjcv3N2gZOnm3b2u/GSFHTynyQbehP9r 6GsaPMWis0L7iwk+XwhSx2LE1AVxv8Rk5Pihg+g+EpuoHtQ2TS9x9o0o9oOpE9Jh wZG7SMA0j0GMS0zbaRL/UJScIINZc+18ofLx/d33SdNDWKBWY8o9PeU1VlnpDsog zCtLkykPAgMBAAGjggFqMIIBZjASBgNVHRMBAf8ECDAGAQH/AgEMMB0GA1UdDgQW BBS5CcqcHtvTbDprru1U8VuTBjUuXjCB4QYDVR0jBIHZMIHWgBS5CcqcHtvTbDpr ru1U8VuTBjUuXqGBsqSBrzCBrDELMAkGA1UEBhMCRVUxQzBBBgNVBAcTOk1hZHJp ZCAoc2VlIGN1cnJlbnQgYWRkcmVzcyBhdCB3d3cuY2FtZXJmaXJtYS5jb20vYWRk cmVzcykxEjAQBgNVBAUTCUE4Mjc0MzI4NzEbMBkGA1UEChMSQUMgQ2FtZXJmaXJt YSBTLkEuMScwJQYDVQQDEx5HbG9iYWwgQ2hhbWJlcnNpZ24gUm9vdCAtIDIwMDiC CQDJzdPp1X0jzjAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCow KAYIKwYBBQUHAgEWHGh0dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZI hvcNAQEFBQADggIBAICIf3DekijZBZRG/5BXqfEv3xoNa/p8DhxJJHkn2EaqbylZ UohwEurdPfWbU1Rv4WCiqAm57OtZfMY18dwY6fFn5a+6ReAJ3spED8IXDneRRXoz X1+WLGiLwUePmJs9wOzL9dWCkoQ10b42OFZyMVtHLaoXpGNR6woBrX/sdZ7LoR/x fxKxueRkf2fWIyr0uDldmOghp+G9PUIadJpwr2hsUF1Jz//7Dl3mLEfXgTpZALVz a2Mg9jFFCDkO9HB+QHBaP9BrQql0PSgvAm11cpUJjUhjxsYjV5KTXjXBjfkK9yyd Yhz2rXzdpjEetrHHfoUm+qRqtdpjMNHvkzeyZi99Bffnt0uYlDXA2TopwZ2yUDMd SqlapskD7+3056huirRXhOukP9DuqqqHW2Pok+JrqNS4cnhrG+055F3Lm6qH1U9O AP7Zap88MQ8oAgF9mOinsKJknnn4SPIVqczmyETrP3iZ8ntxPjzxmKfFGBI/5rso M0LpRQp8bfKGeS/Fghl9CYl8slR2iK7ewfPM4W7bMdaTrpmg7yVqc5iJWzouE4ge v8CSlDQb4ye3ix5vQv/n6TebUB0tovkC7stYWDpxvGjjqsGvHCgfotwjZT+B6q6Z 09gwzxMNTxXJhLynSC34MCN32EZLeW32jO06f2ARePTpm67VVMB0gNELQp/B -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzES MBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFU V0NBIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMz WhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJVEFJV0FO LUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB AQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFE AcK0HMMxQhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HH K3XLfJ+utdGdIzdjp9xCoi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeX RfwZVzsrb+RH9JlF/h3x+JejiB03HFyP4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/z rX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1ry+UPizgN7gr8/g+YnzAx 3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkq hkiG9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeC MErJk/9q56YAf4lCmtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdls XebQ79NqZp4VKIV66IIArB6nCWlWQtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62D lhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVYT0bf+215WfKEIlKuD8z7fDvn aspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocnyYh0igzyXxfkZ YiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFezCCA2OgAwIBAgIBATANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJUVzES MBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFU V0NBIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDc0NzEz WhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJVEFJV0FO LUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC AQDLAQxkgbtQSJnzHNkgsIukhxL3rk/OXnPLqIYV4hz5mWMG2hzWCrz+lLreYIFo nLLyS3cB2rcbxvn1YJAgePwxSj41d8AaHoxZVXhVcEGSCZsCSsNvrAuY6bJSfZp/ v10fA4dC/cMc6mMwZdG1jpxpWmvuM42vuvpjJFCJLckFHmzrjl6OAUihgMM4RStO H/QiwvguSnZ+6s6c0RiHV/a2+u3MkFWOgMo1udpkZRbM6WRRWT77is6AsKWSRWP9 m5YAvFxeho7FSd8UqMmRm3j3HIwmhmia+YHDgXs9M9sQXj0EadZm4K453Ini5ib7 UX97qAlrhyY4zdmNLZ49yrHzK5v9Ru2B28+FIb7ARcnlid12l7+0gUQpO7eYFzTy uKqasHtBVSbBPLQkl5atG482cbcr87aDAjD6sgoTvEu2D/mjnWNuIlDTKNxfNgc8 KaxFaOoiQF0/CccKMo/KtOXo19fKi2T/b2Ul7A10qLUcGibmKLJyzs36xCRKNxLi 2LcJzqwuJz3CFOvqMIw3ynMZhYmzu/s4Qx15paWLGSSgphJSGv7RV8GdEnudldZs e0odwa4VAk0sY6B1Jz/+8gAgMkrlsawuE+BIpvROkVQM2XRYPhF17fqcwqq7SH/L 9l9cJrAJh3rE/Zx+rzNnQlcWU/7xPUNAUoq2NXFP/AE85QIDAQABo0IwQDAOBgNV HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUyERa/n/9qZuG Nb7ipfYZ+16/b1kwDQYJKoZIhvcNAQELBQADggIBABKL3dUInShvCPGJVm1AB6v7 4QioBKS+132FrlpYtmdMAWnNrIs2jlz9OzYB+dWheyh6TISTzcQLGfASBIlNj+2g mSWFuyyVmWww7W0Eb+Yr+4kQu0yTDL6T1dYJhJMAcM/OxXga7VVU3y8OWVj4NYWu T/gh0pLeIbqZ0ZfxYJ6sREOnYK+J4lHSOYkQlBXPBNIhgu/K7WkVTxmrvJNqmQHg AtEcOp0xyvX7xPZNmrsgjD9vcTJ/J0tEf4FR6ZMbYg6kh1893VRAuSSXYleVjscQ kaeYxVhCUKmWHOVKtray3E6R8oDSkehQCNMWQIkPaBOuw1xXFxMX7TMdsuqR9qou cURAibLgZr2xvRc9TT0PVzklZQRqKoPbOLXOv+QA100oN5CoqabksVXwys/jDGov R5a31OTvyDcDPH/rgZKKKmmYHQE7SIota6/lz7K03dZneoABCwRXJbZlQg9J4SPK QKrCLPyFr2UYqgcx2y2u68Nx5mjfN5f5mj/xJV7w31fp/BLgOQaHdN1jk2uBxPPg 2wkU2L+/QG9xgSZo96WFF0BSA75ckxTlQVIVd7w1oUdzKgyXXIzeMTxjjPCbX1RP 0uJbbDwcw+c0ZnOmQaMgMkR7zeq8aZf9Q3AxvDKComWYo0Avakb0AFAuVeDbek7g bcho4VTolYMYvvrNjx1m -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIICTCCBfGgAwIBAgIQZ9a23dRLHqpL7JR4cjWXZjANBgkqhkiG9w0BAQUFADCB sTELMAkGA1UEBhMCQ0wxHTAbBgNVBAgTFFJlZ2lvbiBNZXRyb3BvbGl0YW5hMREw DwYDVQQHEwhTYW50aWFnbzEUMBIGA1UEChMLRS1DRVJUQ0hJTEUxIDAeBgNVBAsT F0F1dG9yaWRhZCBDZXJ0aWZpY2Fkb3JhMR8wHQYJKoZIhvcNAQkBFhBzY2xpZW50 ZXNAY2NzLmNsMRcwFQYDVQQDEw5FLUNFUlQgUk9PVCBDQTAeFw0wODA5MDUxOTM0 MDhaFw0yODA5MDUxOTM5NDFaMIGxMQswCQYDVQQGEwJDTDEdMBsGA1UECBMUUmVn aW9uIE1ldHJvcG9saXRhbmExETAPBgNVBAcTCFNhbnRpYWdvMRQwEgYDVQQKEwtF LUNFUlRDSElMRTEgMB4GA1UECxMXQXV0b3JpZGFkIENlcnRpZmljYWRvcmExHzAd BgkqhkiG9w0BCQEWEHNjbGllbnRlc0BjY3MuY2wxFzAVBgNVBAMTDkUtQ0VSVCBS T09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApICHJduVUuwY Pz4hL5SBnacjGsNqTkZemtgnf/Sk8RiwvZRM1Ir7RIhFxF4nmX5jaoQSVHHXWTu4 qBJOsact4OJ8RxTNtXC0YpTHYqP7bZU6ZlzqfsqM/uofY/XJW+tAnYTYM6jdgax+ ++i4mbuSeWT/Cm+DN43eK60PDbvnw2H9sEqzGuAQ5h0VDDddi9+8PSvy7YoBpEBL /UJoGRRjNDVbxURrzFCYnJ7ta93rB0M7HWwLBH/O4rxiutM+y0sUImw7tFb0uifb IUUPvhaVklS18fkXp+fth8Gd1pK3rGGNF3ZQdh5RM85vfiQrjhRvzXzErWNwntLO 7jxXxhqUQAVdY44l15Fj4sZiWh/Q7PfU3w9NVs1axY0nqnaDjmYaHJXj+YKWnqIT VE3W1lIO7EOGQf9URp6UNaRgELYEy5jZ9nx967OhA+nYuULfb2AmbkfwiRPlgSwH +/1W2PvYae4+h6n5jPLyldF8yIqdw8TBNNMj5Qs4rHIW2sP05nKblJstP1O3XbHc ZBAjiTnGx46Fe398NJiFNvG6x4mHIuLyPqngHecrMGOsdFX34DLJ4y5uiRW+LFHY EAp4qc86DTfpgpIKlYfnAzxOe9vo5nIICNn/nTvZo/fy8hUQnTlO9y5zJcvtv1Mw 1X//GfcXXVQqNkhWzzXrdKzUosa6OFECAwEAAaOCAhkwggIVMA4GA1UdDwEB/wQE AwIBBjATBgkrBgEEAYI3FAIEBh4EAEMAQTAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud DgQWBBRQ3YLRiJItjzzpul3HYmbib3urIDCCARMGA1UdHwSCAQowggEGMIIBAqCB /6CB/IaBumxkYXA6Ly8vQ049RS1DRVJUJTIwUk9PVCUyMENBLENOPXBraS1yb290 LENOPUNEUCxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1TZXJ2aWNlcyxD Tj1Db25maWd1cmF0aW9uLERDPWVjZXJ0cGtpLERDPWNsP2NlcnRpZmljYXRlUmV2 b2NhdGlvbkxpc3Q/YmFzZT9vYmplY3RDbGFzcz1jUkxEaXN0cmlidXRpb25Qb2lu dIY9aHR0cDovL3BraS1yb290LmVjZXJ0cGtpLmNsL0NlcnRFbnJvbGwvRS1DRVJU JTIwUk9PVCUyMENBLmNybDAQBgkrBgEEAYI3FQEEAwIBADBiBgNVHSAEWzBZMFcG CCsGAQQBw1IFMEswSQYIKwYBBQUHAgEWPWh0dHA6Ly93d3cuZS1jZXJ0Y2hpbGUu Y2wvaHRtbC9wcm9kdWN0b3MvZG93bmxvYWQvQ1BTdjEuNy5wZGYwMQYDVR0lBCow KAYIKwYBBQUHAwEGCCsGAQUFBwMCBggrBgEFBQcDBAYIKwYBBQUHAwgwDQYJKoZI hvcNAQEFBQADggIBAHWDlT8h7XR5Lt7bHnbyIcLblmC+m7DFTgtXLkkoorbDpi5Q BIsATKmzcoWk5nRvQm/Sm9Q/+NToMWtXPoHLsYbOsH7Z2OPt7uAE13RwrVUXMbG8 FAVXmVKi53vD7ttKs7P67KdDtMITVl8KMtENWFXgMD9kBwSqz28LKa/tiR0cwOaV AxmBt7Fw7OF6fTg6U8Xc0rnqginxTa5d2ejFnahGJGkLaikpGqnNL9zi6YMZ4nNp 6+ry1tdoFhleymLrSguPBFb17pVdEhHjDVCgzfTrYX6oIKLEUjcx56aFMGPftkop jliIz7V1WGhcm9I/EoasppONh5P3MRipV9LON6UXRV9nPoLb5TpvCpiSBr9gXTqE pEUSQ1BAHFaUJf2nUgpsLcTooM1xQuQ1C6hRiaT5hwj4HQj6rg6dAr0tf1luHQGC o4lwY7RhrmMkNXQgTcDXGHLprCyvmVGDbqN7F9j8chXzxxHES0G0csRw/1hRIaHh K2Nl5XSQom3C/5F9rU8HNyUYqp+cLRwodk7fgq7OQm4Gkjy7h3Fxoe40H+wNDhf1 p/ha+mbkfefR6IIxZUe7cp9UleRHmiBM+vgaRcloy0SYbuyZLRBdy9js+wpUSaWP MNPfhhPQVD/uJLOhJ/wg73jjkbnYlvJEum5fiQnKeidO+/mMrMEXt1iJ/3Y+ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFnDCCA4SgAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJGUjET MBEGA1UEChMKQ2VydGlub21pczEXMBUGA1UECxMOMDAwMiA0MzM5OTg5MDMxJjAk BgNVBAMMHUNlcnRpbm9taXMgLSBBdXRvcml0w6kgUmFjaW5lMB4XDTA4MDkxNzA4 Mjg1OVoXDTI4MDkxNzA4Mjg1OVowYzELMAkGA1UEBhMCRlIxEzARBgNVBAoTCkNl cnRpbm9taXMxFzAVBgNVBAsTDjAwMDIgNDMzOTk4OTAzMSYwJAYDVQQDDB1DZXJ0 aW5vbWlzIC0gQXV0b3JpdMOpIFJhY2luZTCCAiIwDQYJKoZIhvcNAQEBBQADggIP ADCCAgoCggIBAJ2Fn4bT46/HsmtuM+Cet0I0VZ35gb5j2CN2DpdUzZlMGvE5x4jY F1AMnmHawE5V3udauHpOd4cN5bjr+p5eex7Ezyh0x5P1FMYiKAT5kcOrJ3NqDi5N 8y4oH3DfVS9O7cdxbwlyLu3VMpfQ8Vh30WC8Tl7bmoT2R2FFK/ZQpn9qcSdIhDWe rP5pqZ56XjUl+rSnSTV3lqc2W+HN3yNw2F1MpQiD8aYkOBOo7C+ooWfHpi2GR+6K /OybDnT0K0kCe5B1jPyZOQE51kqJ5Z52qz6WKDgmi92NjMD2AR5vpTESOH2VwnHu 7XSu5DaiQ3XV8QCb4uTXzEIDS3h65X27uK4uIJPT5GHfceF2Z5c/tt9qc1pkIuVC 28+BA5PY9OMQ4HL2AHCs8MF6DwV/zzRpRbWT5BnbUhYjBYkOjUjkJW+zeL9i9Qf6 lSTClrLooyPCXQP8w9PlfMl1I9f09bze5N/NgL+RiH2nE7Q5uiy6vdFrzPOlKO1E nn1So2+WLhl+HPNbxxaOu2B9d2ZHVIIAEWBsMsGoOBvrbpgT1u449fCfDu/+MYHB 0iSVL1N6aaLwD4ZFjliCK0wi1F6g530mJ0jfJUaNSih8hp75mxpZuWW/Bd22Ql09 5gBIgl4g9xGC3srYn+Y3RyYe63j3YcNBZFgCQfna4NH4+ej9Uji29YnfAgMBAAGj WzBZMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQN jLZh2kS40RR9w759XkjwzspqsDAXBgNVHSAEEDAOMAwGCiqBegFWAgIAAQEwDQYJ KoZIhvcNAQEFBQADggIBACQ+YAZ+He86PtvqrxyaLAEL9MW12Ukx9F1BjYkMTv9s ov3/4gbIOZ/xWqndIlgVqIrTseYyCYIDbNc/CMf4uboAbbnW/FIyXaR/pDGUu7ZM OH8oMDX/nyNTt7buFHAAQCvaR6s0fl6nVjBhK4tDrP22iCj1a7Y+YEq6QpA0Z43q 619FVDsXrIvkxmUP7tCMXWY5zjKn2BCXwH40nJ+U8/aGH88bc62UeYdocMMzpXDn 2NU4lG9jeeu/Cg4I58UvD0KgKxRA/yHgBcUn4YQRE7rWhh1BCxMjidPJC+iKunqj o3M3NYB9Ergzd0A4wPpeMNLytqOx1qKVl4GbUu1pTP+A5FPbVFsDbVRfsbjvJL1v nxHDx2TCDyhihWZeGnuyt++uNckZM6i4J9szVb9o4XVIRFb7zdNIu0eJOqxp9YDG 5ERQL1TEqkPFMTFYvZbF6nVsmnWxTfj3l/+WFvKXTej28xH5On2KOG4Ey+HTRRWq pdEdnV1j6CTmNhTih60bWfVEm/vXd3wfAXBioSAaosUaKPQhA+4u2cGA6rnZgtZb dsLLO7XSAPCjDuGtbkD326C00EauFddEwk01+dIL8hf2rGbVJLJP0RyZwG71fet0 BLj5TXcJ17TPBzAJ8bgAVtkXFhYKK4bfjwEZGuW7gmP/vgt2Fl43N+bYdJeimUV5 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl YyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgxMDAxMTAyOTU2WhcNMzMxMDAxMjM1 OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0G CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN 8ELg63iIVl6bmlQdTQyK9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/ RLyTPWGrTs0NvvAgJ1gORH8EGoel15YUNpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4 hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZFiP0Zf3WHHx+xGwpzJFu5 ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W0eDrXltM EnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGj QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1 A/d2O2GCahKqGFPrAyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOy WL6ukK2YJ5f+AbGwUgC4TeQbIXQbfsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ 1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzTucpH9sry9uetuUg/vBa3wW30 6gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7hP0HHRwA11fXT 91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4p TpPDpFQUWw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUx KzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAd BgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNl YyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgxMDAxMTA0MDE0WhcNMzMxMDAxMjM1 OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lzdGVtcyBFbnRlcnBy aXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBDZW50 ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0G CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUd AqSzm1nzHoqvNK38DcLZSBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiC FoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/FvudocP05l03Sx5iRUKrERLMjfTlH6VJi 1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx9702cu+fjOlbpSD8DT6Iavq jnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGVWOHAD3bZ wI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGj QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/ WSA2AHmgoCJrjNXyYdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhy NsZt+U2e+iKo4YFWz827n+qrkRk4r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPAC uvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNfvNoBYimipidx5joifsFvHZVw IEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR3p1m0IvVVGb6 g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN 9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlP BSeOE6Fuwg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHHzCCBgegAwIBAgIESPx+9TANBgkqhkiG9w0BAQUFADCBrjESMBAGCgmSJomT 8ixkARkWAnJzMRUwEwYKCZImiZPyLGQBGRYFcG9zdGExEjAQBgoJkiaJk/IsZAEZ FgJjYTEWMBQGA1UEAxMNQ29uZmlndXJhdGlvbjERMA8GA1UEAxMIU2VydmljZXMx HDAaBgNVBAMTE1B1YmxpYyBLZXkgU2VydmljZXMxDDAKBgNVBAMTA0FJQTEWMBQG A1UEAxMNUG9zdGEgQ0EgUm9vdDAeFw0wODEwMjAxMjIyMDhaFw0yODEwMjAxMjUy MDhaMIGuMRIwEAYKCZImiZPyLGQBGRYCcnMxFTATBgoJkiaJk/IsZAEZFgVwb3N0 YTESMBAGCgmSJomT8ixkARkWAmNhMRYwFAYDVQQDEw1Db25maWd1cmF0aW9uMREw DwYDVQQDEwhTZXJ2aWNlczEcMBoGA1UEAxMTUHVibGljIEtleSBTZXJ2aWNlczEM MAoGA1UEAxMDQUlBMRYwFAYDVQQDEw1Qb3N0YSBDQSBSb290MIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqPK9iL7Ar0S+m0qiYxzWVqsdKbIcqhUeRdGs naBh1TX55FqDNmND3jhXFfzwlGL0B4BXg1eosxW8+00jeF/a9seBFr6r3+fcg1Nz K7bdY4iNRfMN3X2/6IiwZsFDXTfSbaGcmkbDsz/QwqCKlC6DpjzDYL0szB6LY4J2 QSjkFWtcDGE5VThByshm6Me4l1IQJnC3B7cJHqYTXq6ZWiZvZD3sxNOluVx2ZK1j fYiD4kvMDd7UxtMIQvVbF/Vx4ZEtA5+eHNyLcqToR2QQh2Qwc9jytPFXJpNXy7bH DYiLHc8FMF0E1nY36CAyV78PnDPGCIz2tMKpBrBbMKEeLRK6PwIDAQABo4IDQTCC Az0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwgboGA1UdIASBsjCB rzCBrAYLKwYBBAH6OAoKAQEwgZwwMAYIKwYBBQUHAgEWJGh0dHA6Ly93d3cuY2Eu cG9zdGEucnMvZG9rdW1lbnRhY2lqYTBoBggrBgEFBQcCAjBcGlpPdm8gamUgZWxl a3Ryb25za2kgc2VydGlmaWthdCBST09UIENBIHNlcnZlcmEgU2VydGlmaWthY2lv bm9nIHRlbGEgUG9zdGU6ICJQb3N0YSBDQSBSb290Ii4wEQYJYIZIAYb4QgEBBAQD AgAHMIIBvAYDVR0fBIIBszCCAa8wgcmggcaggcOkgcAwgb0xEjAQBgoJkiaJk/Is ZAEZFgJyczEVMBMGCgmSJomT8ixkARkWBXBvc3RhMRIwEAYKCZImiZPyLGQBGRYC Y2ExFjAUBgNVBAMTDUNvbmZpZ3VyYXRpb24xETAPBgNVBAMTCFNlcnZpY2VzMRww GgYDVQQDExNQdWJsaWMgS2V5IFNlcnZpY2VzMQwwCgYDVQQDEwNBSUExFjAUBgNV BAMTDVBvc3RhIENBIFJvb3QxDTALBgNVBAMTBENSTDEwgeCggd2ggdqGgaNsZGFw Oi8vbGRhcC5jYS5wb3N0YS5ycy9jbj1Qb3N0YSUyMENBJTIwUm9vdCxjbj1BSUEs Y249UHVibGljJTIwS2V5JTIwU2VydmljZXMsY249U2VydmljZXMsY249Q29uZmln dXJhdGlvbixkYz1jYSxkYz1wb3N0YSxkYz1ycz9jZXJ0aWZpY2F0ZVJldm9jYXRp b25MaXN0JTNCYmluYXJ5hjJodHRwOi8vc2VydGlmaWthdGkuY2EucG9zdGEucnMv Y3JsL1Bvc3RhQ0FSb290LmNybDArBgNVHRAEJDAigA8yMDA4MTAyMDEyMjIwOFqB DzIwMjgxMDIwMTI1MjA4WjAfBgNVHSMEGDAWgBTyy43iNe8QQ8Tae8r664kDoSKv uDAdBgNVHQ4EFgQU8suN4jXvEEPE2nvK+uuJA6Eir7gwHQYJKoZIhvZ9B0EABBAw DhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUAA4IBAQBwRqHI5BcFZg+d4kMx SB2SkBnEhQGFFm74ks57rlIWxJeNCih91cts49XlDjJPyGgtNAg9c6iTQikzRgxE Z/HQmpxpAeWR8Q3JaTwzS04Zk2MzBSkhodj/PlSrnvahegLX3P+lPlR4+dPByhKV +YmeFOLyoUSyy+ktdTXMllW7OAuIJtrWrO/TUqILSzpT2ksiU8zKKiSaYqrEMpp+ 3MzBsmzNj9m0wM/1AsCMK4RbG0C8ENBQ4WHWZlaaBJGl49W9oC4igbHZONrkqIdf PEYElt7Jmju/rXhsHUlJtGm5cA8Fkla2/a+u+CAtRyPPthzNxJuATvm/McBUvrsx f/M+ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBM MSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5D ZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBU cnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3 WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMg Uy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIw IAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0B AQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rH UV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LM TXPb865Px1bVWqeWifrzq2jUI4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVU BBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et0brM kUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8x AcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNV HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15y sHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfL I9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8 J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qY VoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI 03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFhDCCA2ygAwIBAgIQAIG73WskH9q0vo8b2ghVxDANBgkqhkiG9w0BAQUFADA7 MQswCQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xGTAXBgNVBAsMEEFDIFJB SVogRk5NVC1SQ00wHhcNMDgxMDI5MTU1OTU1WhcNMzAwMTAxMDAwMDAwWjA7MQsw CQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xGTAXBgNVBAsMEEFDIFJBSVog Rk5NVC1SQ00wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC6cYB6TIZu f8gTbcDGfRwAl48sDCO7EJpAqRq3h4j4m1Zq++Z7jouSjqclXVkR2zYut1EXH6kI HwQXJFiqN0oY3+U51Ff918EskQGR4iLUA8BY/HdH7I8+dEO6rDSNTTh2Z46wyG8w M1hxXLT1a27UAVC4E35sSqNJ0SAZ7rzAKRhlp97+790KkCHnGmeSQhCYX08wvD4c RbQQ12hAFMBA+ud3F3rmC49lWzzZmlLbtb2eRs8965EFAsCWsnZMTRCWO5L6nH8P md++IzVFHgJc/rWom5kl2l7zIsM59eQqLtPGH8RsqsUcagEFSi/SxcGoNCZdZqXS AiH5GLcG9U6Zb6irTFHoz1AYxXfIOQksSZIymai7Fxd5sFrF5qPEWWVHNYNeqeg1 C5m75M0gxptKBjm1aPwiuu5VjCtO6vOx4/y2mZrVQvpxTQjPhx5qcX3507TppXGB e8JOR5al9naFoyiP6YBugVOlbV+4SPnC+TamLkn/uJbCjAezm4hY/OsbHN4tcOKX kjChieO8Vagn1kvtkK2L+mMlWS2oNd3KlzO85c3HndHs714OSpAGJmOtudk1LQe6 dmUsrFePffQHlNeBApZdowdJ1XrQV/kb51NGdaqweULLaHEI6WC9OWnO9K/DVkDH rVKiCeRvhkeKH+soJ12DIK8EyWxWmotG9QIDAQABo4GDMIGAMA8GA1UdEwEB/wQF MAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBT3fcX9xOiaG3dkp/UdoMy/ h2CabTA+BgNVHSAENzA1MDMGBFUdIAAwKzApBggrBgEFBQcCARYdaHR0cDovL3d3 dy5jZXJ0LmZubXQuZXMvZHBjcy8wDQYJKoZIhvcNAQEFBQADggIBAHa5Jte8YHw7 w8eEUZJZebaNv1PgvIigtdlM6a31Zk7voGDIC7iR7TOOgvGGlf7G0xqJq0872TMf 0AvHsfVPpEu7AwwjXGyw3qxy+mneABDN8dbPNlK+f/wmQfPy/DDiMcbbED6pdLpP 7O0gmcmw4qKjqUKZM8t/96oC6SSWKvjkzl1BoAYJVVra3xpP6zn8X+CpqUTXGOqV sUR72uo4CXQeZyg/4Is5LFP6DOA59ysaDjEB1GZ5iHSdSEiOtJNh5r8pCe++Bqka bAhwBAq/bgl2pGRDzh9XnZeebPh0FxxRA/pgU9RWRpbQUJ/GnTPzQ7Go16LJsMmD sX3H3KyBdteJ7UMm1v+iXKItoCRHqkaaaTEJwf0QebCF7HAg5j1BVKJKYi/W3kzD nI+9y6ZVlBzdvUHPKGWN0E3Xh9FM00NzIezXLhdnMoe20Bt0qmnH5GyH130Zmuw9 RPGqgllyzUXb2mZC4ThsNl9U3SZWV6LZPqQK8u/8GYAf27qqgLzYUc1UatV/2G+1 3Bb7QOJVVJDD3Ycz0f8epWKLNkSsqL/A1sSUd7O9xHUkaen/OZSr/FFnJOpAHuuJ LRMGfa4HocMM9dRask63IR0XxeW58h/jhgFdCwZ5XcnKPxZ+gR5NfvCaPCXFznR5 nkrh8en1JUb2xN7kRGRzHcY5PnrmhXsY -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsx CzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJ WiBGTk1ULVJDTTAeFw0wODEwMjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJ BgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBG Tk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALpxgHpMhm5/ yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcfqQgf BBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAz WHFctPVrbtQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxF tBDXaEAUwED653cXeuYLj2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z 374jNUUeAlz+taibmSXaXvMiwzn15Cou08YfxGyqxRxqAQVKL9LFwag0Jl1mpdIC IfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mwWsXmo8RZZUc1g16p6DUL mbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnTtOmlcYF7 wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peS MKGJ47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2 ZSysV4999AeU14ECll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMet UqIJ5G+GR4of6ygnXYMgrwTJbFaai0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUw AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFPd9xf3E6Jobd2Sn9R2gzL+H YJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1odHRwOi8vd3d3 LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1 RXxlDPiyN8+sD8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYM LVN0V2Ue1bLdI4E7pWYjJ2cJj+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf 77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrTQfv6MooqtyuGC2mDOL7Nii4LcK2N JpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW+YJF1DngoABd15jm fZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7Ixjp 6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp 1txyM/1d8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B 9kiABdcPUXmsEKvU7ANm5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wok RqEIr9baRRmW1FMdW4R58MD3R++Lj8UGrp1MYp3/RgT408m2ECVAdf4WqslKYIYv uu8wd+RU4riEmViAqhOLUTpPSPaLtrM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQG EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3 MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl cnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBBcmFueSAoQ2xhc3MgR29sZCkgRsWR dGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgxMjA2MTUwODIxWjCB pzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxOZXRM b2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlm aWNhdGlvbiBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNz IEdvbGQpIEbFkXRhbsO6c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEAxCRec75LbRTDofTjl5Bu0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrT lF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw/HpYzY6b7cNGbIRwXdrz AZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAkH3B5r9s5 VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRG ILdwfzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2 BJtr+UBdADTHLpl1neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAG AQH/AgEEMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2M U9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwWqZw8UQCgwBEIBaeZ5m8BiFRh bvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTtaYtOUZcTh5m2C +C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2F uLjbvrW5KfnaNwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2 XjG4Kvte9nHfRCaexOYNkbQudZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGITCCBAmgAwIBAgIGSUEt7AAQMA0GCSqGSIb3DQEBCwUAMIGtMQswCQYDVQQG EwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3 MDUGA1UECwwuVGFuw7pzw610dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNl cnZpY2VzKTE7MDkGA1UEAwwyTmV0TG9jayBQbGF0aW5hIChDbGFzcyBQbGF0aW51 bSkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUxMjQ0WhcNMjgxMjA2MTUx MjQ0WjCBrTELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQK DAxOZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAo Q2VydGlmaWNhdGlvbiBTZXJ2aWNlcykxOzA5BgNVBAMMMk5ldExvY2sgUGxhdGlu YSAoQ2xhc3MgUGxhdGludW0pIEbFkXRhbsO6c8OtdHbDoW55MIICIjANBgkqhkiG 9w0BAQEFAAOCAg8AMIICCgKCAgEAzfLuxBp663QpTLa95NYKF2xl4mY9xNG8DLZa 1itwXy3MIdFZEOSxE732zCKV1mxGTpEys+v1rMsEAU923VM+eJ/5Xry1ghNGyhDj HS1pK5QyEHMhq6k4xeNuE2TVY6ntCWbsim+JjRGG0PW/MpYLdXD1KFhCXqxptPX8 kTkuopFA0TxUQYcjZFBIeWhLaJNLcuuAabNKHJC+skGjpc0XwNEaaX8CGEq1Yocm Vy1sqCwhOfWXXpuapvjnTHnEeztW3Hr4tFjOdgquIlXrj8eEZHu9a8qVT9i+MRO/ jaEKK9V5t/V2rdpRXIFHYqiq/89T4DRxzw0lU6meY0evhZH4zxkR5U75z+3jNQUB IgPPmnzqHVFay/1zPTkLMevEO8qFKhEUAKAbgaIJiEjzfKJkoexntFiH8BTqqb6l IkFN7L2kDug9h/cvqs41hk8wV5KNNq541v0Y/NclHs96/Bn9oD9yFzYIQT+XNpUM iZVxRfqE1tQgYLNFCvK3lT0L5aTDuBLykWzpbWCD9kURBbrmR4PZkeJu4btGa0gb vMb7z37eLLuQhO62JznnjaIxD9+BtyxsAOKx2CoXXBseR4lLF1EUQEBPxDkYMsKA YDblekdn9qgFVMFdlqAftohSDAK+jVV+FEvDogHunIpBXflflpEJjrTktcUE39Y2 rVm0stcCAwDzkaNFMEMwEgYDVR0TAQH/BAgwBgEB/wIBBDAOBgNVHQ8BAf8EBAMC AQYwHQYDVR0OBBYEFOahE4P+l0vy2P8xoad0M4nOXn+kMA0GCSqGSIb3DQEBCwUA A4ICAQBphELA414TYZcgSfH0FoWln6QRCCXEY4aP8Euvsyn1B1caYscbRW6vXRa3 wdBkgzuX9UO2RZDxZiqDJCr/iOl6C/nCW3qvY/cJeIZIWTRem2oQTvFulYk2SmjQ b5vgfk+3NQ/jebEFryd8qokKQ976DO/ZVy8occ1pa1JCyYowRVmhzPpZSo/31t1E pbMuWxEY4rK15xFTOP6CTNNzvmWSGjqo0tKqvNS+bTZS/2vU0rUbN/MXQvEup9WQ bHSddPX6XyIb09x1qLX/8hrRvCsAXDzFuIYIVEminCP776aNcPRCUk0bIACB+KC4 9HQjnL70uQ7sHmrYZUoVdfF3W27YseYPtJa4HfqGyJJui+l936IO1fHxfK5K42a/ Xfxb70iynmnHfZCgVbaUcIG5Cr2JdVPshKkDpd9RmQjQdAwC1nNyBnuLu12qTvxn Z9iOEAMZLTc61HepOhydwHl7bCl3Mk1KizCIwuc2zmijmpiG+YkVnr+qUX3xUEZU DwIuXJ/j3lczFf4YkmGo0ikFXWVEHpvj7/vcBd8Vq6bYC6Rzskw64J7Us2rlOg4K 8E7PeIEfvqmYb7FHUX1CMzazpqkCUgV0fips1KqSVrA+OyNYsY01pxOPZx5xFaaz tQOGuCBmwEhvuazUSgNVsjffBN0iDFOGKkoqocE4PjzlPN91lw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4 MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8 RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK 6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH WD9f -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDbTCCAlWgAwIBAgIBATANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQGEwJKUDEr MCkGA1UEChMiSmFwYW4gQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcywgSW5jLjEcMBoG A1UEAxMTU2VjdXJlU2lnbiBSb290Q0ExMTAeFw0wOTA0MDgwNDU2NDdaFw0yOTA0 MDgwNDU2NDdaMFgxCzAJBgNVBAYTAkpQMSswKQYDVQQKEyJKYXBhbiBDZXJ0aWZp Y2F0aW9uIFNlcnZpY2VzLCBJbmMuMRwwGgYDVQQDExNTZWN1cmVTaWduIFJvb3RD QTExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/XeqpRyQBTvLTJsz i1oURaTnkBbR31fSIRCkF/3frNYfp+TbfPfs37gD2pRY/V1yfIw/XwFndBWW4wI8 h9uuywGOwvNmxoVF9ALGOrVisq/6nL+k5tSAMJjzDbaTj6nU2DbysPyKyiyhFTOV MdrAG/LuYpmGYz+/3ZMqg6h2uRMft85OQoWPIucuGvKVCbIFtUROd6EgvanyTgp9 UK31BQ1FT0Zx/Sg+U/sE2C3XZR1KG/rPO7AxmjVuyIsG0wCR8pQIZUyxNAYAeoni 8McDWc/V1uinMrPmmECGxc0nEovMe863ETxiYAcjPitAbpSACW22s293bzUIUPsC h8U+iQIDAQABo0IwQDAdBgNVHQ4EFgQUW/hNT7KlhtQ60vFjmqC+CfZXt94wDgYD VR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEB AKChOBZmLqdWHyGcBvod7bkixTgm2E5P7KN/ed5GIaGHd48HCJqypMWvDzKYC3xm KbabfSVSSUOrTC4rbnpwrxYO4wJs+0LmGJ1F2FXI6Dvd5+H0LgscNFxsWEr7jIhQ X5Ucv+2rIrVls4W6ng+4reV6G4pQOh29Dbx7VFALuUKvVaAYga1lme++5Jy/xIWr QbJUb9wlze144o4MjQlJ3WN7WmmWAiGovVJZ6X01y8hSyn+B/tlr0/cR7SXf+Of5 pPpyl4RTDaXQMhhRdlkUbA/r7F+AjHVDg8OFmP9Mni0N5HeDk061lgeLKBObjBmN QSdJQO7e5iNEOdyhIta6A/I= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF4DCCA8igAwIBAgIQLu/b+9iJPY9JHJNy/kXf7TANBgkqhkiG9w0BAQUFADBQ MQswCQYDVQQGEwJMVjEoMCYGA1UECxMfU2VydGlmaWthY2lqYXMgcGFrYWxwb2p1 bXUgZGFsYTEXMBUGA1UEAxMORS1NRSBTU0kgKFJDQSkwHhcNMDkwNTE5MDg0NTU2 WhcNMjcwNTE5MDg0ODE1WjBQMQswCQYDVQQGEwJMVjEoMCYGA1UECxMfU2VydGlm aWthY2lqYXMgcGFrYWxwb2p1bXUgZGFsYTEXMBUGA1UEAxMORS1NRSBTU0kgKFJD QSkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDEBGsAw7DgLjvoUwUF CL7IhCdV1h2KEuIXIlps/7PdtpnDysHq+dgltd86nZ0/UsXp8qy/iXSKVK5Oz27y Xq7avRIHmZXPZKv+mZFkWYzJvkRqMZuY6rrq0SEOKAs5m+PWiqb3Aro/PdlZ9HmZ 3tMkm4twGyqE1uUJDyYmJFiPJV7zxZ10iaU2xeVSsuvohpNHbqcph6R+3LSjyzJW 90WA2lzHL6Cn1+/1/LWozYSVYvipKyM7bdO3ksjqwbwUTehrnBZ60+wH+wclEE8U h3uSNs5WgmVLEyYG2KOjpt/Cevt7NQWiEz0+drwcV4MDUcc03lr1PL02JZwWD03O 6A0ay11DohRvunxg1AKFdsVrKrhFsVx3RxGtoCWpZpGMURdtYVUKGT+bAv/E9dbS s+klU+EEPY8i0KJl5a6ntOAdkWrChpL3Ol0Tp3pMQt9as0qIRCzvR7qpr9bPYnOK BiIWLMLsHwao00dQWTIS5bmdYjWeyl4KtJ0jiMLTTywsyZPofrgJ7KbZ3WPhyahq aNyEUaxaEuc7prUHCrGqTrO0olffN2wWTquZMnrwnCMli8qaqIzgOCG0zvdsYcji DBJZBoEmNloPNXPUFkX93pXe1ktcn3PZvhm957/kVWrIa0T3x7gziHkZDQZk6K8L oXUMUmW6CiOVcfdj/H7ljI/M0QIDAQABo4G1MIGyMA4GA1UdDwEB/wQEAwIBBjAY BggrBgEFBQcBAwQMMAowCAYGBACORgEBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFDsmA7rP41lGQlCtFJy/Azvv1j4xMBAGCSsGAQQBgjcVAQQDAgEAMEQGA1Ud IAQ9MDswOQYLKwYBBAGB+j0BAQEwKjAoBggrBgEFBQcCARYcaHR0cDovL3d3dy5l bWUubHYvcmVwb3NpdG9yeTANBgkqhkiG9w0BAQUFAAOCAgEAheamlOTZRl+dv5O7 +Wt2ZCiuvzxFKoqTeWzTS4iGIGsiJjg9HBOq62GXbC4+V5xsQ6LebUDEMfJtukYW sy3Gu6bc5S+x2MHVkR4Rf/tfodwdYfhtm2Hw4j8rcdUNy97fZT+gb5WbesvbNTcp XV6duVSxrGAS5WPZza9SGwWWE3zaJHUBrdSepcvBEkVPV68jvym86o6tePiHI+hI y0Covl0z1uzGBkPCZyro44UuYJ5ELytPMbEHnZUh1SqSr4CR08cpvc3xFQyfAe74 LTukB3BJeSTtvKHTllGCn8LIvN4jmsdQK5q2eFKqzpX2YDuimfkmZvRHLEElvEH6 1ot/vV+CfNNFhbRM2OyzF+9EOvUoZe/1nnHMId7o1lEcEPtA/EnlXIQXr6oZXqLt Th6i+8pHHBxkPhSRojkZNIh/kcs7nRlw6ij7/FAPzL09XgIDa3k1REF27rYtdITh gnHTJbDTw5lEqz/iDKXuvab8pBEA7py9N9HWYsQwFC0QCpeKiPUlPJa+RkAaisCF dsSgSeBJpecZtQnzzE3tFl6a1NPIadDYijeFa07kqgeSXNRxcYFI03j1VmD+zALU AJMfTJJAl75yU3kuJlK+pqN0sZTZFGM6blvRPJInUpAyWpLSD05bCwY6YuXWJwwB 9iUCuIsQKUKp92nK3OsKkksoMYY= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGFDCCA/ygAwIBAgIIU+w77vuySF8wDQYJKoZIhvcNAQEFBQAwUTELMAkGA1UE BhMCRVMxQjBABgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1h cHJvZmVzaW9uYWwgQ0lGIEE2MjYzNDA2ODAeFw0wOTA1MjAwODM4MTVaFw0zMDEy MzEwODM4MTVaMFExCzAJBgNVBAYTAkVTMUIwQAYDVQQDDDlBdXRvcmlkYWQgZGUg Q2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBBNjI2MzQwNjgwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDDUtd9 thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQM cas9UX4PB99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefG L9ItWY16Ck6WaVICqjaY7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15i NA9wBj4gGFrO93IbJWyTdBSTo3OxDqqHECNZXyAFGUftaI6SEspd/NYrspI8IM/h X68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyIplD9amML9ZMWGxmPsu2b m8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctXMbScyJCy Z/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirja EbsXLZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/T KI8xWVvTyQKmtFLKbpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF 6NkBiDkal4ZkQdU7hwxu+g/GvUgUvzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVh OSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMBIGA1UdEwEB/wQIMAYBAf8CAQEwDgYD VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRlzeurNR4APn7VdMActHNHDhpkLzCBpgYD VR0gBIGeMIGbMIGYBgRVHSAAMIGPMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3LmZp cm1hcHJvZmVzaW9uYWwuY29tL2NwczBcBggrBgEFBQcCAjBQHk4AUABhAHMAZQBv ACAAZABlACAAbABhACAAQgBvAG4AYQBuAG8AdgBhACAANAA3ACAAQgBhAHIAYwBl AGwAbwBuAGEAIAAwADgAMAAxADcwDQYJKoZIhvcNAQEFBQADggIBABd9oPm03cXF 661LJLWhAqvdpYhKsg9VSytXjDvlMd3+xDLx51tkljYyGOylMnfX40S2wBEqgLk9 am58m9Ot/MPWo+ZkKXzR4Tgegiv/J2Wv+xYVxC5xhOW1//qkR71kMrv2JYSiJ0L1 ILDCExARzRAVukKQKtJE4ZYm6zFIEv0q2skGz3QeqUvVhyj5eTSSPi5E6PaPT481 PyWzOdxjKpBrIF/EUhJOlywqrJ2X3kjyo2bbwtKDlaZmp54lD+kLM5FlClrD2VQS 3a/DTg4fJl4N3LON7NWBcN7STyQF82xO9UxJZo3R/9ILJUFI/lGExkKvgATP0H5k SeTy36LssUzAKh3ntLFlosS88Zj0qnAHY7S42jtM+kAiMFsRpvAFDsYCA0irhpuF 3dvd6qJ2gHN99ZwExEWN57kci57q13XRcrHedUTnQn3iV2t93Jm8PYMo6oCTjcVM ZcFwgbg4/EMxsvYDNEeyrPsiBsse3RdHHF9mudMaotoRsaS8I8nkvof/uZS2+F0g StRf571oe2XyFR7SOqkt6dhrJKyXWERHrVkY8SFlcN7ONGCoQPHzPKTDKCOM/icz Q0CgFzzr6juwcqajuUpLXhZI9LK8yIySxZ2frHI2vDSANGupi5LAuBft7HZT9SQB jLMi6Et8Vcad+qMUu2WFbm5PEn4KPJ2V -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDEl MCMGA1UEChMcU0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMe U2VjdXJpdHkgQ29tbXVuaWNhdGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoX DTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRy dXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3VyaXR5IENvbW11bmlj YXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANAV OVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGr zbl+dp+++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVM VAX3NuRFg3sUZdbcDE3R3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQ hNBqyjoGADdH5H5XTz+L62e4iKrFvlNVspHEfbmwhRkGeC7bYRr6hfVKkaHnFtWO ojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1KEOtOghY6rCcMU/Gt1SSw awNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8QIH4D5cs OPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 DQEBCwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpF coJxDjrSzG+ntKEju/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXc okgfGT+Ok+vx+hfuzU7jBBJV1uXk3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8 t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6qtnRGEmyR7jTV7JqR50S+kDFy 1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29mvVXIwAHIRc/ SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYD VQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0 ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0G CSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTAeFw0wOTA2MTYxMTMwMThaFw0y OTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UEBwwIQnVkYXBlc3Qx FjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUtU3pp Z25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o dTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvP kd6mJviZpWNwrZuuyjNAfW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tc cbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG0IMZfcChEhyVbUr02MelTTMuhTlAdX4U fIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKApxn1ntxVUwOXewdI/5n7 N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm1HxdrtbC xkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1 +rUCAwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G A1UdDgQWBBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPM Pcu1SCOhGnqmKrs0aDAbBgNVHREEFDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqG SIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0olZMEyL/azXm4Q5DwpL7v8u8h mLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfXI/OMn74dseGk ddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c 2Pm2G2JwCz02yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5t HMN1Rq41Bab2XD0h7lbwyYIiLXpUq3DDfSJlgnCW -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50 cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0 aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1 U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0 jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/ jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v 1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDdTCCAl2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBcMQswCQYDVQQGEwJVUzEZ MBcGA1UECgwQVmVyaXpvbiBCdXNpbmVzczERMA8GA1UECwwIT21uaVJvb3QxHzAd BgNVBAMMFlZlcml6b24gR2xvYmFsIFJvb3QgQ0EwHhcNMDkwNzMwMTQyNzA0WhcN MzQwNzMwMTQyNzA0WjBcMQswCQYDVQQGEwJVUzEZMBcGA1UECgwQVmVyaXpvbiBC dXNpbmVzczERMA8GA1UECwwIT21uaVJvb3QxHzAdBgNVBAMMFlZlcml6b24gR2xv YmFsIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCKAAxw Hb/rNIbDmUU1Hn9D96tvJC3NGcIQu7DKKVupIKurcizE4gI5bYK4xRHq+PuznmL4 Mx6wH8nj9jfbBMg7Y0824oWkJR3HaR8EvWhFE5YHH5RQ9T7FJ1SewElXRI4HY9Sm ru0imcxNlmkEE252iZ90FpT5HVS9ornSgwEiDE1EgKr+NYknJaeGicbVGpLjj8WV oBRymuhWxQJVHJf5IC7Q9TwTGVr24fkLA4Jpp4y31m+cVj6d6CoJYG1L5vuLmRT3 NE9lWYCNuVfIojUh2IhxVl3uglctJpAYn5qcnI/v1MVjp1R9R5GHfRoSqBsYb6lv sSe65AR0zjcef2bFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ BAQDAgEGMB0GA1UdDgQWBBRMOBG4mABbWitwPqp45NVnZ2enfjANBgkqhkiG9w0B AQsFAAOCAQEAAV+gsQYB9HnXZRhgPs95oLrCI08j34eWX4EOOBUuXMgCaCkg/Ivu pYoYgWRcmDV+OTCCpIKKULW6w+ha1qie4sMX29vE67AKIA3pnuP/YFRH8Tud1Cg8 oq6j+6qLgiIqNYeQuBxZR5DVnS76SeNlqDbrx+QcaNyzMWyrTs4kgBXIEFkQEXJN epyYnMT8YeCzsp1OoMbCWasY1qJVRewpqiU31k5KPQtAweST5PzNkQv45qvMs3bE Yr8Z7Ya2ecMpVFS8mX1GV8+mz/RUKpoDZUcBoUIqyyVHbnxeAEuR2fkbEAZw+UIV pl+q10Ae/clInZeB6lxowqDniaFTTb/H4w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFfjCCA2agAwIBAgIJAKqIsFoLsXabMA0GCSqGSIb3DQEBCwUAMEwxCzAJBgNV BAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxJjAkBgNVBAMTHVN3aXNzU2ln biBTaWx2ZXIgUm9vdCBDQSAtIEczMB4XDTA5MDgwNDEzMTkxNFoXDTM3MDgwNDEz MTkxNFowTDELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEmMCQG A1UEAxMdU3dpc3NTaWduIFNpbHZlciBSb290IENBIC0gRzMwggIiMA0GCSqGSIb3 DQEBAQUAA4ICDwAwggIKAoICAQC+h5sF5nF8Um9t7Dep6bPczF9/01DqIZsE8D2/ vo7JpRQWMhDPmfzscK1INmckDBcy1inlSjmxN+umeAxsbxnKTvdR2hro+iE4bJWc L9aLzDsCm78mmxFFtrg0Wh2mVEhSyJ14cc5ISsyneIPcaKtmHncH0zYYCNfUbWD4 8HnTMzYJkmO3BJr1p5baRa90GvyC46hbDjo/UleYfrycjMHAslrfxH7+DKZUdoN+ ut3nKvRKNk+HZS6lujmNWWEp89OOJHCMU5sRpUcHsnUFXA2E2UTZzckmRFduAn2V AdSrJIbuPXD7V/qwKRTQnfLFl8sJyvHyPefYS5bpiC+eR1GKVGWYSNIS5FR3DAfm vluc8d0Dfo2E/L7JYtX8yTroibVfwgVSYfCcPuwuTYxykY7IQ8GiKF71gCTc4i+H O1MA5cvwsnyNeRmgiM14+MWKWnflBqzdSt7mcG6+r771sasOCLDboD+Uxb4Subx7 J3m1MildrsUgI5IDe1Q5sIkiVG0S48N46jpA/aSTrOktiDzbpkdmTN/YF+0W3hrW 10Fmvx2A8aTgZBEpXgwnBWLr5cQEYtHEnwxqVdZYOJxmD537q1SAmZzsSdaCn9pF 1j9TBgO3/R/shn104KS06DK2qgcj+O8kQZ5jMHj0VN2O8Fo4jhJ/eMdvAlYhM864 uK1pVQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd BgNVHQ4EFgQUoYxFkwoSYwunV18ySn3hIee3PmYwHwYDVR0jBBgwFoAUoYxFkwoS YwunV18ySn3hIee3PmYwDQYJKoZIhvcNAQELBQADggIBAIeuYW1IOCrGHNxKLoR4 ScAjKkW4NU3RBfq5BTPEZL3brVQWKrA+DVoo2qYagHMMxEFvr7g0tnfUW44dC4tG kES1s+5JGInBSzSzhzV0op5FZ+1FcWa2uaElc9fCrIj70h2na9rAWubYWWQ0l2Ug MTMDT86tCZ6u6cI+GHW0MyUSuwXsULpxQOK93ohGBSGEi6MrHuswMIm/EfVcRPiR i0tZRQswDcoMT29jvgT+we3gh/7IzVa/5dyOetTWKU6A26ubP45lByL3RM2WHy3H 9Qm2mHD/ONxQFRGEO3+p8NgkVMgXjCsTSdaZf0XRD46/aXI3Uwf05q79Wz55uQbN uIF4tE2g0DW65K7/00m8Ne1jxrP846thWgW2C+T/qSq+31ROwktcaNqjMqLJTVcY UzRZPGaZ1zwCeKdMcdC/2/HEPOcB5gTyRPZIJjAzybEBGesC8cwh+joCMBedyF+A P90lrAKb4xfevcqSFNJSgVPm6vwwZzKpYvaTFxUHMV4PG2n19Km3fC2z7YREMkco BzuGaUWpxzaWkHJ02BKmcyPRTrm2ejrEKaFQBhG52fQmbmIIEiAW8AFXF9QFNmeX 61H5/zMkDAUPVr/vPRxSjoreaQ9aH/DVAzFEs5LG6nWorrvHYAOImP/HBIRSkIbh tJOpUC/o69I2rDBgp9ADE7UK -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFejCCA2KgAwIBAgIJAN7E8kTzHab8MA0GCSqGSIb3DQEBCwUAMEoxCzAJBgNV BAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxJDAiBgNVBAMTG1N3aXNzU2ln biBHb2xkIFJvb3QgQ0EgLSBHMzAeFw0wOTA4MDQxMzMxNDdaFw0zNzA4MDQxMzMx NDdaMEoxCzAJBgNVBAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxJDAiBgNV BAMTG1N3aXNzU2lnbiBHb2xkIFJvb3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEB BQADggIPADCCAgoCggIBAMPon8hlWp1nG8FFl7S0h0NbYWCAnvJ/XvlnRN1E+qu1 q3f/KhlMzm/Ej0Gf4OLNcuDR1FJhQQkKvwpw++CDaWEpytsimlul5t0XlbBvhI46 PmRaQfsbWPz9Kz6ypOasyYK8zvaV+Jd37Sb2WK6eJ+IPg+zFNljIe8/Vh6GphxoT Z2EBbaZpnOKQ8StoZfPosHz8gj3erdgKAAlEeROc8P5udXvCvLNZAQt8xdUt8L// bVfSSYHrtLNQrFv5CxUVjGn/ozkB7fzc3CeXjnuL1Wqm1uAdX80Bkeb1Ipi6LgkY OG8TqIHS+yE35y20YueBkLDGeVm3Z3X+vo87+jbsr63ST3Q2AeVXqyMEzEpel89+ xu+MzJUjaY3LOMcZ9taKABQeND1v2gwLw7qX/BFLUmE+vzNnUxC/eBsJwke6Hq9Y 9XWBf71W8etW19lpDAfpNzGwEhwy71bZvnorfL3TPbxqM006PFAQhyfHegpnU9t/ gJvoniP6+Qg6i6GONFpIM19k05eGBxl9iJTOKnzFat+vvKmfzTqmurtU+X+P388O WsStmryzOndzg0yTPJBotXxQlRHIgl6UcdBBGPvJxmXszom2ziKzEVs/4J0+Gxho DaoDoWdZv2udvPjyZS+aQTpF2F7QNmxvOx5jtI6YTBPbIQ6fe+3qoKpxw+ujoNIl AgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud DgQWBBRclwZGNKvfMMV8xQ1VcWYwtWCPnjAfBgNVHSMEGDAWgBRclwZGNKvfMMV8 xQ1VcWYwtWCPnjANBgkqhkiG9w0BAQsFAAOCAgEAd0tN3uqFSqssJ9ZFx/FfIMFb YO0Hy6Iz3DbPx5TxBsfV2s/NrYQ+/xJIf0HopWZXMMQd5KcaLy1Cwe9Gc7LV9Vr9 Dnpr0sgxow1IlldlY1UYwPzkisyYhlurDIonN/ojaFlcJtehwcK5Tiz/KV7mlAu+ zXJPleiP9ve4Pl7Oz54RyawDKUiKqbamNLmsQP/EtnM3scd/qVHbSypHX0AkB4gG tySz+3/3sIsz+r8jdaNc/qplGsK+8X2BdwOBsY3XlQ16PEKYt4+pfVDh31IGmqBS VHiDB2FSCTdeipynxlHRXGPRhNzC29L6Wxg2fWa81CiXL3WWHIQHrIuOUxG+JCGq Z/LBrYic07B4Z3j101gDIApdIPG152XMDiDj1d/mLxkrhWjBBCbPj+0FU6HdBw7r QSbHtKksW+NpPWbAYhvAqobAN8MxBIZwOb5rXyFAQaB/5dkPOEtwX0n4hbgrLqof k0FD+PuydDwfS1dbt9RRoZJKzr4Qou7YFCJ7uUG9jemIqdGPAxpg/z+HiaCZJyJm sD5onnKIUTidEz5FbQXlRrVz7UOGsRQKHrzaDb8eJFxmjw6+of3G62m8Q3nXA3b5 3IeZuJjEzX9tEPkQvixC/pwpTYNrCr21jsRIiv0hB6aAfR+b6au9gmFECnEnX22b kJ6u/zYks2gD1pWMa3M= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFgTCCA2mgAwIBAgIIIj+pFyDegZQwDQYJKoZIhvcNAQELBQAwTjELMAkGA1UE BhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzEoMCYGA1UEAxMfU3dpc3NTaWdu IFBsYXRpbnVtIFJvb3QgQ0EgLSBHMzAeFw0wOTA4MDQxMzM0MDRaFw0zNzA4MDQx MzM0MDRaME4xCzAJBgNVBAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxKDAm BgNVBAMTH1N3aXNzU2lnbiBQbGF0aW51bSBSb290IENBIC0gRzMwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQCUoO8TG59EIBvNxaoiu9nyUj56Wlh35o2h K8ncpPPksxOUAGKbHPJDUEOBfq8wNkmsGIkMGEW4PsdUbePYmllriholqba1Dbd9 I/BffagHqfc+hi7IAU3c5jbtHeU3B2kSS+OD0QQcJPAfcHHnGe1zSG6VKxW2VuYC 31bpm/rqpu7gwsO64MzGyHvXbzqVmzqPvlss0qmgOD7WiOGxYhOO3KswZ82oaqZj K4Kwy8c9Tu1y9n2rMk5lAusPmXT4HBoojA5FAJMsFJ9txxue9orce3jjtJRHHU0F bYR6kFSynot1woDfhzk/n/tIVAeNoCn1+WBfWnLou5ugQuAIADSjFTwT49YaawKy lCGjnUG8KmtOMzumlDj8PccrM7MuKwZ0rJsQb8VORfddoVYDLA1fer0e3h13kGva pS2KTOnfQfTnS+x9lUKfTKkJD0OIPz2T5yv0ekjaaMTdEoAxGl0kVCamJCGzTK3a Fwg2AlfGnIZwyXXJnnxh2HjmuegUafkcECgSXUt1ULo80GdwVVVWS/s9HNjbeU2X 37ie2xcs1TUHuFCp9473Vv96Z0NPINnKZtY4YEvulDHWDaJIm/80aZTGNfWWiO+q ZsyBputMU/8ydKe2nZhXtLomqfEzM2J+OrADEVf/3G8RI60+xgrQzFS3LcKTHeXC pozH2O9T9wIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB /zAdBgNVHQ4EFgQUVio/kFj0F1oUstcIG4VbVGpUGigwHwYDVR0jBBgwFoAUVio/ kFj0F1oUstcIG4VbVGpUGigwDQYJKoZIhvcNAQELBQADggIBAGztiudDqHknm7jP hz5kOBiMEUKShjfgWMMb7gQu94TsgxBoDH94LZzCl442ThbYDuprSK1Pnl0NzA2p PhiFfsxomTk11tifhsEy+01lsyIUS8iFZtoX/3GRrJxWV95xLFZCv/jNDvCi0//S IhX70HgKfuGwWs6ON9upnueVz2PyLA3S+m/zyNX7ALf3NWcQ03tS7BAy+L/dXsmm gqTxsL8dLt0l5L1N8DWpkQFH+BAClFvrPusNutUdYyylLqvn4x6j7kuqX7FmAbSC WvlGS8fx+N8svv113ZY4mjc6bqXmMhVus5DAOYp0pZWgvg0uiXnNKVaOw15XUcQF bwRVj4HpTL1ZRssqvE3JHfLGTwXkyAQN925P2sM6nNLC9enGJHoUPhxCMKgCRTGp /FCp3NyGOA9bkz9/CE5qDSc6EHlWwxW4PgaG9tlwZ691eoviWMzGdU8yVcVsFAko O/KV5GreLCgHraB9Byjd1Fqj6aZ8E4yZC1J429nR3z5aQ3Z/RmBTws3ndkd8Vc20 OWQQW5VLNV1EgyTV4C4kDMGAbmkAgAZ3CmaCEAxRbzeJV9vzTOW4ue4jZpdgt1Ld 2Zb7uoo7oE3OXvBETJDMIU8bOphrjjGD+YMIUssZwTVr7qEVW4g/bazyNJJTpjAq E9fmhqhd2ULSx52peovL3+6iMcLl -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFdjCCA16gAwIBAgIQXmjWEXGUY1BWAGjzPsnFkTANBgkqhkiG9w0BAQUFADBV MQswCQYDVQQGEwJDTjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxKjAoBgNV BAMTIUNlcnRpZmljYXRpb24gQXV0aG9yaXR5IG9mIFdvU2lnbjAeFw0wOTA4MDgw MTAwMDFaFw0zOTA4MDgwMTAwMDFaMFUxCzAJBgNVBAYTAkNOMRowGAYDVQQKExFX b1NpZ24gQ0EgTGltaXRlZDEqMCgGA1UEAxMhQ2VydGlmaWNhdGlvbiBBdXRob3Jp dHkgb2YgV29TaWduMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvcqN rLiRFVaXe2tcesLea9mhsMMQI/qnobLMMfo+2aYpbxY94Gv4uEBf2zmoAHqLoE1U fcIiePyOCbiohdfMlZdLdNiefvAA5A6JrkkoRBoQmTIPJYhTpA2zDxIIFgsDcScc f+Hb0v1naMQFXQoOXXDX2JegvFNBmpGN9J42Znp+VsGQX+axaCA2pIwkLCxHC1l2 ZjC1vt7tj/id07sBMOby8w7gLJKA84X5KIq0VC6a7fd2/BVoFutKbOsuEo/Uz/4M x1wdC34FMr5esAkqQtXJTpCzWQ27en7N1QhatH/YHGkR+ScPewavVIMYe+HdVHpR aG53/Ma/UkpmRqGyZxq7o093oL5d//xWC0Nyd5DKnvnyOfUNqfTq1+ezEC8wQjch zDBwyYaYD8xYTYO7feUapTeNtqwylwA6Y3EkHp43xP901DfA4v6IRmAR3Qg/UDar uHqklWJqbrDKaiFaafPz+x1wOZXzp26mgYmhiMU7ccqjUu6Du/2gd/Tkb+dC221K mYo0SLwX3OSACCK28jHAPwQ+658geda4BmRkAjHXqc1S+4RFaQkAKtxVi8QGRkvA Sh0JWzko/amrzgD5LkhLJuYwTKVYyrREgk/nkR4zw7CT/xH8gdLKH3Ep3XZPkiWv HYG3Dy+MwwbMLyejSuQOmbp8HkUff6oZRZb9/D0CAwEAAaNCMEAwDgYDVR0PAQH/ BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFOFmzw7R8bNLtwYgFP6H EtX2/vs+MA0GCSqGSIb3DQEBBQUAA4ICAQCoy3JAsnbBfnv8rWTjMnvMPLZdRtP1 LOJwXcgu2AZ9mNELIaCJWSQBnfmvCX0KI4I01fx8cpm5o9dU9OpScA7F9dY74ToJ MuYhOZO9sxXqT2r09Ys/L3yNWC7F4TmgPsc9SnOeQHrAK2GpZ8nzJLmzbVUsWh2e JXLOC62qx1ViC777Y7NhRCOjy+EaDveaBk3e1CNOIZZbOVtXHS9dCF4Jef98l7VN g64N1uajeeAz0JmWAjCnPv/So0M/BVoG6kQC2nz4SNAzqfkHx5Xh9T71XXG68pWp dIhhWeO/yloTunK0jF02h+mmxTwTv97QRCbut+wucPrXnbes5cVAWubXbHssw1ab R80LzvobtCHXt2a49CUwi1wNuepnsvRtrtWhnk/Yn+knArAdBtaP4/tIEp9/EaEQ PkxROpaw0RPxx9gmrjrKkcRpnd8BKWRRb2jaFOwIQZeQjdCygPLPwj2/kWjFgGce xGATVdVhmVd8upUPYUk6ynW8yQqTP2cOEvIo4jEbwFcW3wh8GcF+Dx+FHgo2fFt+ J7x6v+Db9NpSvd4MVHAxkUOVyLzwPt0JfjBkUO1/AaQzZ01oT74V77D2AhGiGxMl OtzCWfHjXEa7ZywCRuoeSKbmW9m1vFGikpbbqsY3Iqb+zCB0oy2pLmvLwIIRIbWT ee5Ehr7XHuQe+w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWDCCA0CgAwIBAgIQUHBrzdgT/BtOOzNy0hFIjTANBgkqhkiG9w0BAQsFADBG MQswCQYDVQQGEwJDTjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxGzAZBgNV BAMMEkNBIOayg+mAmuagueivgeS5pjAeFw0wOTA4MDgwMTAwMDFaFw0zOTA4MDgw MTAwMDFaMEYxCzAJBgNVBAYTAkNOMRowGAYDVQQKExFXb1NpZ24gQ0EgTGltaXRl ZDEbMBkGA1UEAwwSQ0Eg5rKD6YCa5qC56K+B5LmmMIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEA0EkhHiX8h8EqwqzbdoYGTufQdDTc7WU1/FDWiD+k8H/r D195L4mx/bxjWDeTmzj4t1up+thxx7S8gJeNbEvxUNUqKaqoGXqW5pWOdO2XCld1 9AXbbQs5uQF/qvbW2mzmBeCkTVL829B0txGMe41P/4eDrv8FAxNXUDf+jJZSEExf v5RxadmWPgxDT74wwJ85dE8GRV2j1lY5aAfMh09Qd5Nx2UQIsYo06Yms25tO4dnk UkWMLhQfkWsZHWgpLFbE4h4TV2TwYeO5Ed+w4VegG63XX9Gv2ystP9Bojg/qnw+L NVgbExz03jWhCl3W6t8Sb8D7aQdGctyB9gQjF+BNdeFyb7Ao65vh4YOhn0pdr8yb +gIgthhid5E7o9Vlrdx8kHccREGkSovrlXLp9glk3Kgtn3R46MGiCWOc76DbT52V qyBPt7D3h1ymoOQ3OMdc4zUPLK2jgKLsLl3Az+2LBcLmc272idX10kaO6m1jGx6K yX2m+Jzr5dVjhU1zZmkR/sgO9MHHZklTfuQZa/HpelmjbX7FF+Ynxu8b22/8DU0G AbQOXDBGVWCvOGU6yke6rCzMRh+yRpY/8+0mBe53oWprfi1tWFxK1I5nuPHa1UaK J/kR8slC/k7e3x9cxKSGhxYzoacXGKUN5AXlK8IrC6KVkLn9YDxOiT7nnO4fuwEC AwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFOBNv9ybQV0T6GTwp+kVpOGBwboxMA0GCSqGSIb3DQEBCwUAA4ICAQBqinA4 WbbaixjIvirTthnVZil6Xc1bL3McJk6jfW+rtylNpumlEYOnOXOvEESS5iVdT2H6 yAa+Tkvv/vMx/sZ8cApBWNromUuWyXi8mHwCKe0JgOYKOoICKuLJL8hWGSbueBwj /feTZU7n85iYr83d2Z5AiDEoOqsuC7CsDCT6eiaY8xJhEPRdF/d+4niXVOKM6Cm6 jBAyvd0zaziGfjk9DgNyp115j0WKWa5bIW4xRtVZjc8VX90xJc/bYNaBRHIpAlf2 ltTW/+op2znFuCyKGo3Oy+dCMYYFaA6eFN0AkLppRQjbbpCBhqcqBT/mhDn4t/lX X0ykeVoQDF7Va/81XwVRHmyjdanPUIPTfPRm94KNPQx96N97qA4bLJyuQHCH2u2n FoJavjVsIE4iYdm8UXrNemHcSxH5/mc0zy4EZmFcV5cjjPOGG0jfKq+nwf/Yjj4D u9gqsPoUJbJRa4ZDhS4HIxaAjUz7tGM7zMN07RujHv41D198HRaG9Q7DlfEvr10l O1Hm13ZBONFLAzkopR6RctR9q5czxNM+4Gm2KHmgCY0c0f9BckgG/Jou5yD5m6Le ie2uPAmvylezkolwQOQvT8Jwg0DXJCxr5wkf09XHwQj02w47HAcLQxGEIYbpgNR1 2KvxAmLBsX5VYc8T1yaw15zLKYs4SgsOkI26oQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2 8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk 6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn 0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN sSi6 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMx EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoT EUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRp ZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIz NTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQH EwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UE AxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIw DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKD E6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH /PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7Rnwy DfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlRJQVh GkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGR tDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEA AaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE FDqahQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmX WWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu 9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTr gIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo 2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI 4uJEvlz36hz1 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg 8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID4TCCAsmgAwIBAgIOYyUAAQACFI0zFQLkbPQwDQYJKoZIhvcNAQEFBQAwezEL MAkGA1UEBhMCREUxHDAaBgNVBAoTE1RDIFRydXN0Q2VudGVyIEdtYkgxJDAiBgNV BAsTG1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQTEoMCYGA1UEAxMfVEMgVHJ1 c3RDZW50ZXIgVW5pdmVyc2FsIENBIElJSTAeFw0wOTA5MDkwODE1MjdaFw0yOTEy MzEyMzU5NTlaMHsxCzAJBgNVBAYTAkRFMRwwGgYDVQQKExNUQyBUcnVzdENlbnRl ciBHbWJIMSQwIgYDVQQLExtUQyBUcnVzdENlbnRlciBVbml2ZXJzYWwgQ0ExKDAm BgNVBAMTH1RDIFRydXN0Q2VudGVyIFVuaXZlcnNhbCBDQSBJSUkwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC2pxisLlxErALyBpXsq6DFJmzNEubkKLF 5+cvAqBNLaT6hdqbJYUtQCggbergvbFIgyIpRJ9Og+41URNzdNW88jBmlFPAQDYv DIRlzg9uwliT6CwLOunBjvvya8o84pxOjuT5fdMnnxvVZ3iHLX8LR7PH6MlIfK8v zArZQe+f/prhsq75U7Xl6UafYOPfjdN/+5Z+s7Vy+EutCHnNaYlAJ/Uqwa1D7KRT yGG299J5KmcYdkhtWyUB0SbFt1dpIxVbYYqt8Bst2a9c8SaQaanVDED1M4BDj5yj dipFtK+/fz6HP3bFzSreIMUWWMv5G/UPyw0RUmS40nZid4PxWJ//AgMBAAGjYzBh MB8GA1UdIwQYMBaAFFbn4VslQ4Dg9ozhcbyO5YAvxEjiMA8GA1UdEwEB/wQFMAMB Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRW5+FbJUOA4PaM4XG8juWAL8RI 4jANBgkqhkiG9w0BAQUFAAOCAQEAg8ev6n9NCjw5sWi+e22JLumzCecYV42Fmhfz dkJQEw/HkG8zrcVJYCtsSVgZ1OK+t7+rSbyUyKu+KGwWaODIl0YgoGhnYIg5IFHY aAERzqf2EQf27OysGh+yZm5WZ2B6dF7AbZc2rrUNXWZzwCUyRdhKBgePxLcHsU0G DeGl6/R1yrqc0L2z0zIkTO5+4nYES0lT2PLpVDP85XEfPRRclkvxOvIAu2y0+pZV CIgJwcyRGSmwIC3/yzikQOEXvnlhgP8HA4ZMTnsGnxGGjYnuJ8Tb4rwZjgvDwxPH LQNjO9Po5KIqwoIIlBZU8O8fJ5AluA0OKBtHd0e9HKgl8ZS0Zg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRF MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBD bGFzcyAzIENBIDIgMjAwOTAeFw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NTha ME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxJzAlBgNVBAMM HkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIwDQYJKoZIhvcNAQEB BQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOADER03 UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42 tSHKXzlABF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9R ySPocq60vFYJfxLLHLGvKZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsM lFqVlNpQmvH/pStmMaTJOKDfHR+4CS7zp+hnUquVH+BGPtikw8paxTGA6Eian5Rp /hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUCAwEAAaOCARowggEWMA8G A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ4PGEMA4G A1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVj dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUy MENBJTIwMiUyMDIwMDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRl cmV2b2NhdGlvbmxpc3QwQ6BBoD+GPWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3Js L2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAwOS5jcmwwDQYJKoZIhvcNAQEL BQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm2H6NMLVwMeni acfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4K zCUqNQT4YJEVdT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8 PIWmawomDeCTmGCufsYkl4phX5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3Y Johw1+qRzT65ysCQblrGXnRl11z+o+I= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRF MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBD bGFzcyAzIENBIDIgRVYgMjAwOTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUw NDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxELVRydXN0IEdtYkgxKjAoBgNV BAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAwOTCCASIwDQYJKoZI hvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfSegpn ljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM0 3TP1YtHhzRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6Z qQTMFexgaDbtCHu39b+T7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lR p75mpoo6Kr3HGrHhFPC+Oh25z1uxav60sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8 HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure3511H3a6UCAwEAAaOCASQw ggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyvcop9Ntea HNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFw Oi8vZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xh c3MlMjAzJTIwQ0ElMjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1E RT9jZXJ0aWZpY2F0ZXJldm9jYXRpb25saXN0MEagRKBChkBodHRwOi8vd3d3LmQt dHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xhc3NfM19jYV8yX2V2XzIwMDku Y3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+PPoeUSbrh/Yp 3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNF CSuGdXzfX2lXANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7na xpeG0ILD5EJt/rDiZE4OJudANCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqX KVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVvw9y4AyHqnxbxLFS1 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFYzCCA0ugAwIBAgIBOzANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJJTDEW MBQGA1UEChMNU3RhcnRDb20gTHRkLjEsMCoGA1UEAxMjU3RhcnRDb20gQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkgRzIwHhcNMTAwMTAxMDEwMDAxWhcNMzkxMjMxMjM1 OTAxWjBTMQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjEsMCoG A1UEAxMjU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgRzIwggIiMA0G CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2iTZbB7cgNr2Cu+EWIAOVeq8Oo1XJ JZlKxdBWQYeQTSFgpBSHO839sj60ZwNq7eEPS8CRhXBF4EKe3ikj1AENoBB5uNsD vfOpL9HG4A/LnooUCri99lZi8cVytjIl2bLzvWXFDSxu1ZJvGIsAQRSCb0AgJnoo D/Uefyf3lLE3PbfHkffiAez9lInhzG7TNtYKGXmu1zSCZf98Qru23QumNK9LYP5/ Q0kGi4xDuFby2X8hQxfqp0iVAXV16iulQ5XqFYSdCI0mblWbq9zSOdIxHWDirMxW RST1HFSr7obdljKF+ExP6JV2tgXdNiNnvP8V4so75qbsO+wmETRIjfaAKxojAuuK HDp2KntWFhxyKrOq42ClAJ8Em+JvHhRYW6Vsi1g8w7pOOlz34ZYrPu8HvKTlXcxN nw3h3Kq74W4a7I/htkxNeXJdFzULHdfBR9qWJODQcqhaX2YtENwvKhOuJv4KHBnM 0D4LnMgJLvlblnpHnOl68wVQdJVznjAJ85eCXuaPOQgeWeU1FEIT/wCc976qUM/i UUjXuG+v+E5+M5iSFGI6dWPPe/regjupuznixL0sAA7IF6wT700ljtizkC+p2il9 Ha90OrInwMEePnWjFqmveiJdnxMaz6eg6+OGCtP95paV1yPIN93EfKo2rJgaErHg TuixO/XWb/Ew1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQE AwIBBjAdBgNVHQ4EFgQUS8W0QGutHLOlHGVuRjaJhwUMDrYwDQYJKoZIhvcNAQEL BQADggIBAHNXPyzVlTJ+N9uWkusZXn5T50HsEbZH77Xe7XRcxfGOSeD8bpkTzZ+K 2s06Ctg6Wgk/XzTQLwPSZh0avZyQN8gMjgdalEVGKua+etqhqaRpEpKwfTbURIfX UfEpY9Z1zRbkJ4kd+MIySP3bmdCPX1R0zKxnNBFi2QwKN4fRoxdIjtIXHfbX/dtl 6/2o1PXWT6RbdejF0mCy2wl+JYt7ulKSnj7oxXehPOBKc2thz4bcQ///If4jXSRK 9dNtD2IEBVeC2m6kMyV5Sy5UGYvMLD0w6dEG/+gyRr61M3Z3qAFdlsHB1b6uJcDJ HgoJIIihDsnzb02CVAAgp9KP5DlUFy6NHrgbuxu9mk47EDTcnIhT76IxW1hPkWLI wpqazRVdOKnWvvgTtZ8SafJQYqz7Fzf07rh1Z2AQ+4NQ+US1dZxAF7L+/XldblhY XzD8AK6vM8EOTmy6p6ahfzLbOOCxchcKK5HsamMm7YnUeMx0HgX4a/6ManY5Ka5l IxKVCCIcl85bBu4M4ru8H0ST9tg4RQUh7eStqxK2A6RCLi3ECToDZ2mEmuFZkIoo hdVddLHRDiBYmxOlsGOm7XtH/UVVMKTumtTm4ofvmMkyghEpIrwACjFeLQ/Ajulr so8uBtjRkcfGEvRM/TAXw8HaOFvjqermobp573PYtlNXLfbQ4ddI -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCB hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMTE5 MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgT EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNh dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR 6FSS0gpWsawNJN3Fz0RndJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8X pz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZFGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC 9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+5eNu/Nio5JIk2kNrYrhV /erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pGx8cgoLEf Zd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z +pUX2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7w qP/0uK3pN/u6uPQLOvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZah SL0896+1DSJMwBGB7FY79tOi4lu3sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVIC u9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+CGCe01a60y1Dma/RMhnEw6abf Fobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5WdYgGq/yapiq crxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB /wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvl wFTPoCWOAvn9sKIN9SCYPBMtrFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM 4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV 2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSgtZx8jb8uk2Intzna FxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwWsRqZ CuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiK boHGhfKppC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmcke jkk9u+UJueBPSZI9FoJAzMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yL S0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHqZJx64SIDqZxubw5lT2yHh17zbqD5daWb QOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk527RH89elWsn2/x20Kk4yl 0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB NVOFBkpdn627G190 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UE BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz dCBDb21tZXJjaWFsMB4XDTEwMDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDEL MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp cm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC AQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6EqdbDuKP Hx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yr ba0F8PrVC8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPAL MeIrJmqbTFeurCA+ukV6BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1 yHp52UKqK39c/s4mT6NmgTWvRLpUHhwwMmWd5jyTXlBOeuM61G7MGvv50jeuJCqr VwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNVHQ4EFgQUnZPGU4teyq8/ nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ KoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYG XUPGhi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNj vbz4YYCanrHOQnDiqX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivt Z8SOyUOyXGsViQK8YvxO8rUzqrJv0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9g N53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0khsUlHRUe072o0EclNmsxZt9YC nlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UE BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVz dCBOZXR3b3JraW5nMB4XDTEwMDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDEL MAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZp cm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC AQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SEHi3y YJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbua kCNrmreIdIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRL QESxG9fhwoXA3hA/Pe24/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp 6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gbh+0t+nvujArjqWaJGctB+d1ENmHP4ndG yH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNVHQ4EFgQUBx/S55zawm6i QLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJ KoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfO tDIuUFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzu QY0x2+c06lkh1QF612S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZ Lgo/bNjR9eUJtGxUAArgFU2HdW23WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4u olu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9/ZFvgrG+CJPbFEfxojfHRZ48 x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UE BhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVz dCBQcmVtaXVtMB4XDTEwMDEyOTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkG A1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1U cnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxBLf qV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtnBKAQ JG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ +jjeRFcV5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrS s8PhaJyJ+HoAVt70VZVs+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5 HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmdGPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d7 70O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5Rp9EixAqnOEhss/n/fauG V+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NIS+LI+H+S qHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S 5u046uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4Ia C1nEWTJ3s7xgaVY5/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TX OwF0lkLgAOIua+rF7nKsu7/+6qqo+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYE FJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByvMiPIs0laUZx2 KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B 8OWycvpEgjNC6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQ MKSOyARiqcTtNd56l+0OOF6SL5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc 0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK+4w1IX2COPKpVJEZNZOUbWo6xbLQ u4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmVBtWVyuEklut89pMF u+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFgIxpH YoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8 GKa1qF60g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaO RtGdFNrHF+QFlozEJLUbzxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6e KeC2uAloGRwYQw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMC VVMxFDASBgNVBAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQ cmVtaXVtIEVDQzAeFw0xMDAxMjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJ BgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1UcnVzdDEgMB4GA1UEAwwXQWZmaXJt VHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNMF4bFZ0D 0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQN8O9 ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0G A1UdDgQWBBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4G A1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/Vs aobgxCd05DhT1wV/GzTjxi+zygk8N53X57hG8f2h4nECMEJZh0PUUd+60wkyWs6I flc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKMeQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B 3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/ Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2 VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT 79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6 c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3 ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs 8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/ qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG jjxDah2nGN59PRbxYvnKkKj9 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGHDCCBASgAwIBAgIES45gAzANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJE SzESMBAGA1UEChMJVFJVU1QyNDA4MSIwIAYDVQQDExlUUlVTVDI0MDggT0NFUyBQ cmltYXJ5IENBMB4XDTEwMDMwMzEyNDEzNFoXDTM3MTIwMzEzMTEzNFowRTELMAkG A1UEBhMCREsxEjAQBgNVBAoTCVRSVVNUMjQwODEiMCAGA1UEAxMZVFJVU1QyNDA4 IE9DRVMgUHJpbWFyeSBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB AJlJodr3U1Fa+v8HnyACHV81/wLevLS0KUk58VIABl6Wfs3LLNoj5soVAZv4LBi5 gs7E8CZ9w0F2CopW8vzM8i5HLKE4eedPdnaFqHiBZ0q5aaaQArW+qKJx1rT/AaXt alMB63/yvJcYlXS2lpexk5H/zDBUXeEQyvfmK+slAySWT6wKxIPDwVapauFY9QaG +VBhCa5jBstWS7A5gQfEvYqn6csZ3jW472kW6OFNz6ftBcTwufomGJBMkonf4ZLr 6t0AdRi9jflBPz3MNNRGxyjIuAmFqGocYFA/OODBRjvSHB2DygqQ8k+9tlpvzMRr kU7jq3RKL+83G1dJ3/LTjCLz4ryEMIC/OJ/gNZfE0qXddpPtzflIPtUFVffXdbFV 1t6XZFhJ+wBHQCpJobq/BjqLWUA86upsDbfwnePtmIPRCemeXkY0qabC+2Qmd2Fe xyZphwTyMnbqy6FG1tB65dYf3mOqStmLa3RcHn9+2dwNfUkh0tjO2FXD7drWcU0O I9DW8oAypiPhm/QCjMU6j6t+0pzqJ/S0tdAo+BeiXK5hwk6aR+sRb608QfBbRAs3 U/q8jSPByenggac2BtTN6cl+AA1Mfcgl8iXWNFVGegzd/VS9vINClJCe3FNVoUnR YCKkj+x0fqxvBLopOkJkmuZw/yhgMxljUi2qYYGn90OzAgMBAAGjggESMIIBDjAP BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjARBgNVHSAECjAIMAYGBFUd IAAwgZcGA1UdHwSBjzCBjDAsoCqgKIYmaHR0cDovL2NybC5vY2VzLnRydXN0MjQw OC5jb20vb2Nlcy5jcmwwXKBaoFikVjBUMQswCQYDVQQGEwJESzESMBAGA1UEChMJ VFJVU1QyNDA4MSIwIAYDVQQDExlUUlVTVDI0MDggT0NFUyBQcmltYXJ5IENBMQ0w CwYDVQQDEwRDUkwxMB8GA1UdIwQYMBaAFPZt+LFIs0FDAduGROUYBbdezAY3MB0G A1UdDgQWBBT2bfixSLNBQwHbhkTlGAW3XswGNzANBgkqhkiG9w0BAQsFAAOCAgEA VPAQGrT7dIjD3/sIbQW86f9CBPu0c7JKN6oUoRUtKqgJ2KCdcB5ANhCoyznHpu3m /dUfVUI5hc31CaPgZyY37hch1q4/c9INcELGZVE/FWfehkH+acpdNr7j8UoRZlkN 15b/0UUBfGeiiJG/ugo4llfoPrp8bUmXEGggK3wyqIPcJatPtHwlb6ympfC2b/Ld v/0IdIOzIOm+A89Q0utx+1cOBq72OHy8gpGb6MfncVFMoL2fjP652Ypgtr8qN9Ka /XOazktiIf+2Pzp7hLi92hRc9QMYexrV/nnFSQoWdU8TqULFUoZ3zTEC3F/g2yj+ FhbrgXHGo5/A4O74X+lpbY2XV47aSuw+DzcPt/EhMj2of7SA55WSgbjPMbmNX0rb oenSIte2HRFW5Tr2W+qqkc/StixgkKdyzGLoFx/xeTWdJkZKwyjqge2wJqws2upY EiThhC497+/mTiSuXd69eVUwKyqYp9SD2rTtNmF6TCghRM/dNsJOl+osxDVGcwvt WIVFF/Onlu5fu1NHXdqNEfzldKDUvCfii3L2iATTZyHwU9CALE+2eIA+PIaLgnM1 1oCfUnYBkQurTrihvzz9PryCVkLxiqRmBVvUz+D4N5G/wvvKDS6t6cPCS+hqM482 cbBsn0R9fFLO4El62S9eH1tqOzO20OAOK65yJIsOpSE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDrTCCApWgAwIBAgIQPr1DlqNlQqhJtpGoElEmzzANBgkqhkiG9w0BAQUFADBm MQswCQYDVQQGEwJTRTEoMCYGA1UEChMfU3dlZGlzaCBTb2NpYWwgSW5zdXJhbmNl IEFnZW5jeTEtMCsGA1UEAxMkU3dlZGlzaCBHb3Zlcm5tZW50IFJvb3QgQXV0aG9y aXR5IHYxMB4XDTEwMDQxNDEzMzMyM1oXDTMwMDQxNDEzNDMxOVowZjELMAkGA1UE BhMCU0UxKDAmBgNVBAoTH1N3ZWRpc2ggU29jaWFsIEluc3VyYW5jZSBBZ2VuY3kx LTArBgNVBAMTJFN3ZWRpc2ggR292ZXJubWVudCBSb290IEF1dGhvcml0eSB2MTCC ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMocT3QV99VeycBYhDLn3PxR kZDOESLhJXCDQnD7cNwbC2/CLdCK211WKaq3XoW1d+fBWiBiyJdCYcJDcNtYbHXg C7YyiuCkLUW+51s7i3qx/QXMM3+f8Fvtco7NM9PJEovAk0Cjj4Zu342I8+ZqTG+l RvpoplsQloMuV6BPjxVZNqVuaIRFHhCHtuJV1bi/q7euZb9XR4zE4+QCjfPcM9vv J0f47MvyPOcJ8/nl+YvH6VrgLZOrqik2L37GyPbw5oBFMRY0avDoSTlEYQDzm+a+ CmUbMiN5YnesIn8bpF16sOes2OB2Ay9972v3N++jomQczNd92oKizrUBTfgJLDUC AwEAAaNXMFUwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYD VR0OBBYEFF0HuveNXUnwXJWQCfNe6qzQ9kO2MBAGCSsGAQQBgjcVAQQDAgEAMA0G CSqGSIb3DQEBBQUAA4IBAQAw36Sc6JRzKWuuHwxwukz+ZH2sT69JD3KLpLIASGlO 1fKEMwuNc9vOaQ63Yr+xkeRq4RJJfEZHm4TYmCksB4Mk8B7rcZ2RaHr3W0OtggQP Q98mvYJdtpG8Yr5DWqNUb35RMS35N5y2H6j75xGbFgvuYRJU1aWE2f+fkiedU/e2 CzMXxuuy6oLy9kN2HLwxkzCd6UhiDj5DO6wpzJTgK2yavxfe8crw5h2F0g+rvYed gk2GMDW0CdNAqLu3Iv54qNvRUGmAM2AtfY+EueyH0pjpC8zbeRhxE1ig+WvWzuRG TAB3ii9cna6JBevqHOCaY/m7RqO5Sr2dGn3KhM3+kPG6 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHBDCCBOygAwIBAgIQDziMcP6mlV3pXZyHr3kEmTANBgkqhkiG9w0BAQsFADB1 MQswCQYDVQQGEwJFUzFBMD8GA1UEChM4QWdlbmNpYSBOb3RhcmlhbCBkZSBDZXJ0 aWZpY2FjaW9uIFMuTC5VLiAtIENJRiBCODMzOTU5ODgxIzAhBgNVBAMTGkFOQ0VS VCBDZXJ0aWZpY2Fkb3MgQ0dOIFYyMB4XDTEwMDUyNTE2MzEyMloXDTMwMDUyNTE2 MzEyM1owdTELMAkGA1UEBhMCRVMxQTA/BgNVBAoTOEFnZW5jaWEgTm90YXJpYWwg ZGUgQ2VydGlmaWNhY2lvbiBTLkwuVS4gLSBDSUYgQjgzMzk1OTg4MSMwIQYDVQQD ExpBTkNFUlQgQ2VydGlmaWNhZG9zIENHTiBWMjCCAiIwDQYJKoZIhvcNAQEBBQAD ggIPADCCAgoCggIBAJ1ScOknGIPK6sSZ2KbhLhSvbh4OZMqBN1UnHBd3WGcfjMn5 wopiZSh0m+LRvlUHdnbufG1OY1seSiV14Aeh0NKCp84PM+u6FMBlskou5WW8ItKv Gg7Ky/NkZSssmaOXi4t1MP5m+sFPSzdQjD/z3pl6ToecIEZyl/5WG2ZOoIJTo1zY KEYMBRdvONZcnw4lIsGG41waVNuunWV9AJLfqCEhxVsQJnThsXNXZHx9FwMM6vcU lw/5xe5ddbDFxgoLtD5J4xnGm0ST/FoVZAqyg/+AXogJ0Mogo1v7283hGncjGHAa i+1EP9YaqDY44Z0vp3fEerPAcrJyzR4/EF4aiHSN8BLF969J3JWvK020kMr57u8M 478WNyNT4yn69HRpaD1XbRRgimRpKGRN+jZH/bgSzsOGqlzcZjkHTzvj48Vors7g OVwggz8SCjizAMFcE5ciXjpLNZn4xB7e+YgRjoTJizLy0te/Igc/YHgudRyiuiMS 0/BPUDnsyXcnx1oqjtO5tXQEmRUvLoZfjwbByuriqB9NfTOEkaSSw9CmSF1mGneE IFCc6gQLDCOWz7Gc/Lm6H5eo06sDZS99rlTHeeIcNt1t0gaYAf3O/D9Lw9Ku/4nY OTED2LFkdwPG+KON/Cp55xC9uW2RHD6dy7xVfyL+YYT42NSnIXo5XnIy60x1AgMB AAGjggGOMIIBijAPBgNVHRMBAf8EBTADAQH/MIIBJQYDVR0gBIIBHDCCARgwggEU BgkrBgEEAYGTaAQwggEFMCUGCCsGAQUFBwIBFhlodHRwOi8vd3d3LmFuY2VydC5j b20vY3BzMIHbBggrBgEFBQcCAjCBzjANFgZBTkNFUlQwAwIBAR6BvABBAGcAZQBu AGMAaQBhACAATgBvAHQAYQByAGkAYQBsACAAZABlACAAQwBlAHIAdABpAGYAaQBj AGEAYwBpAG8AbgAuACAAUABhAHMAZQBvACAAZABlAGwAoABHAGUAbgBlAHIAYQBs ACAATQBhAHIAdABpAG4AZQB6ACAAQwBhAG0AcABvAHMAIAA0ADYAIAA2AGEAIABw AGwAYQBuAHQAYQAgADIAOAAwADEAMAAgAE0AYQBkAHIAaQBkMA4GA1UdDwEB/wQE AwIBhjAdBgNVHQ4EFgQUBW7hoZruB6/O9bTTZT0EUOLQm0QwHwYDVR0jBBgwFoAU BW7hoZruB6/O9bTTZT0EUOLQm0QwDQYJKoZIhvcNAQELBQADggIBAH9UQBkkykwT 9hP5XGKVMNW44JOAbNQVRtQnPpJSqtyBY4ZA29Ulr5+TbAr1TaH+VJZdh68Rkw+L 8uPwH0qf/KnRyVB3X5gICC16i4EQzDsCVFjlxqf098ro9jcGfucR12yFY/eoow7i JWIEpPJiU5xHtKdku4Hl1l5WEb5FEWHCZun0DXSoq/lbv4KykaZQ+4d+b7vI6wWi uRDXG0IHVc+J5r/7ufBqOVdTcIy9S6Npvx+LplxNZYq5AAnoaL8JJwdNXtpSCYzl cZOKzIWO0jdeU9yCbQtWSoR5CvQQJUT1b10aZrXN1RBLh1pO1H/kcazuaJ+8+i5Y wcSef6RZheBSDvLHR3UVLSx2jA9FBTVg+Hs7dzJ/KIAJ2jG8cX3hrJHNYAp5IOxu O7eE4HLzqUrQL+Rb49Ia1Eq89Xb5fyoZSOvdDs+ZVkW4fdYJjg7Os4RoSYRUNUvk mRuv86gU81SYCoB+T7zyZi0m/zCNp/a925qP5eHfu7cyDvmSb2nj5HbTADbxLV7H E1/V2Wot6NEba3bLGG4OBRD1WvJJG1m0herKGXTMu1LiN4zCagIlwtJxpJLbjsnW qW7QhShtXG0IeAKweQxXbwtaAeOEhAL2z/KrY+sCarnLShjVOSI8VkqqlYjmMAAf jSEhyVfuubdEKYhPtiunFO6O7m++FtAT -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHEjCCBPqgAwIBAgIQCb1WBSoTFvRoT3QOqX0cSDANBgkqhkiG9w0BAQsFADB8 MQswCQYDVQQGEwJFUzFBMD8GA1UEChM4QWdlbmNpYSBOb3RhcmlhbCBkZSBDZXJ0 aWZpY2FjaW9uIFMuTC5VLiAtIENJRiBCODMzOTU5ODgxKjAoBgNVBAMTIUFOQ0VS VCBDZXJ0aWZpY2Fkb3MgTm90YXJpYWxlcyBWMjAeFw0xMDA1MjUxNjU2MTRaFw0z MDA1MjUxNjU2MTRaMHwxCzAJBgNVBAYTAkVTMUEwPwYDVQQKEzhBZ2VuY2lhIE5v dGFyaWFsIGRlIENlcnRpZmljYWNpb24gUy5MLlUuIC0gQ0lGIEI4MzM5NTk4ODEq MCgGA1UEAxMhQU5DRVJUIENlcnRpZmljYWRvcyBOb3RhcmlhbGVzIFYyMIICIjAN BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsua5xh1qKi1Jxfz81GRA0OAULveg wv+S80GmtD/avhkUkZR20xXMXn94UHrb2sVFqsscI3lzkKi7ZwFzjs5A+Rqpqofk k5IPXGhcXvAGYCtY3DxtPMd6MGsFqpKGcyrS8hqIxNvlWmaOdclCP5uIKEAe9alc HvrIQaEwqwuc7haiwS2lhfrtoAzof5ZKe72PmqIYdtKv3bc9EKtSEIiuHeu4MnSW 9LeqJ/elBw3jlFdqVCB3zR28eS3knLTeUYj+VtY9i6HP+lIejAVzd9YFz2MAUYdh 41C+mZfh/B4ReWtOas+chQoclirAIDYUxQkXYjv0rerV1/3QOSp409Ciz8hzMAlH xU4Z/bgw1A+AmIiGwUxBeiPFQ/1eErg+D7G3gWIMfm/je5rCwkcRIR/PntEwzoPB EE1Ad9e1wksyQEL6m7Csz+sh2BnrZMVr3VUtgIdEfEw8qw3YEr80goyxqsS4a+gO RnfSiwYdQvusvcnnM7Mib37VLgPFXwUWhnzt457RFncaRtjJ0IzkXFwhBZHxZOSs xTeutb1nE64p5bNCxHAJo11M6zcg4/D1czM7wvyOUYU2KsuB2w6JI9ni4Wi6LER3 PhxAuvBnjhiH8D3X6T9HWzVCzacEzkhyKQUatNGi5w15ipZtZ1ItOyPm+YKc1rN5 XhTeZUgz/B1C6C0CAwEAAaOCAY4wggGKMA8GA1UdEwEB/wQFMAMBAf8wggElBgNV HSAEggEcMIIBGDCCARQGCSsGAQQBgZNoATCCAQUwJQYIKwYBBQUHAgEWGWh0dHA6 Ly93d3cuYW5jZXJ0LmNvbS9jcHMwgdsGCCsGAQUFBwICMIHOMA0WBkFOQ0VSVDAD AgEBHoG8AEEAZwBlAG4AYwBpAGEAIABOAG8AdABhAHIAaQBhAGwAIABkAGUAIABD AGUAcgB0AGkAZgBpAGMAYQBjAGkAbwBuAC4AIABQAGEAcwBlAG8AIABkAGUAbACg AEcAZQBuAGUAcgBhAGwAIABNAGEAcgB0AGkAbgBlAHoAIABDAGEAbQBwAG8AcwAg ADQANgAgADYAYQAgAHAAbABhAG4AdABhACAAMgA4ADAAMQAwACAATQBhAGQAcgBp AGQwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBT2Ejqre1jBjUNvdoHS8rjT7xfq CzAfBgNVHSMEGDAWgBT2Ejqre1jBjUNvdoHS8rjT7xfqCzANBgkqhkiG9w0BAQsF AAOCAgEAVDXTomXJ2TbFU9G0jXI0ibqnCJ/pNRC5uAwG+WSqlZYoqMijgNxWwL9y TVa/f10E1a0oW02988MPFbBx2laNQFVXpn1ioq0TaVGqlFC6vQAwUPXdpE4JepQx a9tzA73z2hoPjC+yyTe8VNULIzf15Fs3ZolPtMcFpGXcWTCmEyt+Fe3sEBeJUsmd 36JM7fYPHqZJsA1RszGxUZnLtNEjeNJLqLQdFqag0D4HfmU/Jc5kThsuS02ChRpl 2+7iA/BZJAWPme95gt/uKjdow2pQAVlfn2jcLFFgK13gUjw7cLgA0zeoPlsedgha 1Lt2MK75yPKOpI8KdX0amOG/0DaULzzBUtNp6hpgN4yA201txppdjaBhUbs9DeYS oJ9vWVZ0MmcK/DcGwTrkK46EH9ohDEmIQ9Ol9YINdobDLMyQu7O4q8bLrsAXUZ7T gPck2hzszhKDzk42MDl1+HR2kIKePkBMDBS5Gh5IarAx6oh/gEFAU3s4S4eQYHpL zmdGaHV3jgBdILDkkzdtA99YOeiaxaTr7GEzCIUka08G6a2QpTZibOPdfQkfM7+3 u/fJdQX3W6v6h1mvGmcQfoTcjHDWROkQwdibLtHGQGrq5loPEH1s+1WHuk21cQOe F4942lU9V14iCmqY8I0Izd2WQlobzbpvJ7h0J6g/5aDWc8deLyE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF7TCCA9WgAwIBAgIQKMw6Jb+6RKxEmptYa0M5qjANBgkqhkiG9w0BAQsFADCB iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMp TWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTAwHhcNMTAw NjIzMjE1NzI0WhcNMzUwNjIzMjIwNDAxWjCBiDELMAkGA1UEBhMCVVMxEzARBgNV BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv c29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlm aWNhdGUgQXV0aG9yaXR5IDIwMTAwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQC5CJ4o5OTsBk5QaLNBxXvrrraOr4G6IkQfZTRpTL5wQBfyFnvief2G7Q05 9BuorZKQHss9do9a2bWREC48BY2KbSRU5x/tVq2DtFCcFaUXdIhZIPwIxYR202jU byh4zly481CQRP/jY1++oZoslhUE1gf+HoQh4EIxEcQoNpTPUKRinsnWq3EAslsM 5pbUCiSW9f/G1bcb18u3IWKvEtyhXTfjGvsaRpjAm8DnYx8qCJMCfh5qjvKfGInk IoWisYRXQP/1DthvnO3iRTEBzRfpf7CBReOqIUAmoXKqp088AQV+7oNYsV4GY5li kXiCtw2TDCRqtBvbJ+xflQQ/k0ow9ZcYs6f5GaeTMx0ByNsiUlzXJclG+aL7h1lD vptisY0thkQaRqx4YX4wCfquicRBKiJmA5E5RZzHiwyoyg0v+1LqDPdjMyOd/rAf rWfWp1ADxgRwY7UssYZaQ7f7rvluKW4hIUEmBozJw+6wwoWTobmF2eYybEtMP9Zd o+W1nXfDnMBVt3QA47g4q4OXUOGaQiQdxsCjMNEaWshSNPdz8ccYHzOteuzLQWDz I5QgwkhFrFxRxi6AwuJ3Fb2Fh+02nZaR7gC1o3Dsn+ONgGiDdrqvXXBSIhbiZvu6 s8XC9z4vd6bK3sGmxkhMwzdRI9Mn17hOcJbwoUR2r3jPmuFmEwIDAQABo1EwTzAL BgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU1fZWy4/oolxi aNE9lJBb186aGMQwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQELBQADggIB AKylloy/u66m9tdxh0MxVoj9HDJxWzW31PCR8q834hTx8wImBT4WFH8UurhP+4my sufUCcxtuVs7ZGVwZrfysVrfGgLz9VG4Z215879We+SEuSsem0CcJjT5RxiYadgc 17bRv49hwmfEte9gQ44QGzZJ5CDKrafBsSdlCfjN9Vsq0IQz8+8f8vWcC1iTN6B1 oN5y3mx1KmYi9YwGMFafQLkwqkB3FYLXi+zA07K9g8V3DB6urxlToE15cZ8PrzDO Z/nWLMwiQXoH8pdCGM5ZeRBV3m8Q5Ljag2ZAFgloI1uXLiaaArtXjMW4umliMoCJ nqH9wJJ8eyszGYQqY8UAaGL6n0eNmXpFOqfp7e5pQrXzgZtHVhB7/HA2hBhz6u/5 l02eMyPdJgu6Krc/RNyDJ/+9YVkrEbfKT9vFiwwcMa4y+Pi5Qvd/3GGadrFaBOER PWZFtxhxvskkhdbz1LpBNF0SLSW5jaYTSG1LsAd9mZMJYYF0VyaKq2nj5NnHiMwk 2OxSJFwevJEU4pbe6wrant1fs1vb1ILsxiBQhyVAOvvH7s3+M+Vuw4QJVQMlOcDp NV1lMaj2v6AJzSnHszYyLtyV84PBWs+LjfbqsyH4pO0eMQ62TBGrYAukEiMiF6M2 ZIKRBBLgq28ey1AFYbRA/1mGcdHVM2l8qXOKONdkDPFp -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID9zCCAt+gAwIBAgIESJ8AATANBgkqhkiG9w0BAQUFADCBijELMAkGA1UEBhMC Q04xMjAwBgNVBAoMKUNoaW5hIEludGVybmV0IE5ldHdvcmsgSW5mb3JtYXRpb24g Q2VudGVyMUcwRQYDVQQDDD5DaGluYSBJbnRlcm5ldCBOZXR3b3JrIEluZm9ybWF0 aW9uIENlbnRlciBFViBDZXJ0aWZpY2F0ZXMgUm9vdDAeFw0xMDA4MzEwNzExMjVa Fw0zMDA4MzEwNzExMjVaMIGKMQswCQYDVQQGEwJDTjEyMDAGA1UECgwpQ2hpbmEg SW50ZXJuZXQgTmV0d29yayBJbmZvcm1hdGlvbiBDZW50ZXIxRzBFBgNVBAMMPkNo aW5hIEludGVybmV0IE5ldHdvcmsgSW5mb3JtYXRpb24gQ2VudGVyIEVWIENlcnRp ZmljYXRlcyBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm35z 7r07eKpkQ0H1UN+U8i6yjUqORlTSIRLIOTJCBumD1Z9S7eVnAztUwYyZmczpwA// DdmEEbK40ctb3B75aDFk4Zv6dOtouSCV98YPjUesWgbdYavi7NifFy2cyjw1l1Vx zUOFsUcW9SxTgHbP0wBkvUCZ3czY28Sf1hNfQYOL+Q2HklY0bBoQCxfVWhyXWIQ8 hBouXJE0bhlffxdpxWXvayHG1VA6v2G5BY3vbzQ6sm8UY78WO5upKv23KzhmBsUs 4qpnHkWnjQRmQvaPK++IIGmPMowUc9orhpFjIpryp9vOiYurXccUwVswah+xt54u gQEC7c+WXmPbqOY4twIDAQABo2MwYTAfBgNVHSMEGDAWgBR8cks5x8DbYqVPm6oY NJKiyoOCWTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4E FgQUfHJLOcfA22KlT5uqGDSSosqDglkwDQYJKoZIhvcNAQEFBQADggEBACrDx0M3 j92tpLIM7twUbY8opJhJywyA6vPtI2Z1fcXTIWd50XPFtQO3WKwMVC/GVhMPMdoG 52U7HW8228gd+f2ABsqjPWYWqJ1MFn3AlUa1UeTiH9fqBk1jjZaM7+czV0I664zB echNdn3e9rG3geCg+aF4RhcaVpjwTj2rHO3sOdwHSPdj/gauwqRcalsyiMXHM4Ws ZkJHwlgkmeHlPuV1LI5D1l08eB6olYIpUNHRFrrvwb562bTYzB5MRuF3sTGrvSrI zo9uoV1/A3U05K2JRVRevq4opbs/eHnrc7MKDf2+yfdWrPa37S+bISnHOLaVxATy wy39FCqQmbkHzJ8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGGTCCBAGgAwIBAgIIPtVRGeZNzn4wDQYJKoZIhvcNAQELBQAwajEhMB8GA1UE AxMYU0cgVFJVU1QgU0VSVklDRVMgUkFDSU5FMRwwGgYDVQQLExMwMDAyIDQzNTI1 Mjg5NTAwMDIyMRowGAYDVQQKExFTRyBUUlVTVCBTRVJWSUNFUzELMAkGA1UEBhMC RlIwHhcNMTAwOTA2MTI1MzQyWhcNMzAwOTA1MTI1MzQyWjBqMSEwHwYDVQQDExhT RyBUUlVTVCBTRVJWSUNFUyBSQUNJTkUxHDAaBgNVBAsTEzAwMDIgNDM1MjUyODk1 MDAwMjIxGjAYBgNVBAoTEVNHIFRSVVNUIFNFUlZJQ0VTMQswCQYDVQQGEwJGUjCC AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANqoVgLsfJXwTukK0rcHoyKL ULO5Lhk9V9sZqtIr5M5C4myh5F0lHjMdtkXRtPpZilZwyW0IdmlwmubHnAgwE/7m 0ZJoYT5MEfJu8rF7V1ZLCb3cD9lxDOiaN94iEByZXtaxFwfTpDktwhpz/cpLKQfC eSnIyCauLMT8I8hL4oZWDyj9tocbaF85ZEX9aINsdSQePHWZYfrSFPipS7HYfad4 0hNiZbXWvn5qA7y1svxkMMPQwpk9maTTzdGxxFOHe0wTE2Z/v9VlU2j5XB7ltP82 mUWjn2LAfxGCAVTeD2WlOa6dSEyJoxA74OaD9bDaLB56HFwfAKzMq6dgZLPGxXvH VUZ0PJCBDkqOWZ1UsEixUkw7mO6r2jS3U81J2i/rlb4MVxH2lkwEeVyZ1eXkvm/q R+5RS+8iJq612BGqQ7t4vwt+tN3PdB0lqYljseI0gcSINTjiAg0PE8nVKoIV8IrE QzJW5FMdHay2z32bll0eZOl0c8RW5BZKUm2SOdPhTQ4/YrnerbUdZbldUv5dCamc tKQM2S9FdqXPjmqanqqwEaHrYcbrPx78ZrQSnUZ/MhaJvnFFr5Eh2f2Tv7QCkUL/ SR/tixVo3R+OrJvdggWcRGkWZBdWX0EPSk8ED2VQhpOX7EW/XcIc3M/E2DrmeAXQ xVVVqV7+qzohu+VyFPcLAgMBAAGjgcIwgb8wHQYDVR0OBBYEFCkgy/HDD9oGjhOT h/5fYBopu/O2MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUKSDL8cMP2gaO E5OH/l9gGim787YwEQYDVR0gBAowCDAGBgRVHSAAMEkGA1UdHwRCMEAwPqA8oDqG OGh0dHA6Ly9jcmwuc2d0cnVzdHNlcnZpY2VzLmNvbS9yYWNpbmUtR3JvdXBlU0cv TGF0ZXN0Q1JMMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEATEZn 4ERQ9cW2urJRCiUTHbfHiC4fuStkoMuTiFJZqmD1zClSF/8E5ze0MRFGfisebKeL PEeaXvSqXZA7RT2fSsmKe47A7j55i5KjyJRKuCgRa6YlX129x8j7g09VMeZc8BN8 471/Kiw3N5RJr4QfFCeiWBCPCjk3GhIgQY8Z9qkfGe2yNLKtfTNEi18KB0PydkVF La3kjQ4A/QQIqudr+xe9sAhWDjUqcvCz5006Tw3c82ASszhkjNv54SaNL+9O6CRH PjY0imkPKGuLh8a9hSb50+tpIVZgkdb34GLCqHGuLt5mI7VSRqakSDcsfwEWVxH3 Jw0O5Q/WkEXhHj8h3NL8FhgTPk1qsiZqQF4leP049KxYejcbmEAEx47J1MRnYbGY rvDNDty5r2WDewoEij9hqvddQYbmxkzCTzpcVuooO6dEz8hKZPVyYC3jQ7hK4HU8 MuSqFtcRucFF2ZtmY2blIrc07rrVdC8lZPOBVMt33lfUk+OsBzE6PlwDg1dTx/D+ aNglUE0SyObhlY1nqzyTPxcCujjXnvcwpT09RAEzGpqfjtCf8e4wiHPvriQZupdz FcHscQyEZLV77LxpPqRtCRY2yko5isune8YdfucziMm+MG2chZUh6Uc7Bn6B4upG 5nBYgOao8p0LadEziVkw82TTC/bOKwn7fRB2LhA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGOTCCBCGgAwIBAgIBAzANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyA0IFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgNCBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvc7UiaoKOf4BGO2ciS dTpVwVEiygt6pDUNxeZXLYPwKm8iODcxbXyFJKIGL0OCPUUwQCUc7lhHQebwngAe +PQvEbuSsphFLdMfgMl2FBPDzEDmres5YPzPyN8q/YwSUe/PDGTGV+gjUV3nZlLq Zr2Tf516KPEZcG6EnzBHt7A5axMs60tNLq8/v/0CE0o55z4zxRCRUb4PR51NUvws 8+MTogCC4RQMzdKes/Lggdq+mZJT432Zd0Ph4UgpgZ7WBVc6cdw+mK1YcG9Gu34y A+KDm1lX9/izzVQW7LatoRwaktHUKZ6PzbPofVDxwoKsur20dVag9UVdGH0sjPF7 QcyGsZqESwoqXZuW4c36qxYnQeeVNabLiqeW86XMUfktfR5D+9xttbk4vQX7WPou 0+xeZC2vWAFKfCJG00HLPeSWXklDOLuJ6/ScaTkSA1yEu+WMHurgZrvAv4z+ngpN PWg/QHbWMqnqRbhqB1KOzVHxXShjDNNZOPzJ/YLJRSC85ujMogzLe2Q5SUZF9XMc apcg6yFC97QgUrdK/XW8yw8MZxFXH/cw8auQzF08lgVi08pVAUtGxYCHHHLQc1Qh 6tejnNOuf9RT2Sj8V97lP1JKu8gmJEdTHHO6z8a0MM1eccdWvEk4JebFEAl42dQd XM1u7duRXKFTFFaqjSeppo4bAgMBAAGjIDAeMA4GA1UdDwEB/wQEAwIBBjAMBgNV HRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQDRC4XUXqaW8JCJEtkBUwjs8u1D OIk+wSlk7lP/T/6LCg6S9P19KIDOAQn8MdFrWwOJsi5gA+XvY5aRKV/gP5j4aJhV Y8pPa1NyurNiGhmewRMdmYL6q6ozgLn3Gvx5TALQ4YOLLHgsL1wwYTJPu9E+lV9g dlXRDm95Tg/6uHWxH/4Ni1uLKELKTUVjUgJJzJ0PX7s5P2GbsVx0Q5pWw5l/n8RN wNOP18tUl/X7SH4ngv66Y+3ob+OE62k+CPLKJKo0jmJAhw3HUdQBddxmev/pujJv T49yNWwJ7Vt4sKlI+nyRQbKsjjE3JZUMRaVVShlRjFWTCRXJ9EABnLV1fKoB/rJp TRiaAut0APt7aPTww3+mnfTs6EK664N5l/yjdhlbcX8mZ+lPKnuzy37z/PWnv/s/ l3V10MvreOMDa46C0BFh+zc9p5gLHv47XtnPAKUX6ez9DLXduMa8vfPSMO6FDoX5 UjNIhufGr7+/DNBLcED1XshVP1AcTwfZZ3YUfWYNwrXKooI0A4b40lq2EpYmeXoX bZipzPngyxpF//PADMVi/hPGLb9qF+pjDX4+JH5iOU4nORtBS8OxYHFR+gmCrA+N 19fv3h2rK1G9+ADz1IHHDZgcPewvowbI9VUApz/9l6uwtUqZWQiIkGwcsP/o7XyZ Py1q7W19ZhLu2OAx7A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGOTCCBCGgAwIBAgIBAjANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyAzIFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgMyBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMp4Gge89vu0t4m80BlW OCpZnQfqGvn4+GhnXo/vyvf1aonmo5V/qdspJBw10DiWbD5WJP9eYlGQLofonMfa vDPxnqFvC44KJPT4TZCmss1eEdPCl0z1X0AdJiRNjQkQC/+7IBuTJhkMQz/pjrwx NxBukcpIglZGx7y5Op5GgWbP2ehcEM85nmXDnsVa9EvMRJlmhvRyG6NTSequR80y DXDmoKB2B53/WO/kPJHAteTcuAEM0/6zQqA7YQLUN1vXTEWV0nVd9W4wX1dRi7L/ fsiLnKqjQTcMEJGopoVcucePBVGy0HjS4ktJ6dQapzusqjPmmioDQJhvdFITMZTR EsG0yzD5/0S4kltS1jDZM9F14xmlFhW3VFfxVlDOTr4DOy/stjDuFGBeX3o19E5k BxHqpQdmG26T4rBPXtbgROCz3K7vuP2os+zs5TmIRLShuxRgZI/WkpPL88xQ3ekH yGdn+fCHhJGyAGLpv0oVdMW/BEwFRl0Ky+XqYQDhb0GxNI6mAKJ8pqWm+mxMQ+Wo Jpo0mB6HmOdMeNGPnwVVXYpLyc+gC30GkJwYkrLEstfjRdlrc8OXOb8pHgYJVUC6 vNpIdUPt/kR+PSzmYpED/T2J7370XSSPpQsrsz56KSi8uz+/63eFBCaLlLKQ9euN T6JEIlConCpESAB4GaudCJYVAgMBAAGjIDAeMA4GA1UdDwEB/wQEAwIBBjAMBgNV HRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQBFyZ6kYCWJ/peZuMLxrtc1EzZ5 0N2CIlHxTPZjNqENWyDyG4XWdo95D6urosg3uClj7OWetniWpv+KEJ4Ubhpt316b uSyFcgnyNxqbebMo7+WW3Uabh4yN+EGMFOGtRV/LpyQMwTe6LALEq4w1OAnpkPFm cqWRQnkJChROmnSWRaEvIKSXdCrLAbPLVireLFhfF2diviu6ERMtEEBFYfDDxe+P GdA6wmUK2WjonAYgN7qfSxY5YHjgdWJVwNnLNyEJEJA5z1yZ7N+s1lpHQSOruKch B5IUrIzaiiQW6xSISLzvgc6OFt890lpvn8BBcSWJBizmvE/tpJHzxu1U3dmTAyKq hAeoc9unWolN9u1ygOuDeESpIiRomLE/qUHy7OkEpCIzX+Z13L2eJfXjZGUewfNX Jy7JwDJ4RNvYOBN24R1/4BeHmn9NSwduuFc4hbnpU06XOg0fU7mBckVG88h+pgnu GDR1fofn6CDu3BbU5seEqtpvX5zM61gGQZOM5cxZDGhlOTwpFmHxaftHucLYZ4Ek C/T8SWIArwej/56gDsMBiyFn1jsbPOCht23cVUvj0C6d1p7KbrqzuvBgfn8FUONB 8b5AOpBX4C1pbAvBvHrBjvsJ3uqVfzmbw2OfSfV4r35JgqyfbowSGlC2wOPchILY 69K7Dl02hgJJKwU7Vw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEOTCCAyGgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyAyIFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgMiBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALd8aXxg9Wwm9KocF39d 1BFL5/Pa53On+qRCSWg/2qVAXAZoX07Mvb6BOCQtzCagRG0DyyPgu96FU0uUX197 qsgal/7XI5PtsGq92PwAPrOSBOBLvk87mKed7c1j8IHnbJjUbGBVAOW5POY0lV3g /XGH6f+B7uV3bxj/88l8pZXdgtwU2aLhvs0nc7tFWz90sWJ4ZhAiLPVo8xeIFjua Gx37FK4NuvKQVaLVMNYrlTLHOW57ZdJ3OM5uVqXZI6s4sjtRhcAdG7cRLwVpR9gC ypKo4TPehQib7ZDV2CGZcb+29XPvZwiYZNLyKnpLIRbhH1hh3pFHHyGfH/6MI4aD GCcCAwEAAaMgMB4wDgYDVR0PAQH/BAQDAgEGMAwGA1UdEwQFMAMBAf8wDQYJKoZI hvcNAQEFBQADggEBACPByWyDecjPhX88XrtWrP9gR1GnnErxh8RNh9/mTA3kM+l+ CFMQoutCPq9I8ccdFZd0dhy9dCJD6FlZPg3Kccbnl6h+91uf3nToG1FCSWPAo+iU j9aets0F1s6g6rGHsLsuCrroXTs8AP9vFl1lZFBQNf8XuHYYx/FrXw3Z6OoTI2F/ Yc5rSQeBMFIh8qHBmO/GQvMv4w5oaUXzkdFkUabaSnmaJFvDTLGHEcfh91z4Il43 1nZHe79pn1XVMCUsSqtMhOQlWqTSYcah4JBzLH+pvjac/m4hV0WRQaoCbVO4MLvc wucgMw5Ve/tCkwcaSF4t/kS3H2S+G8NNnerWMmA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGWDCCBECgAwIBAgIBAjANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyAzIFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgMyBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMp4Gge89vu0t4m80BlW OCpZnQfqGvn4+GhnXo/vyvf1aonmo5V/qdspJBw10DiWbD5WJP9eYlGQLofonMfa vDPxnqFvC44KJPT4TZCmss1eEdPCl0z1X0AdJiRNjQkQC/+7IBuTJhkMQz/pjrwx NxBukcpIglZGx7y5Op5GgWbP2ehcEM85nmXDnsVa9EvMRJlmhvRyG6NTSequR80y DXDmoKB2B53/WO/kPJHAteTcuAEM0/6zQqA7YQLUN1vXTEWV0nVd9W4wX1dRi7L/ fsiLnKqjQTcMEJGopoVcucePBVGy0HjS4ktJ6dQapzusqjPmmioDQJhvdFITMZTR EsG0yzD5/0S4kltS1jDZM9F14xmlFhW3VFfxVlDOTr4DOy/stjDuFGBeX3o19E5k BxHqpQdmG26T4rBPXtbgROCz3K7vuP2os+zs5TmIRLShuxRgZI/WkpPL88xQ3ekH yGdn+fCHhJGyAGLpv0oVdMW/BEwFRl0Ky+XqYQDhb0GxNI6mAKJ8pqWm+mxMQ+Wo Jpo0mB6HmOdMeNGPnwVVXYpLyc+gC30GkJwYkrLEstfjRdlrc8OXOb8pHgYJVUC6 vNpIdUPt/kR+PSzmYpED/T2J7370XSSPpQsrsz56KSi8uz+/63eFBCaLlLKQ9euN T6JEIlConCpESAB4GaudCJYVAgMBAAGjPzA9MA4GA1UdDwEB/wQEAwIBBjAMBgNV HRMEBTADAQH/MB0GA1UdDgQWBBRhs3lSnUqVklGOgiRw045AyMVm0DANBgkqhkiG 9w0BAQUFAAOCAgEAf8azJIRQN/nEsMUwPBbpUA16urQ70iPl6Yl4auXjGwUekRzO BpeNZhYHRO+BuQh+o8c5NLi/mm2NsMEgQi4N9wsGA09uy7y3sC8ZcY2OrwpNWDGL RJkqKGaFx4AmZrBHwjmy+k8+Vb3ciSdLczME/ntHkMkFwC0z+LcIgilBQ/0mU+b6 HzdWjU8Xutj9OoRw2D7wM67EBUhUobnVIT/qPsepMUf3m65KYpjRZyBl3nnhsTIe a9/7gGtHXDnHDgiqx6PuKek04pv5dbgm64idtDkRLnD9UQQyuw95hFAhRXwv5Nn/ JTgGI6tOsQ7cOzEKrdpLAGlrLuLDDMkFAUVm4aWJYRxkmY0LmJCzfmY7C9ir6HUO 2X+abn3JgyfJvOg0OMJahzJyBwz+1ZTR8MB48oCoRvVrmuzi2RaOivqE9tFSyZyy IVZgQ6YQ939Jv74H01BkbQK6KlUsz9nCbq98C0jQ8eGnwq10j4bk7ar6XIN9Quh9 Bx0HVcwraTK5d4JoxnfyImmmyQpdh5nlcZ59LxMe0vT9CXknWCsKh4Eq+2ojLUsk hXQWRxgPCcX+qUgk46zQaT1fU5gyvezgUcFTSrH2O/A0SPWa3tzR4OO9JbNE6Dpz yXnQrNHt4gAKX6EdZllKc2jUBXIzOKdrr5HbDceMQOiekIjJ+/4k14Gs894= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGWDCCBECgAwIBAgIBAzANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyA0IFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgNCBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvc7UiaoKOf4BGO2ciS dTpVwVEiygt6pDUNxeZXLYPwKm8iODcxbXyFJKIGL0OCPUUwQCUc7lhHQebwngAe +PQvEbuSsphFLdMfgMl2FBPDzEDmres5YPzPyN8q/YwSUe/PDGTGV+gjUV3nZlLq Zr2Tf516KPEZcG6EnzBHt7A5axMs60tNLq8/v/0CE0o55z4zxRCRUb4PR51NUvws 8+MTogCC4RQMzdKes/Lggdq+mZJT432Zd0Ph4UgpgZ7WBVc6cdw+mK1YcG9Gu34y A+KDm1lX9/izzVQW7LatoRwaktHUKZ6PzbPofVDxwoKsur20dVag9UVdGH0sjPF7 QcyGsZqESwoqXZuW4c36qxYnQeeVNabLiqeW86XMUfktfR5D+9xttbk4vQX7WPou 0+xeZC2vWAFKfCJG00HLPeSWXklDOLuJ6/ScaTkSA1yEu+WMHurgZrvAv4z+ngpN PWg/QHbWMqnqRbhqB1KOzVHxXShjDNNZOPzJ/YLJRSC85ujMogzLe2Q5SUZF9XMc apcg6yFC97QgUrdK/XW8yw8MZxFXH/cw8auQzF08lgVi08pVAUtGxYCHHHLQc1Qh 6tejnNOuf9RT2Sj8V97lP1JKu8gmJEdTHHO6z8a0MM1eccdWvEk4JebFEAl42dQd XM1u7duRXKFTFFaqjSeppo4bAgMBAAGjPzA9MA4GA1UdDwEB/wQEAwIBBjAMBgNV HRMEBTADAQH/MB0GA1UdDgQWBBQWhC37G+e0HmiY00IgGm5+T5FXAjANBgkqhkiG 9w0BAQUFAAOCAgEAe+MNYzpkIG3M/Cy46dar29erJilHogxW7XXMlZlSNssg+xE0 F0JOdQWw2OS4sIQvmBm5+9A5bHIGGMlcinp0CDdIaf0ioV3F13gT8ChCQcPJwzkJ B9Sh+DciaeTfMlVvwny5k/GyN3XMrtIzlow29wHt42TpC2hbEKoBNpl8z5qUXf0a WWGiZRV9nhdk1J9TmAH9cVfQXUARFj8/RNKvyfwIMn12+NVD6Nw2aAfDTsOWl1fG fTZe23Ct/q7UiJ21pGDWo2K+fPk0Hvy79EpyxYMeRmjDDpeDGD3TDgoRNXxplcWr KvXIORBNDIkwKYlJG0SXkfTqZSEbPwpDcoIcbRFd4CJFX2FMoqb636NGuuGBYGwy tPzk3DYF5DP36493SaqNCu9IiuZBl347q0OH8ghgC2/XWWb9K7svzjNPcuC217NT V4nwO7xu4hC/cz5ij6UI6VNnwU7BLkJDp7Kk+RaLQu7cNH9Is5DbJOLI5FM1U5zq N4XPv5gGNUcm165t3YSpY1gmQfV1Mi5hnk+TUlL2WiPrwaBzJiUiQpGRkYBP/4jO XnPnlsLtCRL3dpapeWKQSYGDnwwyMuJbyt1INKyHjnGVrkzkfHgdp1HDvRH6AtGV iXMIRiKJaQDPT4DBTVuUxMqZUZgvDb19VGTUCtonWac3u1YM0AaicrkSuVs= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEWDCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBzjELMAkGA1UEBhMCWkEx FTATBgNVBAgTDFdlc3Rlcm4gQ2FwZTEWMBQGA1UEBxMNU29tZXJzZXQgV2VzdDEq MCgGA1UEChMhU291dGggQWZyaWNhbiBQb3N0IE9mZmljZSBMaW1pdGVkMRowGAYD VQQLExFTQVBPIFRydXN0IENlbnRyZTEdMBsGA1UEAxMUU0FQTyBDbGFzcyAyIFJv b3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraWFkbWluQHRydXN0Y2VudHJlLmNvLnph MB4XDTEwMDkxNTAwMDAwMFoXDTMwMDkxNDAwMDAwMFowgc4xCzAJBgNVBAYTAlpB MRUwEwYDVQQIEwxXZXN0ZXJuIENhcGUxFjAUBgNVBAcTDVNvbWVyc2V0IFdlc3Qx KjAoBgNVBAoTIVNvdXRoIEFmcmljYW4gUG9zdCBPZmZpY2UgTGltaXRlZDEaMBgG A1UECxMRU0FQTyBUcnVzdCBDZW50cmUxHTAbBgNVBAMTFFNBUE8gQ2xhc3MgMiBS b290IENBMSkwJwYJKoZIhvcNAQkBFhpwa2lhZG1pbkB0cnVzdGNlbnRyZS5jby56 YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALd8aXxg9Wwm9KocF39d 1BFL5/Pa53On+qRCSWg/2qVAXAZoX07Mvb6BOCQtzCagRG0DyyPgu96FU0uUX197 qsgal/7XI5PtsGq92PwAPrOSBOBLvk87mKed7c1j8IHnbJjUbGBVAOW5POY0lV3g /XGH6f+B7uV3bxj/88l8pZXdgtwU2aLhvs0nc7tFWz90sWJ4ZhAiLPVo8xeIFjua Gx37FK4NuvKQVaLVMNYrlTLHOW57ZdJ3OM5uVqXZI6s4sjtRhcAdG7cRLwVpR9gC ypKo4TPehQib7ZDV2CGZcb+29XPvZwiYZNLyKnpLIRbhH1hh3pFHHyGfH/6MI4aD GCcCAwEAAaM/MD0wDgYDVR0PAQH/BAQDAgEGMAwGA1UdEwQFMAMBAf8wHQYDVR0O BBYEFKudI5P9HzNKMi2qJFryLWSpAZpBMA0GCSqGSIb3DQEBBQUAA4IBAQBWUlG5 DwLh9i6csTFapvjOvO4ChBUJ8ShSX+fhLL3beQp6v+tintWGRynudDDsTHW1HuOq M++t4WpMvzcBvlWDTKlS2DeYUG9o3UdBtywwyG5MByzG00m5tVzSy8zUNsYHDRhP P2MAxOy2iPsBZGOt0fd3fGRUKxI9NBWF8KC6eSlfmJtC6q7BqJ8TiYpt6bg4yWHt YOz3KlgFm6FgeIMX4X5f6P144GtWKoZ2rlvCXutF5DC4Me1ksV0uwD2ADccnE9N2 4ob73NuACoHh/Qj5C8QxtGNb54wz5Qa2Umqz1+lr4zJ4MmaUTt2Nd23TJChbVGF3 Amd1lEtXS+ZsxTlv -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDNzCCAh+gAwIBAgICJxwwDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCR1Ix HTAbBgNVBAoTFEF0aGVucyBFeGNoYW5nZSBTLkEuMRYwFAYDVQQDEw1BVEhFWCBS b290IENBMB4XDTEwMTAxODE1NTYwM1oXDTMwMTAxNzIxMDAwMFowRDELMAkGA1UE BhMCR1IxHTAbBgNVBAoTFEF0aGVucyBFeGNoYW5nZSBTLkEuMRYwFAYDVQQDEw1B VEhFWCBSb290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzRo9 MLWzOLK/eruuodbXhfAiOqSJacThYgTJcNM8MxLi5jjld6QkRGQNt65MWt3hGAY+ 7ZtaBfXh3hLtNircR9mRUZntsb9qc6EKCCSoio0cC1nTv3AjVUSgjDDFzm1PsOy+ 84wx3wpa3NNXXAWgM5U7l49UC7j1a33Hxay1eY4GOPGoKVU9mjbQJ180ahJ4FyjZ mEns2VpS2iY6+u5MpiaOqD5VH7If4bWb+To19u2RHP0LECT9H/nT4wAlsQslwLd9 mjwHOoAL1qj+kUXowdLFIm/T5XEftiw2tFig7c1KaORqV/ShdezXAJnV9plc607J u9cao0VZAA+MO9t0NQIDAQABozMwMTAPBgNVHRMBAf8EBTADAQH/MBEGA1UdDgQK BAhD4oDou9K3wTALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAD8BY3UH Mitfdf92jtOpuG/MUD2EV08og+h8o26ivPfCuq46q07QD5IouN1bLNvl1h86k+GR DteqXwFhLD5hT96VFU3MPeoy4qP++Bap8rwp/CmefXKlXaFrAtVfSPSgO8sYRvA9 F1WD0ClhkbuaQUnRE75BlPI+wySrn8drQpBCeX5aUfs8XgshH8vZSBMVsWp/A8TR ulHScImqCEqHHPZ6mLHUUQVVxpAXb8PgBMB69C8YolZCcy62spvROb4JwgJKJBf5 96y9cQe/leKX5aGECI2y4kSh3IkwO6gMBXpddgBPHm9xfys52kVCOTHSqTJA1Dhj E5Y3mkld2cf9uEw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg Q2xhc3MgMyBSb290IENBMB4XDTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFow TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw HgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB BQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRHsJ8Y ZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3E N3coTRiR5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9 tznDDgFHmV0ST9tD+leh7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX 0DJq1l1sDPGzbjniazEuOQAnFN44wOwZZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c /3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH2xc519woe2v1n/MuwU8X KhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV/afmiSTY zIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvS O1UQRwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D 34xFMFbG02SrZvPAXpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgP K9Dx2hzLabjKSWJtyNBjYt1gD1iqj6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3 AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEe4zf/lb+74suwv Tg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAACAj QTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXS IGrs/CIBKM+GuIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2 HJLw5QY33KbmkJs4j1xrG0aGQ0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsa O5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8ZORK15FTAaggiG6cX0S5y2CBNOxv 033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2KSb12tjE8nVhz36u dmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz6MkE kbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg41 3OEMXbugUZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvD u79leNKGef9JOxqDDPDeeOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq 4/g7u9xN12TyUb7mqqta6THuBrxzvxNiCp/HuZc= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEd MBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3Mg Q2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1ow TjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAw HgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEB BQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr 6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPV L4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC91 1K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHx MlAQTn/0hpPshNOOvEu/XAFOBz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZ QmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu8lkB arcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clr Us3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLi FRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRS P/TizPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN 9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxP AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1Rpzz uvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h 9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3t OluwlN5E40EIosHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo +fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7 KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2 DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0Us H8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK7 5t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h 3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPz Y11aWOIv4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEAzCCAuugAwIBAgIQVID5oHPtPwBMyonY43HmSjANBgkqhkiG9w0BAQUFADB1 MQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1 czEoMCYGA1UEAwwfRUUgQ2VydGlmaWNhdGlvbiBDZW50cmUgUm9vdCBDQTEYMBYG CSqGSIb3DQEJARYJcGtpQHNrLmVlMCIYDzIwMTAxMDMwMTAxMDMwWhgPMjAzMDEy MTcyMzU5NTlaMHUxCzAJBgNVBAYTAkVFMSIwIAYDVQQKDBlBUyBTZXJ0aWZpdHNl ZXJpbWlza2Vza3VzMSgwJgYDVQQDDB9FRSBDZXJ0aWZpY2F0aW9uIENlbnRyZSBS b290IENBMRgwFgYJKoZIhvcNAQkBFglwa2lAc2suZWUwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDIIMDs4MVLqwd4lfNE7vsLDP90jmG7sWLqI9iroWUy euuOF0+W2Ap7kaJjbMeMTC55v6kF/GlclY1i+blw7cNRfdCT5mzrMEvhvH2/UpvO bntl8jixwKIy72KyaOBhU8E2lf/slLo2rpwcpzIP5Xy0xm90/XsY6KxX7QYgSzIw WFv9zajmofxwvI6Sc9uXp3whrj3B9UiHbCe9nyV0gVWw93X2PaRka9ZP585ArQ/d MtO8ihJTmMmJ+xAdTX7Nfh9WDSFwhfYggx/2uh8Ej+p3iDXE/+pOoYtNP2MbRMNE 1CV2yreN1x5KZmTNXMWcg+HCCIia7E6j8T4cLNlsHaFLAgMBAAGjgYowgYcwDwYD VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBLyWj7qVhy/ zQas8fElyalL1BSZMEUGA1UdJQQ+MDwGCCsGAQUFBwMCBggrBgEFBQcDAQYIKwYB BQUHAwMGCCsGAQUFBwMEBggrBgEFBQcDCAYIKwYBBQUHAwkwDQYJKoZIhvcNAQEF BQADggEBAHv25MANqhlHt01Xo/6tu7Fq1Q+e2+RjxY6hUFaTlrg4wCQiZrxTFGGV v9DHKpY5P30osxBAIWrEr7BSdxjhlthWXePdNl4dp1BUoMUq5KqMlIpPnTX/dqQG E5Gion0ARD9V04I8GtVbvFZMIi5GQ4okQC3zErg7cBqklrkar4dBGmoYDQZPxz5u uSlNDUmJEYcyW+ZLBMjkXOZ0c5RdFpgTlf7727FE5TpwrDdr5rMzcijJs1eg9gIW iAYLtqZLICjU3j2LrTcFU3T+bsy8QxdxXvnFzBqpYe73dgzzcvRyrc9yAjYHR8/v GVCJYMzpJJUPwssd8m92kMfMdcGWxZ0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEYDCCA0igAwIBAgICATAwDQYJKoZIhvcNAQELBQAwWTELMAkGA1UEBhMCVVMx GDAWBgNVBAoTD1UuUy4gR292ZXJubWVudDENMAsGA1UECxMERlBLSTEhMB8GA1UE AxMYRmVkZXJhbCBDb21tb24gUG9saWN5IENBMB4XDTEwMTIwMTE2NDUyN1oXDTMw MTIwMTE2NDUyN1owWTELMAkGA1UEBhMCVVMxGDAWBgNVBAoTD1UuUy4gR292ZXJu bWVudDENMAsGA1UECxMERlBLSTEhMB8GA1UEAxMYRmVkZXJhbCBDb21tb24gUG9s aWN5IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2HX7NRY0WkG/ Wq9cMAQUHK14RLXqJup1YcfNNnn4fNi9KVFmWSHjeavUeL6wLbCh1bI1FiPQzB6+ Duir3MPJ1hLXp3JoGDG4FyKyPn66CG3G/dFYLGmgA/Aqo/Y/ISU937cyxY4nsyOl 4FKzXZbpsLjFxZ+7xaBugkC7xScFNknWJidpDDSPzyd6KgqjQV+NHQOGgxXgVcHF mCye7Bpy3EjBPvmE0oSCwRvDdDa3ucc2Mnr4MrbQNq4iGDGMUHMhnv6DOzCIJOPp wX7e7ZjHH5IQip9bYi+dpLzVhW86/clTpyBLqtsgqyFOHQ1O5piF5asRR12dP8Qj wOMUBm7+nQIDAQABo4IBMDCCASwwDwYDVR0TAQH/BAUwAwEB/zCB6QYIKwYBBQUH AQsEgdwwgdkwPwYIKwYBBQUHMAWGM2h0dHA6Ly9odHRwLmZwa2kuZ292L2ZjcGNh L2NhQ2VydHNJc3N1ZWRCeWZjcGNhLnA3YzCBlQYIKwYBBQUHMAWGgYhsZGFwOi8v bGRhcC5mcGtpLmdvdi9jbj1GZWRlcmFsJTIwQ29tbW9uJTIwUG9saWN5JTIwQ0Es b3U9RlBLSSxvPVUuUy4lMjBHb3Zlcm5tZW50LGM9VVM/Y0FDZXJ0aWZpY2F0ZTti aW5hcnksY3Jvc3NDZXJ0aWZpY2F0ZVBhaXI7YmluYXJ5MA4GA1UdDwEB/wQEAwIB BjAdBgNVHQ4EFgQUrQx6dVzl85jEeZgOrCj9l/TnAvwwDQYJKoZIhvcNAQELBQAD ggEBAI9z2uF/gLGH9uwsz9GEYx728Yi3mvIRte9UrYpuGDco71wb5O9Qt2wmGCMi TR0mRyDpCZzicGJxqxHPkYnos/UqoEfAFMtOQsHdDA4b8Idb7OV316rgVNdF9IU+ 7LQd3nyKf1tNnJaK0KIyn9psMQz4pO9+c+iR3Ah6cFqgr2KBWfgAdKLI3VTKQVZH venAT+0g3eOlCd+uKML80cgX2BLHb94u6b2akfI8WpQukSKAiaGMWMyDeiYZdQKl Dn0KJnNR6obLB6jI/WNaNZvSr79PMUjBhHDbNXuaGQ/lj/RqDG8z2esccKIN47lQ A2EC/0rskqTcLe4qNJMHtyznGI8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIJmDCCB4CgAwIBAgIBCjANBgkqhkiG9w0BAQwFADCCAR4xPjA8BgNVBAMTNUF1 dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIFJhaXogZGVsIEVzdGFkbyBWZW5lem9s YW5vMQswCQYDVQQGEwJWRTEQMA4GA1UEBxMHQ2FyYWNhczEZMBcGA1UECBMQRGlz dHJpdG8gQ2FwaXRhbDE2MDQGA1UEChMtU2lzdGVtYSBOYWNpb25hbCBkZSBDZXJ0 aWZpY2FjaW9uIEVsZWN0cm9uaWNhMUMwQQYDVQQLEzpTdXBlcmludGVuZGVuY2lh IGRlIFNlcnZpY2lvcyBkZSBDZXJ0aWZpY2FjaW9uIEVsZWN0cm9uaWNhMSUwIwYJ KoZIhvcNAQkBFhZhY3JhaXpAc3VzY2VydGUuZ29iLnZlMB4XDTEwMTIyODE2NDEz NloXDTMwMTIyMzIzNTk1OVowggEeMT4wPAYDVQQDEzVBdXRvcmlkYWQgZGUgQ2Vy dGlmaWNhY2lvbiBSYWl6IGRlbCBFc3RhZG8gVmVuZXpvbGFubzELMAkGA1UEBhMC VkUxEDAOBgNVBAcTB0NhcmFjYXMxGTAXBgNVBAgTEERpc3RyaXRvIENhcGl0YWwx NjA0BgNVBAoTLVNpc3RlbWEgTmFjaW9uYWwgZGUgQ2VydGlmaWNhY2lvbiBFbGVj dHJvbmljYTFDMEEGA1UECxM6U3VwZXJpbnRlbmRlbmNpYSBkZSBTZXJ2aWNpb3Mg ZGUgQ2VydGlmaWNhY2lvbiBFbGVjdHJvbmljYTElMCMGCSqGSIb3DQEJARYWYWNy YWl6QHN1c2NlcnRlLmdvYi52ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC ggIBAME77xNS8ZlW47RsBeEaaRZhJoZ4rw785UAFCuPZOAVMqNS1wMYqzy95q6Gk UO81ER/ugiQX/KMcq/4HBn83fwdYWxPZfwBfK7BP2p/JsFgzYeFP0BXOLmvoJIzl Jb6FW+1MPwGBjuaZGFImWZsSmGUclb51mRYMZETh9/J5CLThR1exStxHQptwSzra zNFpkQY/zmj7+YZNA9yDoroVFv6sybYOZ7OxNDo7zkSLo45I7gMwtxqWZ8VkJZkC 8+p0dX6mkhUT0QAV64Zc9HsZiH/oLhEkXjhrgZ28cF73MXIqLx1fyM4kPH1yOJi/ R72nMwL7D+Sd6mZgI035TxuHXc2/uOwXfKrrTjaJDz8Jp6DdessOkxIgkKXRjP+F K3ze3n4NUIRGhGRtyvEjK95/2g02t6PeYiYVGur6ruS49n0RAaSS0/LJb6XzaAAe 0mmO2evnEqxIKwy2mZRNPfAVW1l3wCnWiUwryBU6OsbFcFFrQm+00wOicXvOTHBM aiCVAVZTb9RSLyi+LJ1llzJZO3pq3IRiiBj38Nooo+2ZNbMEciSgmig7YXaUcmud SVQvLSL+Yw+SqawyezwZuASbp7d/0rutQ59d81zlbMt3J7yB567rT2IqIydQ8qBW k+fmXzghX+/FidYsh/aK+zZ7Wy68kKHuzEw1Vqkat5DGs+VzAgMBAAGjggLbMIIC 1zASBgNVHRMBAf8ECDAGAQH/AgECMDcGA1UdEgQwMC6CD3N1c2NlcnRlLmdvYi52 ZaAbBgVghl4CAqASDBBSSUYtRy0yMDAwNDAzNi0wMB0GA1UdDgQWBBStuyIdxuDS Aaj9dlBSk+2YwU2u0zCCAVAGA1UdIwSCAUcwggFDgBStuyIdxuDSAaj9dlBSk+2Y wU2u06GCASakggEiMIIBHjE+MDwGA1UEAxM1QXV0b3JpZGFkIGRlIENlcnRpZmlj YWNpb24gUmFpeiBkZWwgRXN0YWRvIFZlbmV6b2xhbm8xCzAJBgNVBAYTAlZFMRAw DgYDVQQHEwdDYXJhY2FzMRkwFwYDVQQIExBEaXN0cml0byBDYXBpdGFsMTYwNAYD VQQKEy1TaXN0ZW1hIE5hY2lvbmFsIGRlIENlcnRpZmljYWNpb24gRWxlY3Ryb25p Y2ExQzBBBgNVBAsTOlN1cGVyaW50ZW5kZW5jaWEgZGUgU2VydmljaW9zIGRlIENl cnRpZmljYWNpb24gRWxlY3Ryb25pY2ExJTAjBgkqhkiG9w0BCQEWFmFjcmFpekBz dXNjZXJ0ZS5nb2IudmWCAQowCwYDVR0PBAQDAgEGMDcGA1UdEQQwMC6CD3N1c2Nl cnRlLmdvYi52ZaAbBgVghl4CAqASDBBSSUYtRy0yMDAwNDAzNi0wMFQGA1UdHwRN MEswJKAioCCGHmh0dHA6Ly93d3cuc3VzY2VydGUuZ29iLnZlL2xjcjAjoCGgH4Yd bGRhcDovL2FjcmFpei5zdXNjZXJ0ZS5nb2IudmUwNwYIKwYBBQUHAQEEKzApMCcG CCsGAQUFBzABhhtodHRwOi8vb2NzcC5zdXNjZXJ0ZS5nb2IudmUwQAYDVR0gBDkw NzA1BgVghl4BAjAsMCoGCCsGAQUFBwIBFh5odHRwOi8vd3d3LnN1c2NlcnRlLmdv Yi52ZS9kcGMwDQYJKoZIhvcNAQEMBQADggIBABxZEOVepFHBR7tlsgtV4i+poye8 4TyKx2wDVqOpKaKbipXYH/e2EmAWvnr0/QOBT/2BgapPgXAeLu/AkhJ7uw+FiMT5 HUG1uiQqwygmE8r5APvXw1z5aOkbwRgiyaJsZMP4OcNOId3Wwt7ltizJXDjw3l5q 5Cf0uDPEy2GSM1OozPydzVP7KAvv7X+wj3QitjVXgKiuBa4pCjuypP0949TBkPY/ zrzkRP7RwX4oL/0AJDIgiMRvGHuRDkiQvJZiYIFtFAAaUbq1XWmNYUccLKxORSCp SEWjh0mjeJDdNkJ/2HZv/W2DAcb5f5ggf5YuImCroifAsDUk0Mm/M5kiUw5uH2JM JvwkM8rBA8ypF2FjMyTMaEDvr6LihcOIMNNFG+5W6lYKDwpHmzBZ2EnRMJAMJyom CChcMh8n160LSeUXUWPP5g07YFEavUMJUOaRtWPmZJeqC5cTAQaGXKUflb5Qjguy 0mR/26tM5kPG5IWNav6N/ruUVR6RUycI07pnPTqhycHFFLr5Q1zFjiGMgqL9KjIl 1RaMFVbAmPwuso4ZpBZxw0vdcf5x7CId8MGMmIGHtL8CuMQwMUfCwLCvezNjCt2s RZvBzICH9NmYXpyG/poE/2ZK/HthVL5XYwUHxqcBdVnkbjk7APSqnfOfiL/P0SUr 339z7RaGqZBlD3Ap -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDkDCCAnigAwIBAgIQHKAtwVI7am2LXB+VSu2sMDANBgkqhkiG9w0BAQUFADBi MQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3Jp dHkwHhcNMTEwMTAxMDAwMDAwWhcNMzAxMjMxMjM1OTU5WjBiMQswCQYDVQQGEwJV UzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydO ZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwz c7MEL7xxjOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPP OCwGJgl6cvf6UDL4wpPTaaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rl mGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXTcrA/vGp97Eh/jcOrqnErU2lBUzS1sLnF BgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc/Qzpf14Dl847ABSHJ3A4 qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMBAAGjQjBA MB0GA1UdDgQWBBQhMMn7ANdOmNqHqirQpy6xQDGnTDAOBgNVHQ8BAf8EBAMCAQYw DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAwomEoOiMZv3/EwUb wzqOmEmK+KoAXCb9cmqjfhIblK5U+CGPp5NP9xbvubmzMsAlITFmNywJsP4ysDfs PLjOjwiqCJAHXHXV4U4sywIk6aJe6fV4NSIGHPIfiLHhXMyWVPpvScyN8VYD7c8s nyfe5cqDRL5GQPlXLtJ/MS3Og9z+cGuE0KOf/5fQqNcC7LEs8O9zOD2ZrMRPAb/V aurGLjIpFwrL5mme0Uq19t+OGfiV6UWpDs1tQVkgnnPGbHEcnNRNMKhzCaAV86BF JsNb/bu52C3XH/UFMBn2rg+OYo/fyE+G2R1hFrPJ8Lv7x/WvASJH7NjazxzzU2a6 UwkB+Q== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID0DCCArigAwIBAgIQIKTEf93f4cdTYwcTiHdgEjANBgkqhkiG9w0BAQUFADCB gTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNV BAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xMTAxMDEwMDAw MDBaFw0zMDEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEbMBkGA1UECBMSR3Jl YXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFDT01P RE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0 aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3 UcEbVASY06m/weaKXTuH+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI 2GqGd0S7WWaXUF601CxwRM/aN5VCaTwwxHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8 Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV4EajcNxo2f8ESIl33rXp +2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA1KGzqSX+ DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5O nKVIrLsm9wIDAQABo0IwQDAdBgNVHQ4EFgQUC1jli8ZMFTekQKkwqSG+RzZaVv8w DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQAD ggEBAC/JxBwHO89hAgCx2SFRdXIDMLDEFh9sAIsQrK/xR9SuEDwMGvjUk2ysEDd8 t6aDZK3N3w6HM503sMZ7OHKx8xoOo/lVem0DZgMXlUrxsXrfViEGQo+x06iF3u6X HWLrp+cxEmbDD6ZLLkGC9/3JG6gbr+48zuOcrigHoSybJMIPIyaDMouGDx8rEkYl Fo92kANr3ryqImhrjKGsKxE5pttwwn1y6TPn/CbxdFqR5p2ErPioBhlG5qfpqjQi pKGfeq23sqSaM4hxAjwu1nqyH6LKwN0vEJT9s4yEIHlG1QXUEOTS22RPuFvuG8Ug R1uUq27UlTMdphVx8fiUylQ5PsE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIINDCCBhygAwIBAgIRAP11BI16YIaTaUyqADxl0z0wDQYJKoZIhvcNAQELBQAw gaYxCzAJBgNVBAYTAkNIMTswOQYDVQQKEzJUaGUgRmVkZXJhbCBBdXRob3JpdGll cyBvZiB0aGUgU3dpc3MgQ29uZmVkZXJhdGlvbjERMA8GA1UECxMIU2VydmljZXMx IjAgBgNVBAsTGUNlcnRpZmljYXRpb24gQXV0aG9yaXRpZXMxIzAhBgNVBAMTGlN3 aXNzIEdvdmVybm1lbnQgUm9vdCBDQSBJMB4XDTExMDIxNTA5MDAwMFoXDTM1MDIx NTA4NTk1OVowgaYxCzAJBgNVBAYTAkNIMTswOQYDVQQKEzJUaGUgRmVkZXJhbCBB dXRob3JpdGllcyBvZiB0aGUgU3dpc3MgQ29uZmVkZXJhdGlvbjERMA8GA1UECxMI U2VydmljZXMxIjAgBgNVBAsTGUNlcnRpZmljYXRpb24gQXV0aG9yaXRpZXMxIzAh BgNVBAMTGlN3aXNzIEdvdmVybm1lbnQgUm9vdCBDQSBJMIICIjANBgkqhkiG9w0B AQEFAAOCAg8AMIICCgKCAgEAyA5y9AEvhnsLwmOwjWjtHz3euYObXKFdug82JxEE rQZUILceoObOvvCZaXIZNWRmMY0svY5CCp/GyqmQLNq8hTAD2TKWlvC+oCINJGzU xn9aTFEkLVRyCHwz6cwox2ZlI2lrlbTrvuOH52PX5PsHrRKS6+fkCkOyqd/HkLwm W5H5o7eHnJS5EI2IxVhcMrwW7A5XT/6nk3iP4MU5uweIYMFUZeuHvp8xl3E8+ovI g2xSluCswO/LaQiVW+Dgu68npMIX8VGfhHZh2CTi/mFtZDVJ6jnEIWK9zOIC/0hr OK9px7mSLYIRjb0LiYUq6re0ss1L69H6qvDgTAk8Td/2MR2GMKhBiFdwLCdR3s+L Tj8C8lClF+BnG3IMQTEfAaKWPjzbAradlOYCTvPwGYKyCCMT65HNUdOqRsJzmJg/ usPumvz6za9yCjcTj/mgULPq+z8svPpjVTX00ry4cdKR6+nKylzsUWaonlkFIi+j GttP4EViIzxdVfswlSs0os+ntEvAM8k0UZ3TsyvfxeosLMffRB+2jbn+81zNNy+w bJxKCL3o9db6cOVpMjdcXwvLP+SIAszKs3gvfb9IsyGwH4h5m1qKcdghhCkPSgQx Kr0NIUTOdJ0m00kd+Iao5RJ3xcBzDFCDapBrocr40JXZNYbHEaM7FMfLhlhWDfuD 9wECAwEAAaOCAlkwggJVMA8GA1UdEwEB/wQFMAMBAf8wgZsGA1UdIASBkzCBkDCB jQYIYIV0AREDAQAwgYAwQwYIKwYBBQUHAgEWN2h0dHA6Ly93d3cucGtpLmFkbWlu LmNoL2Nwcy9DUFNfMl8xNl83NTZfMV8xN18zXzFfMC5wZGYwOQYIKwYBBQUHAgIw LRorVGhpcyBpcyB0aGUgU3dpc3MgR292ZXJubWVudCBSb290IENBIEkgQ1BTLjCB jgYDVR0fBIGGMIGDMIGAoH6gfIZ6bGRhcDovL2FkbWluZGlyLmFkbWluLmNoOjM4 OS9jbj1Td2lzcyUyMEdvdmVybm1lbnQlMjBSb290JTIwQ0ElMjBJLG91PUNlcnRp ZmljYXRpb24lMjBBdXRob3JpdGllcyxvdT1TZXJ2aWNlcyxvPUFkbWluLGM9Q0gw HQYDVR0OBBYEFLUbg7s7T7LS++UDjtRhXdEajrCiMA4GA1UdDwEB/wQEAwIBBjCB 4wYDVR0jBIHbMIHYgBS1G4O7O0+y0vvlA47UYV3RGo6woqGBrKSBqTCBpjELMAkG A1UEBhMCQ0gxOzA5BgNVBAoTMlRoZSBGZWRlcmFsIEF1dGhvcml0aWVzIG9mIHRo ZSBTd2lzcyBDb25mZWRlcmF0aW9uMREwDwYDVQQLEwhTZXJ2aWNlczEiMCAGA1UE CxMZQ2VydGlmaWNhdGlvbiBBdXRob3JpdGllczEjMCEGA1UEAxMaU3dpc3MgR292 ZXJubWVudCBSb290IENBIEmCEQD9dQSNemCGk2lMqgA8ZdM9MA0GCSqGSIb3DQEB CwUAA4ICAQAl2t94sCbcn5nrM5zJRbpcY1KNbgNzqnRIxQ0L0hcMLAvSxiWD1FTN B4FUL2d2Jafp13+WR3ekHZtF//HY9p5HDnSME8TyvtYHKBg8mHXB2+uSiCbmBmSO +dL94pk1gdHYdRe1c+rd6BgilRYZClkqItyGWkNPJWg2qdiTAI9excNhhvDSFAmV UcR+2FLusI2KiHGl1yin9NwGWCVexFUYCJV0fLgB507Y1vZ8IENIDaPg3lTEqF8A SUPTRTuCZW7ui6MBIlaa8c4p5QzEa+3nTvixVYGtcf+E+whX5kfKrYf4Rvj68DWE 7bTYiJcid6SPFsg8Z9HhbgSse482zd6lCKwqjfWnHZ/Hw5EhQqOGgbkq2LHpOB1U CJg5ChHKMg4zzfRM6qhKBukYPkHGz6D24CtrII6nIALrMEGBsOjkrqQYiSvfFPAS KW14+k1E+7I05a/zjjX3w84sCxi00HmPE78Di2a4tWHUrA79eD0JrbXSLE9WQZmI RAx+Z+Nkn/paKlh3UWmxzSyapzQQBXT6bkVjy4tSrUeRohLIoiYExdAiHgOzspI3 VFf9iYN1A20tO7PxpKIQfJyTjaNQhDmLlVlB9gJ2Boq8DpDn2TrrrSZeV1PRb8h1 4KuRe2uhf/kbUKjc/k0G4RWKpBDrHgbPVEgVlii2Ix8a43ylj/o3Vw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIIODCCBiCgAwIBAgIQDp8XmaWxPZzL7Abro/AOaTANBgkqhkiG9w0BAQsFADCB pzELMAkGA1UEBhMCQ0gxOzA5BgNVBAoTMlRoZSBGZWRlcmFsIEF1dGhvcml0aWVz IG9mIHRoZSBTd2lzcyBDb25mZWRlcmF0aW9uMREwDwYDVQQLEwhTZXJ2aWNlczEi MCAGA1UECxMZQ2VydGlmaWNhdGlvbiBBdXRob3JpdGllczEkMCIGA1UEAxMbU3dp c3MgR292ZXJubWVudCBSb290IENBIElJMB4XDTExMDIxNjA5MDAwMFoXDTM1MDIx NjA4NTk1OVowgacxCzAJBgNVBAYTAkNIMTswOQYDVQQKEzJUaGUgRmVkZXJhbCBB dXRob3JpdGllcyBvZiB0aGUgU3dpc3MgQ29uZmVkZXJhdGlvbjERMA8GA1UECxMI U2VydmljZXMxIjAgBgNVBAsTGUNlcnRpZmljYXRpb24gQXV0aG9yaXRpZXMxJDAi BgNVBAMTG1N3aXNzIEdvdmVybm1lbnQgUm9vdCBDQSBJSTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBAKksEu2/wCLphugcN4KDm2gFbxbjiKgBD8txnn9H kEvMJXfI8NdpLpFoVyGysgchM+5MpDclmEy0RjJO1vlri1GK7yw38pjV9dS0t+cA yu/BE16Uq267nL36a4+r+B42Vmk4ZjrQ9DMNADkCqMUcCyG3XCAMYdCtrs6OXtk6 6d7/R3x4Vw4ccfRgHN3bmhgpr9mAo5+FhGMzke+9dO7dA3rI+uCE5tm9Tn76bk92 0V0+qOiHRZB5862u9cJdEU0p94gTydWTcwGr3e39r3f7aU7vj1Icz/UsWmzs/oKb 23w5q3UjfjiQT5SOLWJYnvfncvyUW3JWxZ2jrqu1tsDXdlAAPD9HiJJaYNS/Mhum lEANdnnpPM7ksx3HjPXohjG52CtQSoASidcsUIDmZy+2k5ytrAVSIlMgmQ69l8bh 2nOpHYnyxFnmh+ZWKw6VAhqHxnn+mWrpdOzwEvkUKCCVljovXVe1b/+TvLYoaiyk KHhGYa9BJKTz+gSO8YoZopFz4nePtKf5nP9uUey9H5YT6GORXodob+vYfC4QT1AY kMe3dO8zwIHfM+MakytVBCx80iu3Ywz+rXu9tjqXuT0DI3RzA6YsWQBs1dXo7K9C zNN/cItgYOeyoLaKUkz+CpbLzzqwWAjuHELJhndCbj+0rJAAWEIcQMRuuEXIvDM2 370nAgMBAAGjggJcMIICWDAPBgNVHRMBAf8EBTADAQH/MIGdBgNVHSAEgZUwgZIw gY8GCGCFdAERAxUBMIGCMEQGCCsGAQUFBwIBFjhodHRwOi8vd3d3LnBraS5hZG1p bi5jaC9jcHMvQ1BTXzJfMTZfNzU2XzFfMTdfM18yMV8xLnBkZjA6BggrBgEFBQcC AjAuGixUaGlzIGlzIHRoZSBTd2lzcyBHb3Zlcm5tZW50IFJvb3QgQ0EgSUkgQ1BT LjCBjwYDVR0fBIGHMIGEMIGBoH+gfYZ7bGRhcDovL2FkbWluZGlyLmFkbWluLmNo OjM4OS9jbj1Td2lzcyUyMEdvdmVybm1lbnQlMjBSb290JTIwQ0ElMjBJSSxvdT1D ZXJ0aWZpY2F0aW9uJTIwQXV0aG9yaXRpZXMsb3U9U2VydmljZXMsbz1BZG1pbixj PUNIMB0GA1UdDgQWBBTlhG+JaT12ABd/wau9rl/BfbrhYjAOBgNVHQ8BAf8EBAMC AQYwgeMGA1UdIwSB2zCB2IAU5YRviWk9dgAXf8Grva5fwX264WKhga2kgaowgacx CzAJBgNVBAYTAkNIMTswOQYDVQQKEzJUaGUgRmVkZXJhbCBBdXRob3JpdGllcyBv ZiB0aGUgU3dpc3MgQ29uZmVkZXJhdGlvbjERMA8GA1UECxMIU2VydmljZXMxIjAg BgNVBAsTGUNlcnRpZmljYXRpb24gQXV0aG9yaXRpZXMxJDAiBgNVBAMTG1N3aXNz IEdvdmVybm1lbnQgUm9vdCBDQSBJSYIQDp8XmaWxPZzL7Abro/AOaTANBgkqhkiG 9w0BAQsFAAOCAgEAgzdXdck4UL9BBpZwwtnH17BaAM2jQE/T0vmKh5GyictdpLxv Tz5U9so8s8RMi8c+9NnEYt3HVZ7R+dJE5x5Pz+juKxyoAfAzB/vhOxTTz1CRXtjq QsZ5WIWq+9zbcMqV+fQOYgJwaUQtaE/RcOooUma3cd4l6KGnb7ChJsfXyiBk3MBz PBCiFB70rcE+FJA5NmOIbyjgYKWR92Lkms/StXGeXTv2mSztkToInLSEhUnj4bqm tmiztrZPS1xTCldsoQeS9mKeqPqK1vNrpw+yK2a9r0JHCE/o13yfhg/6WoO+LW8A BLV2hxav3U86lrQ0V7fi/0H/3kIcZsWF68JyH7gcTu4X8mLvCgSsm6uh8u7uokAk HEfeQosYtKlXs088YjIcrWxErbzVHGM4Pckzpvu8KDdERuN6YvqASDXinhuIGUyz Qf3ud+BZgBphHjWkQXqzwY1E6cUhWems00TKdoU2FEYKHhY0psQ0d8OCOEghAv4S bNrX6rDs9s0szPObCmOA0/ULfQQthA3C2Uwrl/HVVPePswrivVg8mfKvORuQ+Tvn t0XnWmp9wZ8UbzBXmBmgB0Pr7tEIhtdJnBIKADsPp0GxSquQs9S9CeeID54kDiv7 YT1VmdNY5LjHffQVTWUOGHlBybvpmsFZGEQ0YtXoOHvKhRiYhnnNfbpH25U= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF7TCCA9WgAwIBAgIQP4vItfyfspZDtWnWbELhRDANBgkqhkiG9w0BAQsFADCB iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMp TWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTEwHhcNMTEw MzIyMjIwNTI4WhcNMzYwMzIyMjIxMzA0WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv c29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlm aWNhdGUgQXV0aG9yaXR5IDIwMTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCygEGqNThNE3IyaCJNuLLx/9VSvGzH9dJKjDbu0cJcfoyKrq8TKG/Ac+M6 ztAlqFo6be+ouFmrEyNozQwph9FvgFyPRH9dkAFSWKxRxV8qh9zc2AodwQO5e7BW 6KPeZGHCnvjzfLnsDbVU/ky2ZU+I8JxImQxCCwl8MVkXeQZ4KI2JOkwDJb5xalwL 54RgpJki49KvhKSn+9GY7Qyp3pSJ4Q6g3MDOmT3qCFK7VnnkH4S6Hri0xElcTzFL h93dBWcmmYDgcRGjuKVB4qRTufcyKYMME782XgSzS0NHL2vikR7TmE/dQgfI6B0S /Jmpaz6SfsjWaTr8ZL22CZ3K/QwLopt3YEsDlKQwaRLWQi3BQUzK3Kr9j1uDRprZ /LHR47PJf0h6zSTwQY9cdNCssBAgBkm3xy0hyFfj0IbzA2j70M5xwYmZSmQBbP3s MJHPQTySx+W6hh1hhMdfgzlirrSSL0fzC/hV66AfWdC7dJse0Hbm8ukG1xDo+mTe acY1logC8Ea4PyeZb8txiSk190gWAjWP1Xl8TQLPX+uKg09FcYj5qQ1OcunCnAfP SRtOBA5jUYxe2ADBVSy2xuDCZU7JNDn1nLPEfuhhbhNfFcRf2X7tHc7uROzLLoax 7Dj2cO2rXBPB2Q8Nx4CyVe0096yb5MPa50c8prWPMd/FS6/r8QIDAQABo1EwTzAL BgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUci06AjGQQ7kU BU7h6qfHMdEjiTQwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQELBQADggIB AH9yzw+3xRXbm8BJyiZb/p4T5tPw0tuXX/JLP02zrhmu7deXoKzvqTqjwkGw5biR nhOBJAPmCf0/V0A5ISRW0RAvS0CpNoZLtFNXmvvxfomPEf4YbFGq6O0JlbXlccmh 6Yd1phV/yX43VF50k8XDZ8wNT2uoFwxtCJJ+i92Bqi1wIcM9BhS7vyRep4TXPw8h Ir1LAAbblxzYXtTFC1yHblCk6MM4pPvLLMWSZpuFXst6bJN8gClYW1e1QGm6CHmm ZGIVnYeWRbVmIyADixxzoNOieTPgUFmG2y/lAiXqcyqfABTINseSO+lOAOzYVgm5 M0kS0lQLAausR7aRKX1MtHWAUgHoyoL2n8ysnI8X6i8msKtyrAv+nlEex0NVZ09R s1fWtuzuUrc66U7h14GIvE+OdbtLqPA1qibUZ2dJsnBMO5PcHd94kIZysjik0dyS TclY6ysSXNQ7roxrsIPlAT/4CTL2kzU0Iq/dNw13CYArzUgA8YyZGUcFAenRv9FO 0OYoQzeZpApKCNmacXPSqs0xE2N2oTdvkjgefRI8ZjLny23h/FKJ3crWZgWalmG+ oijHHKOnNlA8OqTfSm7mhzvO6/DggTedEzxSjr25HTTGHdUKaj2YKXCMiSrRq4IQ SB/c9O+lxbtVGjhjhE63bK2VVOxlIhBJF7jAHscPrFRH -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFoTCCA4mgAwIBAgIBATANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTDEZ MBcGA1UEChMQRGlnaWRlbnRpdHkgQi5WLjEkMCIGA1UEAxMbRGlnaWRlbnRpdHkg TDMgUm9vdCBDQSAtIEcyMB4XDTExMDQyOTEwNDQxOVoXDTMxMTExMDEwNDQxOVow TjELMAkGA1UEBhMCTkwxGTAXBgNVBAoTEERpZ2lkZW50aXR5IEIuVi4xJDAiBgNV BAMTG0RpZ2lkZW50aXR5IEwzIFJvb3QgQ0EgLSBHMjCCAiIwDQYJKoZIhvcNAQEB BQADggIPADCCAgoCggIBALgRo0XeAUdWDWK4jrpYZlz6MsZrgG64f/hT337fYqjB V0+aRSXISkUtUzgksyCsT+qt/5oQr3/iDsq0DiQlkc52jhCpL5lTp5BLBItterlB G9MBeYyfQWu5kNeBEhoHltAJr+nkaiFTgLiGnmJoQ62zahX69m0DMmo1sVATSMd6 tSETnASc2pP5aivBpxj99sB+Wfb75w4Rtdwj6hzvZwVXzhfp8Xux0TIkjM9l59S8 NhlwfKInIdaA0i0VT0q14FWQlVGTIYDznEQf/x1VVeTiEBGUFlPQ/q/z75e6RuJ3 W8vWolkRiKbnVUHDkmUdIxRiFH8lciD2pIcpbwf8/uDQGNKX+RSONsboDBiX8XYc 9CTa40r5t0wSGWfz8OFT+13kwHRjXyWRCtk+9DOs5At1X87mmLxUDZ2iMcUVVF0i HIs6VKYN0dcjOqw+qkoXZHYtDftU5euCPDlBQ53hrnlgz2bux3GDewxrCdueok1O RpNot/pn4dq/35GA2qOiia1ebMxLd3Vkb40k44iIC+M/6b+n5VZiDYN/vWphyJCJ eFsMrxIq4pOtZOfZRS72sMirRe5wOG+7NT4W/quew2Yv874JYNVvgL1N26+N/gxg M2sP6J1rxDB3nyxQONCYaew36J4P5GLq+v8RRFTZ782TdZFM4YllppS5U/n5SWPF AgMBAAGjgYkwgYYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD VR0OBBYEFCsjIGC9LYqIR6ytK74CObqY1OyYMEQGA1UdIAQ9MDswOQYEVR0gADAx MC8GCCsGAQUFBwIBFiNodHRwOi8vcGtpLmRpZ2lkZW50aXR5LmV1L3ZhbGlkYXRp ZTANBgkqhkiG9w0BAQsFAAOCAgEAqf3vuo8bfjISZx1BDS2mi8/y9K1WeH4KmNib qNG0SywmrOTSf2c3vQmN5blzETpuCcdXZAchNPgOXSrYkXzxVFG8nPAMakL0PAFO k0VBPazzmEsecR4zWTL/fDDwXOThvi0uterdYiEOPbQNlfzJuNm6oPdip+3DA64I LEHV70NxOLcUcq4/9BR0R9jejFF5zu+xVKxwR5Z+LS7dm+6hAS4Z775YYHEtrZdb WmAwyzKCYk5W5WdqtNIxVHI/AtC8MDmPt0MJKh8mOwzHfB2bgGCEDuku0vkVu1vg iqQA6eMp+yhbvTZFYCFDMf9woV9cg1uXfA23U1nsmLVO4imx1HxG4+jjQ+o6ljUf U/EEFiXjLPNooaaR3xX7vZ/mTp7CVGt+IlfjpJxcIiUfga+ZyN8RFUhD+LMzqSN/ DjOPvEYdQ7Q7YPWXhRmiFrBV3BpwKWXa2X4JFzTribrpYZLY3jRjPEpVar/ahu3O M967U2/PHNqUT3ZUrGVVEFOayLhr3AbmuuVR1UF/H8TAQaFgkTTzE4LRoXfT90zk Gf/XRJqwtbzcyl6P3M7xoGk24ESSLpn6vK+zx3g6VWbHa6XkaSbpNB0fKpcK6Xep d1tzSDKBv//R7IPFcINpnpgbw1ffkZUcgPyN6JaDBdOfeoh7+uhX8cGEKL3N1hzM peJJCnM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UE AwwJQUNDVlJBSVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQsw CQYDVQQGEwJFUzAeFw0xMTA1MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQ BgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwHUEtJQUNDVjENMAsGA1UECgwEQUND VjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCb qau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gMjmoY HtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWo G2ioPej0RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpA lHPrzg5XPAOBOp0KoVdDaaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhr IA8wKFSVf+DuzgpmndFALW4ir50awQUZ0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/ 0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDGWuzndN9wrqODJerWx5eH k6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs78yM2x/47 4KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMO m3WR5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpa cXpkatcnYGMN285J9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPl uUsXQA+xtrn13k/c4LOsOxFwYIRKQ26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYI KwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRwOi8vd3d3LmFjY3YuZXMvZmls ZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEuY3J0MB8GCCsG AQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeT VfZW6oHlNsyMHj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIG CCsGAQUFBwICMIIBFB6CARAAQQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUA cgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBhAO0AegAgAGQAZQAgAGwAYQAgAEEA QwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUAYwBuAG8AbABvAGcA 7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBjAHQA cgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAA QwBQAFMAIABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUA czAwBggrBgEFBQcCARYkaHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2Mu aHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRt aW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2MV9kZXIuY3JsMA4GA1Ud DwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZIhvcNAQEF BQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdp D70ER9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gU JyCpZET/LtZ1qmxNYEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+m AM/EKXMRNt6GGT6d7hmKG9Ww7Y49nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepD vV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJTS+xJlsndQAJxGJ3KQhfnlms tn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3sCPdK6jT2iWH 7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szA h1xA2syVP1XgNce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xF d3+YJ5oyXSrjhO7FmGYvliAd3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2H pPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3pEfbRD0tVNEYqi4Y7 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDHzCCAgegAwIBAgIEGZk8PzANBgkqhkiG9w0BAQUFADAiMQswCQYDVQQGEwJD TjETMBEGA1UEChMKQ0ZDQSBHVCBDQTAeFw0xMTA2MTMwODE1MDlaFw0yNjA2MDkw ODE1MDlaMCIxCzAJBgNVBAYTAkNOMRMwEQYDVQQKEwpDRkNBIEdUIENBMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv3PGWiuMePZYt/zSF5ClK3TsgSyT zVLMbuQqyyShMeStMG7jmCIx1yGbn9UPNy9auziit3kmZ9YNxRcqnLlUBOENdYZu 2MzFgGcbyIwtACaGPHp5Prapwk4gsDeXxoV2EoIK51S7i/49ruPsa1hD9qU361ii vZDE5fvKa8owbLd7ifYx0oz/T8KWJUOpcTUlCxjhrMijJLZxk4zxXfycEAV7/8Bb 4LGXrR/Y/kX1wB+dW0c5HAb622aF2yQj6nvSOSD46yqyGlHzlFooAk6nXEduz/zZ 6OZhWhYnxxUNmNno0wM1kCnfsi+NEHcjyLh60xFhavP/gZKl7EJLaE6A1wIDAQAB o10wWzAfBgNVHSMEGDAWgBSMdlDOJdN5Kzz0bZ2a4Z4FT+g9JTAMBgNVHRMEBTAD AQH/MAsGA1UdDwQEAwIBxjAdBgNVHQ4EFgQUjHZQziXTeSs89G2dmuGeBU/oPSUw DQYJKoZIhvcNAQEFBQADggEBAL67lljU3YmJDyzN+mNFdg05gJqN+qhFYT0hVejO aMcZ6cKxB8KLOy/PYYWQp1IXMjqvCgUVyMbO3Y6UJgb40GDus27UDbpa3augfFBy ptWQk1bXWTnb6H+zlXhTgVJSX/SSgQLB+yK50QNXp37L+8BGvBN0TCgrdpJpH8FQ kRHFTN4LlIwXg4yvN4e06mtvolo1QWGFL5wXwPu5DqJhBkd2vJAJmHQN0ggvveQN cvGmX8N8wH3qvNOrIJHLXAWMnag1+jZWuwnzhF3W8eIsntl+8YKg4bcvfu35e6AA uLLeHXnhgfNSWZoUXefCEfOawzp4I75OZt6kOWnymDosCgA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF2TCCA8GgAwIBAgIQHp4o6Ejy5e/DfEoeWhhntjANBgkqhkiG9w0BAQsFADBk MQswCQYDVQQGEwJjaDERMA8GA1UEChMIU3dpc3Njb20xJTAjBgNVBAsTHERpZ2l0 YWwgQ2VydGlmaWNhdGUgU2VydmljZXMxGzAZBgNVBAMTElN3aXNzY29tIFJvb3Qg Q0EgMjAeFw0xMTA2MjQwODM4MTRaFw0zMTA2MjUwNzM4MTRaMGQxCzAJBgNVBAYT AmNoMREwDwYDVQQKEwhTd2lzc2NvbTElMCMGA1UECxMcRGlnaXRhbCBDZXJ0aWZp Y2F0ZSBTZXJ2aWNlczEbMBkGA1UEAxMSU3dpc3Njb20gUm9vdCBDQSAyMIICIjAN BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlUJOhJ1R5tMJ6HJaI2nbeHCOFvEr jw0DzpPMLgAIe6szjPTpQOYXTKueuEcUMncy3SgM3hhLX3af+Dk7/E6J2HzFZ++r 0rk0X2s682Q2zsKwzxNoysjL67XiPS4h3+os1OD5cJZM/2pYmLcX5BtS5X4HAB1f 2uY+lQS3aYg5oUFgJWFLlTloYhyxCwWJwDaCFCE/rtuh/bxvHGCGtlOUSbkrRsVP ACu/obvLP+DHVxxX6NZp+MEkUp2IVd3Chy50I9AU/SpHWrumnf2U5NGKpV+GY3aF y6//SSj8gO1MedK75MDvAe5QQQg1I3ArqRa0jG6F6bYRzzHdUyYb3y1aSgJA/MTA tukxGggo5WDDH8SQjhBiYEQN7Aq+VRhxLKX0srwVYv8c474d2h5Xszx+zYIdkeNL 6yxSNLCK/RJOlrDrcH+eOfdmQrGrrFLadkBXeyq96G4DsguAhYidDMfCd7Camlf0 uPoTXGiTOmekl9AbmbeGMktg2M7v0Ax/lZ9vh0+Hio5fCHyqW/xavqGRn1V9TrAL acywlKinh/LTSlDcX3KwFnUey7QYYpqwpzmqm59m2I2mbJYV4+by+PGDYmy7Velh k6M99bFXi08jsJvllGov34zflVEpYKELKeRcVVi3qPyZ7iVNTA6z00yPhOgpD/0Q VAKFyPnlw4vP5w8CAwEAAaOBhjCBgzAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0hBBYw FDASBgdghXQBUwIBBgdghXQBUwIBMBIGA1UdEwEB/wQIMAYBAf8CAQcwHQYDVR0O BBYEFE0mICKJS9PVpAqhb97iEoHF8TwuMB8GA1UdIwQYMBaAFE0mICKJS9PVpAqh b97iEoHF8TwuMA0GCSqGSIb3DQEBCwUAA4ICAQAyCrKkG8t9voJXiblqf/P0wS4R fbgZPnm3qKhyN2abGu2sEzsOv2LwnN+ee6FTSA5BesogpxcbtnjsQJHzQq0Qw1zv /2BZf82Fo4s9SBwlAjxnffUy6S8w5X2lejjQ82YqZh6NM4OKb3xuqFp1mrjX2lhI REeoTPpMSQpKwhI3qEAMw8jh0FcNlzKVxzqfl9NX+Ave5XLzo9v/tdhZsnPdTSpx srpJ9csc1fV5yJmz/MFMdOO0vSk3FQQoHt5FRnDsr7p4DooqzgB53MBfGWcsa0vv aGgLQ+OswWIJ76bdZWGgr4RVSJFSHMYlkSrQwSIjYVmvRRGFHQEkNI/Ps/8XciAT woCqISxxOQ7Qj1zB09GOInJGTB2Wrk9xseEFKZZZ9LuedT3PDTcNYtsmjGOpI99n Bjx8Oto0QuFmtEYE3saWmA9LSHokMnWRn6z3aOkquVVlzl1h0ydw2Df+n7mvoC5W t6NlUe07qxS/TFED6F+KBZvuim6c779o+sjaC+NCydAXFJy3SuCvkychVSa1ZC+N 8f+mQAWFBVzKBxlcCxMoTFh/wqXvRdpg065lYZ1Tg3TCrvJcwhbtkj6EPnNgiLx2 9CzP0H1907he0ZESEOnN3col49XtmS++dYFLJPlFRpTJKSFTnCZFqhMX5OfNeOI5 wSsSnqaeG8XmDtkx2Q== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF4DCCA8igAwIBAgIRAPL6ZOJ0Y9ON/RAdBB92ylgwDQYJKoZIhvcNAQELBQAw ZzELMAkGA1UEBhMCY2gxETAPBgNVBAoTCFN3aXNzY29tMSUwIwYDVQQLExxEaWdp dGFsIENlcnRpZmljYXRlIFNlcnZpY2VzMR4wHAYDVQQDExVTd2lzc2NvbSBSb290 IEVWIENBIDIwHhcNMTEwNjI0MDk0NTA4WhcNMzEwNjI1MDg0NTA4WjBnMQswCQYD VQQGEwJjaDERMA8GA1UEChMIU3dpc3Njb20xJTAjBgNVBAsTHERpZ2l0YWwgQ2Vy dGlmaWNhdGUgU2VydmljZXMxHjAcBgNVBAMTFVN3aXNzY29tIFJvb3QgRVYgQ0Eg MjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMT3HS9X6lds93BdY7Bx UglgRCgzo3pOCvrY6myLURYaVa5UJsTMRQdBTxB5f3HSek4/OE6zAMaVylvNwSqD 1ycfMQ4jFrclyxy0uYAyXhqdk/HoPGAsp15XGVhRXrwsVgu42O+LgrQ8uMIkqBPH oCE2G3pXKSinLr9xJZDzRINpUKTk4RtiGZQJo/PDvO/0vezbE53PnUgJUmfANykR HvvSEaeFGHR55E+FFOtSN+KxRdjMDUN/rhPSays/p8LiqG12W0OfvrSdsyaGOx9/ 5fLoZigWJdBLlzin5M8J0TbDC77aO0RYjb7xnglrPvMyxyuHxuxenPaHZa0zKcQv idm5y8kDnftslFGXEBuGCxobP/YCfnvUxVFkKJ3106yDgYjTdLRZncHrYTNaRdHL OdAGalNgHa/2+2m8atwBz735j9m9W8E6X47aD0upm50qKGsaCnw8qyIL5XctcfaC NYGu+HuB5ur+rPQam3Rc6I8k9l2dRsQs0h4rIWqDJ2dVSqTjyDKXZpBy2uPUZC5f 46Fq9mDU5zXNysRojddxyNMkM3OxbPlq4SjbX8Y96L5V5jcb7STZDxmPX2MYWFCB UWVv8p9+agTnNCRxunZLWB4ZvRVgRaoMEkABnRDixzgHcgplwLa7JSnaFp6LNYth 7eVxV4O1PHGf40+/fh6Bn0GXAgMBAAGjgYYwgYMwDgYDVR0PAQH/BAQDAgGGMB0G A1UdIQQWMBQwEgYHYIV0AVMCAgYHYIV0AVMCAjASBgNVHRMBAf8ECDAGAQH/AgED MB0GA1UdDgQWBBRF2aWBbj2ITY1x0kbBbkUe88SAnTAfBgNVHSMEGDAWgBRF2aWB bj2ITY1x0kbBbkUe88SAnTANBgkqhkiG9w0BAQsFAAOCAgEAlDpzBp9SSzBc1P6x XCX5145v9Ydkn+0UjrgEjihLj6p7jjm02Vj2e6E1CqGdivdj5eu9OYLU43otb98T PLr+flaYC/NUn81ETm484T4VvwYmneTwkLbUwp4wLh/vx3rEUMfqe9pQy3omywC0 Wqu1kx+AiYQElY2NfwmTv9SoqORjbdlk5LgpWgi/UOGED1V7XwgiG/W9mR4U9s70 WBCCswo9GcG/W6uqmdjyMb3lOGbcWAXH7WMaLgqXfIeTK7KK4/HsGOV1timH59yL Gn602MnTihdsfSlEvoqq9X46Lmgxk7lq2prg2+kupYTNHAq4Sgj5nPFhJpiTt3tm 7JFe3VE/23MPrQRYCd0EApUKPtN236YQHoA96M2kZNEzx5LH4k5E4wnJTsJdhw4S nr8PyQUQ3nqjsTzyP6WqJ3mtMX0f/fwZacXduT98zca0wjAefm6S139hdlqP65VN vBFuIXxZN5nQBrz5Bm0yFqXZaajh3DyAHmBR3NdUIR7KYndP+tiPsys6DXhyyWhB WkdKwqPrGtcKqzwyVcgKEZzfdNbwQBUdyLmPtTbFr/giuMod89a2GQ+fYWVq6nTI fI/DT11lgh/ZDYnadXL77/FHZxOzyNEZiCcmmpl5fx7kLD977vHeTYuWl8PVP3wb I+2ksx0WckNLIOFZfsLorSa/ovc= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UE AwwVQXRvcyBUcnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQG EwJERTAeFw0xMTA3MDcxNDU4MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMM FUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsGA1UECgwEQXRvczELMAkGA1UEBhMC REUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVhTuXbyo7LjvPpvMp Nb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr54rM VD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+ SZFhyBH+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ 4J7sVaE3IqKHBAUsR320HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0L cp2AMBYHlT8oDv3FdU9T1nSatCQujgKRz3bFmx5VdJx4IbHwLfELn8LVlhgf8FQi eowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7Rl+lwrrw7GWzbITAPBgNV HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZbNshMBgG A1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3 DQEBCwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8j vZfza1zv7v1Apt+hk6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kP DpFrdRbhIfzYJsdHt6bPWHJxfrrhTZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pc maHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a961qn8FYiqTxlVMYVqL2Gns2D lmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G3mB/ufNPRJLv KrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFwzCCA6ugAwIBAgISESGFDLOcajL6vmcbgT+khhWPMA0GCSqGSIb3DQEBCwUA MF4xCzAJBgNVBAYTAkZSMQ4wDAYDVQQKEwVBTlNTSTEXMBUGA1UECxMOMDAwMiAx MzAwMDc2NjkxJjAkBgNVBAMTHUlHQy9BIEFDIHJhY2luZSBFdGF0IGZyYW5jYWlz MB4XDTExMDcwODA5MDAwMFoXDTI4MDQxNTA5MDAwMFowXjELMAkGA1UEBhMCRlIx DjAMBgNVBAoTBUFOU1NJMRcwFQYDVQQLEw4wMDAyIDEzMDAwNzY2OTEmMCQGA1UE AxMdSUdDL0EgQUMgcmFjaW5lIEV0YXQgZnJhbmNhaXMwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQCqfCifETCYzW9uLIUSJjsIBspB/VJPQ73AJidxdhpZ ltgJ6weqJk5PPkuh45eHhWaBccm5FXZvd1AYkxAtN4hNF7fzRb0iLrcnmFvHBf29 M+2i9VMdKCNlv0A1bs5qC8Op9SUMqyLwuMDEfTcMo2J87rTbPSE5p5yJ45uiEPiK tkovLphpK2qghtrxCOW+TGcWLSVh89UNCxdERwnURgWdD8CITWHkJMTHaAmvrNKv uZUmb4AE/HasqscjtuQGkVVE7GTbmYEc0lZ0/dYyKLvLyTcN+2lsb7qjawaMakAu Fzo56tAM31ocum+kMrC4zD53G9OLH4b6/z4+b1yIRufjD/qrHfN9S/hUbk7M3DJa Y3iiMq8zeOpD4Ux6TdeUBi3mT6VCkq8oik/DFeypa6nf4N0TArzMff8t5gepvnWW 6kJeWxreojOzY72rBfmL5r1N0W1WmuuJPJ/AeOS+JXAGxRFzoMjKFMs61PKcKjza Xxcz2XYUN6pJh2XZ9NkuGV/5oM2ouUEybXGmpMv3YyLQKeS6gRpqKR2apaRcRlQk RdTI7Xp5heyEd25nTWQPQ956g6Sn2Nu1U0z+YsgTw2I2pSgxMpu0lofimcYfVr9G o6lkMeXVsUuoZsxbof8W/Ao4KmiPdyUmrZF0hWjIfxrlWhS4fQ63IzHAZLcFL0FY VQIDAQABo3sweTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNV HSAEDzANMAsGCSqBegGBXwEBAjAdBgNVHQ4EFgQUn6rTKZbfAOVD4PFjrN4SjsIn ePowHwYDVR0jBBgwFoAUn6rTKZbfAOVD4PFjrN4SjsInePowDQYJKoZIhvcNAQEL BQADggIBAHW1ddGONmacSPeFDU4Fu02anLQOKKIEvFAwu/SUTJiQhavgUmRP0tIu YpOQsIUNiFT7xlRsnuuVeYBeopcWH/JndEGcVfS3aptKFoa9BR9mgHB+ydH1LSFx UDmlrYimJhyL1yUcOtbj9MIMn1fBZMhXUSMWI40PI2pWS//6xp81k8YiwGXxr96p bBi+V2VZzfQjVWQh2O2VYWkzcmpR9p/llW2O3mtzJxOUXn6XSMAyFr49N+3W3I68 XC38YqjP9pD3sYsJ6zokYw3IlkXUL3dIQvUtYucnC+ARhhndpxD3YwaRMGladfSs +aGNl8ag7zofkyVIVjoaiCEZk8OVIEkIVUlNolOcmZxzaS6n9cq3DiXvNyNfkNhD fu6EF2onXn/SLT+sPq8wp42RxPSPCR3z95EO4xi63ETJfQVTA7duoPN519EaT9C4 bIh2wYCYVYVTYc9EV0zeTg0WUfE9iYGufQutirXuVsTGzBELGNT8/Xn7/gQRnCPv dnLHjb65Hnh28pocrWNCx9jtbWGQwiEqDwgULSBDJXwYtbegpH25pQwZ/smrPedb 3q/6VxknhecjDvTNDRkwPorkxhEe8LR9aWObDpaGkOD7A29bWT4dIfVXZ1Ym8ocZ B4S6LJA6wyikBVogzalblXU5fyJQCk5/F/ezrNMHpr4tUgowTHgQ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGATCCA+mgAwIBAgIRAI9hcRW6eVgXjH0ROqzW264wDQYJKoZIhvcNAQELBQAw RTEfMB0GA1UEAxMWQ29tU2lnbiBHbG9iYWwgUm9vdCBDQTEVMBMGA1UEChMMQ29t U2lnbiBMdGQuMQswCQYDVQQGEwJJTDAeFw0xMTA3MTgxMDI0NTRaFw0zNjA3MTYx MDI0NTVaMEUxHzAdBgNVBAMTFkNvbVNpZ24gR2xvYmFsIFJvb3QgQ0ExFTATBgNV BAoTDENvbVNpZ24gTHRkLjELMAkGA1UEBhMCSUwwggIiMA0GCSqGSIb3DQEBAQUA A4ICDwAwggIKAoICAQCyKClzKh3rm6n1nvigmV/VU1D4hSwYW2ro3VqpzpPo0Ph3 3LguqjXd5juDwN4mpxTpD99d7Xu5X6KGTlMVtfN+bTbA4t3x7DU0Zqn0BE5XuOgs 3GLH41Vmr5wox1bShVpM+IsjcN4E/hMnDtt/Bkb5s33xCG+ohz5dlq0gA9qfr/g4 O9lkHZXTCeYrmVzd/il4x79CqNvGkdL3um+OKYl8rg1dPtD8UsytMaDgBAopKR+W igc16QJzCbvcinlETlrzP/Ny76BWPnAQgaYBULax/Q5thVU+N3sEOKp6uviTdD+X O6i96gARU4H0xxPFI75PK/YdHrHjfjQevXl4J37FJfPMSHAbgPBhHC+qn/014DOx 46fEGXcdw2BFeIIIwbj2GH70VyJWmuk/xLMCHHpJ/nIF8w25BQtkPpkwESL6esaU b1CyB4Vgjyf16/0nRiCAKAyC/DY/Yh+rDWtXK8c6QkXD2XamrVJo43DVNFqGZzbf 5bsUXqiVDOz71AxqqK+p4ek9374xPNMJ2rB5MLPAPycwI0bUuLHhLy6nAIFHLhut TNI+6Y/soYpi5JSaEjcY7pxI8WIkUAzr2r+6UoT0vAdyOt7nt1y8844a7szo/aKf woziHl2O1w6ZXUC30K+ptXVaOiW79pBDcbLZ9ZdbONhS7Ea3iH4HJNwktrBJLQID AQABo4HrMIHoMA8GA1UdEwEB/wQFMAMBAf8wgYQGA1UdHwR9MHswPKA6oDiGNmh0 dHA6Ly9mZWRpci5jb21zaWduLmNvLmlsL2NybC9jb21zaWduZ2xvYmFscm9vdGNh LmNybDA7oDmgN4Y1aHR0cDovL2NybDEuY29tc2lnbi5jby5pbC9jcmwvY29tc2ln bmdsb2JhbHJvb3RjYS5jcmwwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBQCRZPY DUhirGm6rgZbPvuqJpFQsTAfBgNVHSMEGDAWgBQCRZPYDUhirGm6rgZbPvuqJpFQ sTANBgkqhkiG9w0BAQsFAAOCAgEAk1V5V9701xsfy4mfX+tP9Ln5e9h3N+QMwUfj kr+k3e8iXOqADjTpUHeBkEee5tJq09ZLp/43F5tZ2eHdYq2ZEX7iWHCnOQet6Yw9 SU1TahsrGDA6JJD9sdPFnNZooGsU1520e0zNB0dNWwxrWAmu4RsBxvEpWCJbvzQL dOfyX85RWwli81OiVMBc5XvJ1mxsIIqli45oRynKtsWP7E+b0ISJ1n+XFLdQo/Nm WA/5sDfT0F5YPzWdZymudMbXitimxC+n4oQE4mbQ4Zm718Iwg3pP9gMMcSc7Qc1J kJHPH9O7gVubkKHuSYj9T3Ym6c6egL1pb4pz/uT7cT26Fiopc/jdqbe2EAfoJZkv hlp/zdzOoXTWjiKNA5zmgWnZn943FuE9KMRyKtyi/ezJXCh8ypnqLIKxeFfZl69C BwJsPXUTuqj8Fic0s3aZmmr7C4jXycP+Q8V+akMEIoHAxcd960b4wVWKqOcI/kZS Q0cYqWOY1LNjznRt9lweWEfwDBL3FhrHOmD4++1N3FkkM4W+Q1b2WOL24clDMj+i 2n9Iw0lc1llHMSMvA5D0vpsXZpOgcCVahfXczQKi9wQ3oZyonJeWx4/rXdMtagAB VBYGFuMEUEQtybI+eIbnp5peO2WAAblQI4eTy/jMVowe5tfMEXovV3sz9ULgmGb3 DscLP1I= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIBATANBgkqhkiG9w0BAQsFADBQMQswCQYDVQQGEwJLUjEc MBoGA1UECgwTR292ZXJubWVudCBvZiBLb3JlYTENMAsGA1UECwwER1BLSTEUMBIG A1UEAwwLR1BLSVJvb3RDQTEwHhcNMTEwODAzMDY1MjMwWhcNMzEwODAzMDY1MjMw WjBQMQswCQYDVQQGEwJLUjEcMBoGA1UECgwTR292ZXJubWVudCBvZiBLb3JlYTEN MAsGA1UECwwER1BLSTEUMBIGA1UEAwwLR1BLSVJvb3RDQTEwggEiMA0GCSqGSIb3 DQEBAQUAA4IBDwAwggEKAoIBAQCh/m8EBbDJhGQyN2+g5dTlsgjtaRKqhgj3gkYK BgtuXsXkaTVxbf99AvbN3QE8+WCIaPJUd0091UGmLzaBVyW4ct+iUNrX/FXyzjaf bNbbl1nfHhaZhkiOTVQhmY5zuj96evEtJMevnxe6iRADOPWnqp+CxT2IzcSFkQCq 7L2qn8hU2/LpXUvnAYglJZi8t6Ef+r03P1r8dA5OzZ8yV3qhD1R1wsNQtCzMgwcE rFRZhFZYuxpfmS5y0fZW0seeTjcdxHiR3whYI5U6AI7DjdWIrT9Cd9ByV4aevkBh qkePPIYGmUPXnnqCkdHdnzkMH0WP9TBhD2jTXZKdcFtTyEJrAgMBAAGjQjBAMB0G A1UdDgQWBBR4A+sMjKbTVXWkh7Tr0ZpmD0xzizAOBgNVHQ8BAf8EBAMCAQYwDwYD VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEARGJWATwo81x7UEQugNbi cL8IWXoV51SZVH3kz49fNUjVoq1n2yzfaMddlblbflDNObp/68DxTlSXCeqFHkgi /WvyVHERRECXnF0WeeelI+Q8XdF3IJZLT3u5Ss0VAB2loCuC+4hBWSRQu2WZu2Yk s9eBN0x6NmtopRmnf2d6VrcFA+WOgUeTjXiDkG52IaPw0w1uTfmRw5epky5idyY2 bfJ1JeVUINMJnOWpgLkOH3xxakoD8F1Fbi6C3t7MmKupojUq/toUDms6zTk3DIkc wd7PALNWL5U8TxNLoroTHSf/lzaOv3o9KDRa0FQo58bPI7MdbRWE4F3mS/ZIrnv7 jQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGMjCCBBqgAwIBAgIQWMv5ZJZxdJVA9K0IrGTk4zANBgkqhkiG9w0BAQsFADBz MQswCQYDVQQGEwJJTDEYMBYGA1UECgwPUGVyc29uYWxJRCBMdGQuMR0wGwYDVQQL DBRDZXJ0aWZpY2F0ZSBTZXJ2aWNlczErMCkGA1UEAwwiUGVyc29uYWxJRCBUcnVz dHdvcnRoeSBSb290Q0EgMjAxMTAeFw0xMTA5MDEwODM1MjFaFw00MTA5MDEwODQ1 MTZaMHMxCzAJBgNVBAYTAklMMRgwFgYDVQQKDA9QZXJzb25hbElEIEx0ZC4xHTAb BgNVBAsMFENlcnRpZmljYXRlIFNlcnZpY2VzMSswKQYDVQQDDCJQZXJzb25hbElE IFRydXN0d29ydGh5IFJvb3RDQSAyMDExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A MIICCgKCAgEAsJWMYP4FDmoz7feL4/LV8nzTVkJU9yvyiKX157dshwErab4FSUTY 2yF6KteKMaEhEJ7T4m5jgoVUhE0oJhviE/dR+y/rEtU9OYxkn6QTh8PYyfopI44J j0lGxNTJV1hpnxfPc3Sl7soYucfBMM1POjUIU/jsGvtvMO32nwnw8NDEjjt5Ti6F IlzUfXDR/5K6H9RVU2e6KFgt9xOM/KULnDimRhwO6Kp4K/UKMNM7YIbIf6WbomMB L9DTEiWFfpbNMbHkm47qLJOkYqg31faP3yGa0z4d4VARcFSbBBedTathzo8qLO95 5ndFWdZo1bZLmquRSw5hF7lYwp5moY+JwUMgQrB/gJxKKrd6IEHGTcSSb3p+XVu5 o8lOyuVQZbwAAHlH8EUEsCL7DpiqYR1PYGNyj7WwBJR/EKwZPydiadYcV905Tzjq AJr9KJ1AJsBAncSgSchBtWc9oEuUKRKpWCdZBH+P0Yx+DLMIFzSsj7lcvelwoX7C pWVh6bYQUI/c5HRh8V9ye39cLy18q9ZDMRAcWXfKSEoYomQLAFlnx9TKw5saCFIV vtfFxrcv5mKcpsfY3vAV+645VS1vUHUu/aAHtF96fgSL9pmide3JO9U9z2dSPT7v H3CaGDynIAZJDLFlrDO71H9HaYj2ioHundS0xy8D6K4ayVYFZ2moyIECAwEAAaOB wTCBvjALBgNVHQ8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQU x38LyC9Xjiny9gGL6yelVo79pB4wEAYJKwYBBAGCNxUBBAMCAQAwagYDVR0gBGMw YTBfBggrBgEEAeEYATBTMFEGCCsGAQUFBwIBFkVodHRwOi8vd3d3LmljYS5jby5p bC9yZXBvc2l0b3J5L2Nwcy9QZXJzb25hbElEX1ByYWN0aWNlX1N0YXRlbWVudC5w ZGYwDQYJKoZIhvcNAQELBQADggIBAEJliyT6khU0Ghz6yM5Nei9739ADQRzUpOH7 6MytCd0dpAjZqCB9l58MSfGlwubVd0aXfqSQonnpvRpeNIJmCVL8UNGP0Kscov// Pe7+I/i/I7PNvuH3z+TYEuOUyE7M13uwN5t36u1cgcjMj8454+RlXd6C2I8jaeFR r1+3T5BppJllU7rm/a94Z5RKyMN/jAJPSuaHmPY4t0j4bSh/98ZsJVT9Ltbq2gbi sf0HaPCvgIy0wul0FaQav7nKQ1sS54VHXlID8JHg6VBx1CECLHuGkXA2xpy2dPkq Vfch+2+gBl3XMBLyUfHJODaPyGZhQdnHS4JoUqP1iQwVvE4qlawxaacb4tTXSPSR 9QN8eRY+LA1p4Yo3Hp98GFVBL1/npHKbVfPjAbACpYQSakCmq+ShrOsD2bxfJFYn rSDgZjVFPUcJ8AWxb3F+QLDQFV4rrFKBqPuD9SxXRIY05BRq4899mnfYbEhcy5rh pvu/EaIG5R9xvTS1z73EQhbFKfjUwEyKst7FlIKGm8zgqQZEMSQkTfrt4UIlZqLB 14AX73qVZUM+ZtMF8QHkQlZEAHhrnTYg+2X/QFzoaDUf4SagggN2A8twRhEkrt8v YP3xJwADvUsn27yclzdRK+V4tME2kBCM/z0A1LpIn0jKhzGa7cSaU9LdcxQ/CYKh XWVOTSbi -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UE BhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8w MzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDkyMjExMjIwMlowazELMAkGA1UEBhMC SVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1 ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENB MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNv UTufClrJwkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX 4ay8IMKx4INRimlNAJZaby/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9 KK3giq0itFZljoZUj5NDKd45RnijMCO6zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/ gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1fYVEiVRvjRuPjPdA1Yprb rxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2oxgkg4YQ 51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2F be8lEfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxe KF+w6D9Fz8+vm2/7hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4F v6MGn8i1zeQf1xcGDXqVdFUNaBr8EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbn fpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5jF66CyCU3nuDuP/jVo23Eek7 jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLYiDrIn3hm7Ynz ezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQAL e3KHwGCmSUyIWOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70 jsNjLiNmsGe+b7bAEzlgqqI0JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDz WochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKxK3JCaKygvU5a2hi/a5iB0P2avl4V SM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+Xlff1ANATIGk0k9j pwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC4yyX X04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+Ok fcvHlXHo2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7R K4X9p2jIugErsWx0Hbhzlefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btU ZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXemOR/qnuOf0GZvBeyqdn6/axag67XH/JJU LysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9vwGYT7JZVEc+NHt4bVaT LnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICqDCCAi2gAwIBAgIQIW4zpcvTiKRvKQe0JzzE2DAKBggqhkjOPQQDAzCBlDEL MAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYD VQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBD bGFzcyAxIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0g RzQwHhcNMTExMDA1MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBlDELMAkGA1UEBhMC VVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZTeW1h bnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAxIFB1 YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzQwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAATXZrUb266zYO5G6ohjdTsqlG3zXxL24w+etgoUU0hS yNw6s8tIICYSTvqJhNTfkeQpfSgB2dsYQ2mhH7XThhbcx39nI9/fMTGDAzVwsUu3 yBe7UcvclBfb6gk7dhLeqrWjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E BTADAQH/MB0GA1UdDgQWBBRlwI0l9Qy6l3eQP54u4Fr1ztXh5DAKBggqhkjOPQQD AwNpADBmAjEApa7jRlP4mDbjIvouKEkN7jB+M/PsP3FezFWJeJmssv3cHFwzjim5 axfIEWi13IMHAjEAnMhE2mnCNsNUGRCFAtqdR+9B52wmnQk9922Q0QVEL7C8g5No 8gxFSTm/mQQc0xCg -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICqDCCAi2gAwIBAgIQNBdlEkA7t1aALYDLeVWmHjAKBggqhkjOPQQDAzCBlDEL MAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYD VQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBD bGFzcyAyIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0g RzQwHhcNMTExMDA1MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBlDELMAkGA1UEBhMC VVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZTeW1h bnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAyIFB1 YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzQwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAATR2UqOTA2ESlG6fO/TzPo6mrWnYxM9AeBJPvrBR8mS szrX/m+c95o6D/UOCgrDP8jnEhSO1dVtmCyzcTIK6yq99tdqIAtnRZzSsr9TImYJ XdsR8/EFM1ij4rjPfM2Cm72jQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E BTADAQH/MB0GA1UdDgQWBBQ9MvM6qQyQhPmijGkGYVQvh3L+BTAKBggqhkjOPQQD AwNpADBmAjEAyKapr0F/tckRQhZoaUxcuCcYtpjxwH+QbYfTjEYX8D5P/OqwCMR6 S7wIL8fip29lAjEA1lnehs5fDspU1cbQFQ78i5Ry1I4AWFPPfrFLDeVQhuuea9// KabYR9mglhjb8kWz -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF0zCCA7ugAwIBAgIVALhZFHE/V9+PMcAzPdLWGXojF7TrMA0GCSqGSIb3DQEB DQUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dp ZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBIDIwHhcNMTExMDA2 MDgzOTU2WhcNNDYxMDA2MDgzOTU2WjCBgDELMAkGA1UEBhMCUEwxIjAgBgNVBAoT GVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0 aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0 d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvfl4+ObV gAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC5SBLm9qbe7mZXdmbgEvXhEAr J9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Vaqp3cKcniNQfrcE1K1sGzVrih QTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ0WeNG0a+RzDVLnLRxWPa52N5 RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet/5oCl8HGUJKbAiy9qbk0WQq/ hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNeGWJoRVfkkIJCu0LW8GHgwaM9 ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcVWiNoidQ+3k4nsPBADLxNF8tN orMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbls+V1e9dBkQXcXWnjlQ1DufyD ljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZbMI28iQzV13D4h1L92u+sUS4H s07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+NqcnoYsylfzGuXIkosagpZ6w7xQ EmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbljnX98sy50IdbzAYQYLuDNbde Z95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEAAaNCMEAwDwYDVR0TAQH/BAUw AwEB/zAdBgNVHQ4EFgQUtqFUOQLDoD+Oirz61PgcptE6Dv0wDgYDVR0PAQH/BAQD AgEGMA0GCSqGSIb3DQEBDQUAA4ICAQCdU8KBJdw1LK4K3VqbRjBWu9S0bEuG5gql 0pKKmo3cj7TudvQDy+ubAXirKmu1uiNOMXy1LN0taWczbmNdORgS+KAoU0SHq2rE kpYfKqIcup3dJ/tSTbCPWujtjcNo45KgJgyHkLAD6mplKAjERnjgW7oO8DPcJ7Z+ iD29kqSWfkGogAh71jYSvBAVmyS8q619EYkvMe340s9Tjuu0U6fnBMovpiLEEdzr mMkiXUFq3ApSBFu8LqB9x7aSuySg8zfRK0OozPFoeBp+b2OQe590yGvZC1X2eQM9 g8dBQJL7dgs3JRc8rz76PFwbhvlKDD+KxF4OmPGt7s/g/SE1xzNhzKI3GEN8M+mu doKCB0VIO8lnbq2jheiWVs+8u/qry7dXJ40aL5nzIzM0jspTY9NXNFBPz0nBBbrF qId744aP+0OiEumsUewEdkzw+o+5MRPpCLckCfmgtwc2WFfPxLt+SWaVNQS2dzW4 qVMpX5KF+FLEWk79BmE5+33QdkeSzOwrvYRu5ptFwX1isVMtnnWg58koUNflvKiq B3hquXS0YPOEjQPcrpHadEQNe0Kpd9YrfKHGbBNTIqkSmqX5TyhFNbCXT0ZlhcX0 /WKiomr8NDAGft8M4HOBlslEKt4fguxscletKWSk8cYpjjVgU85r2QK+OTB14Pdc Y2rwQMEsjQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCB gDELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMu QS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIG A1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMCIYDzIwMTExMDA2MDgz OTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UEChMZ VW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRp ZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3 b3JrIENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWA DGSdhhuWZGc/IjoedQF97/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn 0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+oCgCXhVqqndwpyeI1B+twTUrWwbNWuKFB OJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40bRr5HMNUuctHFY9rnY3lE fktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2puTRZCr+E Sv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1m o130GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02i sx7QBlrd9pPPV3WZ9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOW OZV7bIBaTxNyxtd9KXpEulKkKtVBRgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgez Tv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pyehizKV/Ma5ciSixqClnrDvFAS adgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vMBhBgu4M1t15n 3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMC AQYwDQYJKoZIhvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQ F/xlhMcQSZDe28cmk4gmb3DWAl45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTf CVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuAL55MYIR4PSFk1vtBHxgP58l1cb29 XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMoclm2q8KMZiYcdywm djWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tMpkT/ WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jb AoJnwTnbw3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksq P/ujmv5zMnHCnsZy4YpoJ/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Ko b7a6bINDd82Kkhehnlt4Fj1F4jNy3eFmypnTycUm/Q1oBEauttmbjL4ZvrHG8hnj XALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLXis7VmFxWlgPF7ncGNf/P 5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7zAYspsbi DrW5viSP -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIE5DCCA8ygAwIBAgIBATANBgkqhkiG9w0BAQsFADB/MQswCQYDVQQGEwJteTEL MAkGA1UECgwCVE0xNDAyBgNVBAsMK1RNIEFwcGxpZWQgQnVzaW5lc3MgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkxLTArBgNVBAMMJFRNIEFwcGxpZWQgQnVzaW5lc3Mg Um9vdCBDZXJ0aWZpY2F0ZTAeFw0xMTEwMTAwNjIzMzlaFw0zMTEwMTAwNjUzMzla MH8xCzAJBgNVBAYTAm15MQswCQYDVQQKDAJUTTE0MDIGA1UECwwrVE0gQXBwbGll ZCBCdXNpbmVzcyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEtMCsGA1UEAwwkVE0g QXBwbGllZCBCdXNpbmVzcyBSb290IENlcnRpZmljYXRlMIIBIDANBgkqhkiG9w0B AQEFAAOCAQ0AMIIBCAKCAQEAxbd1GV7r9EIJjbFqbG4ydqQFBw+PK2Q672vHtxtX WiUzwGEYo4IdgHft7RxkskC6yMJVtV+Owt2RbvPF56M5m0wvfqPm948VXH0bWrqW lpOgYXIgRIgnq0FHdz5eMKWLNegwRqBY6k4CbT1iDTnzZK5m7twSfhlL0b/CgkT6 +deZSOyzDPRiZzWbnUZoR5emIl4TVgALUfX7ZF9b4L/yb+9F1K7Gr9ycH+0UHbKm 7wc45wh3Nqq5qDw5GuWRaKqQjsGYGeTqbYWTGwbm3FELoQDsxK5ypxxpEXI+3M7z OFfXGhpXFE2LUHZFVXMwI29Lr0pIQpNCX/nx2jlcBtUPyQIBA6OCAWswggFnMIGr BgNVHSMEgaMwgaCAFEAa+7SWN5aD3yw7FO0cxsveIG0IoYGEpIGBMH8xCzAJBgNV BAYTAm15MQswCQYDVQQKDAJUTTE0MDIGA1UECwwrVE0gQXBwbGllZCBCdXNpbmVz cyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEtMCsGA1UEAwwkVE0gQXBwbGllZCBC dXNpbmVzcyBSb290IENlcnRpZmljYXRlggEBMB0GA1UdDgQWBBRAGvu0ljeWg98s OxTtHMbL3iBtCDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zB3BgNV HR8EcDBuMGygaqBohmZsZGFwOi8vbGRhcC50bWNhLmNvbS5teTozODkvY249YXJs MWRwMSxvdT1BUkwsb3U9VE0gQXBwbGllZCBCdXNpbmVzcyBDZXJ0aWZpY2F0aW9u IEF1dGhvcml0eSxvPVRNLGM9bXkwDQYJKoZIhvcNAQELBQADggEBAECJXpdECqtm MStt3E6m5y2xR/9SefPt26eB6To8VWf1RdHuGXn9N+CupCiiGDjez9KXkqQ5vFSD 7x2hgWfIjCZlhrrKbwBCWE26GWa3G0BRJZLQghWIbGIy4vFAEt2+wO8Q8iaEJfX0 ag9ZPyMZHb0NvDk6vNrcbj8OjCaRJDPM/TM5jF2iu0eX5xAqhCZUsSt+X/mqf+3H /sojplW/38pe4Ps+p1LWKjqle2PyhfwhNCvBrvBBkBg/RcQjjbw7ht2qRmdphyGi Vxamp3w7/okgRxj61XL9XDpotTvhPMIrS3hTVVqy9oa+wD3bSP/wwHoQ1B7f5LYu whrUDnpqoHY= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID9jCCAt6gAwIBAgIQJDJ18h0v0gkz97RqytDzmDANBgkqhkiG9w0BAQsFADCB lDELMAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8w HQYDVQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRl YyBDbGFzcyAxIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 IC0gRzYwHhcNMTExMDE4MDAwMDAwWhcNMzcxMjAxMjM1OTU5WjCBlDELMAkGA1UE BhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZT eW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAx IFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzYwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHOddJZKmZgiJM6kXZBxbje/SD 6Jlz+muxNuCad6BAwoGNAcfMjL2Pffd543pMA03Z+/2HOCgs3ZqLVAjbZ/sbjP4o ki++t7JIp4Gh2F6Iw8w5QEFa0dzl2hCfL9oBTf0uRnz5LicKaTfukaMbasxEvxvH w9QRslBglwm9LiL1QYRmn81ApqkAgMEflZKf3vNI79sdd2H8f9/ulqRy0LY+/3gn r8uSFWkI22MQ4uaXrG7crPaizh5HmbmJtxLmodTNWRFnw2+F2EJOKL5ZVVkElauP N4C/DfD8HzpkMViBeNfiNfYgPym4jxZuPkjctUwH4fIa6n4KedaovetdhitNAgMB AAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW BBQzQejIORIVk0jyljIuWvXalF9TYDANBgkqhkiG9w0BAQsFAAOCAQEAFeNzV7EX tl9JaUSm9l56Z6zS3nVJq/4lVcc6yUQVEG6/MWvL2QeTfxyFYwDjMhLgzMv7OWyP 4lPiPEAz2aSMR+atWPuJr+PehilWNCxFuBL6RIluLRQlKCQBZdbqUqwFblYSCT3Q dPTXvQbKqDqNVkL6jXI+dPEDct+HG14OelWWLDi3mIXNTTNEyZSPWjEwN0ujOhKz 5zbRIWhLLTjmU64cJVYIVgNnhJ3Gw84kYsdMNs+wBkS39V8C3dlU6S+QTnrIToNA DJqXPDe/v+z28LSFdyjBC8hnghAXOKK3Buqbvzr46SMHv3TgmDgVVXjucgBcGaP0 0jPg/73RVDkpDw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID9jCCAt6gAwIBAgIQZIKe/DcedF38l/+XyLH/QTANBgkqhkiG9w0BAQsFADCB lDELMAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8w HQYDVQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRl YyBDbGFzcyAyIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 IC0gRzYwHhcNMTExMDE4MDAwMDAwWhcNMzcxMjAxMjM1OTU5WjCBlDELMAkGA1UE BhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZT eW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAy IFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzYwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNzOkFyGOFyz9AYxe9GPo15gRn V2WYKaRPyVyPDzTS+NqoE2KquB5QZ3iwFkygOakVeq7t0qLA8JA3KRgmXOgNPLZs ST/B4NzZS7YUGQum05bh1gnjGSYc+R9lS/kaQxwAg9bQqkmi1NvmYji6UBRDbfkx +FYW2TgCkc/rbN27OU6Z4TBnRfHU8I3D3/7yOAchfQBeVkSz5GC9kSucq1sEcg+y KNlyqwUgQiWpWwNqIBDMMfAr2jUs0Pual07wgksr2F82owstr2MNHSV/oW5cYqGN KD6h/Bwg+AEvulWaEbAZ0shQeWsOagXXqgQ2sqPy4V93p3ec5R7c6d9qwWVdAgMB AAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW BBSHjCCVyJhK0daABkqQNETfHE2/sDANBgkqhkiG9w0BAQsFAAOCAQEAgY6ypWaW tyGltu9vI1pf24HFQqV4wWn99DzX+VxrcHIa/FqXTQCAiIiCisNxDY7FiZss7Y0L 0nJU9X3UXENX6fOupQIR9nYrgVfdfdp0MP1UR/bgFm6mtApI5ud1Bw8pGTnOefS2 bMVfmdUfS/rfbSw8DVSAcPCIC4DPxmiiuB1w2XaM/O6lyc+tHc+ZJVdaYkXLFmu9 Sc2lo4xpeSWuuExsi0BmSxY/zwIa3eFsawdhanYVKZl/G92IgMG/tY9zxaaWI4Sm KIYkM2oBLldzJbZev4/mHWGoQClnHYebHX+bn5nNMdZUvmK7OaxoEkiRIKXLsd3+ b/xa5IJVWa8xqQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDcTCCAlmgAwIBAgIVAOYJ/nrqAGiM4CS07SAbH+9StETRMA0GCSqGSIb3DQEB BQUAMFAxCzAJBgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGlj emVuaW93YSBTLkEuMRcwFQYDVQQDDA5TWkFGSVIgUk9PVCBDQTAeFw0xMTEyMDYx MTEwNTdaFw0zMTEyMDYxMTEwNTdaMFAxCzAJBgNVBAYTAlBMMSgwJgYDVQQKDB9L cmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRcwFQYDVQQDDA5TWkFGSVIg Uk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKxHL49ZMTml 6g3wpYwrvQKkvc0Kc6oJ5sxfgmp1qZfluwbv88BdocHSiXlY8NzrVYzuWBp7J/9K ULMAoWoTIzOQ6C9TNm4YbA9A1jdX1wYNL5Akylf8W5L/I4BXhT9KnlI6x+a7BVAm nr/Ttl+utT/Asms2fRfEsF2vZPMxH4UFqOAhFjxTkmJWf2Cu4nvRQJHcttB+cEAo ag/hERt/+tzo4URz6x6r19toYmxx4FjjBkUhWQw1X21re//Hof2+0YgiwYT84zLb eqDqCOMOXxvH480yGDkh/QoazWX3U75HQExT/iJlwnu7I1V6HXztKIwCBjsxffbH 3jOshCJtywcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AQYwHQYDVR0OBBYEFFOSo33/gnbwM9TrkmdHYTMbaDsqMA0GCSqGSIb3DQEBBQUA A4IBAQA5UFWd5EL/pBviIMm1zD2JLUCpp0mJG7JkwznIOzawhGmFFaxGoxAhQBEg haP+E0KR66oAwVC6xe32QUVSHfWqWndzbODzLB8yj7WAR0cDM45ZngSBPBuFE3Wu GLJX9g100ETfIX+4YBR/4NR/uvTnpnd9ete7Whl0ZfY94yuu4xQqB5QFv+P7IXXV lTOjkjuGXEcyQAjQzbFaT9vIABSbeCXWBbjvOXukJy6WgAiclzGNSYprre8Ryydd fmjW9HIGwsIO03EldivvqEYL1Hv1w/Pur+6FUEOaL68PEIUovfgwIB2BAw+vZDuw cH0mX548PojGyg434cDjkSXa3mHF -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEMTCCAxmgAwIBAgIBADANBgkqhkiG9w0BAQUFADCBlTELMAkGA1UEBhMCR1Ix RDBCBgNVBAoTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1 dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUAwPgYDVQQDEzdIZWxsZW5pYyBBY2FkZW1p YyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIFJvb3RDQSAyMDExMB4XDTExMTIw NjEzNDk1MloXDTMxMTIwMTEzNDk1MlowgZUxCzAJBgNVBAYTAkdSMUQwQgYDVQQK EztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIENl cnQuIEF1dGhvcml0eTFAMD4GA1UEAxM3SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl c2VhcmNoIEluc3RpdHV0aW9ucyBSb290Q0EgMjAxMTCCASIwDQYJKoZIhvcNAQEB BQADggEPADCCAQoCggEBAKlTAOMupvaO+mDYLZU++CwqVE7NuYRhlFhPjz2L5EPz dYmNUeTDN9KKiE15HrcS3UN4SoqS5tdI1Q+kOilENbgH9mgdVc04UfCMJDGFr4PJ fel3r+0ae50X+bOdOFAPplp5kYCvN66m0zH7tSYJnTxa71HFK9+WXesyHgLacEns bgzImjeN9/E2YEsmLIKe0HjzDQ9jpFEw4fkrJxIH2Oq9GGKYsFk3fb7u8yBRQlqD 75O6aRXxYp2fmTmCobd0LovUxQt7L/DICto9eQqakxylKHJzkUOap9FNhYS5qXSP FEDH3N6sQWRstBmbAmNtJGSPRLIl6s5ddAxjMlyNh+UCAwEAAaOBiTCBhjAPBgNV HRMBAf8EBTADAQH/MAsGA1UdDwQEAwIBBjAdBgNVHQ4EFgQUppFC/RNhSiOeCKQp 5dgTBCPuQSUwRwYDVR0eBEAwPqA8MAWCAy5ncjAFggMuZXUwBoIELmVkdTAGggQu b3JnMAWBAy5ncjAFgQMuZXUwBoEELmVkdTAGgQQub3JnMA0GCSqGSIb3DQEBBQUA A4IBAQAf73lB4XtuP7KMhjdCSk4cNx6NZrokgclPEg8hwAOXhiVtXdMiKahsog2p 6z0GW5k6x8zDmjR/qw7IThzh+uTczQ2+vyT+bOdrwg3IBp5OjWEopmr95fZi6hg8 TqBTnbI6nOulnJEWtk2C4AwFSKls9cz4y51JtPACpf1wA+2KIaWuE4ZJwzNzvoc7 dIsXRSZMFpGD/md9zU1jZ/rzAxKWeAaNsWftjj++n08C9bMJL/NMh98qy5V8Acys Nnq/onN694/BtZqhFLKPM58N7yLcZnuEvUUXBj08yrl3NI/K6s8/MT7jiOOASSXI l7WdmplNsDz4SgCbZN2fOUvRJ9e4 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQEL BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00 MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEgRzMwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakEPBtV wedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWe rNrwU8lmPNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF341 68Xfuw6cwI2H44g4hWf6Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh 4Pw5qlPafX7PGglTvF0FBM+hSo+LdoINofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXp UhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/lg6AnhF4EwfWQvTA9xO+o abw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV7qJZjqlc 3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/G KubX9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSt hfbZxbGL0eUQMk1fiyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KO Tk0k+17kBL5yG6YnLUlamXrXXAkgt3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOt zCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB BjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZIhvcNAQELBQAD ggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2 cDMT/uFPpiN3GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUN qXsCHKnQO18LwIE6PWThv6ctTr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5 YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP+V04ikkwj+3x6xn0dxoxGE1nVGwv b2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh3jRJjehZrJ3ydlo2 8hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fawx/k NSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNj ZgKAvQU6O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhp q1467HxpvMc7hU6eFbm0FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFt nh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOVhMJKzRwuJIczYOXD -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQEL BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00 MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIgRzMwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFhZiFf qq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMW n4rjyduYNM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ym c5GQYaYDFCDy54ejiK2toIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+ O7q414AB+6XrW7PFXmAqMaCvN+ggOp+oMiwMzAkd056OXbxMmO7FGmh77FOm6RQ1 o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+lV0POKa2Mq1W/xPtbAd0j IaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZoL1NesNKq IcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz 8eQQsSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43eh vNURG3YBZwjgQQvD6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l 7ZizlWNof/k19N+IxWA1ksB8aRxhlRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALG cC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB BjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZIhvcNAQELBQAD ggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RC roijQ1h5fq7KpVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0Ga W/ZZGYjeVYg3UQt4XAoeo0L9x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4n lv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgzdWqTHBLmYF5vHX/JHyPLhGGfHoJE +V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6XU/IyAgkwo1jwDQHV csaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+NwmNtd dbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNg KCLjsZWDzYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeM HVOyToV7BjjHLPj4sHKNJeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4 WSr2Rz0ZiC3oheGe7IUIarFsNMkd7EgrO3jtZsSOeWmD3n+M -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQEL BQAwSDELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAc BgNVBAMTFVF1b1ZhZGlzIFJvb3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00 MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM aW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMgRzMwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286IxSR /xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNu FoM7pmRLMon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXR U7Ox7sWTaYI+FrUoRqHe6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+c ra1AdHkrAj80//ogaX3T7mH1urPnMNA3I4ZyYUUpSFlob3emLoG+B01vr87ERROR FHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3UVDmrJqMz6nWB2i3ND0/k A9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f75li59wzw eyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634Ryl sSqiMd5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBp VzgeAVuNVejH38DMdyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0Q A4XN8f+MFrXBsj6IbGB/kE+V9/YtrQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB BjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZIhvcNAQELBQAD ggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnI FUBhynLWcKzSt/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5Wvv oxXqA/4Ti2Tk08HS6IT7SdEQTXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFg u/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9DuDcpmvJRPpq3t/O5jrFc/ZSXPsoaP 0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGibIh6BJpsQBJFxwAYf 3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmDhPbl 8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+ DhcI00iX0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HN PlopNLk9hM6xZdRZkZFWdSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ ywaZWWDYWGWVjUTR939+J399roD1B0y2PpxxVJkES/1Y+Zj0 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEBDCCAuygAwIBAgIIGHqpqMKWIQwwDQYJKoZIhvcNAQELBQAwYjELMAkGA1UE BhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAkBgNVBAsTHUFwcGxlIENlcnRp ZmljYXRpb24gQXV0aG9yaXR5MRYwFAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTEy MDIwMTIyMTIxNVoXDTI3MDIwMTIyMTIxNVoweTEtMCsGA1UEAwwkRGV2ZWxvcGVy IElEIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSYwJAYDVQQLDB1BcHBsZSBDZXJ0 aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UE BhMCVVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCJdk8GW5pB7qUj KwKjX9dzP8A1sIuECj8GJH+nlT/rTw6Tr7QO0Mg+5W0Ysx/oiUe/1wkI5P9WmCkV 55SduTWjCs20wOHiYPTK7Cl4RWlpYGtfipL8niPmOsIiszFPHLrytjRZQu6wqQID GJEEtrN4LjMfgEUNRW+7Dlpbfzrn2AjXCw4ybfuGNuRsq8QRinCEJqqfRNHxuMZ7 lBebSPcLWBa6I8WfFTl+yl3DMl8P4FJ/QOq+rAhklVvJGpzlgMofakQcbD7EsCYf Hex7r16gaj1HqVgSMT8gdihtHRywwk4RaSaLy9bQEYLJTg/xVnTQ2QhLZniiq6yn 4tJMh1nJAgMBAAGjgaYwgaMwHQYDVR0OBBYEFFcX7aLP3HyYoRDg/L6HLSzy4xdU MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/ CF4wLgYDVR0fBCcwJTAjoCGgH4YdaHR0cDovL2NybC5hcHBsZS5jb20vcm9vdC5j cmwwDgYDVR0PAQH/BAQDAgGGMBAGCiqGSIb3Y2QGAgYEAgUAMA0GCSqGSIb3DQEB CwUAA4IBAQBCOXRrodzGpI83KoyzHQpEvJUsf7xZuKxh+weQkjK51L87wVA5akR0 ouxbH3Dlqt1LbBwjcS1f0cWTvu6binBlgp0W4xoQF4ktqM39DHhYSQwofzPuAHob tHastrW7T9+oG53IGZdKC1ZnL8I+trPEgzrwd210xC4jUe6apQNvYPSlSKcGwrta 4h8fRkV+5Jf1JxC3ICJyb3LaxlB1xT0lj12jAOmfNoxIOY+zO+qQgC6VmmD0eM70 DgpTPqL6T9geroSVjTK8Vk2J6XgY4KyaQrp6RhuEoonOFOiI0ViL9q5WxCwFKkWv C9lLqQIPNKyIx2FViUTJJ3MH7oLlTvVw -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDHjCCAgagAwIBAgIDB7HTMA0GCSqGSIb3DQEBDQUAMDcxCzAJBgNVBAYTAlNJ MQ8wDQYDVQQKEwZIYWxjb20xFzAVBgNVBAMTDkhhbGNvbSBSb290IENBMB4XDTEy MDIwODA5NTU0MVoXDTMyMDIwODA5NTU0MVowNzELMAkGA1UEBhMCU0kxDzANBgNV BAoTBkhhbGNvbTEXMBUGA1UEAxMOSGFsY29tIFJvb3QgQ0EwggEiMA0GCSqGSIb3 DQEBAQUAA4IBDwAwggEKAoIBAQCJuYXK/vR1fX/snUI3urqNvOw9FwP92UVl1s3J Tl+MSFyXCFcUiy2cPJBJmc9pr0mN2xwBsG7p9OqRZ13Ks2lP2MzBDT3uqgN24Mlw op/+65vQtsmW0/D7W9DwB6tMXk2k4kdeBWh0po4iR+5+02eEVDeSRw7zo+wVGvNt e78ZNSGPgkusVJwJzW62wVe90Ek9b59zjrFsfr3+1rs9A+jmTBq07q+0g04ykFT2 ThvhL86lNBqOoyD52T4ia29u4/rZM1wIoPcVAD2cEJJKVc2Asgaq/dePt1qSJyQP MzwouvEfaLV3KV6uwtqNNnDiejIbI6bexWENmqUSILXzllm1AgMBAAGjMzAxMA8G A1UdEwEB/wQFMAMBAf8wEQYDVR0OBAoECE6U2Ipjws95MAsGA1UdDwQEAwIBBjAN BgkqhkiG9w0BAQ0FAAOCAQEAKb7nseT6A6IPr3ZZnfhOU008BIOfoeKM9pTZtK5o KlZrMlMogwdyTLBOqB2BgyFnAzfRjMbBToTpNDvT9fUnto0jBVK4TDLyLtrRKn0+ gwMq0rHjmumKg0LwLAqhUw/AK+KPGk6VuUW8S2c6vTLzraWPj8Mu6vb0e2LQbm7F YTETZuZnSZk7L4BPenxzigMNX/WzMigKisDh+bijJu7cG1fPdhpPU772SotXFysv mYaq3ozatqhs32g21mGLbsBzTrc5RfR9zknE8x35qXds7++SFRMnmUbon6mKG58p L6IdPtYrx+RVEDoY97N7Ty7HACLt5DHQ57jkVE/BgEUlbg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDvTCCAqWgAwIBAgIQD2tVL56/kHsPZimpvfTYzjANBgkqhkiG9w0BAQsFADBk MQswCQYDVQQGEwJVUzEdMBsGA1UEChMUU3ltYW50ZWMgQ29ycG9yYXRpb24xNjA0 BgNVBAMTLVN5bWFudGVjIEVudGVycHJpc2UgTW9iaWxlIFJvb3QgZm9yIE1pY3Jv c29mdDAeFw0xMjAzMTUwMDAwMDBaFw0zMjAzMTQyMzU5NTlaMGQxCzAJBgNVBAYT AlVTMR0wGwYDVQQKExRTeW1hbnRlYyBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtU3lt YW50ZWMgRW50ZXJwcmlzZSBNb2JpbGUgUm9vdCBmb3IgTWljcm9zb2Z0MIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtT2wcu6R6aVpnBFNevmz+j3ylJsj t6YD7GIY/IUSIv7BcX1Uk7mRfWL2yqg4FWX4dz3lgiA61LXRbo0GSb3fgg4khefv eC0Y8uALaEY+JBDIV+4ObXGm07FWHNcp1bLqVAUKqDyhuCVSBwWg3+fc7lw7QbWr XDMy0s7r6Zb4QPQKujMd+FYDCYL1ZwfEwDTBXfxFu+o8mtV0cW3VhtPC/IW8VOuj 1fJP1UWvV7zwIsCPokXIdTR33qFtN3Kzc40Ma1O6WeGoPoBX0l9Z7mh1z4Gco8pF jDfbBXI0HDIC+NX5LA3aWJ7EF7SbyZDEiFk/cZGQRBi+Iot5ki5CsIuXWwIDAQAB o2swaTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAnBgNVHREEIDAe pBwwGjEYMBYGA1UEAxMPTVBLSS0yMDQ4LTEtMTExMB0GA1UdDgQWBBRN7N8mBtwk EMC2mfTXOcdvGfgmKDANBgkqhkiG9w0BAQsFAAOCAQEAqVdZ0AFUFEavx3lUDGoq W9g6HYHkiKMxtPHzNfFGc1xDyf68omoZwL0vX8s4o21u6BRe8nh+RXrhu/Qum0Xr 4B1QHDRbf5iKhg+H2uRkJnf8Cd8jQU8On/oO+kSF8CmXpJTi9EAtkRx29Khg3nGm sAXiT2nZGQuJOuD6qyv68bMy7fx8cGVe0HsRe53oWxpKdqR7UTmsfakMdDjou1Xf xM7ApyFauBufAcWnEP59+WoImQHR9jVQOOT2Q+QY2IBM7McE4mGMfUntz7Sl8fKQ kgkINXOgIzLK6ZyeHL4LByx3XhdM2pyC4YAbpfPa94i/vzkn+CT+sUvIl+3kEhQl iA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkzCCA3ugAwIBAgIRAJBmYahiPWVEdwQ/cZrDlwwwDQYJKoZIhvcNAQEFBQAw OzELMAkGA1UEBhMCU0UxETAPBgNVBAoMCEluZXJhIEFCMRkwFwYDVQQDDBBTSVRI UyBSb290IENBIHYxMB4XDTEyMDMyOTA3NTQ0OVoXDTMyMDMyOTA3NTQ0OVowOzEL MAkGA1UEBhMCU0UxETAPBgNVBAoMCEluZXJhIEFCMRkwFwYDVQQDDBBTSVRIUyBS b290IENBIHYxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwO3mnqis qP/YNbn8+/CVTz89RyPRksnJ+PDiH4atiD/gAM9PEZVhPaXWIBnRiNLCVglFIKEq 6iLD6rrMQmmeuIWfcMBsp75vo1zdQ4gHzcop32l6Hy2fVmobYiAhYcZQS2V1SUa/ XNcpHsIehULhDjhNwzZxQkRROtFYzMm0qmxAx4PxxwmfSvNr8wcWNfSCjl6LhNxx ebn7bldFt8VwOv9CAtE0v4VwbU+P5x8ZIffVNLzuWeYuIvNxgmIZnwVkfDsicRil LcF4WJnRr96UQAYZdhNQhyPLR1eubMUT6pqFUsPKVyYf3hZtrXF+8thh/eY2TnEa ndMgNa0SIVh1NouJFqQ3KM+ggzpAo8oR77TlkBvjZZJnmG8OKeVnGNeI+o22x3ql oH+RHqu2+XSYdlJgL1o3majb0T7WhGpvUtO02hrHuLLRlBEfxYiJ6Vupo5Tmon1N pzKJod4ma83Vo/IyG9o1E4kRSU2/RjG76S0T+A4Apf4D9VZGPI8TK+Dlxx4D34rq RoVFhtntXgu4ZJP00FguKY1FV02JdZBlzGo7wZyAubSANQOO324qk76mvgoBRG9A c6oqghyEdn9p3bG7kljoQFFyXPc+OUT6pZmgf42LsEFYd60ixaDAuv0xmTVq2ckg Gl7zvbwIf91JLS+dkRANW6g/z7RXcztb4GcCAwEAAaOBkTCBjjAPBgNVHRMBAf8E BTADAQH/MEwGA1UdIARFMEMwQQYJKoVwSggBAgEBMDQwMgYIKwYBBQUHAgEWJmh0 dHA6Ly9jcHMuc2l0aHMuc2Uvc2l0aHNyb290Y2F2MS5odG1sMA4GA1UdDwEB/wQE AwIBBjAdBgNVHQ4EFgQUMvmdT2npmI2g1ox9+R3Oozy6dhUwDQYJKoZIhvcNAQEF BQADggIBAB8/43hYyArKNCIJ2LIFi9FlnOHX130KwByYpSRSODPaZCIjgK7+PYC+ T4/dg/YNTDNa1aM7UIpSWiYUc1GU5FKXY9u3Bqjvj63i7d6jvyDRRtsteOgsJ0Sc POy3F/yJl/Ojol7CWVPgz+S1ATtjUyjTr2ZLNDmvYQ4+m+6zidaToDsBxLMjVBA8 TdeqsNrZbMowRC3dsihiikFg8kATbLB8PkHgi6Y08eeuUYcDjpl/2Wii9pwNeYKy n98kyGZg6LZIRCfIa1a3RIXOArfTinFcV1FXIYzqwlEPUD+AqwRNyVLd5KXyLh9t dbqHHZAL7hiEgHO7i5WEimENTl1in+NmDPs2DifTSPgGiAalX+5+XN2tCh09HKpA eZh5uFCMNo0LCjYL1T7nXYHdbNxtsW8NdJ4sL8IF8kQRsjP6gcVKbT5F1izia18u 5EOVURuZMQXfJRtz0XucxHNJ+2Jg2Wlj3dE+ZW1H+mRMA1hQ2aa+5Spo6z+LEPHm uyIGKJqgpJhpbza01A0ODH3AKTG7LAMn4WenvdGLLraHxArgCQuCoeZPWJ372Phh 4cqXxLi3UDnMMU79LRwa9kfjbOwbBeh/FzUQhNoz5zTmtaTrxCIHSvabWNgPnED7 sYtfov2Z6qJ7WWLRXq7RSnIYK0s2OXIHmlrwYzrPG/nP3UhzWXDk -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIExTCCA62gAwIBAgIESbY1GDANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJT QTEyMDAGA1UECgwpTmF0aW9uYWwgQ2VudGVyIGZvciBEaWdpdGFsIENlcnRpZmlj YXRpb24xHzAdBgNVBAsMFlNhdWRpIE5hdGlvbmFsIFJvb3QgQ0EwHhcNMTIwNDI5 MDY1NTIwWhcNMjkxMTI5MDcyNTIwWjBiMQswCQYDVQQGEwJTQTEyMDAGA1UECgwp TmF0aW9uYWwgQ2VudGVyIGZvciBEaWdpdGFsIENlcnRpZmljYXRpb24xHzAdBgNV BAsMFlNhdWRpIE5hdGlvbmFsIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB DwAwggEKAoIBAQDj5CziDK+WOay1n4cRF/Ojv4FFDfMaDLoy4kzop4bbXNK52zVK Ls1+cYIk+twf8uS8zrfG4sreKWjP7yRbv6YVz57jaUuUufz7nNhjpblp383u3Mhc wKD+KRWTvz2Gg1W1lhy9p3DatwXkOZO/pXnk9ZNGGPLbDecqd2YMgCdKPjzdT5A1 xmuBqj1vCaWMLiFXC7AKkOqhHvpYDUmnzyuyqMA46RPalFhAki/lOL22iSZzhIGN 60pZNDB4KuqLFkjBN5J1mI0KSi5/2xKO1ik5MCvuvYC2KOlXcBSCfYST/gk1vGD1 GHVQlBQkWkwYlxNCogT8mb2oWpvRZ7McG/KfAgMBAAGjggGBMIIBfTAOBgNVHQ8B Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAzBggrBgEFBQcBAQQnMCUwIwYIKwYB BQUHMAGGF2h0dHA6Ly9vY3NwLm5jZGMuZ292LnNhMIHkBgNVHR8Egdwwgdkwgaag gaOggaCGKWh0dHA6Ly93ZWIubmNkYy5nb3Yuc2EvY3JsL25yY2FwYXJ0YTEuY3Js pHMwcTELMAkGA1UEBhMCU0ExMjAwBgNVBAoMKU5hdGlvbmFsIENlbnRlciBmb3Ig RGlnaXRhbCBDZXJ0aWZpY2F0aW9uMR8wHQYDVQQLDBZTYXVkaSBOYXRpb25hbCBS b290IENBMQ0wCwYDVQQDDARDUkwxMC6gLKAqhihodHRwOi8vd2ViLm5jZGMuZ292 LnNhL2NybC9ucmNhY29tYjEuY3JsMB8GA1UdIwQYMBaAFPyZmEEX4/M9Hv23cqm/ oxbkKumqMB0GA1UdDgQWBBT8mZhBF+PzPR79t3Kpv6MW5CrpqjANBgkqhkiG9w0B AQsFAAOCAQEALpUOix3h+/qcQm1Ai7/f7DMESwUOXCI2H6QClDh1/AhZm52FvznN m86ATFaGmU1zZvW2Asm0JEiPC2Pzjn8xgZt8WXeRtSMIeXptPsXVD0eCsO+XLic0 uYfR1AV8Xz0hN6R/yavRmJD3S5EYrsTpI4nou2DGS88L2PcrfSWM4DZk5KuqeD02 +qL0SZIDtRnu13JgsP7JB2q4YAWZP31WBHBI3TPGSOkB88LqRXGaQ1r9vhkzM4ne PFjJEodWE2EmHpEQQ3y8Hgw+0Fp8SX523G4BHUuSqdlm5Xod9LiLYC7slSz/TWTI 7CUAD9jzEqpL1/PSBmXeLdniE6YHskWu6g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEHjCCAwagAwIBAgIET7PQ7jANBgkqhkiG9w0BAQUFADCBiTELMAkGA1UEBhMC WkExETAPBgNVBAoTCExBV3RydXN0MTIwMAYDVQQLEylMQVcgVHJ1c3RlZCBUaGly ZCBQYXJ0eSBTZXJ2aWNlcyBQVFkgTHRkLjEzMDEGA1UEAxMqTEFXdHJ1c3QgUm9v dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAyMDQ4MB4XDTEyMDUxNjE1NDAxOFoX DTMyMDUxNjE2MTAxOFowgYkxCzAJBgNVBAYTAlpBMREwDwYDVQQKEwhMQVd0cnVz dDEyMDAGA1UECxMpTEFXIFRydXN0ZWQgVGhpcmQgUGFydHkgU2VydmljZXMgUFRZ IEx0ZC4xMzAxBgNVBAMTKkxBV3RydXN0IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo b3JpdHkgMjA0ODCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKTckbEK FR42rhFERZfVJTWHixsK0c9w+iZBsfxKDahatWan3B9uHQjppoYLZkRcuFCiMJYC C4jIFVQXr/rX5GoPgMfO5eimmbJLf5JNNmVU7iEwI+QPx0LnXcwvGz5rCqc+0Y8H Lti3+s8YVTWZs9BSuw3nqUsb+/tG/wEJsjdPsf15Ovg27GMq3Ps48bfoYeCR0rt4 FTZ0vR21Xtm9tm4I/Hn2un/kHC1AvR22A6QCyOtqGNt3ZWe1k2o64N0kV6uB4v1x 19de7Y78YMXnufwjprlr99zTJgKabuADhfvFp8ZR7MlpE/QWC+00ASIje90rQZap Okzqald1KwsPFD8CAwEAAaOBizCBiDArBgNVHRAEJDAigA8yMDEyMDUxNjE1NDAx OFqBDzIwMzIwNTE2MTYxMDE4WjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUXN46 MzRJZMSSMXxVXvXyO0/uwx0wHQYDVR0OBBYEFFzeOjM0SWTEkjF8VV718jtP7sMd MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJYl5BxGneuWSlaE5zbA r7IxxqtnyTv3X3GZZK5U4w1KccxcfNI1u0cSx7PEkW1UCTbFREaCF1InNnmLukSU tIJxZdM1Vf7Drj8j9vpFho1VjvbHmc/PP+RHepzwqVQIuqQ/lIxALIQkAyJFx3Ep GFxV/O9dh/2nmoMD3L++jESN6/FiWlNpjYADYLMP53hDTKnZsXJAy1hEx3Xo1oni Sv73kKyE9ybEQOGUuFPcsgPyJiQXZc2yxtOTncJhG1GfzSQbALNltD5qs98Gha2c h3bc08fCFrHFult+FUU9Nnuc8yanErD2np40mrN3C6pHDoXsFWENtjplBI59Oz+I c88= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcx EjAQBgNVBAoTCVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMT VFdDQSBHbG9iYWwgUm9vdCBDQTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5 NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQKEwlUQUlXQU4tQ0ExEDAOBgNVBAsT B1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3QgQ0EwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2CnJfF 10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz 0ALfUPZVr2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfCh MBwqoJimFb3u/Rk28OKRQ4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbH zIh1HrtsBv+baz4X7GGqcXzGHaL3SekVtTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc 46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1WKKD+u4ZqyPpcC1jcxkt2 yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99sy2sbZCi laLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYP oA/pyJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQA BDzfuBSO6N+pjWxnkjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcE qYSjMq+u7msXi7Kx/mzhkIyIqJdIzshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm 4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB /zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6gcFGn90xHNcgL 1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WF H6vPNOw/KP4M8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNo RI2T9GRwoD2dKAXDOXC4Ynsg/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+ nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlglPx4mI88k1HtQJAH32RjJMtOcQWh 15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryPA9gK8kxkRr05YuWW 6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3mi4TW nsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5j wa19hAM8EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWz aGHQRiapIVJpLesux+t3zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmy KwbQBM0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFaTCCA1GgAwIBAgIJAMMDmu5QkG4oMA0GCSqGSIb3DQEBBQUAMFIxCzAJBgNV BAYTAlNLMRMwEQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMu MRkwFwYDVQQDExBDQSBEaXNpZyBSb290IFIxMB4XDTEyMDcxOTA5MDY1NloXDTQy MDcxOTA5MDY1NlowUjELMAkGA1UEBhMCU0sxEzARBgNVBAcTCkJyYXRpc2xhdmEx EzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERpc2lnIFJvb3QgUjEw ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCqw3j33Jijp1pedxiy3QRk D2P9m5YJgNXoqqXinCaUOuiZc4yd39ffg/N4T0Dhf9Kn0uXKE5Pn7cZ3Xza1lK/o OI7bm+V8u8yN63Vz4STN5qctGS7Y1oprFOsIYgrY3LMATcMjfF9DCCMyEtztDK3A fQ+lekLZWnDZv6fXARz2m6uOt0qGeKAeVjGu74IKgEH3G8muqzIm1Cxr7X1r5OJe IgpFy4QxTaz+29FHuvlglzmxZcfe+5nkCiKxLU3lSCZpq+Kq8/v8kiky6bM+TR8n oc2OuRf7JT7JbvN32g0S9l3HuzYQ1VTW8+DiR0jm3hTaYVKvJrT1cU/J19IG32PK /yHoWQbgCNWEFVP3Q+V8xaCJmGtzxmjOZd69fwX3se72V6FglcXM6pM6vpmumwKj rckWtc7dXpl4fho5frLABaTAgqWjR56M6ly2vGfb5ipN0gTco65F97yLnByn1tUD 3AjLLhbKXEAz6GfDLuemROoRRRw1ZS0eRWEkG4IupZ0zXWX4Qfkuy5Q/H6MMMSRE 7cderVC6xkGbrPAXZcD4XW9boAo0PO7X6oifmPmvTiT6l7Jkdtqr9O3jw2Dv1fkC yC2fg69naQanMVXVz0tv/wQFx1isXxYb5dKj6zHbHzMVTdDypVP1y+E9Tmgt2BLd qvLmTZtJ5cUoobqwWsagtQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud DwEB/wQEAwIBBjAdBgNVHQ4EFgQUiQq0OJMa5qvum5EY+fU8PjXQ04IwDQYJKoZI hvcNAQEFBQADggIBADKL9p1Kyb4U5YysOMo6CdQbzoaz3evUuii+Eq5FLAR0rBNR xVgYZk2C2tXck8An4b58n1KeElb21Zyp9HWc+jcSjxyT7Ff+Bw+r1RL3D65hXlaA SfX8MPWbTx9BLxyE04nH4toCdu0Jz2zBuByDHBb6lM19oMgY0sidbvW9adRtPTXo HqJPYNcHKfyyo6SdbhWSVhlMCrDpfNIZTUJG7L399ldb3Zh+pE3McgODWF3vkzpB emOqfDqo9ayk0d2iLbYq/J8BjuIQscTK5GfbVSUZP/3oNn6z4eGBrxEWi1CXYBmC AMBrTXO40RMHPuq2MU/wQppt4hF05ZSsjYSVPCGvxdpHyN85YmLLW1AL14FABZyb 7bq2ix4Eb5YgOe2kfSnbSM6C3NQCjR0EMVrHS/BsYVLXtFHCgWzN4funodKSds+x DzdYpPJScWc/DIh4gInByLUfkmO+p3qKViwaqKactV2zY9ATIKHrkWzQjX2v3wvk F7mGnjixlAxYjOBVqjtjbZqJYLhkKpLGN/R+Q0O3c+gB53+XD9fyexn9GtePyfqF a3qdnom2piiZk4hA9z7NUaPK6u95RyG1/jLix8NRb76AdPCkwzryT+lf3xkK8jsT Q6wxpLPn6/wY1gGp8yqPNg7rtLG8t0zJa7+h89n07eLw4+1knj0vllJPgFOL -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNV BAYTAlNLMRMwEQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMu MRkwFwYDVQQDExBDQSBEaXNpZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQy MDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sxEzARBgNVBAcTCkJyYXRpc2xhdmEx EzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERpc2lnIFJvb3QgUjIw ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbCw3Oe NcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNH PWSb6WiaxswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3I x2ymrdMxp7zo5eFm1tL7A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbe QTg06ov80egEFGEtQX6sx3dOy1FU+16SGBsEWmjGycT6txOgmLcRK7fWV8x8nhfR yyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqVg8NTEQxzHQuyRpDRQjrO QG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa5Beny912 H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJ QfYEkoopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUD i/ZnWejBBhG93c+AAk9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORs nLMOPReisjQS1n6yqEm70XooQL6iFh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1 rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud DwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5uQu0wDQYJKoZI hvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqf GopTpti72TVVsRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkb lvdhuDvEK7Z4bLQjb/D907JedR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka +elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W81k/BfDxujRNt+3vrMNDcTa/F1bal TFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjxmHHEt38OFdAlab0i nSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01utI3 gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18Dr G5gPcFw0sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3Os zMOl6W8KjptlwlCFtaOgUxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8x L4ysEr3vQCj8KWefshNPZiTEUxnpHikV7+ZtsH8tZ/3zbBt1RqPlShfppNcL -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJD TjEwMC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9y aXR5MRUwEwYDVQQDDAxDRkNBIEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkx MjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEwMC4GA1UECgwnQ2hpbmEgRmluYW5j aWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNBIEVWIFJP T1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnVBU03 sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpL TIpTUnrD7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5 /ZOkVIBMUtRSqy5J35DNuF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp 7hZZLDRJGqgG16iI0gNyejLi6mhNbiyWZXvKWfry4t3uMCz7zEasxGPrb382KzRz EpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7xzbh72fROdOXW3NiGUgt hxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9fpy25IGvP a931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqot aK8KgWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNg TnYGmE69g60dWIolhdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfV PKPtl8MeNPo4+QgO48BdK4PRVmrJtqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hv cWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAfBgNVHSMEGDAWgBTj/i39KNAL tbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAd BgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObT ej/tUxPQ4i9qecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdL jOztUmCypAbqTuv0axn96/Ua4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBS ESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sGE5uPhnEFtC+NiWYzKXZUmhH4J/qy P5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfXBDrDMlI1Dlb4pd19 xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjnaH9d Ci77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN 5mydLIhyPDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe /v5WOaHIz16eGWRGENoXkbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+Z AAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3CekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ 5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID6DCCAtCgAwIBAgIQXCwtpvvXVopPgEL9qP3rJjANBgkqhkiG9w0BAQsFADCB jTELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1dlbGxzIEZhcmdvIFdlbGxzU2VjdXJl MRwwGgYDVQQLExNXZWxscyBGYXJnbyBCYW5rIE5BMT4wPAYDVQQDEzVXZWxsc1Nl Y3VyZSBQdWJsaWMgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAwMSBHMjAe Fw0xMjA4MDkxNjM2NDVaFw0zMDEyMjYxNjQ2MzNaMIGNMQswCQYDVQQGEwJVUzEg MB4GA1UEChMXV2VsbHMgRmFyZ28gV2VsbHNTZWN1cmUxHDAaBgNVBAsTE1dlbGxz IEZhcmdvIEJhbmsgTkExPjA8BgNVBAMTNVdlbGxzU2VjdXJlIFB1YmxpYyBSb290 IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IDAxIEcyMIIBIjANBgkqhkiG9w0BAQEF AAOCAQ8AMIIBCgKCAQEAwyWEb3XDCHkxFHAv0geLGyu5Ao0guOzJ+S4atX73DJUc x9qNric9AxqppOpwiThrd45xOhlAjKmzFPwB7Fti2IpnvXT2eFdJ53SATQrXDLM3 RntUn1xBYvIuDifZT4hcuBUa2VvZMJ3Aqag2tdeULyptO19o6yN3Vs4IP/6Y3nuX 8diTmRFCCYUGWwdb9Pkkk9Z6vc//AT6P0/8cvVI7MREroqyvwJHslOf0VAmMB2gN icZ1ub42Zm0jmiZDjbX/gikaU/X3IIJQJBX8t1JGdUdpAwtL1QPvsTx+Hjxk0i2B o2j0VFrXtkFn4u6TTgcoAyVfvFhqGhGHg1+EZd3TFQIDAQABo0IwQDAOBgNVHQ8B Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUIckl02r+pBL/5t1X V8+PPnKECM0wDQYJKoZIhvcNAQELBQADggEBAArE2ntHUGAvYnFg+yWyyCGyui4a YxW+/SR6HI7lr2AZD29yHWLwh8GuPufUesF30mluNZ46GSWZWMrGtvViK4UJAgnf EwvFUo8rEjug+o9EqIormGCHwgbAzNsaXjGi+L5oKbdB64E96EOY1zOH7zUWidaL mWbECjNWzFmuPMeLwAEVkhy7pNyfmvgEdRnquBkSInGX8OAmrWDERyEsQfwWI+sE HC0F6n7eE4QtaItigOdnwzGafqicKT/xQIFJJ0dP8tb4lqjhlqo3Q+j7oore26FH 4K1RoQNutEB2TJM7IbQRx+lKSWDwXResA3FLrQjVeGjNZxomzljPjzjRG2c= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFSzCCAzOgAwIBAgIRALZLiAfiI+7IXBKtpg4GofIwDQYJKoZIhvcNAQELBQAw PzELMAkGA1UEBhMCVFcxMDAuBgNVBAoMJ0dvdmVybm1lbnQgUm9vdCBDZXJ0aWZp Y2F0aW9uIEF1dGhvcml0eTAeFw0xMjA5MjgwODU4NTFaFw0zNzEyMzExNTU5NTla MD8xCzAJBgNVBAYTAlRXMTAwLgYDVQQKDCdHb3Zlcm5tZW50IFJvb3QgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC AQC2/5c8gb4BWCQnr44BK9ZykjAyG1+bfNTUf+ihYHMwVxAA+lCWJP5Q5ow6ldFX eYTVZ1MMKoI+GFy4MCYa1l7GLbIEUQ7v3wxjR+vEEghRK5lxXtVpe+FdyXcdIOxW juVhYC386RyA3/pqg7sFtR4jEpyCygrzFB0g5AaPQySZn7YKk1pzGxY5vgW28Yyl ZJKPBeRcdvc5w88tvQ7Yy6gOMZvJRg9nU0MEj8iyyIOAX7ryD6uBNaIgIZfOD4k0 eA/PH07p+4woPN405+2f0mb1xcoxeNLOUNFggmOd4Ez3B66DNJ1JSUPUfr0t4urH cWWACOQ2nnlwCjyHKenkkpTqBpIpJ3jmrdc96QoLXvTg1oadLXLLi2RW5vSueKWg OTNYPNyoj420ai39iHPplVBzBN8RiD5C1gJ0+yzEb7xs1uCAb9GGpTJXA9ZN9E4K mSJ2fkpAgvjJ5E7LUy3Hsbbi08J1J265DnGyNPy/HE7CPfg26QrMWJqhGIZO4uGq s3NZbl6dtMIIr69c/aQCb/+4DbvVq9dunxpPkUDwH0ZVbaCSw4nNt7H/HLPLo5wK 4/7NqrwB7N1UypHdTxOHpPaY7/1J1lcqPKZc9mA3v9g+fk5oKiMyOr5u5CI9ByTP isubXVGzMNJxbc5Gim18SjNE2hIvNkvy6fFRCW3bapcOFwIDAQABo0IwQDAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBTVZx3gnHosnMvFmOcdByYqhux0zTAOBgNV HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAJA75cJTQijq9TFOjj2Rnk0J 89ixUuZPrAwxIbvx6pnMg/y2KOTshAcOD06Xu29oRo8OURWV+Do7H1+CDgxxDryR T64zLiNB9CZrTxOH+nj2LsIPkQWXqmrBap+8hJ4IKifd2ocXhuGzyl3tOKkpboTe Rmv8JxlQpRJ6jH1i/NrnzLyfSa8GuCcn8on3Fj0Y5r3e9YwSkZ/jBI3+BxQaWqw5 ghvxOBnhY+OvbLamURfr+kvriyL2l/4QOl+UoEtTcT9a4RD4co+WgN2NApgAYT2N vC2xR8zaXeEgp4wxXPHj2rkKhkfIoT0Hozymc26Uke1uJDr5yTDRB6iBfSZ9fYTf hsmL5a4NHr6JSFEVg5iWL0rrczTXdM3Jb9DCuiv2mv6Z3WAUjhv5nDk8f0OJU+jl wqu+Iq0nOJt3KLejY2OngeepaUXrjnhWzAWEx/uttjB8YwWfLYwkf0uLkvw4Hp+g pVezbp3YZLhwmmBScMip0P/GnO0QYV7Ngw5u6E0CQUridgR51lQ/ipgyFKDdLZzn uoJxo4ZVKZnSKdt1OvfbQ/+2W/u3fjWAjg1srnm3Ni2XUqGwB5wH5Ss2zQOXlL0t DjQG/MAWifw3VOTWzz0TBPKR2ck2Lj7FWtClTILD/y58Jnb38/1FoqVuVa4uzM8s iTTa9g3nkagQ6hed8vbs -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFojCCA4qgAwIBAgIGC4LclDN2MA0GCSqGSIb3DQEBCwUAMHAxCzAJBgNVBAYT AkNBMSswKQYDVQQKEyJDYXJpbGxvbiBJbmZvcm1hdGlvbiBTZWN1cml0eSBJbmMu MSIwIAYDVQQLExlDZXJ0aWZpY2F0aW9uIEF1dGhvcml0aWVzMRAwDgYDVQQDEwdD SVNSQ0ExMB4XDTEyMTAxNjE4MjgzM1oXDTMyMTAxNjE4MjgzM1owcDELMAkGA1UE BhMCQ0ExKzApBgNVBAoTIkNhcmlsbG9uIEluZm9ybWF0aW9uIFNlY3VyaXR5IElu Yy4xIjAgBgNVBAsTGUNlcnRpZmljYXRpb24gQXV0aG9yaXRpZXMxEDAOBgNVBAMT B0NJU1JDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDEdvFial/N Kc0ENn9uYX5z9J1m3yJamoNEgWb9ThGwPqzoiLJTOf/jur7U/9OF2L1br2hPM6y4 FH0SW3qVa8c2/iuP9IhgiTqqWThMwV1VgaXf2B8xetOjTvBRy8Mxh64L3speG6F0 OPCSd3E8yxN+oMEKmL3YuPhUNJhOZxaaV0smhl8bZnKqwfJogp1YQXxxIuLPATH+ 4uBWqWjgrTOvNTkunG4GTPMjdi9pJugFOWm39Uga99/ZOTcyVREnBIEfnTyLjINS d8GuLM0rKkrlLfEZabqHXoud4HHIdNLN7m44N2pdGQDSdt2i6247qh31NgZPX15s whDz3W+12nla/tVGRDRIr4YANHwkhN1FkPkWgqyokdTpRjNvfrpHH+Hvr+VQ1sb5 p+1sl6orKU5dxfge9nTJqyT4DVPHaBW+/FyrPXIL0nAEtxbjaanxZ7rGAEx7gDQ1 Ll7tH6Al96WCahB/v49Zb8NGpspCTkIjhQY5NYy18dfBI0JF/S8lcfjzB9MHaL7b mGwq9qVH97BlYK2ufOYRHSdUCGWw2ILAYWvpfo8i1nEda0EgZdhXmh98DlpU4JSw bXXvKDI1PFXDbWf4JL37QPNanTbZNUy74mvZsTYP5G8gGsVvesOROa+vzPP2vSCG utMkITwfNynmn/wav5jfPLogIRKpwjoqkwIDAQABo0IwQDAdBgNVHQ4EFgQU6pUV 2lw5AOKa28S6LWf6ofd1NO0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AcYwDQYJKoZIhvcNAQELBQADggIBADXQ2Lie8gn48J+ybkiy1+qhmyiJOc3+Fmod 6ZyCX1FHOvWe0byuH5/iXErI7O1GQvF8QwcV326X9u2G/J/FCF6CDqMuqAouvI4b MRIo9nkowSK20ZVpQOhZCSeikWR26tATjXD8ZcNvEZ8qSMqnYvWDFOUaFseRi7QJ xc574+QdbZei6csmHmu03D6Ddi9eTahoiVT9TtJGqED22Mp4zzYaPVlljJv1Kx9M gt94eE0mSkdprW8zHwMeIk7ZBlmeRvxQNV/GhRvkG/gAyeDTOqsmQ81H+lr4hQvH Mtq1DS0wKTp5sxTppQ9wJdGNCVCU7U2SnjA3QNtaeEmPDzkvvS7XqwiUySmK992M vYJ8MFti6DVGVjhdkfYOb4zulZ/9dJ3t7RCrzouPt61/TWlJ8McRVZuagvei+jPy RBH6FUtGqZtrl0LWtLcJERR5U6bnfy0nOgo0JETOVYx6gHVzAkvi+kaUfTMUDUJW uaDmL4VIkZ9EuqEoqbEfiXomClNchbl8hJiMKGCltnqNPaAAPdx/qkjpqC6sX96H LVykaxbqveiVtc54CfhxNuWQaNIHlrq8AIsOmG1NcFPAw8wbE5xImpk9EsAnjmGS TGhSb40DHIn104bA/3FJTyBr/dFvkST18UcjTVnf0L1JQv1AOD7i8QVcJegQ5FoC A+O7fCUq -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICpzCCAi2gAwIBAgIQTHm1miicdjFk9YlE0JEC3jAKBggqhkjOPQQDAzCBlDEL MAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYD VQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBD bGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0g RzQwHhcNMTIxMDE4MDAwMDAwWhcNMzcxMjAxMjM1OTU5WjCBlDELMAkGA1UEBhMC VVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZTeW1h bnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAzIFB1 YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzQwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAARXz+qzOU0/oSHgbi84csaHl/OFC0fnD1HI0fSZm8pZ Zf9M+eoLtyXV0vbsMS0yYhLXdoan+jjJZdT+c+KEOfhMSWIT3brViKBfPchPsD+P oVAR5JNGrcNfy/GkapVW6MCjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8E BTADAQH/MB0GA1UdDgQWBBQknbzScfcdwiW+IvGJpSwVOzQeXjAKBggqhkjOPQQD AwNoADBlAjEAuWZoZdsF0Dh9DvPIdWG40CjEsUozUVj78jwQyK5HeHbKZiQXhj5Q Vm6lLZmIuL0kAjAD6qfnqDzqnWLGX1TamPR3vU+PGJyRXEdrQE0QHbPhicoLIsga xcX+i93B3294n5E= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF9jCCA96gAwIBAgIQZWNxhdNvRcaPfzH5CYeSgjANBgkqhkiG9w0BAQwFADCB lDELMAkGA1UEBhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8w HQYDVQQLExZTeW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRl YyBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 IC0gRzYwHhcNMTIxMDE4MDAwMDAwWhcNMzcxMjAxMjM1OTU5WjCBlDELMAkGA1UE BhMCVVMxHTAbBgNVBAoTFFN5bWFudGVjIENvcnBvcmF0aW9uMR8wHQYDVQQLExZT eW1hbnRlYyBUcnVzdCBOZXR3b3JrMUUwQwYDVQQDEzxTeW1hbnRlYyBDbGFzcyAz IFB1YmxpYyBQcmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzYwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC3DrL6TbyachX7d1vb/UMPywv3 YC6zK34Mu1PyzE5l8xm7/zUd99Opu0Attd141Kb5N+qFBXttt+YTSwZ8+3ZjjyAd LTgrBIXy6LDRX01KIclq2JTqHgJQpqqQB6BHIepm+QSg5oPwxPVeluInTWHDs8GM IrZmoQDRVin77cF/JMo9+lqUsITDx7pDHP1kDvEo+0dZ8ibhMblE+avd+76+LDfj rAsY0/wBovGkCjWCR0yrvYpe3xOF/CDMSFmvr0FvyyPNypOn3dVfyGQ7/wEDoApP LW49hL6vyDKyUymQFfewBZoKPPa5BpDJpeFdoDuw/qi2v/WJKFckOiGGceTciotB VeweMCRZ0cBZuHivqlp03iWAMJjtMERvIXAc2xJTDtamKGaTLB/MTzwbgcW59nhv 0DI6CHLbaw5GF4WU87zvvPekXo7p6bVk5bdLRRIsTDe3YEMKTXEGAJQmNXQfu3o5 XE475rgD4seTi4QsJUlF3X8jlGAfy+nN9quX92Hn+39igcjcCjBcGHzmzu/Hbh6H fLPpysh7avRo/IOlDFa0urKNSgrHl5fFiDAVPRAIVBVycmczM/R8t84AJ1NlziTx WmTnNi/yLgLCl99y6AIeoPc9tftoYAP6M6nmEm0G4amoXU48/tnnAGWsthlNe4N/ NEfq4RhtsYsceavnnQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ BAUwAwEB/zAdBgNVHQ4EFgQUOXEIAD7eyIbnkP/k/SEPziQZFvYwDQYJKoZIhvcN AQEMBQADggIBAFBriE1gSM5a4yLOZ3yEp80c/ekMA4w2rwqHDmquV64B0Da78v25 c8FftaiuTKL6ScsHRhY2vePIVzh+OOS/JTNgxtw3nGO7XpgeGrKC8K6mdxGAREeh KcXwszrOmPC47NMOgAZ3IzBM/3lkYyJbd5NDS3Wz2ztuO0rd8ciutTeKlYg6EGhw OLlbcH7VQ8n8X0/l5ns27vAg7UdXEyYQXhQGDXt2B8LGLRb0rqdsD7yID08sAraj 1yLmmUc12I2lT4ESOhF9s8wLdfMecKMbA+r6mujmLjY5zJnOOj8Mt674Q5mwk25v qtkPajGRu5zTtCj7g0x6c4JQZ9IOrO1gxbJdNZjPh34eWR0kvFa62qRa2MzmvB4Q jxuMjvPB27e+1LBbZY8WaPNWxSoZFk0PuGWHbSSDuGLc4EdhGoh7zk5//dzGDVqa pPO1TPbdMaboHREhMzAEYX0c4D5PjT+1ixIAWn2poQDUg+twuxj4pNIcgS23CBHI Jnu21OUPA0Zy1CVAHr5JXW2T8VyyO3VUaTqg7kwiuqya4gitRWMFSlI1dsQ09V4H Mq3cfCbRW4+t5OaqG3Wf61206MCpFXxOSgdy30bJ1JGSdVaw4e43NmUoxRXIK3bM bW8Zg/T92hXiQeczeUaDV/nxpbZt07zXU+fucW14qZen7iCcGRVyFT0E -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIB4TCCAYegAwIBAgIRKjikHJYKBN5CsiilC+g0mAIwCgYIKoZIzj0EAwIwUDEk MCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpH bG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoX DTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBD QSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWdu MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuMZ5049sJQ6fLjkZHAOkrprlOQcJ FspjsbmG+IpXwVfOQvpzofdlQv8ewQCybnMO/8ch5RikqtlxP6jUuc6MHaNCMEAw DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFSwe61F uOJAf/sKbvu+M8k8o4TVMAoGCCqGSM49BAMCA0gAMEUCIQDckqGgE6bPA7DmxCGX kPoUVy0D7O48027KqGx2vKLeuwIgJ6iFJzWbVsaj8kfSt24bAgAXqmemFZHe+pTs ewv4n4Q= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEk MCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpH bG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoX DTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMbR2xvYmFsU2lnbiBFQ0MgUm9vdCBD QSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQDEwpHbG9iYWxTaWdu MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6SFkc 8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8ke hOvRnkmSh5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYI KoZIzj0EAwMDaAAwZQIxAOVpEslu28YxuglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg 515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7yFz9SO8NdCKoCOJuxUnO xwy8p2Fp8fc74SrL+SvzZpA3 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkG A1UEBhMCVVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3 d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVu dHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEzMDEGA1UEAxMq RW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRUMxMB4XDTEy MTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYwFAYD VQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0 L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0g Zm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBD ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEVDMTB2MBAGByqGSM49AgEGBSuBBAAi A2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHyAsWfoPZb1YsGGYZPUxBt ByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef9eNi1KlH Bz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O BBYEFLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVC R98crlOZF7ZvHH3hvxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nX hTcGtXsI/esni0qU+eH6p44mCOh8kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID9zCCAt+gAwIBAgILMTI1MzcyODI4MjgwDQYJKoZIhvcNAQELBQAwWDELMAkG A1UEBhMCSlAxHDAaBgNVBAoTE0phcGFuZXNlIEdvdmVybm1lbnQxDTALBgNVBAsT BEdQS0kxHDAaBgNVBAMTE0FwcGxpY2F0aW9uQ0EyIFJvb3QwHhcNMTMwMzEyMTUw MDAwWhcNMzMwMzEyMTUwMDAwWjBYMQswCQYDVQQGEwJKUDEcMBoGA1UEChMTSmFw YW5lc2UgR292ZXJubWVudDENMAsGA1UECxMER1BLSTEcMBoGA1UEAxMTQXBwbGlj YXRpb25DQTIgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKaq rSVl1gAR1uh6dqr05rRL88zDUrSNrKZPtZJxb0a11a2LEiIXJc5F6BR6hZrkIxCo +rFnUOVtR+BqiRPjrq418fRCxQX3TZd+PCj8sCaRHoweOBqW3FhEl2LjMsjRFUFN dZh4vqtoqV7tR76kuo6hApfek3SZbWe0BSXulMjtqqS6MmxCEeu+yxcGkOGThchk KM4fR8fAXWDudjbcMztR63vPctgPeKgZggiQPhqYjY60zxU2pm7dt+JNQCBT2XYq 0HisifBPizJtROouurCp64ndt295D6uBbrjmiykLWa+2SQ1RLKn9nShjZrhwlXOa 2Po7M7xCQhsyrLEy+z0CAwEAAaOBwTCBvjAdBgNVHQ4EFgQUVqesqgIdsqw9kA6g by5Bxnbne9owDgYDVR0PAQH/BAQDAgEGMHwGA1UdEQR1MHOkcTBvMQswCQYDVQQG EwJKUDEYMBYGA1UECgwP5pel5pys5Zu95pS/5bqcMRswGQYDVQQLDBLmlL/lupzo qo3oqLzln7rnm6QxKTAnBgNVBAMMIOOCouODl+ODquOCseODvOOCt+ODp+ODs0NB MiBSb290MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAH+aCXWs B9FydC53VzDCBJzUgKaD56WgG5/+q/OAvdVKo6GPtkxgEefK4WCB10jBIFmlYTKL nZ6X02aD2mUuWD7b5S+lzYxzplG+WCigeVxpL0PfY7KJR8q73rk0EWOgDiUX5Yf0 HbCwpc9BqHTG6FPVQvSCLVMJEWgmcZR1E02qdog8dLHW40xPYsNJTE5t8XB+w3+m Bcx4m+mB26jIx1ye/JKSLaaX8ji1bnOVDMA/zqaUMLX6BbfeniCq/BNkyYq6ZO/i Y+TYmK5rtT6mVbgzPixy+ywRAPtbFi+E0hOe+gXFwctyTiLdhMpLvNIthhoEdlkf SUJiOxMfFui61/0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGgTCCBGmgAwIBAgIEUVLFjDANBgkqhkiG9w0BAQ0FADCBzzELMAkGA1UEBhMC VEgxSTBHBgNVBAoMQEVsZWN0cm9uaWMgVHJhbnNhY3Rpb25zIERldmVsb3BtZW50 IEFnZW5jeSAoUHVibGljIE9yZ2FuaXphdGlvbikxNzA1BgNVBAsMLlRoYWlsYW5k IE5hdGlvbmFsIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxPDA6BgNVBAMM M1RoYWlsYW5kIE5hdGlvbmFsIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMTAeFw0xMzAzMjcwOTQwMjJaFw0zNjAzMjcxMDEwMjJaMIHPMQswCQYDVQQG EwJUSDFJMEcGA1UECgxARWxlY3Ryb25pYyBUcmFuc2FjdGlvbnMgRGV2ZWxvcG1l bnQgQWdlbmN5IChQdWJsaWMgT3JnYW5pemF0aW9uKTE3MDUGA1UECwwuVGhhaWxh bmQgTmF0aW9uYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTE8MDoGA1UE AwwzVGhhaWxhbmQgTmF0aW9uYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 eSAtIEcxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1rpK5Izxmi6J F8JA84IAMf4TAnplygjYqyKxAppxNEpkWYLlQkbrI/aLWiKxzzbnc20UbfdJlF7v 5zRZZ/aoz1ZZI4RV4vsaEcqj+YqrZx6CE9CLOZq/D8IPPNZh2OqbzxUOvtTwzD9z nAT0onFzfYCwnTHxBvmwE+WISTD2Fn2IVyk6LKKMkJjOERbOTVEP/MeyzPJmGCGA BYitudDFC3gB/k7SCIs28VbPbrpzJgvW96VGamlOlranBlbM5i4xn26L7ZwAVUf0 e6Z6tt8BHUgEC6tCwnBKlL38rFHyqz/W62DfCP/1ErKJKnq5RZklfXzvzxXQSCwQ 1tS8CCe1hinU49PEKpAS9qIq+YuvFv8C83puz6LLarTgcgv7PoV/4ofgL0Mj+IXJ merWQN6g++fedv+PgDnrZxITpvvlo/wmgFlj8tIj6x/GSHNRnbezoFuraoj5v/tx UdxutnbvsFvyy4gwugbbG0HTVbSttOogIfzUd7Y9W6EMLSUhUiNS1zRTbRYEUmb4 1erxLFjyO7HxfkO13IK4XuOH4aOkX+eJDryc6Sk6JafYT2qH1JZElxgWh8JxUoXO eoytHme+ui2/oyEnxecw6QaZG7AM475SZZNNYUvyOOaPGPECUpgupg4dBc8m7AEj Bzb24BM3qUeIA4dHy92yAR9fZBsEm8UCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAQYwHwYDVR0jBBgwFoAUfyN2t4Mqcfcs0YgP3UxfMgpo u38wHQYDVR0OBBYEFH8jdreDKnH3LNGID91MXzIKaLt/MA0GCSqGSIb3DQEBDQUA A4ICAQANZRxaB6merEzJX0/dMWzZ4lMdP5GNWrOMvTSeLk3KWNOvWWJJNnOwYXYR vos2x5Sq+DZpByDfXC8L9o4CFu9SBjjd7TRgqodeF844bVBN5d/lUb4dBJb03Orl 2eqO3p90y4KUU4Fs+14s1aF1lk37MFzNYaCeocyCuVJyC4djYXthNHS2Lt3i4Ye1 SRRhFUdKSz53uQjSNk9YZ0KJgHhaEiPtRTvdvyAmVPxbP2ABGEHjZ3UTtyoVcMzL edIU+PPC4CoQ9/lC2NzaCtMBBdtXmMm26wyZCsqMfe87FijA91/hR1HT+AZFB/AL usKcmOzSf01+/Qb8c8LCVRJi0CNE3yLk+HnnpRBOPsmOqoPpNuqrecYFhM2WaHx0 rD8y/67JQOyPUL9QqLdO1a02atcnM/rn2C3ZN5iFG6YM6nsQE3AenojF3D6OuQ1V 3wHO0El2UdsQYnhBrWljpZUJtxgGb/0EZ9QQD07bO18MY3zrZL1uSwCogfqSMoYw jAm/fVg/ZQ2pN9FF42ZpxGj0YqmoHjfZLplJoLAGjEB/hbH18UxLOKAIzCrZlsDs wA08LkVXw++V2rbL7ltlqCsyr8kn+RVTN3VYH0vql6IiXGdW4qDMNcSswzFAuZwD er3JSA7qahXanLx4b8kV52QD2UkTZkVLLfSEmbPqpxKV5ZMu/A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFyDCCA7CgAwIBAgIBATANBgkqhkiG9w0BAQsFADB1MQswCQYDVQQGEwJQTDEk MCIGA1UECgwbVGVsZWtvbXVuaWthY2phIFBvbHNrYSBTLkEuMScwJQYDVQQLDB5T aWduZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFzAVBgNVBAMMDlNpZ25ldCBS b290IENBMB4XDTEzMDUwNjExMzgwNFoXDTM4MDUwNjExMzgwNFowdTELMAkGA1UE BhMCUEwxJDAiBgNVBAoMG1RlbGVrb211bmlrYWNqYSBQb2xza2EgUy5BLjEnMCUG A1UECwweU2lnbmV0IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRcwFQYDVQQDDA5T aWduZXQgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKlk y7gx4rUPgCcGzEVe6g1f13dql2i2XaB4BUSrMLB6h+9i7ghYVVwX+iuADhx1p2d2 SpbDKGt4+Vrf+mp5p4pUHSqWhvG1F9VdGlb3QBC3DuEH3GcLmaIACNQEInemQ46f 1TCq+p2BRvI9zl7CfsF8nzOvJtod3mD3gqc2zPXIwAKPks9uTv/7/mE/rr+9lmf+ 0K8d1iP3MOZ7iF3p9TNEyoq7pztZjnAXaSgXuxBWpcK0Cw37tHeJEERVbYmr1U0y udf3aZz9ta8DsiG2LGD1X9HCVIgvYO+cVIa1QQczLGwLHBLaR5lmNK6g7G3QY5d/ xAWAk/hCLFTY/tqVGGuF8lz5doc2HrGAH0DgCwqT1K5acVcNOu/h7Htd+BCaN3yp FqLEjlc7EBt2rahxQDOFAz9t2B495zBTx+Pq19AwVcSaZ0J8t0Br3KlEUPLjLkVi cby5bigFOXb1WeqhAzB04N+yCiMVTuNYOqJPeMiIW1GSzjoqNg/O37MCTy78hapD 1ga1eLfIuyMbRY+nNTTKqhQ31Z97MFaP6VcKRqcBl5ssp03/WT3unjMsLPMgu1j4 cx8B0EMiwygXtiQAElW4WxO8v9fZvVn7wlNp9a5SJs2sUrfIHVOaoQSgAkNQnRKp wG5Rwe0RTt/vxBQhurqhDpWDNVLQ559S1ZL5IsOHAgMBAAGjYzBhMA8GA1UdEwEB /wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB8GA1UdIwQYMBaAFFB7Ca9pLS+14JGv jZITK7Ey6EnQMB0GA1UdDgQWBBRQewmvaS0vteCRr42SEyuxMuhJ0DANBgkqhkiG 9w0BAQsFAAOCAgEAECWnACU9/o1G1kDHL9laJIVImKPg0UCh06PABJU0IXYW6daL KqbRNiY+w+VIjmv4BtPJbSCLwfl4hyztdUEoPnD5wnFtMQw34BXi217wwK5QFeyI UVODyaXyz6zC5swQx2wYd1ZYtSSahwNhdk8eWPPblTJ4ESuxIBOxftLl5Hu0MGUD ixvi6N7qEt6Xal4ARdbgWyqQodAr6NF2SWkW79uCtFMySCVsdPDK987d4UmPUtVU FfQIrwZnU5jnrOw1ipsT9B39gegbMc7z4IWS64NazrQXibBO4WFwX+ixMs6bHgp7 GS3IaDYzpFb1ukm9L/yzCrJrml4++0wYr1zwX9mx2wkdRlLHcNu4mCnUOWpePGKH eoqPdr/cp2i6i8U5xglPb3ZCTM8AUwq0H1jGShX9+uG8t3xUhk+8d3kkEk1kXbR6 22k2dGbofeRbKfIw/bXd3qEhYWZgJTtIb86rj02iTMsM+8E29FDBbCxpXEpEHcRc J00k907hP6tlA9O4kXzwhTjWikdELLAOCaWy0vfq7PR1tmtVS8EpO6ZEm8IQ7HqO TB2joiHcZcaAHtSXT/SAUwq6XY07doAnOllbH/VWhuHoili3mvdC71qoSu5U+iSe n7jM7KII4qyCjdIzI8Ju4+T/mfVcZ8WydiIbbSz2BveONFEi6PYZar9QmoI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFrjCCA5agAwIBAgIQUJZucr0Q1oxPa8diP5xwODANBgkqhkiG9w0BAQsFADBx MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlrYXZp bW8gY2VudHJhczEZMBcGA1UECxMQQ0EgUk9PVCBTZXJ2aWNlczEaMBgGA1UEAxMR U1NDIEdETCBDQSBSb290IEEwHhcNMTMwNjA0MTMwMDQ3WhcNMzMwNjA0MTMwMDQ3 WjBxMQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlr YXZpbW8gY2VudHJhczEZMBcGA1UECxMQQ0EgUk9PVCBTZXJ2aWNlczEaMBgGA1UE AxMRU1NDIEdETCBDQSBSb290IEEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCfXEr8HGu3GZfZATc+CukYhtMF6qLa3wmCV+5tK42aFj1VPonXyb7hAaOA NaNG7OER7ag8leU6UoHKTpgIKg+E3LvppPl5tknCFZ6glegPSPdQ1/mmQ9QHCzBB yTYSYrdseAsGPy6znuow/UFjT4QsN84Hpjlke3EVWysB8td9mA0YPtuFmuABUCEk uBujY0PTgVtNDIFOOGvOYMXqB+In4uv2w1SayMmz0SsyNwK8bXuekHcjjZMTJjuH V6NlTyZYFGpjJZrlYfocV/0NLGkPxgrwJjkXAqPWc4FCw0Ixg4vg+ktOWGExKJI8 xskQCMkMW0SsY8LXYhnyce4gt0mDGZ5H2lbFHKykOWgXXxEabKqlko+9G8vF4AKA VdNwU+WLKv5C6r07XONSAH14PybMEa400TIM+Hug0X0944q8vh4ekj84sl8yXjXE fsKSDZ22y1nV6xJq3XIhURGwc+Uy6dbMDt2zOVoi7+T16QZphip8c68YInMsNiXc ValSMbOKjhV9sk4Qe1CKAEy6h+JFU3d+TWUCa4yTtmt17e+Wt0iOqOC6uYKyUm0h /5K60T6wXLGrGQ4Zc0Yr01JIZTTaBDXSeD7PYzWkU+ZL41CDvfObh7Ih2kihekvs suLx1CUFlFMWTCtmJBDI4NecEqSUwgEjk6EApuBuuzni9XpoqQIDAQABo0IwQDAO BgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUoBF+H+SU ZFE7Ejl6bN1Jk/n9wFwwDQYJKoZIhvcNAQELBQADggIBAAzxS4zhTxYW0upikrat 1FKOCxlkSznwmDlzSlLqTs2OZEewMI88Dy3aImXzGVgyPH+DjwoM5VTmqb64rpdW 5rcNGXy9lyxqKqVWc4LeTpiLPRzE0Csru8UM+E7+La6/qWd/V7Nv7f+L01YM7zCM wV6m6VmKPC7cR8/MlF6DrBR2+n68DKMOXBuI7CsbNWiIsfV7xfOzxRq8+++1Xt/w OR51aO1EwksicD5ca5TJEKzw/cgvfiPigacbzgy6RTInUEU5rOD+ALQqdQcMZxu7 ccCC45dWl9Dkd1m5/3xnXIRluwg2qEtOkcJp/h3smhMfdTMsKcbpsGiQI/8jX3/G O6coELgfoojNZBYlT+OAt8BKgFfwkNs6sgIyINVryNgUQMnZOBlUOOvoZTtvXNVF eq/b2diVnranlc0cCR0CHgHpBJVdhZc4Fb2ox5ne00RCXYaDQSR8UYmqQwknNOjx CrWWS7TzoP7yAI1qO3S5Q7lmuc/q6zfO/5vpI/hs0yP96Ongbvj7DVJAiqyAayAQ XdCo/ao9ORErL/9SkTqg3IrHdjYRWYW7MIqkSDCcYUOr1K927cC/F5R4NdtINwjU jmoA6SLdyvDTEjg8mJ9gTG0/Qv3vjJq3HnF6GknUYMnrj/Tpxr9wVIjSx1c7Vs3X btztDXR+5XVBkVeTNH2p9b2H -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFrjCCA5agAwIBAgIQPoxPvOQpg4JNhFWO1TWAzzANBgkqhkiG9w0BAQsFADBx MQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlrYXZp bW8gY2VudHJhczEZMBcGA1UECxMQQ0EgUk9PVCBTZXJ2aWNlczEaMBgGA1UEAxMR U1NDIEdETCBDQSBSb290IEIwHhcNMTMwNjA0MTQyMDE1WhcNMzMwNjA0MTQyMTU1 WjBxMQswCQYDVQQGEwJMVDErMCkGA1UEChMiU2thaXRtZW5pbmlvIHNlcnRpZmlr YXZpbW8gY2VudHJhczEZMBcGA1UECxMQQ0EgUk9PVCBTZXJ2aWNlczEaMBgGA1UE AxMRU1NDIEdETCBDQSBSb290IEIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCPlenS68FzJcc4Z/CDjlO8tsvOunPbTyf2IpA/Qr8h1t5igrRvBAVJCTt3 AddLX1LS2RnHbXwMqToJYuQqGGmMoN3rrBO2DjkRgGlOY1/cPA362YxivmSFMjJZ l1CTid/7/9TYZXHHRlWiG5lhH9xQAMgXeehQsAxe5v52pgFOCchwbPqQs17cPQfN SaNOVl4ST2RBf34MFcOg3rOjKQZJRKFfbz+BoERN8HsKOCjtEu5jl8N7XYxPcd2V OtouqAFGCvNs6LXxHwgA8UCSGyYAMXU5RkkmuaTUcXcRpE8zzAnb2dEhS5JErM54 YoIX+/oStH3V8obt9H6WFOaNA1KvzRei1Ryl/oGmmu195NkOMmYQj9vZMzGBfilX 78yyoWDuilu5Zdt/G5osjycxiYoota+xVtQDIu4lT9iavdJsV7yDpkgfLFUHCTQr uXksAqWgX3x2nyQyPC2S3+tIV4eh9v4j+jSrifVoG44fqm4OpdIh0u+50bFJVzVa hNMe4gJtUhB/4oxNIdsyMhx9zJYiAy1qpwZCbW6Qh/ocXLBP0ANBE/oLU+bBEAJI C3dj9KWcUXuYZtfFdjLlb10UYX0Mu22VQNqpJsf3qcvS/ifBK/axaIb+42JSmVCO K95BIQcbh/VAHXCtz/3CQ6g1VhFCxcteZqHIqGj3/kxXYTZSgQIDAQABo0IwQDAO BgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUcgNGh2H2 HbPUlWO5UHduDauY/i8wDQYJKoZIhvcNAQELBQADggIBAAjbijKBdDNxFuwhhVNI Cm8fcuPjBPgutz/zJJVPnO0T4YiCAvZm97exLYAnra64bf4jBxEIq3RhjCgS+fYQ NPDPtnyjdS0S1JTfdO6xmKux7iJiS1kff/4aZa1N4qQRPxMhtNg1i3ZApl+9MxHf mOMhXh2ju3g2AjvY/WSE2jfNWe38DNB0pGtxPDYSRJ5+bk8KIRxlH0sSbL+Octbd PgBwmAFFK+yVkOPTaTjnK51+ZVlb4duFymP+q7/k0P3kUroa5v7GkLp7zvGkYsVH viTHoHrlIeHGCOAMiYOPgGn97qDfekw600gqFr+uppW13Wgf+w61BYzRskR8YDBW dhe1NU+o1QrrwrVuAu6cXw6jsQGo5VNvfoNBHxXY/+HCthrxRpxkoBrgSsq4prSJ JO57lZli1OJAu86jmn0dcvMbgUF3AF7sPKIwBTzNfEg2E8gysGtvnzgoOGlce+bi rYO7bRPRLrfRdm9dMF65UEVI1kiAk1HJFqkQXWfGy35nfQVP9CDvJCVe7WdDxvtu efuy8sjJzkF8BeCti80KRS7iYp+XkfT5Y+zywmCK3Bv/Iaj/I4eMc42wOswfjzFy Cv2Wod8aU9M2trB3Rt4D9sKALm+XI+ERzFGYP+5A//Q9m4h/jLvhWYa9CTQnXJ4K kzI7VSqpXgsND6mmUQTimyoR -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIIGDCCBgCgAwIBAgIGAT8vMVNvMA0GCSqGSIb3DQEBBQUAMIIBCjELMAkGA1UE BhMCRVMxEjAQBgNVBAgMCUJhcmNlbG9uYTFYMFYGA1UEBwxPQmFyY2Vsb25hIChz ZWUgY3VycmVudCBhZGRyZXNzIGF0IGh0dHA6Ly93d3cuYW5mLmVzL2VzL2FkZHJl c3MtZGlyZWNjaW9uLmh0bWwgKTEnMCUGA1UECgweQU5GIEF1dG9yaWRhZCBkZSBD ZXJ0aWZpY2FjaW9uMRcwFQYDVQQLDA5BTkYgQ2xhc2UgMSBDQTEaMBgGCSqGSIb3 DQEJARYLaW5mb0BhbmYuZXMxEjAQBgNVBAUTCUc2MzI4NzUxMDEbMBkGA1UEAwwS QU5GIEdsb2JhbCBSb290IENBMB4XDTEzMDYxMDE3NDUyOVoXDTMzMDYwNTE3NDUy OVowggEKMQswCQYDVQQGEwJFUzESMBAGA1UECAwJQmFyY2Vsb25hMVgwVgYDVQQH DE9CYXJjZWxvbmEgKHNlZSBjdXJyZW50IGFkZHJlc3MgYXQgaHR0cDovL3d3dy5h bmYuZXMvZXMvYWRkcmVzcy1kaXJlY2Npb24uaHRtbCApMScwJQYDVQQKDB5BTkYg QXV0b3JpZGFkIGRlIENlcnRpZmljYWNpb24xFzAVBgNVBAsMDkFORiBDbGFzZSAx IENBMRowGAYJKoZIhvcNAQkBFgtpbmZvQGFuZi5lczESMBAGA1UEBRMJRzYzMjg3 NTEwMRswGQYDVQQDDBJBTkYgR2xvYmFsIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQDHPi9xy4wynbcUbWjorVUgQKeUAVh937J7P37XmsfH ZLOBZKIIlhhCtRwnDlg7x+BUvtJOTkIbEGMujDygUQ2s3HDYr5I41hTyM2Pl0cq2 EuSGEbPIHb3dEX8NAguFexM0jqNjrreN3hM2/+TOkAxSdDJP2aMurlySC5zwl47K ZLHtcVrkZnkDa0o5iN24hJT4vBDT4t2q9khQ+qb1D8KgCOb02r1PxWXu3vfd6Ha2 mkdB97iGuEh5gO2n4yOmFS5goFlVA2UdPbbhJsb8oKVKDd+YdCKGQDCkQyG4AjmC YiNm3UPG/qtftTH5cWri67DlLtm6fyUFOMmO6NSh0RtR745pL8GyWJUanyq/Q4bF HQB21E+WtTsCaqjGaoFcrBunMypmCd+jUZXl27TYENRFbrwNdAh7m2UztcIyb+Sg VJFyfvVsBQNvnp7GPimVxXZNc4VpxEXObRuPWQN1oZN/90PcZVqTia/SHzEyTryL ckhiLG3jZiaFZ7pTZ5I9wti9Pn+4kOHvE3Y/4nEnUo4mTxPX9pOlinF+VCiybtV2 u1KSlc+YaIM7VmuyndDZCJRXm3v0/qTE7t5A5fArZl9lvibigMbWB8fpD+c1GpGH Eo8NRY0lkaM+DkIqQoaziIsz3IKJrfdKaq9bQMSlIfameKBZ8fNYTBZrH9KZAIhz YwIDAQABo4IBfjCCAXowHQYDVR0OBBYEFIf6nt9SdnXsSUogb1twlo+d77sXMB8G A1UdIwQYMBaAFIf6nt9SdnXsSUogb1twlo+d77sXMA8GA1UdEwEB/wQFMAMBAf8w DgYDVR0PAQH/BAQDAgEGMIIBFQYDVR0RBIIBDDCCAQiCEWh0dHA6Ly93d3cuYW5m LmVzgQtpbmZvQGFuZi5lc6SB5TCB4jE0MDIGA1UECQwrR3JhbiBWaWEgZGUgbGVz IENvcnRzIENhdGFsYW5lcy4gOTk2LiAwODAxODESMBAGA1UEBwwJQmFyY2Vsb25h MScwJQYDVQQKDB5BTkYgQXV0b3JpZGFkIGRlIENlcnRpZmljYWNpb24xEjAQBgNV BAUTCUc2MzI4NzUxMDFZMFcGA1UECwxQSW5zY3JpdGEgZW4gZWwgTWluaXN0ZXJp byBkZWwgSW50ZXJpb3IgZGUgRXNwYcOxYSBjb24gZWwgbnVtZXJvIG5hY2lvbmFs IDE3MS40NDMwDQYJKoZIhvcNAQEFBQADggIBADGB3clTJTMcaGs8j/NktDs2c7HI S3GApxTxog5JuUUUuOmA6Ju0BxXe+f4ZTi/Pb5IZSsBAoM4Gbfn8mkQyfh5BY7iS K3Fnzbl9GGF613eC3T+5Q4DI1lc6n8V+jVRIej9H4nMjH/wzbWmHZcKWA3L/fJXr s8iUrvRacyXx2FyCRUmqHgnca0VNOGt+obz1WUaOCmgWO8Ga06sylddooNLtOIHO vut26a583SDjFbstMWZfz+UD54Jmqr2KnTNmOHHWo/LzbtkErsZNMMlfNn7ri5ek 1NHVrXOB8KaDszxQXxacwSMaXqpUU/X2Tx1DQK+Nb0mEBss9HQu0nfr2OeAxxxrc zt3fLv1Fsy2moQWCAQISMpIF149+VQAOoC5/u06yROCbBtMQniG8Ru8u2f+h5B2+ IT3kJICXTanWfJST0WM3IOJ/efahqPaAMxkc669Zo3+Un9Zb9QfRmLkc/R3LHSFb QngpIwh04MnLhUaOMs4Y38uFUz8XHxJsW7pDxtMZdfGgEx94oNklvzrBP3rxeJxQ 8FknN+Zaf2Lz2T4Q7srTH8ShMddMoiOCRFR5n3DbmamoCeyu5LxbZBud0M99RCoF f4Bov9yNQL8QqnP/ZtcwM2NjbfzYSPqDyt2l5e1oNGdbFewP7N+eaAHpltM7IdHE xJhqqSqPzE7W6RT9 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBl MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv b3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQG EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSA n61UQbVH35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4Htecc biJVMWWXvdMX0h5i89vqbFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9Hp EgjAALAcKxHad3A2m67OeYfcgnDmCXRwVWmvo2ifv922ebPynXApVfSr/5Vh88lA bx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OPYLfykqGxvYmJHzDNw6Yu YjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+RnlTGNAgMB AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQW BBTOw0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPI QW5pJ6d1Ee88hjZv0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I 0jJmwYrA8y8678Dj1JGG0VDjA9tzd29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4Gni lmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAWhsI6yLETcDbYz+70CjTVW0z9 B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0MjomZmWzwPDCv ON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo IhNzbM8m9Yop5w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQsw CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu ZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3Qg RzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBlMQswCQYDVQQGEwJV UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu Y29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJf Zn4f5dwbRXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17Q RSAPWXYQ1qAk8C3eNvJsKTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgFUaFNN6KDec6NHSrkhDAKBggqhkjOPQQD AwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5FyYZ5eEJJZVrmDxxDnOOlY JjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy1vUhZscv 6pZjamVFkpUBtA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI 2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx 1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV 5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY 1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4 NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91 8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl MrY= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQsw CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu ZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAe Fw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUw EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x IDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0CAQYF K4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FG fp4tn+6OYwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPO Z9wj/wMco+I+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAd BgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNpYim8S8YwCgYIKoZIzj0EAwMDaAAwZQIx AK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y3maTD/HMsQmP3Wyr+mt/ oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34VOKa5Vt8 sycX -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBi MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg RzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJV UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQu Y29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3y ithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1If xp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDV ySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiO DCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQ jdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/ CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCi EhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADM fRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QY uKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXK chYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t 9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB hjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2 SV1EY+CtnJYYZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd +SeuMIW59mdNOj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWc fFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqa sjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9N cCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N 0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie 4u1Ki7wb/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mI r/OSmbaz5mEP0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1 /YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCm gKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGQDCCBCigAwIBAgIIdPhg8eijj0EwDQYJKoZIhvcNAQELBQAwgasxCzAJBgNV BAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDE8MDoGA1UECgwzTklTWiBOZW16ZXRp IEluZm9rb21tdW5pa8OhY2nDs3MgU3pvbGfDoWx0YXTDsyBacnQuMUswSQYDVQQD DEJGxZF0YW7DunPDrXR2w6FueWtpYWTDsyAtIEtvcm3DoW55emF0aSBIaXRlbGVz w610w6lzIFN6b2xnw6FsdGF0w7MwHhcNMTMwOTEzMTAyNzA0WhcNMzMwOTEzMTAy NzA0WjCBqzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MTwwOgYDVQQK DDNOSVNaIE5lbXpldGkgSW5mb2tvbW11bmlrw6FjacOzcyBTem9sZ8OhbHRhdMOz IFpydC4xSzBJBgNVBAMMQkbFkXRhbsO6c8OtdHbDoW55a2lhZMOzIC0gS29ybcOh bnl6YXRpIEhpdGVsZXPDrXTDqXMgU3pvbGfDoWx0YXTDszCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBALVCpWRI22SlN/gsuJkCUbmiKMT7cATE2WyKhvcP iRekhMIubE71/0TvW0MOiX83NaVbyOQjW68ZnFvtrNYALttjysNU2K9n1MtgRKJP z6Te/B8xZla34d04ilP8zyMVi4qH/Qkw5ZhHBA4Waa8JBbzH1JBFj2hjvoJYN/vY TG+lrBV3daWIZDhc0mUPUwXOlDCXb3qB6WSYEtEeSp/B8xfbGTYQObgBs7d4TbUM e16qTp25zV04/39J/rdIrwNCbL5kG2H5zmt6m1BxAPNXl8UBdBurySZZbHq/Cpdn lrWARUgBRpxAFORhOCFbiWTiBTYToCrO24gEhkQ13JM0WVdq7VNj+ovCGBY89HHH PgwaEeTODyDDFyOro38TVay0/5bYwC96CZvbHJaNpoz8oWqma9EMnTGsmjH6UvmJ OfovU/PpkS5Qjqq4pCWvG4vZalKIVwrDC5pxn7zKRYrpudWVwbbCztENaUo2PK6N rMt19pAhwwmXzi0SdmJe6w6Pcl8rm7DJChXz/s/3RIRGAf3PZuzQMJd8bazROMFG cgcXDj77MObLNNW1cxNFIQ4dGWtIFtrokakG0Og9b/qM0bj1mQPx69i1abu4iU9S Aqd+PtvsxZcGlftT6+DT58iPiJn/LreXmX2E81H9joND3vOv4DN0xBUcKRenSXPc wE7dAgMBAAGjZjBkMB0GA1UdDgQWBBTVqFEOeTByXrSsFg3TtevqwUvcOjASBgNV HRMBAf8ECDAGAQH/AgEDMB8GA1UdIwQYMBaAFNWoUQ55MHJetKwWDdO16+rBS9w6 MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAYfZkTup7l8LCAtlZ MoBtgpKi+k2Cc2ZanYLSVWIw+CDNp9OJwcZzxNhdST3Ovgx6HchpbD367wD2gZqN O1VPDJ1W2afmTeZrsKK1oP7fXYNbqxHyaxivq2bbG8lLGvdE3fGcgqyaXqioqDGe 3pzBQiKMxBOE5SxDBhspaTPX4AcCH6vuSZ7Xw4iuWRuXy/gbZWABzG3hQCAtSyEB 7B4ssYFr3saM9TSwjMOb3lg+EU3oSEyHlu5aR0tCb57og0iCuZrpPET5UZNUq5RF +aiVrqaIefXmkqhYIi7UlEwYuq39p4VaghNqva5bwCwZXdiTwN11QDNp2U4mCjaH pAEM4d+tDBkYX4jKNbEKe4EHZvl/Dy1tGYrk5IO7Qx1eT9LhKTjBH/Vco1Rg6/hD 3uaVBJmH4cupJDp5LRpwZZ8RJ104LkUNW/gRWS4ONRNq16dUBP5S+EwV5gOZXLKH /KpGCPjTaAdgHC8nUnWTAtjd07GH1P2ZdnzB/AOq78eCSXr6+kvah9sFn1jib75j +hqjNMHPukwiAAcFgF8F5gFzV9SR4dBh74Yo433MyjKX47NtvL/wCaAtxABUM20F h/SHJB2Fzd7DOzeg5Qiv44sBHbgdNmOiEOElK2xS4B3Gx/ZtneDHIuTdsIYupqOY ZTMgdlbbZ/DGXkOCwgptZNXejGw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEDjCCAvagAwIBAgIDD92sMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkRF MRUwEwYDVQQKDAxELVRydXN0IEdtYkgxHzAdBgNVBAMMFkQtVFJVU1QgUm9vdCBD QSAzIDIwMTMwHhcNMTMwOTIwMDgyNTUxWhcNMjgwOTIwMDgyNTUxWjBFMQswCQYD VQQGEwJERTEVMBMGA1UECgwMRC1UcnVzdCBHbWJIMR8wHQYDVQQDDBZELVRSVVNU IFJvb3QgQ0EgMyAyMDEzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA xHtCkoIf7O1UmI4SwMoJ35NuOpNcG+QQd55OaYhs9uFp8vabomGxvQcgdJhl8Ywm CM2oNcqANtFjbehEeoLDbF7eu+g20sRoNoyfMr2EIuDcwu4QRjltr5M5rofmw7wJ ySxrZ1vZm3Z1TAvgu8XXvD558l++0ZBX+a72Zl8xv9Ntj6e6SvMjZbu376Ml1wrq WLbviPr6ebJSWNXwrIyhUXQplapRO5AyA58ccnSQ3j3tYdLl4/1kR+W5t0qp9x+u loYErC/jpIF3t1oW/9gPP/a3eMykr/pbPBJbqFKJcu+I89VEgYaVI5973bzZNO98 lDyqwEHC451QGsDkGSL8swIDAQABo4IBBTCCAQEwDwYDVR0TAQH/BAUwAwEB/zAd BgNVHQ4EFgQUP5DIfccVb/Mkj6nDL0uiDyGyL+cwDgYDVR0PAQH/BAQDAgEGMIG+ BgNVHR8EgbYwgbMwdKByoHCGbmxkYXA6Ly9kaXJlY3RvcnkuZC10cnVzdC5uZXQv Q049RC1UUlVTVCUyMFJvb3QlMjBDQSUyMDMlMjAyMDEzLE89RC1UcnVzdCUyMEdt YkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRpb25saXN0MDugOaA3hjVodHRwOi8v Y3JsLmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2FfM18yMDEzLmNybDAN BgkqhkiG9w0BAQsFAAOCAQEADlkOWOR0SCNEzzQhtZwUGq2aS7eziG1cqRdw8Cqf jXv5e4X6xznoEAiwNStfzwLS05zICx7uBVSuN5MECX1sj8J0vPgclL4xAUAt8yQg t4RVLFzI9XRKEBmLo8ftNdYJSNMOwLo5qLBGArDbxohZwr78e7Erz35ih1WWzAFv m2chlTWL+BD8cRu3SzdppjvW7IvuwbDzJcmPkn2h6sPKRL8mpXSSnON065102ctN h9j8tGlsi6BDB2B4l+nZk3zCRrybN1Kj7Yo8E6l7U0tJmhEFLAtuVqwfLoJs4Gln tQ5tLdnkwBXxP/oYcuEVbSdbLTAoK59ImmQrme/ydUlfXA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAw WjELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAw MiA0ODE0NjMwODEwMDAzNjEZMBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0x MzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjdaMFoxCzAJBgNVBAYTAkZSMRIwEAYD VQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYzMDgxMDAwMzYxGTAX BgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw ggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sO ty3tRQgXstmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9M CiBtnyN6tMbaLOQdLNyzKNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPu I9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8JXrJhFwLrN1CTivngqIkicuQstDuI7pm TLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16XdG+RCYyKfHx9WzMfgIh C59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq4NYKpkDf ePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3Yz IoejwpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWT Co/1VTp2lc5ZmIoJlXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1k JWumIWmbat10TWuXekG9qxf5kBdIjzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5 hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp//TBt2dzhauH8XwIDAQABo4IB GjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE FBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of 1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczov L3d3d3cuY2VydGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilo dHRwOi8vY3JsLmNlcnRpZ25hLmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYr aHR0cDovL2NybC5kaGlteW90aXMuY29tL2NlcnRpZ25hcm9vdGNhLmNybDANBgkq hkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOItOoldaDgvUSILSo3L 6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxPTGRG HVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH6 0BGM+RFq7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncB lA2c5uk5jR+mUYyZDDl34bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdi o2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1 gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS6Cvu5zHbugRqh5jnxV/v faci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaYtlu3zM63 Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayh jWZSaX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw 3kAP+HwV96LOPNdeE4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkjCCA3qgAwIBAgIBATANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJGUjET MBEGA1UEChMKQ2VydGlub21pczEXMBUGA1UECxMOMDAwMiA0MzM5OTg5MDMxHTAb BgNVBAMTFENlcnRpbm9taXMgLSBSb290IENBMB4XDTEzMTAyMTA5MTcxOFoXDTMz MTAyMTA5MTcxOFowWjELMAkGA1UEBhMCRlIxEzARBgNVBAoTCkNlcnRpbm9taXMx FzAVBgNVBAsTDjAwMDIgNDMzOTk4OTAzMR0wGwYDVQQDExRDZXJ0aW5vbWlzIC0g Um9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANTMCQosP5L2 fxSeC5yaah1AMGT9qt8OHgZbn1CF6s2Nq0Nn3rD6foCWnoR4kkjW4znuzuRZWJfl LieY6pOod5tK8O90gC3rMB+12ceAnGInkYjwSond3IjmFPnVAy//ldu9n+ws+hQV WZUKxkd8aRi5pwP5ynapz8dvtF4F/u7BUrJ1Mofs7SlmO/NKFoL21prbcpjp3vDF TKWrteoB4owuZH9kb/2jJZOLyKIOSY008B/sWEUuNKqEUL3nskoTuLAPrjhdsKkb 5nPJWqHZZkCqqU2mNAKthH6yI8H7KsZn9DS2sJVqM09xRLWtwHkziOC/7aOgFLSc CbAK42C++PhmiM1b8XcF4LVzbsF9Ri6OSyemzTUK/eVNfaoqoynHWmgE6OXWk6Ri wsXm9E/G+Z8ajYJJGYrKWUM66A0ywfRMEwNvbqY/kXPLynNvEiCL7sCCeN5LLsJJ wx3tFvYk9CcbXFcx3FXuqB5vbKziRcxXV4p1VxngtViZSTYxPDMBbRZKzbgqg4SG m/lg0h9tkQPTYKbVPZrdd5A9NaSfD171UkRpucC63M9933zZxKyGIjK8e2uR73r4 F2iw4lNVYC2vPsKD2NkJK/DAZNuHi5HMkesE/Xa0lZrmFAYb1TQdvtj/dBxThZng WVJKYe2InmtJiUZ+IFrZ50rlau7SZRFDAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIB BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTvkUz1pcMw6C8I6tNxIqSSaHh0 2TAfBgNVHSMEGDAWgBTvkUz1pcMw6C8I6tNxIqSSaHh02TANBgkqhkiG9w0BAQsF AAOCAgEAfj1U2iJdGlg+O1QnurrMyOMaauo++RLrVl89UM7g6kgmJs95Vn6RHJk/ 0KGRHCwPT5iVWVO90CLYiF2cN/z7ZMF4jIuaYAnq1fohX9B0ZedQxb8uuQsLrbWw F6YSjNRieOpWauwK0kDDPAUwPk2Ut59KA9N9J0u2/kTO+hkzGm2kQtHdzMjI1xZS g081lLMSVX3l4kLr5JyTCcBMWwerx20RoFAXlCOotQqSD7J6wWAsOMwaplv/8gzj qh8c3LigkyfeY+N/IZ865Z764BNqdeuWXGKRlI5nU7aJ+BIJy29SWwNyhlCVCNSN h4YVH5Uk2KRvms6knZtt0rJ2BobGVgjF6wnaNsIbW0G+YSrjcOa4pvi2WsS9Iff/ ql+hbHY5ZtbqTFXhADObE5hjyW/QASAJN1LnDE8+zbz1X5YnpyACleAu6AdBBR8V btaw5BngDwKTACdyxYvRVB9dSsNAl35VpnzBMwQUAR1JIGkLGZOdblgi90AMRgwj Y/M50n92Uaf0yKHxDHYiI0ZSKS3io0EHVmmY0gUJvGnHWmHNj4FgFU2A3ZDifcRQ 8ow7bkrHxuaAKzyBvBGAFhAn1/DNP3nMcyrDflOR1m749fPH0FFNjkulW+YZFzvW gQncItzujrnEj1PhZ7szuIgVRs/taTX/dQ1G885x4cVrhkIGuUE= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIID2DCCAsCgAwIBAgIQYFbFSyNAW2TU7SXa2dYeHjANBgkqhkiG9w0BAQsFADCB hTELMAkGA1UEBhMCREUxKTAnBgNVBAoTIERldXRzY2hlciBTcGFya2Fzc2VuIFZl cmxhZyBHbWJIMScwJQYDVQQLEx5TLVRSVVNUIENlcnRpZmljYXRpb24gU2Vydmlj ZXMxIjAgBgNVBAMTGVMtVFJVU1QgVW5pdmVyc2FsIFJvb3QgQ0EwHhcNMTMxMDIy MDAwMDAwWhcNMzgxMDIxMjM1OTU5WjCBhTELMAkGA1UEBhMCREUxKTAnBgNVBAoT IERldXRzY2hlciBTcGFya2Fzc2VuIFZlcmxhZyBHbWJIMScwJQYDVQQLEx5TLVRS VVNUIENlcnRpZmljYXRpb24gU2VydmljZXMxIjAgBgNVBAMTGVMtVFJVU1QgVW5p dmVyc2FsIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCo 4wvfETeFgpq1bGZ8YT/ARxodRuOwVWTluII5KAd+F//0m4rwkYHqOD8heGxI7Gsv otOKcrKn19nqf7TASWswJYmM67fVQGGY4tw8IJLNZUpynxqOjPolFb/zIYMoDYuv WRGCQ1ybTSVRf1gYY2A7s7WKi1hjN0hIkETCQN1d90NpKZhcEmVeq5CSS2bf1XUS U1QYpt6K1rtXAzlZmRgFDPn9FcaQZEYXgtfCSkE9/QC+V3IYlHcbU1qJAfYzcg6T OtzoHv0FBda8c+CI3KtP7LUYhk95hA5IKmYq3TLIeGXIC51YAQVx7YH1aBduyw20 S9ih7K446xxYL6FlAzQvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P AQH/BAQDAgEGMB0GA1UdDgQWBBSafdfr639UmEUptCCrbQuWIxmkwjANBgkqhkiG 9w0BAQsFAAOCAQEATpYS2353XpInniEXGIJ22D+8pQkEZoiJrdtVszNqxmXEj03z MjbceQSWqXcy0Zf1GGuMuu3OEdBEx5LxtESO7YhSSJ7V/Vn4ox5R+wFS5V/let2q JE8ii912RvaloA812MoPmLkwXSBvwoEevb3A/hXTOCoJk5gnG5N70Cs0XmilFU/R UsOgyqCDRR319bdZc11ZAY+qwkcvFHHVKeMQtUeTJcwjKdq3ctiR1OwbSIoi5MEq 9zpok59FGW5Dt8z+uJGaYRo2aWNkkijzb2GShROfyQcsi1fc65551cLeCNVUsldO KjKNoeI60RAgIjl9NEVvcTvDHfz/sk+o4vYwHg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFdDCCA1ygAwIBAgIEAJiiOTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJO TDEeMBwGA1UECgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFh dCBkZXIgTmVkZXJsYW5kZW4gUm9vdCBDQSAtIEczMB4XDTEzMTExNDExMjg0MloX DTI4MTExMzIzMDAwMFowWjELMAkGA1UEBhMCTkwxHjAcBgNVBAoMFVN0YWF0IGRl ciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5lZGVybGFuZGVuIFJv b3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL4yolQP cPssXFnrbMSkUeiFKrPMSjTysF/zDsccPVMeiAho2G89rcKezIJnByeHaHE6n3WW IkYFsO2tx1ueKt6c/DrGlaf1F2cY5y9JCAxcz+bMNO14+1Cx3Gsy8KL+tjzk7FqX xz8ecAgwoNzFs21v0IJyEavSgWhZghe3eJJg+szeP4TrjTgzkApyI/o1zCZxMdFy KJLZWyNtZrVtB0LrpjPOktvA9mxjeM3KTj215VKb8b475lRgsGYeCasH/lSJEULR 9yS6YHgamPfJEf0WwTUaVHXvQ9Plrk7O53vDxk5hUUurmkVLoR9BvUhTFXFkC4az 5S6+zqQbwSmEorXLCCN2QyIkHxcE1G6cxvx/K2Ya7Irl1s9N9WMJtxU51nus6+N8 6U78dULI7ViVDAZCopz35HCz33JvWjdAidiFpNfxC95DGdRKWCyMijmev4SH8RY7 Ngzp07TKbBlBUgmhHbBqv4LvcFEhMtwFdozL92TkA1CvjJFnq8Xy7ljY3r735zHP bMk7ccHViLVlvMDoFxcHErVc0qsgk7TmgoNwNsXNo42ti+yjwUOH5kPiNL6VizXt BznaqB16nzaeErAMZRKQFWDZJkBE41ZgpRDUajz9QdwOWke275dhdU/Z/seyHdTt XUmzqWrLZoQT1Vyg3N9udwbRcXXIV2+vD3dbAgMBAAGjQjBAMA8GA1UdEwEB/wQF MAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRUrfrHkleuyjWcLhL75Lpd INyUVzANBgkqhkiG9w0BAQsFAAOCAgEAMJmdBTLIXg47mAE6iqTnB/d6+Oea31BD U5cqPco8R5gu4RV78ZLzYdqQJRZlwJ9UXQ4DO1t3ApyEtg2YXzTdO2PCwyiBwpwp LiniyMMB8jPqKqrMCQj3ZWfGzd/TtiunvczRDnBfuCPRy5FOCvTIeuXZYzbB1N/8 Ipf3YF3qKS9Ysr1YvY2WTxB1v0h7PVGHoTx0IsL8B3+A3MSs/mrBcDCw6Y5p4ixp gZQJut3+TcCDjJRYwEYgr5wfAvg1VUkvRtTA8KCWAg8zxXHzniN9lLf9OtMJgwYh /WA9rjLA0u6NpvDntIJ8CsxwyXmA+P5M9zWEGYox+wrZ13+b8KKaa8MFSu1BYBQw 0aoRQm7TIwIEC8Zl3d1Sd9qBa7Ko+gE4uZbqKmxnl4mUnrzhVNXkanjvSr0rmj1A fsbAddJu+2gw7OyLnflJNZoaLNmzlTnVHpL3prllL+U9bTpITAjc5CgSKL59NVzq 4BZ+Extq1z7XnvwtdbLBFNUjA9tbbws+eC8N3jONFrdI54OagQ97wUNNVQQXOEpR 1VmiiXTTn74eS9fGbbeIJG9gkaSChVtWQbzQRKtqE77RLFi3EjNYsjdj3BP1lB0/ QFH1T/U67cjF68IeHRaVesd+QnGTbksVtzDfqu1XhUisHWrdOWnk4Xl4vs4Fv6EM 94B7IWcnMFk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIx GDAWBgNVBAcTD0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxp bXNlbCB2ZSBUZWtub2xvamlrIEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0w KwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24gTWVya2V6aSAtIEthbXUgU00xNjA0 BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRpZmlrYXNpIC0gU3Vy dW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYDVQQG EwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXll IEJpbGltc2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklU QUsxLTArBgNVBAsTJEthbXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBT TTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11IFNNIFNTTCBLb2sgU2VydGlmaWthc2kg LSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr3UwM6q7 a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y86Ij5iySr LqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INr N3wcwv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2X YacQuFWQfw4tJzh03+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/ iSIzL+aFCr2lqBs23tPcLG07xxO9WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4f AJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQUZT/HiobGPN08VFw1+DrtUgxH V8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL BQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPf IPP54+M638yclNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4 lzwDGrpDxpa5RXI4s6ehlj2Re37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c 8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0jq5Rm+K37DwhuJi1/FwcJsoz7UMCf lo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFbDCCA1SgAwIBAgIQDLMPcPKGpDPguQmJ3gHttzANBgkqhkiG9w0BAQsFADBQ MQswCQYDVQQGEwJVUzEYMBYGA1UEChMPV0ZBIEhvdHNwb3QgMi4wMScwJQYDVQQD Ex5Ib3RzcG90IDIuMCBUcnVzdCBSb290IENBIC0gMDMwHhcNMTMxMjA4MTIwMDAw WhcNNDMxMjA4MTIwMDAwWjBQMQswCQYDVQQGEwJVUzEYMBYGA1UEChMPV0ZBIEhv dHNwb3QgMi4wMScwJQYDVQQDEx5Ib3RzcG90IDIuMCBUcnVzdCBSb290IENBIC0g MDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsdEtReIUbMlO+hR6b yQk4nGVITv3meYTaDeVwZnQVal8EjHuu4Kd89g8yRYVTv3J1kq9ukE7CDrDehrXK ym+8VlR7ro0lB/lwRyNk3W7yNccg3AknQ0x5fKVwcFznwD/FYg37owGmhGFtpMTB cxzreQaLXvLta8YNlJU10ZkfputBpzi9bLPWsLOkIrQw7KH1Wc+Oiy4hUMUbTlSi cjqacKPR188mVIoxxUoICHyVV1KvMmYZrVdc/b5dbmd0haMHxC0VSqbydXxxS7vv /lCrC2d5qbKE66PiuBPkhzyU7SI9C8GU/S7akYm1MMSTn5W7lSp2AWRDnf9LQg51 dLvDxJ7t2fruXtSkkqG/cwY1yQI8O+WZYPDThKPcDmNbaxVE9lOizAHXFVsfYrXA PbbMOkzKehYwaIikmNgcpxtQNw+wikJiZb9N8VwwtwHK71XEFi+n5DGlPa9VDYgB YkBcxvVo2rbE3i3teQgHm+pWZNP08aFNWwMk9yQkm/SOGdLq1jLbQA9yd7fyR1Ct W1GLzKi1Ojr/6XiB9/noL3oxP/+gb8OSgcqVfkZp4QLvrGdlKiOI2fE7Bslmzn6l B3UTpApjab7BQ99rCXzDwt3Xd7IrCtAJNkxi302J7k6hnGlW8S4oPQBElkOtoH9y XEhp9rNS0lZiuwtFmWW2q50fkQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G A1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUZw5JLGEXnuvt4FTnhNmbrWRgc2UwDQYJ KoZIhvcNAQELBQADggIBAFPoGFDyzFg9B9+jJUPGW32omftBhChVcgjllI07RCie KTMBi47+auuLgiMox3xRyP7/dX7YaUeMXEQ1BMv6nlrsXWv1lH4yu+RNuehPlqRs fY351mAfPtQ654SBUi0Wg++9iyTOfgF5a9IWEDt4lnSZMvA4vlw8pUCz6zpKXHnA RXKrpY3bU+2dnrFDKR0XQhmAQdo7UvdsT1elVoFIxHhLpwfzx+kpEhtrXw3nGgt+ M4jNp684XoWpxVGaQ4Vvv00Sm2DQ8jq2sf9F+kRWszZpQOTiMGKZr0lX2CI5cww1 dfmd1BkAjI9cIWLkD8YSeaggZzvYe1o9d7e7lKfdJmjDlSQ0uBiG77keUK4tF2fi xFTxibtPux56p3GYQ2GdRsBaKjH3A3HMJSKXwIGR+wb1sgz/bBdlyJSylG8hYD// 0Hyo+UrMUszAdszoPhMY+4Ol3QE3QRWzXi+W/NtKeYD2K8xUzjZM10wMdxCfoFOa 8bzzWnxZQlnu880ULUSHIxDPeE+DDZYYOaN1hV2Rh/hrFKvvV+gJj2eXHF5G7y9u Yg7nHYCCf7Hy8UTIXDtAAeDCQNon1ReN8G+XOqhLQ9TalmnJ5U5ARtC0MdQDht7T DZpWeEVv+pQHARX9GDV/T85MV2RPJWKqfZ6kK0gvQDkunADdg8IhZAjwMMx3k6B/ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBN MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVu VHJ1c3QgUHVibGljIFNlY3RvciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcN MzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0 MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBSb290IENBIDEwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTyP4o7 ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGy RBb06tD6Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlS bdsHyo+1W/CD80/HLaXIrcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF /YTLNiCBWS2ab21ISGHKTN9T0a9SvESfqy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R 3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoSmJxZZoY+rfGwyj4GD3vw EUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFnol57plzy 9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9V GxyhLrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ 2fjXctscvG29ZV/viDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsV WaFHVCkugyhfHMKiq3IXAAaOReyL4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gD W/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMwDQYJKoZIhvcN AQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHV DRDtfULAj+7AmgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9 TaDKQGXSc3z1i9kKlT/YPyNtGtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8G lwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFtm6/n6J91eEyrRjuazr8FGF1NFTwW mhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMxNRF4eKLg6TCMf4Df WN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4Mhn5 +bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJ tshquDDIajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhA GaQdp/lLQzfcaFpPz+vCZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv 8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ3Wl9af0AVqW3rLatt8o+Ae+c -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBK MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQw MTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScw JQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ldhNlT 3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU +ehcCuz/mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gp S0l4PJNgiCL8mdo2yMKi1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1 bVoE/c40yiTcdCMbXTMTEl3EASX2MN0CXZ/g1Ue9tOsbobtJSdifWwLziuQkkORi T0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl3ZBWzvurpWCdxJ35UrCL vYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzyNeVJSQjK Vsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZK dHzVWYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHT c+XvvqDtMwt0viAgxGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hv l7yTmvmcEpB4eoCHFddydJxVdHixuuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5N iGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB /zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZIhvcNAQELBQAD ggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH 6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwt LRvM7Kqas6pgghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93 nAbowacYXVKV7cndJZ5t+qntozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3 +wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmVYjzlVYA211QC//G5Xc7UI2/YRYRK W2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUXfeu+h1sXIFRRk0pT AwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/rokTLq l1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG 4iZZRHUe2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZ mUlO+KWA2yUPHGNiiskzZ2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A 7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7RcGzM7vRX+Bi6hG6H -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF8TCCA9mgAwIBAgIIVE2lvEA1VlowDQYJKoZIhvcNAQELBQAwgYUxCzAJBgNV BAYTAlBUMUIwQAYDVQQKDDlNVUxUSUNFUlQgLSBTZXJ2acOnb3MgZGUgQ2VydGlm aWNhw6fDo28gRWxlY3Ryw7NuaWNhIFMuQS4xMjAwBgNVBAMMKU1VTFRJQ0VSVCBS b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IDAxMB4XDTE0MDQwNDA4NTk0N1oX DTM5MDQwNDA4NTk0N1owgYUxCzAJBgNVBAYTAlBUMUIwQAYDVQQKDDlNVUxUSUNF UlQgLSBTZXJ2acOnb3MgZGUgQ2VydGlmaWNhw6fDo28gRWxlY3Ryw7NuaWNhIFMu QS4xMjAwBgNVBAMMKU1VTFRJQ0VSVCBSb290IENlcnRpZmljYXRpb24gQXV0aG9y aXR5IDAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAztw/9BluuxVp hvTkzec6cDvHmos7gwCBW/sgFlq+v1gAXynmV29+iiwVB1waY4xCXxbd2omERVcX lqCcoXUiQRo6/cUXkRP2vmIKvG4lLVvAjBBm9+LW+9xIMaMaqOVNSMmiHHP+j2ZA Y3dZBzw9FJ/U94WR0MNC9Rths3eAgCptEgKWi1HZwW8nCxoHNAD/0llMKejXGWPY kbQ//I4OJfKhEgdlyjXeq/4WowiMr39+EvRZFgUf6K10eTL3eAK2tMyr2x44YQQZ ekFA2loRZHUC/WTR1pRCDyLnZc2vkA4MWzEBmVHvRYx9pTjannxL5Kbos6SC1gM0 Lk+3Uat3OAn1Bv7cZhsPP/p974xVvuANhpWh3L3EwwjRRR7yvb5w8eYmxrsIsSil wqXtiNahwPsj8Sc5zOGEBxm8fvbMOP9uELtG6SOJJIH/AOJRANxSUH0TUH0WPUCN 07/5imXYYhIpd8K6wkk0T4p5aclLFfM03s+vhuLlyKlWYUwGVFrFbBnq88hEzSQa dtFxAFlr2XWbzv0Q/rGDoqW3koZ2m0r3HdyMhaZYrYqmaGkXyW0bps8nSyks3XFC GokQ5dWbEl9Ji4S82Ahc+884Qq++0W57kapmQMUFfivQZrbH31L+9EVtI5IhnhIB kHOD4qUJDdfA+IWVHmPRPzXalNE32fUCAwEAAaNjMGEwHQYDVR0OBBYEFNU5HJxb bwSqopVM7yDdKXSkxUVxMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1Tkc nFtvBKqilUzvIN0pdKTFRXEwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA A4ICAQA/51/zIhbeg54g5ILn5Z53yfsrsHQN3xt0Ig9zEKGwF+xMDNQocGpmckRp EJN2Nc8v+I88qxl8cZKVcRs3FcIbKHrvbng43/uPmwEg3K/21o0JZtrERqn8lapE IxLfR8CwFey1sZ5sD5GqpjrlwQ1gbFBAcFxcyM6zzOvtqogZVqWkyAx65XZAZzO0 PZbcd8sjePlTW8+N3rGnjlp6ojJjo4jXJWFaXUk6cubPqpSGbG73guCOZ5MoxagN Te84rXlKZo2EAQgEefNSxkHnmmIGs/USHuzZAEPT65Z3dOF5+RSUhG26VIIFjN8B 8jCIgax6L4tDLHY0zjXnh45OCwqlGlexU1q/a9i+AH7G+e5mMQix35QzhJx3T3tk L++OD1koIsvwXD4r/TXWlf8D7GVSfr7yGfh71VIsUneakWZBcI3VSecLSH+Krt5F Pd3+5tLkksN7zjCgSW43rajTLLY9niHbBlfi8K4G+9nFETehe9sdEXxodiA+9byl 2Wa1Ia1FJsZdHgKjQcTUfYEZyxeXBg/m7HQARsR13T3wQzSvprz89oL7z8X6sw8l pT9mENaegqXbOhN53o2p16aNhtIv2WkN4nV4fklfIquGcChRs3q2oHn61OWDp7B3 ytsBgu/ivk0v08BN0ONpbnwmm+um+0XvsQSKL6ohBvbm1LxBIw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UE AwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0 aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMw HhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBs ZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0 aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJ KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6s XuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5x ZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/Yu OQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVd J2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tv ny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz 8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TU gWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFm IrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOe HwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLD d4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8 DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcm jTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwF AAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+ 8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVA eKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3z ybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG /5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6Z SYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePw N983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDr FEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0 Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60E inpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiK um1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM 6BgD56KyKA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFUTCCAzmgAwIBAgIIAPtxJlitmeUwDQYJKoZIhvcNAQELBQAwNjEWMBQGA1UE AwwNQ0FFRElDT00gUm9vdDEPMA0GA1UECgwGRURJQ09NMQswCQYDVQQGEwJFUzAe Fw0xNDA1MjExMTA2MzVaFw0zNDA1MjExMDIwMDBaMDYxFjAUBgNVBAMMDUNBRURJ Q09NIFJvb3QxDzANBgNVBAoMBkVESUNPTTELMAkGA1UEBhMCRVMwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQDbgMroSXTH0zgu8cUjYvw2jC8efjkL6Qb0 VZulmCmU7YZHMoPzxZJ6BdcpAj4Wwyh/NWQpenm7oeIeYRSN5wDQ3KJUZYrfablx R384OBZGp2kxETVM4Sp//21PlT3jXUhNGVMIWmsh1RIwaZeQry3B9X9BX0k2j024 HhqVX9oPb1wVNcQRvF+Fm72tO1Veu9/Ou69cmWDdH2kaSUgh+QkKz3Kn8PLe5XgZ vhLdzYd5Qc4vRdcLkRARBB4SnfI4A18Waa6gCtrA+eugDRgPeV6RneQfFJw0ExkC RLpRw+55smAUo6+8SC0oOGgBQ2TKDoaDYtCKGaYn8St7SykhW5rMaEIQyEtPDyOy iHzEXG4XcMV3r5XAJaQiCtN8+dhyyNAtvafo0i2LTKFuCvy0QDO7mmv8pOrJ/uA0 iEPMxrw/ddKlqa/6l7k+t85UoE3AXS7BKNhjVHK4rFr1OvsgYQY69KArOKvMgwxJ 1G4+bQ8+cy825vNPs8AA0UVJW4z2o5gdhH+ZCsPqCjzD0yR4SGf1GzsOHQ5DsQR1 waA5dov22QKlHeGeWwe7NldKIU35iWm0bA/Xr6AVJJnn+NdTlOwSv6Sl1+3ujjV3 d9ymfyBUktZj1nKeTSq2j3PzGaHEsB/mNKMLAD6XSSdhqqoEQTM4tVBRzDYV2x// vcpIg0inswIDAQABo2MwYTAdBgNVHQ4EFgQUFM0qWXhjq2EZ6Lg9oeBawHXn+csw DwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQUzSpZeGOrYRnouD2h4FrAdef5 yzAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAD0JGQC2kQJs7A73 4eJisL8zDf1VEvQImvcrLa73nEfYHwYBE7WO57KCCz2EWUPUB9grXBB6JCzKjejV ozmcMczr4Drh1b/Px4d7YP9HOdejRNYIJlvPWlTsiNOOD3k8yKNPpsKOJ/DeEq5e Ga3nIlaKWDLg+QbQqSq0NZsMhiZRAJRHUPylxCVh+VjwRXAuSXZ/EdZvtfkpBeEN w05YH68d7DfQSvkGBoHT26CWuA6RMHnmUN+IuAupXNQH9MmozH2Pk2MJZAAFKmhm Q7uiu/6VrvnEpQqIYkh4JXwqPxFkptMiIEedMtby48ikYXTngsJEuqDRXV+88UQO g08cUIXE6eds/Oa4VeGiGoC3kESnhCKXRyLeqzg3z7XyHD5CcLt1tmUoa8t/gjWq 9vMgeChzB5YwcKUqcVyheaQWuUY9XrQASYWJ0w7fga5YjVjW4cVEeC4cILuiR5e/ dhQ7qSiPnwt10qE87SvHjpCheqKZMGL8hR01czvztVkiG80IsQyddWrbhTsOh58y T5IAAQFMSWiCgEFs+f1xvYv0eApgo56xUh3AiuOexb8rGWqYp7HeFVCfqpQlj6mA gqdyuklkCSdhK268IygzXZ5u8Lm9IDKM3aALmbu0hAQkdSmW96elF7hRBet0rVF5 lvy7+98JLQiSRM7A0rMYxxQivyHx -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFazCCA1OgAwIBAgISESBVg+QtPlRWhS2DN7cs3EYRMA0GCSqGSIb3DQEBDQUA MD4xCzAJBgNVBAYTAkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2Vy dHBsdXMgUm9vdCBDQSBHMTAeFw0xNDA1MjYwMDAwMDBaFw0zODAxMTUwMDAwMDBa MD4xCzAJBgNVBAYTAkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2Vy dHBsdXMgUm9vdCBDQSBHMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB ANpQh7bauKk+nWT6VjOaVj0W5QOVsjQcmm1iBdTYj+eJZJ+622SLZOZ5KmHNr49a iZFluVj8tANfkT8tEBXgfs+8/H9DZ6itXjYj2JizTfNDnjl8KvzsiNWI7nC9hRYt 6kuJPKNxQv4c/dMcLRC4hlTqQ7jbxofaqK6AJc96Jh2qkbBIb6613p7Y1/oA/caP 0FG7Yn2ksYyy/yARujVjBYZHYEMzkPZHogNPlk2dT8Hq6pyi/jQu3rfKG3akt62f 6ajUeD94/vI4CTYd0hYCyOwqaK/1jpTvLRN6HkJKHRUxrgwEV/xhc/MxVoYxgKDE EW4wduOU8F8ExKyHcomYxZ3MVwia9Az8fXoFOvpHgDm2z4QTd28n6v+WZxcIbekN 1iNQMLAVdBM+5S//Ds3EC0pd8NgAM0lm66EYfFkuPSi5YXHLtaW6uOrc4nBvCGrc h2c0798wct3zyT8j/zXhviEpIDCB5BmlIOklynMxdCm+4kLV87ImZsdo/Rmz5yCT mehd4F6H50boJZwKKSTUzViGUkAksnsPmBIgJPaQbEfIDbsYIC7Z/fyL8inqh3SV 4EJQeIQEQWGw9CEjjy3LKCHyamz0GqbFFLQ3ZU+V/YDI+HLlJWvEYLF7bY5KinPO WftwenMGE9nTdDckQQoRb5fc5+R+ob0V8rqHDz1oihYHAgMBAAGjYzBhMA4GA1Ud DwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSowcCbkahDFXxd Bie0KlHYlwuBsTAfBgNVHSMEGDAWgBSowcCbkahDFXxdBie0KlHYlwuBsTANBgkq hkiG9w0BAQ0FAAOCAgEAnFZvAX7RvUz1isbwJh/k4DgYzDLDKTudQSk0YcbX8ACh 66Ryj5QXvBMsdbRX7gp8CXrc1cqh0DQT+Hern+X+2B50ioUHj3/MeXrKls3N/U/7 /SMNkPX0XtPGYX2eEeAC7gkE2Qfdpoq3DIMku4NQkv5gdRE+2J2winq14J2by5BS S7CTKtQ+FjPlnsZlFT5kOwQ/2wyPX1wdaR+v8+khjPPvl/aatxm2hHSco1S1cE5j 2FddUyGbQJJD+tZ3VTNPZNX70Cxqjm0lpu+F6ALEUz65noe8zDUa3qHpimOHZR4R Kttjd5cUvpoUmRGywO6wT/gUITJDT5+rosuoD6o7BlXGEilXCNQ314cnrUlZp5Gr RHpejXDbl85IULFzk/bwg2D5zfHhMf1bfHEhYxQUqq/F3pN+aLHsIqKqkHWetUNy 6mSjhEv9DKgma3GX7lZjZuhCVPnHHd/Qj1vfyDBviP4NxDMcU6ij/UgQ8uQKTuEV V/xuZDDCVRHc6qnNSlSsKWNEz0pAoNZoWRsz+e86i9sgktxChL8Bq4fA1SCC28a5 g4VCXA9DO2pJNdWY9BW/+mGBDAkgGNLQFwzLSABQ6XaCjGTXOqAHVcweMcDvOrRl ++O/QmueD6i9a5jc2NvLi6Td11n0bt3+qsOR0C5CB8AMTVPNJLFMWx5R9N/pkvo= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFbzCCA1egAwIBAgISESChaRu/vbm9UpaPI+hIvyYRMA0GCSqGSIb3DQEBDQUA MEAxCzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlPcGVuVHJ1c3QxHTAbBgNVBAMMFE9w ZW5UcnVzdCBSb290IENBIEcyMB4XDTE0MDUyNjAwMDAwMFoXDTM4MDExNTAwMDAw MFowQDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU9wZW5UcnVzdDEdMBsGA1UEAwwU T3BlblRydXN0IFJvb3QgQ0EgRzIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQDMtlelM5QQgTJT32F+D3Y5z1zCU3UdSXqWON2ic2rxb95eolq5cSG+Ntmh /LzubKh8NBpxGuga2F8ORAbtp+Dz0mEL4DKiltE48MLaARf85KxP6O6JHnSrT78e CbY2albz4e6WiWYkBuTNQjpK3eCasMSCRbP+yatcfD7J6xcvDH1urqWPyKwlCm/6 1UWY0jUJ9gNDlP7ZvyCVeYCYitmJNbtRG6Q3ffyZO6v/v6wNj0OxmXsWEH4db0fE FY8ElggGQgT4hNYdvJGmQr5J1WqIP7wtUdGejeBSzFfdNTVY27SPJIjki9/ca1TS gSuyzpJLHB9G+h3Ykst2Z7UJmQnlrBcUVXDGPKBWCgOz3GIZ38i1MH/1PCZ1Eb3X G7OHngevZXHloM8apwkQHZOJZlvoPGIytbU6bumFAYueQ4xncyhZW+vj3CzMpSZy YhK05pyDRPZRpOLAeiRXyg6lPzq1O4vldu5w5pLeFlwoW5cZJ5L+epJUzpM5ChaH vGOz9bGTXOBut9Dq+WIyiET7vycotjCVXRIouZW+j1MY5aIYFuJWpLIsEPUdN6b4 t/bQWVyJ98LVtZR00dX+G7bw5tYee9I8y6jj9RjzIR9u701oBnstXW5DiabA+aC/ gh7PU3+06yzbXfZqfUAkBXKJOAGTy3HCOV0GEfZvePg3DTmEJwIDAQABo2MwYTAO BgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUajn6QiL3 5okATV59M4PLuG53hq8wHwYDVR0jBBgwFoAUajn6QiL35okATV59M4PLuG53hq8w DQYJKoZIhvcNAQENBQADggIBAJjLq0A85TMCl38th6aP1F5Kr7ge57tx+4BkJamz Gj5oXScmp7oq4fBXgwpkTx4idBvpkF/wrM//T2h6OKQQbA2xx6R3gBi2oihEdqc0 nXGEL8pZ0keImUEiyTCYYW49qKgFbdEfwFFEVn8nNQLdXpgKQuswv42hm1GqO+qT RmTFAHneIWv2V6CG1wZy7HBGS4tz3aAhdT7cHcCP009zHIXZ/n9iyJVvttN7jLpT wm+bREx50B1ws9efAvSyB7DH5fitIw6mVskpEndI2S9G/Tvw/HRwkqWOOAgfZDC2 t0v7NqwQjqBSM2OdAzVWxWm9xiNaJ5T2pBL4LTM8oValX9YZ6e18CL13zSdkzJTa TkZQh+D5wVOAHrut+0dSixv9ovneDiK3PTNZbNTe9ZUGMg1RGUFcPk8G97krgCf2 o6p6fAbhQ8MTOWIaNr3gKC6UAuQpLmBVrkA9sHSSXvAgZJY/X0VdiLWK2gKgW0VU 3jg9CcCoSmVGFvyqv1ROTVu+OEO3KMqLM6oaJbolXCkvW0pujOotnCr2BXbgd5eA iN1nE28daCSLT7d0geX0YJ96Vdc+N9oWaz53rK4YcJUIeSkDiv7BO7M/Gg+kO14f WKGVyasvc0rQLW6aWQ9VGHgtPFGml4vmu7JwqkwR3v98KzfUetF3NI/n+UL3PIEM S1IK -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICHDCCAaKgAwIBAgISESDZkc6uo+jF5//pAq/Pc7xVMAoGCCqGSM49BAMDMD4x CzAJBgNVBAYTAkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2VydHBs dXMgUm9vdCBDQSBHMjAeFw0xNDA1MjYwMDAwMDBaFw0zODAxMTUwMDAwMDBaMD4x CzAJBgNVBAYTAkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2VydHBs dXMgUm9vdCBDQSBHMjB2MBAGByqGSM49AgEGBSuBBAAiA2IABM0PW1aC3/BFGtat 93nwHcmsltaeTpwftEIRyoa/bfuFo8XlGVzX7qY/aWfYeOKmycTbLXku54uNAm8x Ik0G42ByRZ0OQneezs/lf4WbGOT8zC5y0xaTTsqZY1yhBSpsBqNjMGEwDgYDVR0P AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNqDYwJ5jtpMxjwj FNiPwyCrKGBZMB8GA1UdIwQYMBaAFNqDYwJ5jtpMxjwjFNiPwyCrKGBZMAoGCCqG SM49BAMDA2gAMGUCMHD+sAvZ94OX7PNVHdTcswYO/jOYnYs5kGuUIe22113WTNch p+e/IQ8rzfcq3IUHnQIxAIYUFuXcsGXCwI4Un78kFmjlvPl5adytRSv3tjFzzAal U5ORGpOucGpnutee5WEaXw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICITCCAaagAwIBAgISESDm+Ez8JLC+BUCs2oMbNGA/MAoGCCqGSM49BAMDMEAx CzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlPcGVuVHJ1c3QxHTAbBgNVBAMMFE9wZW5U cnVzdCBSb290IENBIEczMB4XDTE0MDUyNjAwMDAwMFoXDTM4MDExNTAwMDAwMFow QDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU9wZW5UcnVzdDEdMBsGA1UEAwwUT3Bl blRydXN0IFJvb3QgQ0EgRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARK7liuTcpm 3gY6oxH84Bjwbhy6LTAMidnW7ptzg6kjFYwvWYpa3RTqnVkrQ7cG7DK2uu5Bta1d oYXM6h0UZqNnfkbilPPntlahFVmhTzeXuSIevRHr9LIfXsMUmuXZl5mjYzBhMA4G A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRHd8MUi2I5 DMlv4VBN0BBY3JWIbTAfBgNVHSMEGDAWgBRHd8MUi2I5DMlv4VBN0BBY3JWIbTAK BggqhkjOPQQDAwNpADBmAjEAj6jcnboMBBf6Fek9LykBl7+BFjNAk2z8+e2AcG+q j9uEwov1NcoG3GRvaBbhj5G5AjEA2Euly8LQCGzpGPta3U1fJAuwACEl74+nBCZx 4nxp5V2a+EEfOzmTk51V6s2N8fvB -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDWjCCAkKgAwIBAgIBMTANBgkqhkiG9w0BAQsFADA+MQswCQYDVQQGEwJKUDEO MAwGA1UEChMFTEdQS0kxHzAdBgNVBAMTFkFwcGxpY2F0aW9uIENBIEczIFJvb3Qw HhcNMTQwNjAzMTUwMDAwWhcNMzQwNjAzMTQ1OTU5WjA+MQswCQYDVQQGEwJKUDEO MAwGA1UEChMFTEdQS0kxHzAdBgNVBAMTFkFwcGxpY2F0aW9uIENBIEczIFJvb3Qw ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNRT730ZYiXJEvPgoAA8y2 92xU/Cg31AQY7K2Yya/Tpbnn2b9O5qOZPJluoSAeRhvidVW80uz2iBrsNEVLg53T subdB4nBCNn4O4uSZHJdmjvMrTeJx9xgeQjgcKz3K+2fA0kfjj6DqG7iklxU0Xnf 7Bg6fbhtj9ajJU2tH0CmX9SqTrFwGFmZ8gtUaT55KESI93GXzX8F3MrcdkqQTGtg 6PomMdi1+Of8bYskarbvQtcjVMUaY4o7x/yqbTyPy2zaILDyvGUcAUwilQ0cIx+s 1fnOdVvqML1MASQfddRhScMbmWWOCFw5OM0pwzhFzWR5t5tNR+pYMvqm9pLwwbdf AgMBAAGjYzBhMB0GA1UdDgQWBBSpNSpIviw37YbbfFWHACa+GC1cLjAOBgNVHQ8B Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSpNSpIviw37Ybb fFWHACa+GC1cLjANBgkqhkiG9w0BAQsFAAOCAQEAtoK9xUbQcYulkT1+LVr5nIR9 ByeVHedNyHzs5pPoVhp6MEg7DPpO9Qmyr4itlOz9sq0v5gV0IRuEizgqw+3vRmi1 3VL6cMJ1T/+jQS48F5RMCSK0jsF/xKas7YNoz2Ve7Hq9xWbu0KN/8lexCMJ5cOty f0FZCXl18byxIf6Ds0Q9iaO+sXrYncMf5sRU4Y3l2FDc5FY3e74oAPMsd9ojf2CY PQUW8nhprZnDOnRsPpqylO2PqvZTa+fIt+g8jPvHfE8ZXaRmFel/h6DQ1a0gpEYJ RazlyGWHuwbf/NdoVkNzogCZMpLCDqAcDpG9lVi8k5+EwqVm52XNKeJi8gWSYA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDNTCCAh2gAwIBAgIBATANBgkqhkiG9w0BAQsFADA8MQswCQYDVQQGEwJVUzEW MBQGA1UEChMNQ2lzY28gU3lzdGVtczEVMBMGA1UEAxMMQ2lzY28gUlhDLVIyMB4X DTE0MDcwOTIxNDY1NloXDTM0MDcwOTIxNDY1NlowPDELMAkGA1UEBhMCVVMxFjAU BgNVBAoTDUNpc2NvIFN5c3RlbXMxFTATBgNVBAMTDENpc2NvIFJYQy1SMjCCASIw DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANtCMXsK05wqTce60mQGZLAIL8wT 6i02PnfuPth2FAGDwUtPL4jLHBJW8uVJJEBLom3pyhPpc/jaqd1g6dddKxwK4Y2L vHW/c1j86IMqjXLeE9//u58xND+hiOhBx1QQpO+BFe4jpQW6NRKYqWlz7G5aPO+M fk3zDWEnEWRpoisf2jNOnNYVqRQdEY4+xZ9NHTsATS3NbAGFADRi7Vx0C6dSieI+ CtNsTRG6dMU8x8/IX40VzREyPtIqMSWtGwuz0xk6KayB1ADYuBW8mH5jfufIOLn1 /XSgVz7flasyfJ8iKbW1eoIgpGNyXJGBI39iPWTYZswh+Ok7swZskj0mPzECAwEA AaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE FJByBGD93fqE7I5aBFj3z/vDcgkWMA0GCSqGSIb3DQEBCwUAA4IBAQCBDfRhZWOb blcaSjp0A8tREiYjHaDW9oR6Pk3xd5SMYE2axpy45nFjbfXCr9HTBz+mi8SrunUw P4lzgv+P+EyyT/Kmt6KRrm2z+CPr6JUaexYgsennNi/TRmiqdWRXY4gyrYSsCgJB jw3A7srAUvZSma6JEiP2E4skx3KVHmliwyBaK04KSkKKwY4b+oQIZVq2cgySm2bB 1q2+SMI5jMk9pRUh0anImbDyZPCARsIQuhUD5MOSYh+GiG7oTurvsf70H1RxuZrQ /RwhDKseClSVWzBiLtiDW3LOAo5UNjqyQAZgZcS1yhAsGcsPXB7eel783IZDbq7Q kK4RSUNGApEO -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFmTCCA4GgAwIBAgIIcYwvOXxAdEAwDQYJKoZIhvcNAQELBQAwWjELMAkGA1UE BhMCR1IxGzAZBgNVBAoMEkJZVEUgQ29tcHV0ZXIgUy5BLjEuMCwGA1UEAwwlQllU RSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IDAwMTAeFw0xNDA5MTAyMjAx NTRaFw0zOTA5MTAyMjAxNTRaMFoxCzAJBgNVBAYTAkdSMRswGQYDVQQKDBJCWVRF IENvbXB1dGVyIFMuQS4xLjAsBgNVBAMMJUJZVEUgUm9vdCBDZXJ0aWZpY2F0aW9u IEF1dGhvcml0eSAwMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDt Ei4Xc55v9POZ6J4IVwk0JBFAH4whfhuvOMPRx+YU5fobul5m9SVp9+3NboJwr7pC 8LEZXCv8RYQYLHoXT2GFRhl8zsGNn1SedyVmD+D2+JLKKc4nVxUqbII4bSfmvk1z DnOv43E9vAlCD9UNoe19a673wfBszcKXoVj9NRWWF0yfv/XxOUtwt+dKbBw/wXBb z9aL6+9vMOhfyEZ3IWIWXsZURTn1dLpnJGilcVs+wfsJk+simfjS9XsCbI9Y4qvv 3XQh5CRplEDWwQQYDthC8P3XigXAXxuK6y7ADQcGcwGFjh/BwIqhWKZRuViRQg9u 4bwK6LsogxV15Q3+STApKULCwjb/pDx9Lvfa8qIvFrxhqJlYGKRJxmoHEusbfLTO 5/shgCtwpsjOrVUeHx2E0P1UakxWY8jdfqD5OdvvfFr3jDWlbipW+v7jX5NUcg5o 40krk001IpcUlWZPp3c6LiVM9gmLEhtxxXKnm7m86xygpclUg2HcV1WttebaeCt2 p/742/6MM6SKo0ZcrbIKEg6K5FCe8LjLmVNMZCFrijgq4IiGANQXrGay574tOynl +KeU24xY+NJLMJ/yxGJlUEdygM+kcEC2vUT+2b8oKy43x7NRDoIptbFvrX4sk8Cp f5H6xx818LuXyU9hKJCEQeh9IUDFyYY87ZqthZyiUwIDAQABo2MwYTAdBgNVHQ4E FgQUtE1mt9OzyJl8ATLQkTr31qgSMd4wDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME GDAWgBS0TWa307PImXwBMtCROvfWqBIx3jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI hvcNAQELBQADggIBABwa6wauVb07PzYsYZ7qx1P8cKoyb+RCquu9hewbilrylZYp oQQGks4kV/9AI3hOyfgwTUJVRE43on1rjmj+Dv5/37CfY1Hz4cWllJ+KIyhI80GL 0v547dnQCA9tfdWdlazV/hJmGuS+dVTz0U2cThPUnnA0bai6CjOIja0FN/5LeX99 A0F5Ew2fPfc4nDVaRE8+PKLlgcV/X3ZPGztub5ptt+0PyzIfiLRFDJwR0vgEWhM3 WZiBzkz05ZQoBMS1U8lUjXA/aAHbzBMK5CWjbJntELN6IKlJvAX0+Bto1rogHYJn ZuCwn1zKNdJFrtWIGdt6BpuMoDeHUSO+Rdpcs39rz8aoHDOKex2R+p687H07RRVP G6c7NbR581uCUOCcp+0WddtjgGKh2hgCaoDegqpETUQ4KKpu+hhjOWD3QylJWrok wL+zCpcdZ0laIrJnBJxYqfgMNFxAlrSHtUVhGeWO7wbekRXAuIrKlMkKdX1xO1iB M8j3B0FVmClDtcuaQ+ly+s/wizG85++5auNBnSE+DRWohb0bToeOR7IQ/jcYaoTl iRwUY+i5g6m1u+hjmnoZjMt09/gXCPGLGdi07B5uSXM/XCDdNSqWd+lGbxY7y6nv mwohEcjDpMkjRW0/YpWd0yjHnQ+z/jeNHUiyUOYluU4zYTbWFhzKMjcgdhws -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGAzCCA+ugAwIBAgIQL9Z6QyKTMpBF6VM0PuJ0ZjANBgkqhkiG9w0BAQsFADCB kzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE9MDsGA1UEAxM0 TWljcm9zb2Z0IFRpbWUgU3RhbXAgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkg MjAxNDAeFw0xNDEwMjIyMjA4NTdaFw0zOTEwMjIyMjE1MTlaMIGTMQswCQYDVQQG EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMT0wOwYDVQQDEzRNaWNyb3NvZnQg VGltZSBTdGFtcCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MIICIjAN BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArgHUXaBYyu3ozOE3RkYQW3rEUrgL WI8FPV2prVHiA4ngBhL4AnhrXgRQz7bWBAxUHnk3IDzjfmMdRXeYFB2+diLcWqo/ 5G9AYiRjyDzATIcoPWt4a5g5lRpBf3NR/gf8FHzzj4QJ4fjCL6FOvTl9zGNniQyA BM2wgskAiz4JhwOdwnlCxFwhkSuVGmw1R2zIvzwKTur2hXDVxV/BnkfbXMIyYVoI 1nGdLIGffri+baHYZkNpCuTzcvCRSyhgqNXj3YSuKGVVn4QrSnXtJKYsdTHUhXd0 8oBVAmNB8nAI9MjCU5HbFAdlIAmB5orXmw/KDNcbX/3R5XSFXBD7msmmK55Dlsxb cnPQD1WZhxgbPfgpeLBv0XS85SC6Q4sUOGlkoXMPwRYpeU+bhSlosT6ZKo+y3EcG zd/Q6yLcHlccflmQJaMDgr6Myx2buY0quKEQ5/qtFv7s5VPGrcCXfESbgfN6pvn/ rvqsF6mmYL1nPHlshQtVrzHEw1mQDqHVfEg5i63juw7k5frf/dqdnltvGzIOpjfT qqosBBdl08ZORyStglCZQSvWs+cmWrE1m+ZxVeHIb6JEHchchPz5eAF2wT53k/Ki lOHacDDsZAquoqEdP4NDc0DS4IlwWa+NLtTUIQphpPT3I4ZDgCiyHEMMRdr8Bvgl QAd1aXjjphOD15cCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMB Af8wHQYDVR0OBBYEFMvR8s5I/QGf6laqV9F+mVj4P//gMBAGCSsGAQQBgjcVAQQD AgEAMA0GCSqGSIb3DQEBCwUAA4ICAQAT2NPko9gmzv07R++AvvujloXafZWcKQBi Rr5ICsoU7B1Osh71zkavWQEQOP3iZQu/+A/mddncM8qD764gV30n+shcID4Lhiy8 EnYDXNMhU6DPPvdFGSIPbiE3xmiHxJwpVaOQ6KkevrNB549H6R00xWQkW0wy7Laa DLhW4AbkQIvyEAf6jo5mIOYcS+Slo7suBulFe57J/5SKV8Fpo11lWN20wmNKpt1j MRiv7RYY2sFqPx/Sqpa2YW/Vgym0eWbBwVADHNDqLsa6z8aYbdYbxs4QsMnxQxor 1/8VNIY72Uo8bT4juwI9zlTDSiXvRjx5W46zwiqCEkVSlsIJ1Ep4nt1vn/mfcEqa o03vLfqqlvq0fdY2l87w2HzSL1ZUCgBg0DyOaOLNKao9LiCDy7JVRqDfuJF5KJJB Dv4mOEN103el3YdS8U2dv9yjLfIeD0kspRGwijYTObD1G5J3tIPdmJ4Fr6CjCdDf HXaYQkQBc7CyqTtS5bZvq4zy1Rcpf2/45aM0625FkkhNAlW2N6ECsTTfx7KSPQK9 NxoG4aGAjpIlMc72geeu5ZIXrFnEkqzfyCwnUkIeJh14h7lOi/dHescBcNWhyQui Igg4/MqowjtT3As2O+Gjyq33tgjDE1WvAzpptOmk0S3NZ9TDQspjX56ApOxjbHLE WOUH+pb4jQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFiDCCA3CgAwIBAgIIfQmX/vBH6nowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UE BhMCQ04xMjAwBgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZ IENPLixMVEQuMR8wHQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMB4XDTE0 MTEyNjA1MTMxNVoXDTQwMTIzMTE1NTk1OVowYjELMAkGA1UEBhMCQ04xMjAwBgNV BAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZIENPLixMVEQuMR8w HQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEA2aMW8Mh0dHeb7zMNOwZ+Vfy1YI92hhJCfVZmPoiC7XJj Dp6L3TQsAlFRwxn9WVSEyfFrs0yw6ehGXTjGoqcuEVe6ghWinI9tsJlKCvLriXBj TnnEt1u9ol2x8kECK62pOqPseQrsXzrj/e+APK00mxqriCZ7VqKChh/rNYmDf1+u KU49tm7srsHwJ5uu4/Ts765/94Y9cnrrpftZTqfrlYwiOXnhLQiPzLyRuEH3FMEj qcOtmkVEs7LXLM3GKeJQEK5cy4KOFxg2fZfmiJqwTTQJ9Cy5WmYqsBebnh52nUpm MUHfP/vFBu8btn4aRjb3ZGM74zkYI+dndRTVdVeSN72+ahsmUPI2JgaQxXABZG12 ZuGR224HwGGALrIuL4xwp9E7PLOR5G62xDtw8mySlwnNR30YwPO7ng/Wi64HtloP zgsMR6flPri9fcebNaBhlzpBdRfMK5Z3KpIhHtmVdiBnaM8Nvd/WHwlqmuLMc3Gk L30SgLdTMEZeS1SZD2fJpcjyIMGC7J0R38IC+xo70e0gmu9lZJIQDSri3nDxGGeC jGHeuLzRL5z7D9Ar7Rt2ueQ5Vfj4oR24qoAATILnsn8JuLwwoC8N9VKejveSswoA HQBUlwbgsQfZxw9cZX08bVlX5O2ljelAU58VS6Bx9hoh49pwBiFYFIeFd3mqgnkC AwEAAaNCMEAwHQYDVR0OBBYEFOLJQJ9NzuiaoXzPDj9lxSmIahlRMA8GA1UdEwEB /wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQDRSVfg p8xoWLoBDysZzY2wYUWsEe1jUGn4H3++Fo/9nesLqjJHdtJnJO29fDMylyrHBYZm DRd9FBUb1Ov9H5r2XpdptxolpAqzkT9fNqyL7FeoPueBihhXOYV0GkLH6VsTX4/5 COmSdI31R9KrO9b7eGZONn356ZLpBN79SWP8bfsUcZNnL0dKt7n/HipzcEYwv1ry L3ml4Y0M2fmyYzeMN2WFcGpcWwlyua1jPLHd+PwyvzeG5LuOmCd+uh8W4XAR8gPf JWIyJyYYMoSf/wA6E7qaTfRPuBRwIrHKK5DOKcFw9C+df/KQHtZa37dG/OaG+svg IHZ6uqbL9XzeYqWxi+7egmaKTjowHz+Ay60nugxe19CxVsp3cbK1daFQqUBDF8Io 2c9Si1vIY9RCPqAzekYu9wogRlR+ak8x8YF+QnQ4ZXMn7sZ8uI7XpTrXmKGcjBBV 09tL7ECQ8s1uV9JiDnxXk7Gnbc2dg7sq5+W2O3FYrf3RRbxake5TFW/TRQl1brqQ XR4EzzffHqhmsYzmIGrv/EhOdJhCrylvLmrH+33RZjEizIYAfmaDDEL0vTSSwxrq T8p+ck0LcIymSLumoRT2+1hEmRSuqguTaaApJUqlyyvdimYHFngVV3Eb7PVHhPOe MTd61X8kreS8/f3MboPoDKi3QWwH3b08hpcv0g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBt MQswCQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUg Rm91bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9i YWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAwMzJaFw0zOTEyMDExNTEwMzFaMG0x CzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBG b3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh bCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3 HEokKtaXscriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGx WuR51jIjK+FTzJlFXHtPrby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX 1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNk u7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4oQnc/nSMbsrY9gBQHTC5P 99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvgGUpuuy9r M2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUw AwEB/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUB BAMCAQAwDQYJKoZIhvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrgh cViXfa43FK8+5/ea4n32cZiZBKpDdHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5 gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0VQreUGdNZtGn//3ZwLWoo4rO ZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEuiHZeeevJuQHHf aPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEg MB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2Jh bFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQx MjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSNjET MBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCAiIwDQYJ KoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQssgrRI xutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1k ZguSgMpE3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxD aNc9PIrFsmbVkJq3MQbFvuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJw LnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqMPKq0pPbzlUoSB239jLKJz9CgYXfIWHSw 1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+azayOeSsJDa38O+2HBNX k7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05OWgtH8wY2 SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/h bguyCLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4n WUx2OVvq+aWh2IMP0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpY rZxCRXluDocZXFSxZba/jJvcE+kNb7gu3GduyYsRtYQUigAZcIN5kZeR1Bonvzce MgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD AQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNVHSMEGDAWgBSu bAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGt Ixg93eFyRJa0lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr61 55wsTLxDKZmOMNOsIeDjHfrYBzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLj vUYAGm0CuiVdjaExUd1URhxN25mW7xocBFymFe944Hn+Xds+qkxV/ZoVqW/hpvvf cDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr3TsTjxKM4kEaSHpz oHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB10jZp nOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfs pA9MRf/TuTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+v JJUEeKgDu+6B5dpffItKoZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R 8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+tJDfLRVpOoERIyNiwmcUVhAn21klJwGW4 5hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFrjCCA5agAwIBAgIEVJGosDANBgkqhkiG9w0BAQsFADBSMQswCQYDVQQGEwJD QTEVMBMGA1UEChMMTm90YXJpdXMgSW5jMSwwKgYDVQQDEyNOb3Rhcml1cyBSb290 IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xNDEyMTcxNTMwNTFaFw0zNDEyMTcx NjAwNTFaMFIxCzAJBgNVBAYTAkNBMRUwEwYDVQQKEwxOb3Rhcml1cyBJbmMxLDAq BgNVBAMTI05vdGFyaXVzIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MIICIjAN BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArVK5kig4XFE/X2tRUy/8uc3z573P aPUWc28qWqN+IsxfJjK/0x/HXuexkOvGIvXpXhkSohLzvCL5iNX3O2HFGcjiY2uM 8ds/IqD73Fn6ZvB+dMZKsQ3JUh2Lt05M3ZbLmOYOQPu9Oh6kLJBe3oTWYednACoz DjOD8jeivk6oqkZtGEhGdyY0v2aBbyCT/PEy8WDyFi2fTkQdnes4LW2lWE0B++Jd xB6K/9VC3AwFp/bkhONn7NGpT5nen8YLlB/lMLcHqHnwYOqzoZzCZTea6LnBPFms YAvmBu04B1gBTKV+15zzbDNPIDZrVcpOVm/4OO7PlGXlSC9NPlDMqU45tv6KCBF1 xv17Srqj95O0nXjkoYuo7HeCKPebkSQe8fzPkUR76AZeKm/Kd4mAXRBgubZxolux Zifq92R8d+gKCi+PSFPitC+oNB/y5Mn1S74bcxH2HJlbsRHRRd6uGuGxxUN4Ob3J 6sDcg/sL4sLVyT9KQcWdPuHwJgKaU223hg9yTwxDC67EVGA2SoNOyVCmbQf68A/E 9AXz1WYd6+S/HKX9uOcYNzq7BBobhw3Sknt0joirijo+14CjSFeQKM/UQ1yUNy6L rxISTqo4pg21iRz5eWRtZfcRlD6h3D4ix4MpqWbEmY/NGk35xyWszPer86vmuP3j 6e3PKzkoir8wFJMCAwEAAaOBizCBiDArBgNVHRAEJDAigA8yMDE0MTIxNzE1MzA1 MVqBDzIwMzQxMjE3MTYwMDUxWjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUmckQ Sn14uolWMU717DVzPaQb7W4wHQYDVR0OBBYEFJnJEEp9eLqJVjFO9ew1cz2kG+1u MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAII/6ndKCHTpbRuOrXnd 2bEQ8Z13TBfrLRjoL5TGU2ZeoKWRUrKs/MhQlA7FeaoNJs0VRr2bs7y1eIDfUM1b 3lk/+6a6APlysUPloJvbZJGpvgXXYvrbEr06hvB6YzX82lA0POZvtEIGKoErUh0e T/e1srxsYJrUpyjOpG4Ef+/eRStyMl3mzw1Sjy9AuNPfyYbMCQ5TYAfATzrK9iYG Xkacvw2+HVphJzp9YZO1p1QT3rGgm0lmm7M3vaC6SmXIIuDE7/CVzuifACmk+TIS nHA8ENfrpjx/VVDVQjH7uwnqhErNa3PWjKWUb4Q1mmVaeAgDAvxHs3q+jD4zZy3U AKpqnzgb9U540IvFby8qPYI+W1CAcEG1qGDA/vtYabnYwgwXoBhOBhr/P3KxN+6J b3rcpy+cyVfIgwtLgfHXNi8e7Pe4IGT6iwrmUbgFrFR77DIK484SHVFy+N59201K f5qEsAq4EHHYc3oWrvzF1G3kx58KF2tz4wExbfg6/BySZKXA2KSQwOP5jhkxrTZ2 7Lf7ZTz04PiUm+cYlB8qAnhxnkJdCm29O3vKcEr2xOedos5LmOKW87HWrcAhOKJ5 RkDH30jAB64volYYepq5wxhQFh+j40zDnmAuYC/pDOFZoRszKSuREjx9hTaieBIR 4sBFY8WLdJMuwrRbEWjHccjm -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWjCCA0KgAwIBAgIQT9Irj/VkyDOeTzRYZiNwYDANBgkqhkiG9w0BAQsFADBH MQswCQYDVQQGEwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBF eHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwHhcNMTUwMzEzMDAwMDAwWhcNMzgxMjMx MDAwMDAwWjBHMQswCQYDVQQGEwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNV BAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQCpCQcoEwKwmeBkqh5DFnpzsZGgdT6o+uM4AHrsiWog D4vFsJszA1qGxliG1cGFu0/GnEBNyr7uaZa4rYEwmnySBesFK5pI0Lh2PpbIILvS sPGP2KxFRv+qZ2C0d35qHzwaUnoEPQc8hQ2E0B92CvdqFN9y4zR8V05WAT558aop O2z6+I9tTcg1367r3CTueUWnhbYFiN6IXSV8l2RnCdm/WhUFhvMJHuxYMjMR83dk sHYf5BA1FxvyDrFspCqjc/wJHx4yGVMR59mzLC52LqGj3n5qiAno8geK+LLNEOfi c0CTuwjRP+H8C5SzJe98ptfRr5//lpr1kXuYC3fUfugH0mK1lTnj8/FtDw5lhIpj VMWAtuCeS31HJqcBCF3RiJ7XwzJE+oJKCmhUfzhTA8ykADNkUVkLo4KRel7sFsLz KuZi2irbWWIQJUoqgQtHB0MGcIfS+pMRKXpITeuUx3BNr2fVUbGAIAEBtHoIppB/ TuDvB0GHr2qlXov7z1CymlSvw4m6WC31MJixNnI5fkkE/SmnTHnkBVfblLkWU41G sx2VYVdWf6/wFlthWG82UBEL2KwrlRYaDh8IzTY0ZRBiZtWAXxQgXy0MoHgKaNYs 1+lvK9JKBZP8nm9rZ/+I8U6laUpSNwXqxhaN0sSZ0YIrO7o1dfdRUVjzyAfd5LQD fwIDAQABo0IwQDAdBgNVHQ4EFgQU2XQ65DA9DfcS3H5aBZ8eNJr34RQwDwYDVR0T AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggIBADaN l8xCFWQpN5smLNb7rhVpLGsaGvdftvkHTFnq88nIua7Mui563MD1sC3AO6+fcAUR ap8lTwEpcOPlDOHqWnzcSbvBHiqB9RZLcpHIojG5qtr8nR/zXUACE/xOHAbKsxSQ VBcZEhrxH9cMaVr2cXj0lH2RC47skFSOvG+hTKv8dGT9cZr4QQehzZHkPJrgmzI5 c6sq1WnIeJEmMX3ixzDx/BR4dxIOE/TdFpS/S2d7cFOFyrC78zhNLJA5wA3CXWvp 4uXViI3WLL+rG761KIcSF3Ru/H38j9CHJrAb+7lsq+KePRXBOy5nAliRn+/4Qh8s t2j1da3Ptfb/EX3C8CSlrdP6oDyp+l3cpaDvRKS+1ujl5BOWF3sGPjLtx7dCvHaj 2GU4Kzg1USEODm8uNBNA4StnDG1KQTAYI1oyVZnJF+A83vbsea0rWBmirSwiGpWO vpaQXUJXxPkUAzUrHC1RVwinOt4/5Mi0A3PCwSaAuwtCH60NryZy2sy+s6ODWA2C xR9GUeOcGMyNm43sSet1UNWMKFnKdDTajAshqx7qG+XH/RU+wBeq+yNuJkbL+vmx cmtpzyKEC2IPrNkZAJSidjzULZrtBJ4tBmIQN1IchXIbJ+XMxjHsN+xjWZsLHXbM fjKaiJUINlK73nZfdklJrX+9ZSCyycErdhh2n1ax -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFvDCCA6SgAwIBAgIQIWYVBQUnBQW8irAdrwq+xDANBgkqhkiG9w0BAQsFADB4 MQswCQYDVQQGEwJUTjE5MDcGA1UEAxMwVHVuaXNpYW4gUm9vdCBDZXJ0aWZpY2F0 ZSBBdXRob3JpdHkgLSBUdW5Sb290Q0EyMS4wLAYDVQQKEyVOYXRpb25hbCBEaWdp dGFsIENlcnRpZmljYXRpb24gQWdlbmN5MB4XDTE1MDUwNTA4NTcwMVoXDTI3MDUw NTA4NTcwMVoweDELMAkGA1UEBhMCVE4xOTA3BgNVBAMTMFR1bmlzaWFuIFJvb3Qg Q2VydGlmaWNhdGUgQXV0aG9yaXR5IC0gVHVuUm9vdENBMjEuMCwGA1UEChMlTmF0 aW9uYWwgRGlnaXRhbCBDZXJ0aWZpY2F0aW9uIEFnZW5jeTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBALAH52L70A1Vzme3V41uDKknVB7rqSSrZ4+PnGEP 2ygyLzv4LGWSLa66M5LAK57yH15tI12zWB+NocBtdYUKsBNOW1ZGizm9C4K7OkOb CLpG7vkX683I1+N1E96uUUgKziCVRp8C7FWMdKpa/PzqCTM1bqNHBsfdfoRoDscS ypTD7eZsAm3eAok1swTLRfh8R6TTH9/lXCPi8yJ7uUui/Rc1XUjpv/WzJWOL53jr /HUnvYhcpoU/Qd+VfN16Ro/+Htqxq9jTjs0GjMnYUkIRUqKDj1yDe+Qnto8foF49 0nV9eVOTBpfjA8eWLNoBPHnFO1DosNOhpOLTg31E+BDPoBoq8mWAvXfBmGV2rhIh Yso6vr61mcNbxNG/m8AKylgeFabXIV6xTQrlcHiaaOZ0ZjIUKh4Rvoj3BvZVo8Mf bheQVdGKQIlWQ9VP5qLJiGQABVE/V7Q8tr5qkXFA8aJc8dftnLZX9lnUKhHl1OW/ ux7RyNdfRAWbu4k6radDd34VYHyIXZvspVzSRq0Mi1RF1JRRVUVSqlzYEaz4ViJs 2dIU6bdOQoVURvgBxj0mBnfosjUb8J1CyX/+gCcBUMt/xaxU+mttloxBpKHS57WR SG93HIvCK3T+PFzEXZTOq/EglmvBDFpf+eU1uWyjEGfvkapIDu9It3ZYYtm+nkKz pL01AgMBAAGjQjBAMB0GA1UdDgQWBBTMc8Wjaikxl6eNoNhUwQp1tiM/pjAPBgNV HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEA LvKHSO2Znp8BDDzJCnhTfBg13rblbPQAgOGbi9n6+6r7ZbfSyfXXp8t+ybGicVht WTfW4DMQyrXZcttOJTeqpt0dGL31yYqceojuHwLELZJUfVfiXBkYIwJ6XEmVtpjn wmBBZUC77Fq3cZxQ8nN2+18N7zXPtGmNhehMkBcDC8mzLiA3YxFipk/jNOD7eVXn xsKuQv6wNGxJIw5yB3tmBVVI+xIPoMD6TtH7Pcz+/RZLVlDNESynm/exCs+m6+/d jriuQgh8pIyU6obHQ+P3PIrfR9IwQMgtU/VvEUnMIYyWQ08QoEehVo0fHFvYVlvr NHbhNTpx1MwhL541KPJa3p7k7kdqEOg4vUq0fQR/Ba5ICrQDvy6zChufy63dTdCH IbdHdoKDLcdXvpoVoxswGGyjOnFvZEcoktsRYSCad2Ut+axWE2xLo1//m6To7+dY 6HueO39qp745ChOUyUhOZmTYU0zsQWv9/DYu1w7fYQt7tUCs3UJJbZ6Av2CV8OnA P3u7GOk4tVZOp36KYu+YHvh4QKm72OnltLT542ec7FPPuEK0L5OBNaBs9rogimg9 923/f9NM93qUaAN3Qzs1UapTEj5HExQ5rNZlj6hG/zwh9NK/0EikfqdRm5cS9Zk0 FyNWhBNjyzTKH8q6qAcp80MkCkl//Q7UkPCrQyFinI8= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGCzCCA/OgAwIBAgIQVBIVn6uKdZNElMp3QFYu7zANBgkqhkiG9w0BAQsFADBm MQswCQYDVQQGEwJTRTEoMCYGA1UEChMfU3dlZGlzaCBTb2NpYWwgSW5zdXJhbmNl IEFnZW5jeTEtMCsGA1UEAxMkU3dlZGlzaCBHb3Zlcm5tZW50IFJvb3QgQXV0aG9y aXR5IHYyMB4XDTE1MDUwNTExMTUyM1oXDTQwMDUwNTExMjQxOVowZjELMAkGA1UE BhMCU0UxKDAmBgNVBAoTH1N3ZWRpc2ggU29jaWFsIEluc3VyYW5jZSBBZ2VuY3kx LTArBgNVBAMTJFN3ZWRpc2ggR292ZXJubWVudCBSb290IEF1dGhvcml0eSB2MjCC AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMiutUO8QkVLNWM/AkvF/9s2 1yfFwq5FZqNxhxZNiU9hlOBWRrjQRBPmc5DwYXhBiuAafjesAim+6P8CJsYafAqx j2QpotoHitUkhWgZkjLfnylgWG0qhYARNsm2wtOehAy6URHMVOmrBjASjyB3BcDG jZqbWci2hehwBwKxHv/Xac8WRothL0LNUqbYDnovhy3GLzwiQ7GTfsMWdtnM14vs ERvQyXEUwolJfvGkEKo1PKgbu//sMkDlvSrzpgETyIyXGZDOY/mwa333+YrObuCF 59uU1XogJaA18Kn3r1ooWgzI83Q5izE7IsxJJclvuFx6LiyW4y+jPsp5d2mRWvjw xVM3TlNtSSdWYsrl+XNgqRc7W6Ilry17ybfbzxkROjNxOVlaA+nnLAz/bZxyY2OA BVhThtwodRbC5fATWaGB/wUMmai2PGwuxQ4AmIHpg3dmQztajoVFTLLPuT3knDaT QHpTFSnUEZC6oWCKnav0Skpq3Yeqwe0F2p5bVuGITyprlSiGZlCh79pKspAKNjdJ hZdCeAdn5psgoQxsyc/P/neVhFp6Oxew70z3LZGqzxlvxvkSKOceCqaWzSGwA2JQ gwYg5uje30MWFrmBoPCBNFvLwYn28+giuM64Uj5RHrEFuLcDKwusdHVTJOF2uE8l dl3v0Zrzbkq4fEv4isAZAgMBAAGjgbQwgbEwDgYDVR0PAQH/BAQDAgEGMBIGA1Ud EwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFGNrQmBTVxG7yCSJJQJmRHShDSVuMBAG CSsGAQQBgjcVAQQDAgEAMBEGA1UdIAQKMAgwBgYEVR0gADBHBgNVHSUEQDA+Bggr BgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUFBwMEBgorBgEEAYI3CgMMBggrBgEFBQcD AwYIKwYBBQUHAwgwDQYJKoZIhvcNAQELBQADggIBACMuqoWXS6RcEK/a+D29k1gv ePsZdwM5FkdJclXMh+i9pH/SqySs59RQ7p3Yg8aZIPsWL4jGFzfKix6r0OJsB1i4 ZJGhEKFpN3Ve/tpzFOaKa77CYCEvwPmjBEg2Wze+2mz96ZaOnvFTfI9lRKdVfQuU TlT2/zK9L32cpV5CxEwp4xBkL+bPWjs0VShh0ScSu25Um4FYrNVenVcDoE3R/zd0 po3z+ZX9Kol1enk3/SZ5Lydzf6kZIOXQX5jolgWPmHnpeRBBKQFD9Wk3zFAQaLXY RE4O8pnjJyxqjl+7fbtrcUsGit0q2Ao/W8hyLlhhCg+BaB5Hx+ktuu+N3A6jI8Oy LbVHsYu0PidI59wIYgxU/kPXlUq/By9KQH4GpVGHJokF3TzKT/4cJ+nbiB7Asv7j 7x9+sehZlaBPqwqJAOBzsuccwRdQgIdM0kMZWZXSWxRbClvAfIlxerUKwIpFL+7E wP5ULeeVJHcFLu50xqCQsXPcQtagdclYWQWi3hG/WekNpybCbsBGisYe0/XqD309 cs0ZlUy64GiXjVjAau9597JoarhyNsMkDOgy7b3xn8jv3nXS23aplCc49AFhv2Y4 j2o93ABbs/xE3wNL+fF2JTX/Uh8IHdClFOmLBit4gyxxXE+Rh2PWDA4FiDyUoLFa VBbf3VHDqDYuLIJ8uZqw -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM YyRIHN8wfdVoOw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg 1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K 8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r 2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR 8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz 7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 +XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI 0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY +gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl 7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE 76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H 9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT 4PsJYGw= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM 9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L 93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU 5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy rqXRfboQnoZsG4q5WTP468SQvvG5 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi 9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW 1KyLa2tJElMzrdfkviT8tQp21KW8EA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGSzCCBDOgAwIBAgIRANm1Q3+vqTkPAAAAAFVlrVgwDQYJKoZIhvcNAQELBQAw gb4xCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQL Ex9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykg MjAxNSBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAw BgNVBAMTKUVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0 MB4XDTE1MDUyNzExMTExNloXDTM3MTIyNzExNDExNlowgb4xCzAJBgNVBAYTAlVT MRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1 c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNSBFbnRydXN0LCBJ bmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxMjAwBgNVBAMTKUVudHJ1c3Qg Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEc0MIICIjANBgkqhkiG9w0B AQEFAAOCAg8AMIICCgKCAgEAsewsQu7i0TD/pZJH4i3DumSXbcr3DbVZwbPLqGgZ 2K+EbTBwXX7zLtJTmeH+H17ZSK9dE43b/2MzTdMAArzE+NEGCJR5WIoV3imz/f3E T+iq4qA7ec2/a0My3dl0ELn39GjUu9CH1apLiipvKgS1sqbHoHrmSKvS0VnM1n4j 5pds8ELl3FFLFUHtSUrJ3hCX1nbB76W1NhSXNdh4IjVS70O92yfbYVaCNNzLiGAM C1rlLAHGVK/XqsEQe9IFWrhAnoanw5CGAlZSCXqc0ieCU0plUmr1POeo8pyvi73T DtTUXm6Hnmo9RR3RXRv06QqsYJn7ibT/mCzPfB3pAqoEmh643IhuJbNsZvc8kPNX wbMv9W3y+8qh+CmdRouzavbmZwe+LGcKKh9asj5XxNMhIWNlUpEbsZmOeX7m640A 2Vqq6nPopIICR5b+W45UYaPrL0swsIsjdXJ8ITzI9vF01Bx7owVV7rtNOzK+mndm nqxpkCIHH2E6lr7lmk/MBTwoWdPBDFSoWWG9yHJM6Nyfh3+9nEg2XpWjDrk4JFX8 dWbrAuMINClKxuMrLzOg2qOGpRKX/YAr2hRC45K9PvJdXmd0LhyIRyk0X+IyqJwl N4y6mACXi0mWHv0liqzc2thddG5msP9E36EYxr5ILzeUePiVSj9/E15dWf10hkNj c0kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD VR0OBBYEFJ84xFYjwznooHFs6FRM5Og6sb9nMA0GCSqGSIb3DQEBCwUAA4ICAQAS 5UKme4sPDORGpbZgQIeMJX6tuGguW8ZAdjwD+MlZ9POrYs4QjbRaZIxowLByQzTS Gwv2LFPSypBLhmb8qoMi9IsabyZIrHZ3CL/FmFz0Jomee8O5ZDIBf9PD3Vht7LGr hFV0d4QEJ1JrhkzO3bll/9bGXp+aEJlLdWr+aumXIOTkdnrG0CSqkM0gkLpHZPt/ B7NTeLUKYvJzQ85BK4FqLoUWlFPUa19yIqtRLULVAJyZv967lDtX/Zr1hstWO1uI AeV8KEsD+UmDfLJ/fOPtjqF/YFOOVZ1QNBIPt5d7bIdKROf1beyAN/BYGW5KaHbw H5Lk6rWS02FREAutp9lfx1/cH6NcjKF+m7ee01ZvZl4HliDtC3T7Zk6LERXpgUl+ b7DUUH8i119lAg2m9IUe2K4GS0qn0jFmwvjO5QimpAKWRGhXxNUzzxkvFMSUHHuk 2fCfDrGA4tGeEWSpiBE6doLlYsKA2KSD7ZPvfC+QsDJMlhVoSFLUmQjAJOgc47Ol IQ6SwJAfzyBfyjs4x7dtOvPmRLgOMWuIjnDrnBdSqEGULoe256YSxXXfW8AKbnuk 5F6G+TaU33fD6Q3AOfF5u0aOq0NZJ7cguyPpVkAh7DE9ZapD8j3fcEThuk0mEDuY n/PIjhs4ViFqUZPTkcpG2om3PVODLAgfi49T3f+sHw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGfjCCBGagAwIBAgIEBfXhADANBgkqhkiG9w0BAQ0FADBwMQswCQYDVQQGEwJD WjEtMCsGA1UECgwkUHJ2bsOtIGNlcnRpZmlrYcSNbsOtIGF1dG9yaXRhLCBhLnMu MRkwFwYDVQQDDBBJLkNBIFJvb3QgQ0EvUlNBMRcwFQYDVQQFEw5OVFJDWi0yNjQz OTM5NTAeFw0xNTA1MjcxMjIwMDBaFw00MDA1MjcxMjIwMDBaMHAxCzAJBgNVBAYT AkNaMS0wKwYDVQQKDCRQcnZuw60gY2VydGlmaWthxI1uw60gYXV0b3JpdGEsIGEu cy4xGTAXBgNVBAMMEEkuQ0EgUm9vdCBDQS9SU0ExFzAVBgNVBAUTDk5UUkNaLTI2 NDM5Mzk1MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAqj9VtYmvdhQG KmQmlDgPX/bcBG8xRfUK/Tt/m3Jv+EB8/l39NJkFOJRJurHwvfiZXMBR+qoN++Zx FYVjESp3BpGSBoiz/BThmUa0KYKuhIPutSaHbviLVUSdQNj/Klqq6H/SZeEUR8J8 Mf11YQobjIBKnrTiLhRHMe68BVGupn7PEbjFSL0FVMKE5Kdoa/i4+n4oybnP5CFP ZcmIaKA42XWlETtMHG5LHtSGbMGtBUfTLJQNzIctGi3D1szehP7sa8DhIxOh05wY fuBy11xVvEyzQDEbnEDNmuuADnGu12JuWhZPH/ZlRdGfeoVBGcJ6Os4hkuSUcEy7 qEHGxLs1zfU6nmOpjaBq0SBEqiq2SKVyw86e5FhIRwl/AkHzDRxtCXjw1xTRoFX8 EdZaGgB55TvmCMtSnqQJq2vnbJwqLyJ9+7lQst5Q0y8McrnWs7ezCObre6z0tMX2 wTIfpxkh9dxeN6rHH1ObQz7mnp/aDddWog9TaS1Vv+uGeBG/ptdaTfMOk3Pq/w7Q 54/xyLPw2BhzbKVyiPFwTEdUtpta0bwmN40Y35trLtsLJbOKsuOtBlxtu30XAwcB ijCXiXRtSpR3Luvuz7Aetep29LUUOJXX1dkvP7KkJsxNo1yNCfNeDIUyzlZsAgjx S6Orv8hUoAWFdOR1HXq8nDtgPWr9GZECAwEAAaOCAR4wggEaMA4GA1UdDwEB/wQE AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR2uQNI+9UYoaE3oO3MaIJM UjQ2DTCB1wYDVR0gBIHPMIHMMIHJBgRVHSAAMIHAMIG9BggrBgEFBQcCAjCBsBqB rVRlbnRvIGt2YWxpZmlrb3Zhbnkgc3lzdGVtb3Z5IGNlcnRpZmlrYXQgYnlsIHZ5 ZGFuIHBvZGxlIHpha29uYSAyMjcvMjAwMCBTYi4gdiBwbGF0bmVtIHpuZW5pL1Ro aXMgcXVhbGlmaWVkIHN5c3RlbSBjZXJ0aWZpY2F0ZSB3YXMgaXNzdWVkIGFjY29y ZGluZyB0byBBY3QgTm8uIDIyNy8yMDAwIENvbGwuMA0GCSqGSIb3DQEBDQUAA4IC AQAZVAIlg9silosdlZ6Z2zTOk9AfLntcYCRqDNeFRHgfHEnyFPiDVBmmnTJmuCOm O4Yqnzb8F/xQD2DGN/0kqPd5p46/2AcVVF5SDL74ptjIQUTx9hPcgxlbr91k9zMW hw8VWvFkvNTnVT8yOIma88xIxWwxcZKaJhfCfEcCbTUnn/Ma4aodDXQRqZN8Qahv u46cxQHkc/a6UC7mENS8bxOaOLlpRqUG1vJMbDerPPjbGsZV8Mj4HSFuLwBqseJt WgQtfd0JT/bvFC/AEuoJGSsayqBxm7E6Mrz/QxjzfS/1LojpUbbxSZBM/ybHw1nd dF/BUF04XJ1oVWlqtEB3yV8yKUhUk8GzISN2oVUwaSM/MUnEoc07dlmVWoK0rXG1 vqaRzIAVSi/OlK4YVUl1IES48wGbwXgsjhBMp2StrTrrTB1WLn+U1B7QCtXJVIEO Hv73lPlhOj817tNgyftIsm7C2b56bpgFcACj0RfHxjSvbPVNj11SDN2Am3pt55jj OYVcP4vMRKJANjKTElaQAp4+WWgCH1aIHq/B/g97VY2X2bumk0e6fPhHtjnXjPJA bIecDP4t3dxx/A6RCKRDPYpX3d0H66eXUdC6hJmti3n+yQSQgxMr6ZcNZYnyES03 jku4u9J6OSrF3NBdDd0EJ5ifWP2OhrsFf/DtN5KQ3Zy9/A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGDDCCA/SgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBlzELMAkGA1UEBhMCQVQx DTALBgNVBAgTBFdpZW4xDTALBgNVBAcTBFdpZW4xIzAhBgNVBAoTGmUtY29tbWVy Y2UgbW9uaXRvcmluZyBHbWJIMSowKAYDVQQLEyFHTE9CQUxUUlVTVCBDZXJ0aWZp Y2F0aW9uIFNlcnZpY2UxGTAXBgNVBAMTEEdMT0JBTFRSVVNUIDIwMTUwHhcNMTUw NjExMDAwMDAwWhcNNDAwNjEwMDAwMDAwWjCBlzELMAkGA1UEBhMCQVQxDTALBgNV BAgTBFdpZW4xDTALBgNVBAcTBFdpZW4xIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9u aXRvcmluZyBHbWJIMSowKAYDVQQLEyFHTE9CQUxUUlVTVCBDZXJ0aWZpY2F0aW9u IFNlcnZpY2UxGTAXBgNVBAMTEEdMT0JBTFRSVVNUIDIwMTUwggIgMA0GCSqGSIb3 DQEBAQUAA4ICDQAwggIIAoICAQDUppeo8vSQEUOttIJGQfEvkW9jos0NINy9DDiK ZUoKKzqodKl3oYuO8i+B94QYza3rYraSfeBB5U5UODeC78vg7c+7ysyjS/db/rh8 pwhty0PETCIUZuOdA7l3IatEayFHI8gg+irLkXYddWz4m+kPJulDL5ogBWgYx46Z hS1BB6ZkjljhjZWApE1f9QLYgXnb1effoiL9FKdnFuzZWEzKqd3qGo6pCGRPUSG2 cqJO/1BxvTtl5L1/UxGu3xA5e132R3AX90ORA3phJV8s/PiJETzsOVQWScQhmnHg eYt2HXY9B1m4B7GM3MfNTuH7rUNNP0DvIWIvMUROacdvIsurVEvowvoRaKzIbg7e bMUnlglRAk0Btle/MijVCUOW98SItflU/ho6arcstSRk+0p4csP82U/ITiO5KdgN oUhBkwJtvxKFm8bFYC3wkfyZ/SCUnnFjq9VJq5DshzmFf42FzAvo20s7DvzCdn1G 5zkmnt9V3x6E+UE2JmwCWSuO+7zpHyckYgRnhOE/2J0YTpagJe7KKANPAlHP9zU3 aaS01tbVHhlDJxYfR1HuSglMEVq2Wz1h6DsQvtZG5vQc/bhFvXz6dVrs4VIjDY4f hpdTkVybmyjWjuVuJ60gjKfBQamXN4ss6m4YBZf2zgNS8b15NJtAxyOSdPNv7aPp WfBVSwIBA6NjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD VR0OBBYEFMuw3T2MPN9iLCtmPJ486RVtcbTXMB8GA1UdIwQYMBaAFMuw3T2MPN9i LCtmPJ486RVtcbTXMA0GCSqGSIb3DQEBCwUAA4ICAQCifVUEZu4WFLyCgYclGTli 9P47H+HAcwBxynWp4nPxxQ1Bo12OwS3ZZVvZieLwjsWgfb3LzEZTH1/tILYCKtYT 8p19UUpAVXGtnux26kUgjqr6ekOacGd+E96Y6MuN3R+sNNKhte3+uOcWz/jRODCN NInSzn2B0h7/URhTNpPcCcsIFrgI11owkIoK+S+1z8TNVHIqxr0B51gLbgZAtAnO tI6zmumJkZSselTh++OELIOgT/7r6MH067Ym0zjELa2sRYA0bSE9XYU64nv+VLfd 6IVUy6TxqylQeNcktaMvnq8RZq4YuP1dKM9A11XgLOtSMWhDZgWXkrvF8SEs/RJk MZlDb4udS2D+FF5SsyOo4Zh67hTJoeLMP3YhYv1rDdm0SpXmblt6JMPTxtYfous3 a06j32Lr6w5KCL/rGIj7RxqtwlHD1Xz3HyuzyEpQDmlYIGIBSlvKY5YmIq726ZxA rGcDnZ1pFcLA+F2nJLEnPL8F4quiysmwLX6jwTEgRiFlkt3K3t+TG7xtL1+pFqRX hyxymlqCZ9FE4j0JCoGMHhD9xjRo7P93YXZ/Jvfb/BJGEqrA0fh5haICzIuqpK1s FMC9/GiuRH0i+QpFXewE5vrjpMXm+bIZw9mMqJN7OoppO1ITPB0zAk6WQJ+5lf2T FzPByQv2/b1pEPWtKfvj2g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFoTCCA4mgAwIBAgIQLHA+VOkP2ZggzMbZ9UY/NTANBgkqhkiG9w0BAQsFADBa MQswCQYDVQQGEwJDTjEwMC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmlj YXRpb24gQXV0aG9yaXR5MRkwFwYDVQQDDBBDRkNBIElkZW50aXR5IENBMB4XDTE1 MDYzMDAxMjExMloXDTQwMDYzMDAxMjExMlowWjELMAkGA1UEBhMCQ04xMDAuBgNV BAoMJ0NoaW5hIEZpbmFuY2lhbCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEZMBcG A1UEAwwQQ0ZDQSBJZGVudGl0eSBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC AgoCggIBAOFRJSx4u/rui1XDTiVkGS2UQqTm3oQPITUo3DKJPvs0c3tX6awSKUoM mCzOyb+kT6VtDs7CzhgJMRBwcg0ia5798whuktLAJc+1s+thfxeE/HVrtaxXF0EZ DDTVL1Fu1fdRa3FvMrHi066g1jsUUEgZdPztr7UgqJLgP64H0VC81d2v1tD5zs6S uMaBjMX5OY2+9hsumjhkv7fNcuf/7YlauKR1WuH+rzIMbSJukzWoYuLArgqX0bCq PvY6UB6bUCoH25eVYAM/o6RdGVUhJzpJnsvI7CzMmxdI0wgQsqlvIQH0WmHd096J XbUK8+AV1wZ3C17YaFjfoHe+XxQKRL0tHxo+8aosXQyFDOej24s4BqVbd0zUyt1X leSj6LJkd9k0r2gdKm0/MkcmmTOfCmBoEVZb1gLxhyrYadhRKZej3vchJozd8yyM BY+ZNkqQsVhpOf2U0xfWpinDUAvVu6MhQE+xBxwAZFfjUVRz4+sZdAKIdw/RflWD AszZzHSlAWyvlbC52RindZoeTo9rXkNHKjGEA6yIETDos7F4x0PhrQWHnGhLI597 ND/M/e+cQsvxNhELNdqaeqGvhU4uWmwneQtFgSV2ZG9k52jKluUEMQVYnqi0j/h9 VsTtKDHNbYnikHh78ZAalERJ04PvGCPHamW+n+q0e7VjBONc4Xf7AgMBAAGjYzBh MB8GA1UdIwQYMBaAFMCsdqLTXf/2zRYAWzinf1V9hVlsMA8GA1UdEwEB/wQFMAMB Af8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTArHai013/9s0WAFs4p39VfYVZ bDANBgkqhkiG9w0BAQsFAAOCAgEAfNPmbXRLuY9du1uVIvxlr0psXoETrLoUCE2v 8Hnx0iVCwPjZZCoCNcHhKg86fWoaOhZhG0FGqHVDv9881e1MO0O8LJA5/kyOeetQ vsDNWFihMB46a5GR4TRxlSEUoCASy4MqzIGuRuAebbIMytOCiPpua3i2XK28QSva fkMLgjP9MqwF/KmKfE5YrTcWCfRgdMVT3JNtZYC9cSCF8RCFOGQj0yGCgeu3bSZl TqvQ1hB1huroHTWf6HdWsZO6qfl3BdQeIg1LuIflM58K4QG8kSQurL+hAzASN06V 3rziYz6cM+bYWP5twY+2cwrBGkrB4IsqxzdCZfbFyHXe+UxlqDb/2+ldPczGY/A2 C3sCT89pvcLvpZ4hTl616jBEo4MtMYYJJKRWwYTz63w2czJtF6HnpTCT01q6h2aM BmjJbhNI75kpUd3FBDdj3lY7jKX3XIVAHPDULuM43ojnpoiKkmo7gSehjl/9LIJY lq/asEdwPg4kUwymUeqCo8ttc66xcAeNM4A2P6ywPl8eBrtuVfYZK+xq/ZuaMnqR ortgZGH57BRmxsE3vrrcsNSvGhpdd66EVqGxzGO8kzfDRDi0hDFjuKX4wrGIoNnm RdlHESm7na7pbEGyTl2VwHLlAnbv0NtBPu/gL/ukgvx60RunN4pJo8d/DG9CNhx9 gMl9JH4= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1Ix DzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5k IFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMT N0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9v dENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAxMTIxWjCBpjELMAkG A1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNh ZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkx QDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1 dGlvbnMgUm9vdENBIDIwMTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC AQDC+Kk/G4n8PDwEXT2QNrCROnk8ZlrvbTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA 4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+ehiGsxr/CL0BgzuNtFajT0 AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+6PAQZe10 4S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06C ojXdFPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV 9Cz82XBST3i4vTwri5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrD gfgXy5I2XdGj2HUb4Ysn6npIQf1FGQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6 Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2fu/Z8VFRfS0myGlZYeCsargq NhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9muiNX6hME6wGko LfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNV HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVd ctA4GGqd83EkVAswDQYJKoZIhvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0I XtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+D1hYc2Ryx+hFjtyp8iY/xnmMsVMI M4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrMd/K4kPFox/la/vot 9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+yd+2V Z5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/ea j8GsGsVn82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnh X9izjFk0WaSrT2y7HxjbdavYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQ l033DlZdwJVqwjbDG2jJ9SrcR5q+ss7FJej6A7na+RZukYT1HCjI/CbM1xyQVqdf bzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVtJ94Cj8rDtSvK6evIIVM4 pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGaJI7ZjnHK e7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0 vm9qp/UsQu0yrbYhnr68 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzAN BgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl c2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hl bGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgRUNDIFJv b3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEwMzcxMlowgaoxCzAJ BgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmljIEFj YWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5 MUQwQgYDVQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0 dXRpb25zIEVDQyBSb290Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKg QehLgoRc4vgxEZmGZE4JJS+dQS8KrjVPdJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJa jq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoKVlp8aQuqgAkkbH7BRqNC MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFLQi C4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaep lSTAGiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7Sof TUwJCA3sS61kFyjndc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFzzCCA7egAwIBAgIUaKX9ptAcXj/P5PmZ33psbzmpf/wwDQYJKoZIhvcNAQEL BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ SG9uZyBLb25nMRYwFAYDVQQKEw1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25n a29uZyBQb3N0IFJvb3QgQ0EgMjAeFw0xNTA5MDUwMjM0MzZaFw00MDA5MDUwMjM0 MzZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtvbmcxEjAQBgNVBAcT CUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMXSG9u Z2tvbmcgUG9zdCBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQDicuzwRxAiw0TKBXlbWdg6KyQhJOqviPh9WgVlwxhIAQTBfloDNXlKw8Fi W9JegvcM+7n+0iEdXIe8JuPEpnuZU5cE2N8SSj5lRefOG2WpcDmWFmKBzOngG4K7 7ajgQmvpuskbS0j9nUYSQOUo00xH+mKIZ4QNV0wPcamFf1blFuijQrpHtt3o42r3 Cmnl8xTjXFXdh/9+PFxN+ckbDptO7n6s7E3ToiO3iJt5oIpjRx50V73Hrv2Urh1K RcPH9qVTB9Vp+HPlZje2pTB3qsy68AnFKFeD8KIZ8n5FtGzrSSK6jjojHB2Jso9p RBMoumJVEYKOWX58TbqHt+4z3s3ZwvGULVM7pNAWVA8RIQp+WMOugsHE1SV3D3bb DV73YjO1p/zKHvOGilOI3cIyHz523p+PDIpKUC3IUFEGBUFXm6R20BzGbhZIJs8y R1kWk0tK1J+6fu0f8wV3Q8ctYvFg1Ywo8f4WI4LPWmufbmn81KhJV/c+kglEwl0o vSpUM4ianpdNLK+9C31KO1NEvcLBLdU0zwKgFAlRSorCqgARbprRHdc82fHBftgZ UBLEkSthBW37Mo+HrHrAlbaNB/Uo7r1oi+/+TQZDzRcloP7iVCa05fiQ+w3Yogwx c5RNe/tSFKtlUQoD2vJmA5LffEWXG049exBRp+mDjbk/tJLRHwIDAQABo2MwYTAP BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBxjAfBgNVHSMEGDAWgBRhteBC 3ravpyDq9iTIp52FoFhT2DAdBgNVHQ4EFgQUYbXgQt62r6cg6vYkyKedhaBYU9gw DQYJKoZIhvcNAQELBQADggIBAHqni9ztZvbdyRDfUPHRkDI9j9qRssdTrnH5p+zE aOIO+o4aXyqS44PR/Nry5XrIrKQXLea43ewqF1GidWkoObpYPx9Qs+3DGbcW9cao Wj2g0Hc/UQdFrG+flMu/bC4PiQmSNBk57XqyWWWwdu0nRh1Dz9Q2vGiKm9Tbwis/ zl1UmcoiwXmEmP+6QVi/RUmZuwkblo5YTPrISEKUG4nJ+VJmy51txA3pvF831boI Yf/VS4xj6P734NwZE+lSaraBLBhkbN7YMFf/ixnHv7dyXlauw/YZ0v2u6balMbgy Tsm8OhspH4lhsPvH+4gGKcNWpk1iEPCrUbdk9CRTkIM6p66pEQLgglQjvQS+NLTO 2ao+VJpIAoshGBL4mOCqqvmrriu/tWuDnyLQWFgFFqfdx5Ppe4Qo4tXuqDX5zM62 8CdQUTOHMtRkcojYNUC3rZvuWhSpfoCYPV3Rd3TK+JGG10Lp3KDvMCWfyDpgaA8t UfjxlrBF9ICotJGHKUMpkTmDNWJtuOn8+P6aTihkfg2QaQPyq00+TtGOJwNEl2Da eIpljRZ1/A4scpt4imdisa4sRgWQEThX13YpI6jAfQnfh6vaWx96EzOBsvf+HO/C nmVf5Bnpcq/INRy++9P43eTYwzlO2UNgq3U2VxvLBVrYQ/w3JVcaPbVW6/Lv2yYw saDu -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFqjCCA5KgAwIBAgIQMmmiv0BrjbRHg2Q8iw3JQzANBgkqhkiG9w0BAQsFADBm MQswCQYDVQQGEwJTRTEoMCYGA1UEChMfU3dlZGlzaCBTb2NpYWwgSW5zdXJhbmNl IEFnZW5jeTEtMCsGA1UEAxMkU3dlZGlzaCBHb3Zlcm5tZW50IFJvb3QgQXV0aG9y aXR5IHYzMB4XDTE1MDkyOTExMzIzMloXDTQwMDkyOTExNDIwOVowZjELMAkGA1UE BhMCU0UxKDAmBgNVBAoTH1N3ZWRpc2ggU29jaWFsIEluc3VyYW5jZSBBZ2VuY3kx LTArBgNVBAMTJFN3ZWRpc2ggR292ZXJubWVudCBSb290IEF1dGhvcml0eSB2MzCC AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALoeomkbaE9cj7r0I8deZgV4 hTZYo6J/Z++iDBaSpEqL4KCSh1U3C8TRxNBAQ5cyUE/slUe3P69DBeWElwnvVlTn QzNH/a3xOpuYpOHkUaO5rIwL7iUGCfLTujVnYYzCvSbL12PM14Mz2Uzi7/kbn6jL DXYBLXLJIrtokd6QDzs9tEK9GX2fhFw8fkI3hrFgwkiHUk5cV/7Okq7KPla3s56V mpT4L6HQoi7CVFpszMzWrUtH0C6HgjOoe1A5pyossVsnCp+t9RTr/I1TsnMrVCP0 jJeZl/s13My1+jMUJo11pySm6BQuLaaAKIOaP7jKO8f1GOD97I55+6pCbEpLFn7z ggNuuucRBqWfhCvSYG3pRu5BWpa5FP0cP4YS8VQmJv1ngC/lqC0oLkO3ZMLv5Ld+ ltyEiyfZdj2YgVMU3EJFoVRn+doYZpAKtEeQPAHlK6Nm72/7MoPxM30yIWylRRU/ L/NVkUiTnyXPLTw5O1INGq/H36tvgNiQy55xcmpCaZPqkgA9SQTZo1y6RfsCEP+t aXRSpThjmmaIBLIRuhOqOdWDX+1lW1PInVyyhaB4cDVNXCQQpPYxKpJVQdnzF2yZ E1j63SjQbBO9W4eNk4OtWClWFbRYJ0qbEWygpmdFOs7Q2M7/kDPsWjFND1IS+632 YV/kL28NZjDloE/Pz/1fAgMBAAGjVDBSMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB Af8EBTADAQH/MB0GA1UdDgQWBBScvAHnq2Q19TGbjbX7F5mIAsub8jAQBgkrBgEE AYI3FQEEAwIBADANBgkqhkiG9w0BAQsFAAOCAgEAh6tq5OdrJFI99iKDT1MERTKc YVoWXJxEtaPRltBA/s9mFV5+QAAgFf2nqmTap2FmaMLdUnEloGq53cDNzoYI1Dw1 ES999G/S2gyXA2WXg7Q+OssJdI3rBcp66YCwt1EtIpPjmhnu7ZcIIYOtxwqRX8TK 216vuOeMujpJ0lUDNRkZUErihqe7eD2V/bEfRvJPZvL7v4VktgojGJIJnklFMbbW FFee/IlFdH85zMBqaMjPR9DhHsfTLy35LCQ7/Gq6lBPezHLyoh3LH5/Vg3cmXn6b oK9pn3jbpcFucVxIQk4r2Hi41Q+lP2zLj5DNR9iQGUmF1mz84quqQr/LE5e/aUR1 YzUt2qDH/WH3ykE9VJz0NsDkbiFIn11xYoHT8iXmWYxZQSZIp+PrZ2rT7DS3mPfM yqM2BpXnyDBZ9//JodHkebzfEx8u2bN10QS3IwkhzB0hHCecDiv6wYcYyfr5SYOM Ehb7xRLOOw9C+vAFZX6ox+tSSvmYXnGjrBLHKHEaWnXPh8ofNygcFJ2QUG/Gv0rM xyXPMd1bkU52qBHVdmbZv4BzYrDsw/5EvM1ZEwsMLdihzKpiTVRFXqRSo4xXPBQx k1TOpRZUXi1Cs+5lqbadP2zOYdlWy97qoFbebYYD+reBaozS2PPXtsCsKYRZIw6b l2rmoM7VKlQY71CYeSA= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQEL BQAwUTELMAkGA1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6 ZW5pb3dhIFMuQS4xGDAWBgNVBAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkw NzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJBgNVBAYTAlBMMSgwJgYDVQQKDB9L cmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYDVQQDDA9TWkFGSVIg Uk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5QqEvN QLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT 3PSQ1hNKDJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw 3gAeqDRHu5rr/gsUvTaE2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr6 3fE9biCloBK0TXC5ztdyO4mTp4CEHCdJckm1/zuVnsHMyAHs6A6KCpbns6aH5db5 BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwiieDhZNRnvDF5YTy7ykHN XGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD AgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsF AAOCAQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw 8PRBEew/R40/cof5O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOG nXkZ7/e7DDWQw4rtTw/1zBLZpD67oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCP oky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul4+vJhaAlIDf7js4MNIThPIGy d05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6+/NNIxuZMzSg LvWpCz/UXeHPhJ/iGcJfitYgHuNztw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFkzCCA3ugAwIBAgIRANaWLsEKFZMSr49jvNREyVswDQYJKoZIhvcNAQELBQAw YzELMAkGA1UEBhMCVFcxIzAhBgNVBAoMGkNodW5naHdhIFRlbGVjb20gQ28uLCBM dGQuMS8wLQYDVQQDDCZlUEtJIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg LSBHMjAeFw0xNTExMTcwODIzNDJaFw0zNzEyMzExNTU5NTlaMGMxCzAJBgNVBAYT AlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEvMC0GA1UE AwwmZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggIiMA0G CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCkWR+gL9++4Pvp3LWJ/lqXA8k6d6eO XK/y4xg59ardD0bSaA9XnKdjYNNYzjXCp/aIwk9/Gyjp0KcAxBdNbeIPxQ4mIyCr 9zoookwKC8yOzuYAmlpADdRQGpvRDZyU+dvuXNDxigfNmitALEmkXWJfp2vf7lYI UPNCGGwxsF7lnHOSvA7SDH3FOFe8u1jbJhkC7eNDhIpOVmvbraEx2cwiZ5Z4/3ed zGTFMiBq704w1SQl/Yh5r3Ea/tVLGxWIvBhwqr2tOApmMEbliYXVdiSpqbPmWWAP tKlTwjqdRRrWruN3XsRiNjMvMMS/lfEtOKV16NFqky5Fh0tKot+/WCeaymIZql7U sYBJlt0r7F+Pm+Cdl4j1hAOjr7Olcy1BuuUHt29rcff3yVqvaZmzL8hPQutsa3Fn eN8KrE/XSoUARhrVzbif6pWdD3zRxgWF5gjeiBeB9tW1buqhHNdhquNZQomcWX6x fGQ03WEjKjm1EKv8hqlTGsXrauKATlmRwDiJ/rNd1vuR6dewfdl4CMz1K8wr4aHW lHPB/lH0jH0KtZqKufXa4Mmz2I+qgoONaVMt/QAEGEqg2lTheYyJ63/1gueguXdN rvm6AjuIdut8XbNaE9t8KRZrmdEd5Eghog1eAYjovvGYTT7HFlccX+EIbxxMWENW 94BljHEOogRnTwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRy W7qqcjjuJZAktZQi+gmIyosK+zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEL BQADggIBAAaj8bZzVcZnZiHlnVvWlC5KImDyVAGQof21v8CVvxhfLPZrNQ78Mcjt RA6Sl9yv3VbPtR+6cpwwyJuxkcB2d9QPGpUa6U0UiKHPawKmautkRU1kjd7862zy UwmhhVEV0E+eYvoRuc7IJA5yZIh1NCMwKj+8PDnMzN0LNru9BoKPEgHFcQXRJKGZ bMrk96rtitenCq2v8OCAu6GyP1qHZHCGM3sNHtcAhoNDl3X1O8FI/bYOQ6gCbrg+ f49O4l20fZ4wNC+o8esnh2gNWgpNAdvJFFiV8ppNFqpz2/QliBc4t69ZCQm0Hy0P q/W4J1XuRTAzuO0bjryIbK/4Wipr4KyxBSShCfyjD/OwLXuWuraUBxVFjincWA6p Bdg7OqB7zYrHZoKXz9Yz4Gf8pttALwXlxYt6KnrwsDabDBj2N+lBof2xKPlva73r H0xjcXtQ3Sny/+73x0Vf6DYK6GxbIsPowOcm3OOolYDluToT2wBLGv2uM0d+eJTj sV0rtVa1QoufgcX8k0wQtboKvH434/pUbfUExXCzqQTSUdeFzX1vQ49ZaOUxVhFx +WQpCRP+0B+8iwA4stDKNFZ2EDlWc2bD0UnZvldPPxZ9ani3qIK4W86uhYoKQgwD 0RfEGPfYV4jGgrgHuT79pOku3G+6kJLuZbBQNNMH2gGXD7znc4J7 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIClDCCAhmgAwIBAgIQeThLtBkajXQizP+FMvLkujAKBggqhkjOPQQDAzCBijEL MAkGA1UEBhMCVVMxCzAJBgNVBAgTAkZMMRUwEwYDVQQHEwxKYWNrc29udmlsbGUx ITAfBgNVBAoTGE5ldHdvcmsgU29sdXRpb25zIEwuTC5DLjE0MDIGA1UEAxMrTmV0 d29yayBTb2x1dGlvbnMgRUNDIENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xNTEx MTgwMDAwMDBaFw0zODAxMTgyMzU5NTlaMIGKMQswCQYDVQQGEwJVUzELMAkGA1UE CBMCRkwxFTATBgNVBAcTDEphY2tzb252aWxsZTEhMB8GA1UEChMYTmV0d29yayBT b2x1dGlvbnMgTC5MLkMuMTQwMgYDVQQDEytOZXR3b3JrIFNvbHV0aW9ucyBFQ0Mg Q2VydGlmaWNhdGUgQXV0aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEFOED C2VvrVnWHu7Jv7RMxcZcLzDHn1LbaGHAaRDiknoaw7+SqIk5ivvnoLtxpKDD33fW lDcTX35TXVC640wIx2XiQbDmWfKc+MCyd8EKkSZ38mm2u9BBPCqIGpSRFsY+o0Iw QDAdBgNVHQ4EFgQUm3vryP+D8lKYRzAKVvg4vuPrAM4wDgYDVR0PAQH/BAQDAgGG MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAKlkWPecuRNmIkl/ stEC6RP8HPukNJLkygcNt7FSeCg0y/IhVpGGhsiKC68yhFRliQIxAOx5DZ2J8AwY 6ntXUq0L5tR5W8ub4gZFdRi90Pyn3cfhxyK240EkXSPmqJ8AalAyJQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF4jCCA8qgAwIBAgIQTANLrGcYTH+vRAhNgpbHsjANBgkqhkiG9w0BAQwFADCB ijELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkZMMRUwEwYDVQQHEwxKYWNrc29udmls bGUxITAfBgNVBAoTGE5ldHdvcmsgU29sdXRpb25zIEwuTC5DLjE0MDIGA1UEAxMr TmV0d29yayBTb2x1dGlvbnMgUlNBIENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0x NTExMTgwMDAwMDBaFw0zODAxMTgyMzU5NTlaMIGKMQswCQYDVQQGEwJVUzELMAkG A1UECBMCRkwxFTATBgNVBAcTDEphY2tzb252aWxsZTEhMB8GA1UEChMYTmV0d29y ayBTb2x1dGlvbnMgTC5MLkMuMTQwMgYDVQQDEytOZXR3b3JrIFNvbHV0aW9ucyBS U0EgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A MIICCgKCAgEAhN+opqOMC3geyE0Zld0pkJIgNZAqlI2CMy1wElilCIqewQjzk9Zo wC8Uvnmk/H3M1bw+j+2cSgJhWT2qw290ANL4GjTUVJ5qdEeaL+DS9w/3w90/pb/B +n1CaWAAgOw85ruBN6QeBhQ9V4+QpDVKNHOHthrDXZDvBk1wdjY8gontz2QZgyVD Thzi8WpShv5R5H443xWNTGxgQUpPsEBVRjl1yYE5AHOKYuoPZbePT5dAzs/uwWoo oHGpmSfRPck1c3qAmfh9hrmdeTrt0yr6fqa4/1cqc7Kmv9qJugYb2mWg5r5glIj2 32bhJ2ob/tBeqY0giwrEH36IQS+ywdDztmjtyDvx76oH3n7XIuCB9qXqexb0QlSd ln72YhZTzf0Kq7JCoU4qiEJ1g72M5U165x3jTLje46tgOC1nKf7kX67CqOi/rmz5 67NS8X/p7MIv2Z3KF55C+jtYwT6IYk9fk8GXbWaPHCLzmsH07blrGn42hMgxuPBe K36V5HnPdUzC2AS/OI4os91btthPI26S6DeVroOu1vw5KkYGH/GEdSHWuE6mKpdY ZfWaGAHX9cN/KckQ7nNKQ3Z70aYwUf/WKx0eYoS++b5pl5nHDed8JFB1F/2kIOc1 aANglKfZDcYaLOXiTtXMDsB6MFbvYJK+2S71x/DoRc/ahq7v2HepEicCAwEAAaNC MEAwHQYDVR0OBBYEFA/xSkp1dAURDB3YW5nrv/6qfV7XMA4GA1UdDwEB/wQEAwIB hjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQA9y9JGePX2Ohfo w3tk0cW7kHiN9U+5xC2X+wvmxbjxturoWEs0rXd5LDUfcn0CPu610BaKBjeWte9D 0AkQLJdmx4EfHuYnxYKRWF7zyFtBaICDkbmcgfgn+kXf7nnyXG1wAlTuwFPYQ+sF esz0Ud2p1CJ9ajvy/ojUUkk6hZJkU/hqU2CIj/Jb1K4rUuDq/1R+oeTvhhungwsG Zl4wgIxVoEcz/2seREhLYaoePuhMZMfYbX0Orjw8Qj3KJBpw8WEUnDoY1fAGKZEi sjo6oRZUYxr5M5VEnySjIWQECOKb1d4IUhxiHFMWRzVCJsenDP3zWxN3Aoxc4hbw GB/ZffXfAiSIevNe/xcOs2JnoauxF449Okaw9UaMq4TY9Q6hIOvC8Jl0PY6zA9gk xWzrawxTv2Bp3YwoxW/Pu9KBdyvGfLHESmwVEDcpXa74sREFxBSN7BOjRP1Ni2i4 wf+d1TcuSPgofNz5c1PZtgF1Qnq/C99RULhTsuHudJDLvKrQcYOiq07JELY9HO9A 109DkDO5AZZUXSrVBluShrgGEIEGyJHbKSCyU73zS1tM22kfiW5UP9eJXee1zQy+ P314OAHStmemz1hIlBpF/ZBzScq1Q6AhYo1JBCaq+B8uP/IuofKr9AYesC3EwXBC Pf3DUUmIAA7Kgg2beQLiwC6T3+Ty8Q== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcDCCA1igAwIBAgINAJgzyagAAAAAVlS8bjANBgkqhkiG9w0BAQsFADBDMQsw CQYDVQQGEwJIUjEdMBsGA1UEChMURmluYW5jaWpza2EgYWdlbmNpamExFTATBgNV BAMTDEZpbmEgUm9vdCBDQTAeFw0xNTExMjQxOTA3MzBaFw0zNTExMjQxOTM3MzBa MEMxCzAJBgNVBAYTAkhSMR0wGwYDVQQKExRGaW5hbmNpanNrYSBhZ2VuY2lqYTEV MBMGA1UEAxMMRmluYSBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC CgKCAgEAtHPS1mXTAu9YOvSCgtOn4Ipgsjr1sWU4pyQOIWt96aCdM6J0za6RupS1 zMaAtXHfSHHKdUunv/8m64T+uXIWyMJ+htS/r+5jNbnA5NoFT7hIniIo/1UFI2uB TrMXESwqJR/k4d9hyDzyVmnQVX2WELKoe1aQW6ZeU4tB48eHxzG9NDnsGSHZgMTo DdvaAwwA9Kq1ggYlDMXZGmKd/QpJBfwcvpNG/M6Jkf/NzF9IX9w40HVv0i2rzCIS eIgSH+DVTne8LIlNdnqIm10H2rNnmNE5znpGq8/2fVclE/qExANwrwx2DNJAJHxZ 33c3WVCxJUZOQh0IIglyVcRC6m9vZVnUTuA9o6twfOYJMFV2Yonzb9IKprNuGT2W hnpmlM3yzHrwBwizaa4b/xxxGKJE+dvWDYQQgXRJYWLXEPABpkXAtdBS9FGGPeL3 Fila+kqeJ0uORvFyPqf1pAzgCxeaIv/5fqs1jgGE1XWTf+Z1qHpk3mI6AkcaoCPE TD/Q3E4z52y7+vYYECs0MF/HM1CZAumxWUZVZaa6pIMYi83h8coY4tkg5reEhx8L VnxNMVQm8plWyKZZ1oUz8pDMKFrIbKTLpkdGxJpVOYRkjXfnCj3D0BL3dqjMHLMf WIU6xDaN7JrsDuccyZ9P+9B6BwzGBbCrjbpyXU4j2W8MXPimctECAwEAAaNjMGEw DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU/hGi bBDu3uIDuFWCTiI8huQ+a1QwHQYDVR0OBBYEFP4RomwQ7t7iA7hVgk4iPIbkPmtU MA0GCSqGSIb3DQEBCwUAA4ICAQCkgIYu56adjf+CV2Ny5xpg36uyubIBEmc4QOZA fFi8zEhxWwGXnHkcHnHSO6PY6KLiGAGlRajj9O+ru4p4/MeIffIFYJrbcMN41av4 LTOMa6L2yQPAijxm3Z3o7qdOJQ8U3/gPFi4eF6dYyNkF05iivGRCU/4kyXWqJu5u MjMIYaA2fcq7nbu1cV4GgWr/Z+6miD+2P9MXTM4EzrMLdTnRwOOcs5qiGVYoi5ak s58WSdyEICLt73JMXxCqHwkBO1XIxmyvp9Iunu2wzJFtZMPsGL46akuuAS4/ec00 HDiuuQ1hBHP3nik7p7aQOrgsIzTDuAwGUcI+IZmfPBSQyqkm9UDjIul9zgMX7P+8 0ZkuxGSPPyxZYCQ8sNvDlQiqAHWynQsgGbT3bqmjvWDwMw/iZr1H9giKkDV9RYZK yZ7Ez1/fcd7MyW45iE25Ss8DdAdZK+386+7V0tU5bXcN2NF/L353vmGYjSxScTCE vqDmsLAHCMW0dLeLsti62ADyGcf4oSIKZkSoFgh1XllESEU0NQhK8HslC6ZLUX93 zQ0zOKsAkWZMiMFOKtQ6wLSG3oSAylBvgPlNZYAJFXUtIlbltZEjne4l2BgwKHLb f8MxTo7YvkP6246aBZn999yUiad42J1r6f71JMe60ulED4NLXZ//JBif0dWE6CFJ t9sg5w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEMDCCAxigAwIBAgIJANqb7HHzA7AZMA0GCSqGSIb3DQEBCwUAMIGkMQswCQYD VQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEk MCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRydXN0Q29y IFJvb3RDZXJ0IENBLTEwHhcNMTYwMjA0MTIzMjE2WhcNMjkxMjMxMTcyMzE2WjCB pDELMAkGA1UEBhMCUEExDzANBgNVBAgMBlBhbmFtYTEUMBIGA1UEBwwLUGFuYW1h IENpdHkxJDAiBgNVBAoMG1RydXN0Q29yIFN5c3RlbXMgUy4gZGUgUi5MLjEnMCUG A1UECwweVHJ1c3RDb3IgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MR8wHQYDVQQDDBZU cnVzdENvciBSb290Q2VydCBDQS0xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAv463leLCJhJrMxnHQFgKq1mqjQCj/IDHUHuO1CAmujIS2CNUSSUQIpid RtLByZ5OGy4sDjjzGiVoHKZaBeYei0i/mJZ0PmnK6bV4pQa81QBeCQryJ3pS/C3V seq0iWEk8xoT26nPUu0MJLq5nux+AHT6k61sKZKuUbS701e/s/OojZz0JEsq1pme 9J7+wH5COucLlVPat2gOkEz7cD+PSiyU8ybdY2mplNgQTsVHCJCZGxdNuWxu72CV EY4hgLW9oHPY0LJ3xEXqWib7ZnZ2+AYfYW0PVcWDtxBWcgYHpfOxGgMFZA6dWorW hnAbJN7+KIor0Gqw/Hqi3LJ5DotlDwIDAQABo2MwYTAdBgNVHQ4EFgQU7mtJPHo/ DeOxCbeKyKsZn3MzUOcwHwYDVR0jBBgwFoAU7mtJPHo/DeOxCbeKyKsZn3MzUOcw DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD ggEBACUY1JGPE+6PHh0RU9otRCkZoB5rMZ5NDp6tPVxBb5UrJKF5mDo4Nvu7Zp5I /5CQ7z3UuJu0h3U/IJvOcs+hVcFNZKIZBqEHMwwLKeXx6quj7LUKdJDHfXLy11yf ke+Ri7fc7Waiz45mO7yfOgLgJ90WmMCV1Aqk5IGadZQ1nJBfiDcGrVmVCrDRZ9MZ yonnMlo2HD6CqFqTvsbQZJG2z9m2GM/bftJlo6bEjhcxwft+dtvTheNYsnd6djts L1Ac59v2Z3kf9YKVmgenFK+P3CghZwnS1k1aHBkcjndcw5QkPTJrS37UeJSDvjdN zl/HHk484IkzlQsPpTLWPFp5LBk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGLzCCBBegAwIBAgIIJaHfyjPLWQIwDQYJKoZIhvcNAQELBQAwgaQxCzAJBgNV BAYTAlBBMQ8wDQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5MSQw IgYDVQQKDBtUcnVzdENvciBTeXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRy dXN0Q29yIENlcnRpZmljYXRlIEF1dGhvcml0eTEfMB0GA1UEAwwWVHJ1c3RDb3Ig Um9vdENlcnQgQ0EtMjAeFw0xNjAyMDQxMjMyMjNaFw0zNDEyMzExNzI2MzlaMIGk MQswCQYDVQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEg Q2l0eTEkMCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYD VQQLDB5UcnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxHzAdBgNVBAMMFlRy dXN0Q29yIFJvb3RDZXJ0IENBLTIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCnIG7CKqJiJJWQdsg4foDSq8GbZQWU9MEKENUCrO2fk8eHyLAnK0IMPQo+ QVqedd2NyuCb7GgypGmSaIwLgQ5WoD4a3SwlFIIvl9NkRvRUqdw6VC0xK5mC8tkq 1+9xALgxpL56JAfDQiDyitSSBBtlVkxs1Pu2YVpHI7TYabS3OtB0PAx1oYxOdqHp 2yqlO/rOsP9+aij9JxzIsekp8VduZLTQwRVtDr4uDkbIXvRR/u8OYzo7cbrPb1nK DOObXUm4TOJXsZiKQlecdu/vvdFoqNL0Cbt3Nb4lggjEFixEIFapRBF37120Hape az6LMvYHL1cEksr1/p3C6eizjkxLAjHZ5DxIgif3GIJ2SDpxsROhOdUuxTTCHWKF 3wP+TfSvPd9cW436cOGlfifHhi5qjxLGhF5DUVCcGZt45vz27Ud+ez1m7xMTiF88 oWP7+ayHNZ/zgp6kPwqcMWmLmaSISo5uZk3vFsQPeSghYA2FFn3XVDjxklb9tTNM g9zXEJ9L/cb4Qr26fHMC4P99zVvh1Kxhe1fVSntb1IVYJ12/+CtgrKAmrhQhJ8Z3 mjOAPF5GP/fDsaOGM8boXg25NSyqRsGFAnWAoOsk+xWq5Gd/bnc/9ASKL3x74xdh 8N0JqSDIvgmk0H5Ew7IwSjiqqewYmgeCK9u4nBit2uBGF6zPXQIDAQABo2MwYTAd BgNVHQ4EFgQU2f4hQG6UnrybPZx9mCAZ5YwwYrIwHwYDVR0jBBgwFoAU2f4hQG6U nrybPZx9mCAZ5YwwYrIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYw DQYJKoZIhvcNAQELBQADggIBAJ5Fngw7tu/hOsh80QA9z+LqBrWyOrsGS2h60COX dKcs8AjYeVrXWoSK2BKaG9l9XE1wxaX5q+WjiYndAfrs3fnpkpfbsEZC89NiqpX+ MWcUaViQCqoL7jcjx1BRtPV+nuN79+TMQjItSQzL/0kMmx40/W5ulop5A7Zv2wnL /V9lFDfhOPXzYRZY5LVtDQsEGz9QLX+zx3oaFoBg+Iof6Rsqxvm6ARppv9JYx1RX CI/hOWB3S6xZhBqI8d3LT3jX5+EzLfzuQfogsL7L9ziUwOHQhQ+77Sxzq+3+knYa ZH9bDTMJBzN7Bj8RpFxwPIXAz+OQqIN3+tvmxYxoZxBnpVIt8MSZj3+/0WvitUfW 2dCFmU2Umw9Lje4AWkcdEQOsQRivh7dvDDqPys/cA8GiCcjl/YBeyGBCARsaU1q7 N6a3vLqE6R5sGtRk2tRD/pOLS/IseRYQ1JMLiI+h2IYURpFHmygk71dSTlxCnKr3 Sewn6EAes6aJInKc9Q0ztFijMDvd1GpUk74aTfOTlPf8hAs/hCBcNANExdqtvArB As8e5ZTZ845b2EzwnexhF7sUMlQMAimTHpKG9n/v55IFDlndmQguLvqcAFLTxWYp 5KeXRKQOKIETNcX2b2TmQcTVL8w0RSXPQQCWPUouwpaYT05KnJe32x+SMsj/D1Fu 1uwJ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEIDCCAwigAwIBAgIJAISCLF8cYtBAMA0GCSqGSIb3DQEBCwUAMIGcMQswCQYD VQQGEwJQQTEPMA0GA1UECAwGUGFuYW1hMRQwEgYDVQQHDAtQYW5hbWEgQ2l0eTEk MCIGA1UECgwbVHJ1c3RDb3IgU3lzdGVtcyBTLiBkZSBSLkwuMScwJQYDVQQLDB5U cnVzdENvciBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxFzAVBgNVBAMMDlRydXN0Q29y IEVDQS0xMB4XDTE2MDIwNDEyMzIzM1oXDTI5MTIzMTE3MjgwN1owgZwxCzAJBgNV BAYTAlBBMQ8wDQYDVQQIDAZQYW5hbWExFDASBgNVBAcMC1BhbmFtYSBDaXR5MSQw IgYDVQQKDBtUcnVzdENvciBTeXN0ZW1zIFMuIGRlIFIuTC4xJzAlBgNVBAsMHlRy dXN0Q29yIENlcnRpZmljYXRlIEF1dGhvcml0eTEXMBUGA1UEAwwOVHJ1c3RDb3Ig RUNBLTEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPj+ARtZ+odnbb 3w9U73NjKYKtR8aja+3+XzP4Q1HpGjORMRegdMTUpwHmspI+ap3tDvl0mEDTPwOA BoJA6LHip1GnHYMma6ve+heRK9jGrB6xnhkB1Zem6g23xFUfJ3zSCNV2HykVh0A5 3ThFEXXQmqc04L/NyFIduUd+Dbi7xgz2c1cWWn5DkR9VOsZtRASqnKmcp0yJF4Ou owReUoCLHhIlERnXDH19MURB6tuvsBzvgdAsxZohmz3tQjtQJvLsznFhBmIhVE5/ wZ0+fyCMgMsq2JdiyIMzkX2woloPV+g7zPIlstR8L+xNxqE6FXrntl019fZISjZF ZtS6mFjBAgMBAAGjYzBhMB0GA1UdDgQWBBREnkj1zG1I1KBLf/5ZJC+Dl5mahjAf BgNVHSMEGDAWgBREnkj1zG1I1KBLf/5ZJC+Dl5mahjAPBgNVHRMBAf8EBTADAQH/ MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAQEABT41XBVwm8nHc2Fv civUwo/yQ10CzsSUuZQRg2dd4mdsdXa/uwyqNsatR5Nj3B5+1t4u/ukZMjgDfxT2 AHMsWbEhBuH7rBiVDKP/mZb3Kyeb1STMHd3BOuCYRLDE5D53sXOpZCz2HAF8P11F hcCF5yWPldwX8zyfGm6wyuMdKulMY/okYWLW2n62HGz1Ah3UKt1VkOsqEUc8Ll50 soIipX1TH0XsJ5F95yIW6MBoNtjG8U+ARDL54dHRHareqKucBK+tIA5kmE2la8BI WJZpTdwHjFGTot+fDz2LYLSCjaoITmJF4PkL0uDgPFveXHEnJcLmA4GLEFPjx1Wi tJ/X5g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UE BhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQK DA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZp Y2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTcz OTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcN AQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2R xFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aX qhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcC C52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+lSgQ3 6YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh /l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrF YD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93E JNyAKoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVc US4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8 ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm +Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqi M+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4G A1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGV cpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBc Hadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgs PgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/ q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0 cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jr a6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90I H37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/Y K9f1JmzJBjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtu nLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406ywKBjYZC6VWg3dGq2ktuf oYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShY Ic2wBlX7Jz9TkHCpBB5XJ7k= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMC VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T U0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0 aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNDAzWhcNNDEwMjEyMTgxNDAz WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hvdXN0 b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBS b290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB BAAiA2IABEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI 7Z4INcgn64mMU1jrYor+8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPg CemB+vNH06NjMGEwHQYDVR0OBBYEFILRhXMw5zUE044CkvvlpNHEIejNMA8GA1Ud EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTTjgKS++Wk0cQh6M0wDgYD VR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCWe+0F+S8T kdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+ gA0z5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMC VVMxDjAMBgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9T U0wgQ29ycG9yYXRpb24xNDAyBgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZp Y2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEyMTgxNTIzWhcNNDEwMjEyMTgx NTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAOBgNVBAcMB0hv dXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NMLmNv bSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49 AgEGBSuBBAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMA VIbc/R/fALhBYlzccBYy3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1Kthku WnBaBu2+8KGwytAJKaNjMGEwHQYDVR0OBBYEFFvKXuXe0oGqzagtZFG22XKbl+ZP MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe5d7SgarNqC1kUbbZcpuX 5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJN+vp1RPZ ytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZg h5Mmm7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGoTCCBImgAwIBAgIBATANBgkqhkiG9w0BAQ0FADCBlzELMAkGA1UEBhMCQlIx EzARBgNVBAoMCklDUC1CcmFzaWwxPTA7BgNVBAsMNEluc3RpdHV0byBOYWNpb25h bCBkZSBUZWNub2xvZ2lhIGRhIEluZm9ybWFjYW8gLSBJVEkxNDAyBgNVBAMMK0F1 dG9yaWRhZGUgQ2VydGlmaWNhZG9yYSBSYWl6IEJyYXNpbGVpcmEgdjUwHhcNMTYw MzAyMTMwMTM4WhcNMjkwMzAyMjM1OTM4WjCBlzELMAkGA1UEBhMCQlIxEzARBgNV BAoMCklDUC1CcmFzaWwxPTA7BgNVBAsMNEluc3RpdHV0byBOYWNpb25hbCBkZSBU ZWNub2xvZ2lhIGRhIEluZm9ybWFjYW8gLSBJVEkxNDAyBgNVBAMMK0F1dG9yaWRh ZGUgQ2VydGlmaWNhZG9yYSBSYWl6IEJyYXNpbGVpcmEgdjUwggIiMA0GCSqGSIb3 DQEBAQUAA4ICDwAwggIKAoICAQD3LXgabUWsF+gUXw/6YODeF2XkqEyfk3VehdsI x+3/ERgdjCS/ouxYR0Epi2hdoMUVJDNf3XQfjAWXJyCoTneHYAl2McMdvoqtLB2i leQlJiis0fTtYTJayee9BAIdIrCor1Lc0vozXCpDtq5nTwhjIocaZtcuFsdrkl+n bfYxl5m7vjTkTMS6j8ffjmFzbNPDlJuV3Vy7AzapPVJrMl6UHPXCHMYMzl0KxR/4 7S5XGgmLYkYt8bNCHA3fg07y+Gtvgu+SNhMPwWKIgwhYw+9vErOnavRhOimYo4M2 AwNpNK0OKLI7Im5V094jFp4Ty+mlmfQH00k8nkSUEN+1TGGkhv16c2hukbx9iCfb mk7im2hGKjQA8eH64VPYoS2qdKbPbd3xDDHN2croYKpy2U2oQTVBSf9hC3o6fKo3 zp0U3dNiw7ZgWKS9UwP31Q0gwgB1orZgLuF+LIppHYwxcTG/AovNWa4sTPukMiX2 L+p7uIHExTZJJU4YoDacQh/mfbPIz3261He4YFmQ35sfw3eKHQSOLyiVfev/n0l/ r308PijEd+d+Hz5RmqIzS8jYXZIeJxym4mEjE1fKpeP56Ea52LlIJ8ZqsJ3xzHWu 3WkAVz4hMqrX6BPMGW2IxOuEUQyIaCBg1lI6QLiPMHvo2/J7gu4YfqRcH6i27W3H yzamEQIDAQABo4H1MIHyME4GA1UdIARHMEUwQwYFYEwBAQAwOjA4BggrBgEFBQcC ARYsaHR0cDovL2FjcmFpei5pY3BicmFzaWwuZ292LmJyL0RQQ2FjcmFpei5wZGYw PwYDVR0fBDgwNjA0oDKgMIYuaHR0cDovL2FjcmFpei5pY3BicmFzaWwuZ292LmJy L0xDUmFjcmFpenY1LmNybDAfBgNVHSMEGDAWgBRpqL512cTvbOcTReRhbuVo+LZA XjAdBgNVHQ4EFgQUaai+ddnE72znE0XkYW7laPi2QF4wDwYDVR0TAQH/BAUwAwEB /zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQADggIBABRt2/JiWapef7o/ plhR4PxymlMIp/JeZ5F0BZ1XafmYpl5g6pRokFrIRMFXLyEhlgo51I05InyCc9Td 6UXjlsOASTc/LRavyjB/8NcQjlRYDh6xf7OdP05mFcT/0+6bYRtNgsnUbr10pfsK /UzyUvQWbumGS57hCZrAZOyd9MzukiF/azAa6JfoZk2nDkEudKOY8tRyTpMmDzN5 fufPSC3v7tSJUqTqo5z7roN/FmckRzGAYyz5XulbOc5/UsAT/tk+KP/clbbqd/hh evmmdJclLr9qWZZcOgzuFU2YsgProtVu0fFNXGr6KK9fu44pOHajmMsTXK3X7r/P wh19kFRow5F3RQMUZC6Re0YLfXh+ypnUSCzA+uL4JPtHIGyvkbWiulkustpOKUSV wBPzvA2sQUOvqdbAR7C8jcHYFJMuK2HZFji7pxcWWab/NKsFcJ3sluDjmhizpQax bYTfAVXu3q8yd0su/BHHhBpteyHvYyyz0Eb9LUysR2cMtWvfPU6vnoPgYvOGO1Cz iyGEsgKULkCH4o2Vgl1gQuKWO4V68rFW8a/jvq28sbY+y/Ao0I5ohpnBcQOAawiF bz6yJtObajYMuztDDP8oY656EuuJXBJhuKAJPI/7WDtgfV8ffOh/iQGQATVMtgDN 0gv8bn5NdUX8UMNX1sHhU3H1UpoW -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFRjCCAy6gAwIBAgIQXd+x2lqj7V2+WmUgZQOQ7zANBgkqhkiG9w0BAQsFADA9 MQswCQYDVQQGEwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxGzAZBgNVBAMMElVDQSBH bG9iYWwgRzIgUm9vdDAeFw0xNjAzMTEwMDAwMDBaFw00MDEyMzEwMDAwMDBaMD0x CzAJBgNVBAYTAkNOMREwDwYDVQQKDAhVbmlUcnVzdDEbMBkGA1UEAwwSVUNBIEds b2JhbCBHMiBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAxeYr b3zvJgUno4Ek2m/LAfmZmqkywiKHYUGRO8vDaBsGxUypK8FnFyIdK+35KYmToni9 kmugow2ifsqTs6bRjDXVdfkX9s9FxeV67HeToI8jrg4aA3++1NDtLnurRiNb/yzm VHqUwCoV8MmNsHo7JOHXaOIxPAYzRrZUEaalLyJUKlgNAQLx+hVRZ2zA+te2G3/R VogvGjqNO7uCEeBHANBSh6v7hn4PJGtAnTRnvI3HLYZveT6OqTwXS3+wmeOwcWDc C/Vkw85DvG1xudLeJ1uK6NjGruFZfc8oLTW4lVYa8bJYS7cSN8h8s+1LgOGN+jIj tm+3SJUIsUROhYw6AlQgL9+/V087OpAh18EmNVQg7Mc/R+zvWr9LesGtOxdQXGLY D0tK3Cv6brxzks3sx1DoQZbXqX5t2Okdj4q1uViSukqSKwxW/YDrCPBeKW4bHAyv j5OJrdu9o54hyokZ7N+1wxrrFv54NkzWbtA+FxyQF2smuvt6L78RHBgOLXMDj6Dl NaBa4kx1HXHhOThTeEDMg5PXCp6dW4+K5OXgSORIskfNTip1KnvyIvbJvgmRlld6 iIis7nCs+dwp4wwcOxJORNanTrAmyPPZGpeRaOrvjUYG0lZFWJo8DA+DuAUlwznP O6Q0ibd5Ei9Hxeepl2n8pndntd978XplFeRhVmUCAwEAAaNCMEAwDgYDVR0PAQH/ BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIHEjMz15DD/pQwIX4wV ZyF0Ad/fMA0GCSqGSIb3DQEBCwUAA4ICAQATZSL1jiutROTL/7lo5sOASD0Ee/oj L3rtNtqyzm325p7lX1iPyzcyochltq44PTUbPrw7tgTQvPlJ9Zv3hcU2tsu8+Mg5 1eRfB70VVJd0ysrtT7q6ZHafgbiERUlMjW+i67HM0cOU2kTC5uLqGOiiHycFutfl 1qnN3e92mI0ADs0b+gO3joBYDic/UvuUospeZcnWhNq5NXHzJsBPd+aBJ9J3O5oU b3n09tDh05S60FdRvScFDcH9yBIw7m+NESsIndTUv4BFFJqIRNow6rSn4+7vW4LV PtateJLbXDzz2K36uGt/xDYotgIVilQsnLAXc47QN6MUPJiVAAwpBVueSUmxX8fj y88nZY41F7dXyDDZQVu5FLbowg+UMaeUmMxq67XhJ/UQqAHojhJi6IjMtX9Gl8Cb EGY4GjZGXyJoPd/JxhMnq1MGrKI8hgZlb7F+sSlEmqO6SWkoaY/X5V+tBIZkbxqg DMUIYs6Ao9Dz7GjevjPHF1t/gMRMTLGmhIrDO7gJzRSBuhjjVFc2/tsvfEehOjPI +Vg7RE+xygKJBJYoaMVLuCaJu9YzL1DV/pqJuhgyklTGW+Cd+V7lDSKb9triyCGy YiGqhkCyLmTTX8jjfhFnRR8F/uOi77Oos/N9j/gMHyIfLXC0uAE0djAA5SN4p1bX UB+K+wb1whnw0A== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFPzCCAyegAwIBAgICPs8wDQYJKoZIhvcNAQEMBQAwSDELMAkGA1UEBhMCR1Ix HjAcBgNVBAoTFUFUSEVOUyBTVE9DSyBFWENIQU5HRTEZMBcGA1UEAxMQQVRIRVgg Um9vdCBDQSBHMjAeFw0xNjAzMTUxMTE0MzJaFw0zNjAzMTQyMjAwMDBaMEgxCzAJ BgNVBAYTAkdSMR4wHAYDVQQKExVBVEhFTlMgU1RPQ0sgRVhDSEFOR0UxGTAXBgNV BAMTEEFUSEVYIFJvb3QgQ0EgRzIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCv8F+SyvwcsJAt1CaLvyqZeTbHdIwB76G9cvg0hMwtTdfrk5HLCYO2+tRl M12cmBtew+bgQENlZ2OlcKvlqxZgtqsUezqjbvUZbyrEdKBZdJT2ntf8Mn8M+a8U UbiPWrjVdg6n/XEKPgv8EFJL78LEH1Kh8eXpsRAyrKluW68rt4DJUStKA+w//fBT LO++WqbEAfCcBO3g+n1GvxE36w+BrDoZhwed+F5YqP9jvHB1puCrMdGzgoY2aaOx atU2RdWf8IWKCkUOC0GxEZqx7MAmbUuIN1/sFIOF570+ZZ1K0geHbYaDWLplGcww ldusUvq2zH5uHbmwgFV5U1wNCFZTUrfkl4NjarnSH7xqIREiVhzoPRmEzlmGKtEG JxLbRyukp7DD+B68/qw/sp7csCLFT3Bh0/4o4RUZLHg8P8N9mWA2eW5byThmoaXp LYHGUqyezxteyybZ7dQF7VcmdqQC4zbkTkV+NGcY//wUKPX2vANOvIjLegkorQHj cOi5O1WNEMiUJAduG5pyxAsY+21rZXlv6L2MFaDkoBUU6TvJXfph4nnDCzNKBQ9B UQm8YoB3V+C0uxiSBe2OVCHd9YcYHGqosgJqQoxD1R4fZ+HV3QBjj+ALf0GUYQaW fACPoN9TGUe8VDLZGwu+jp89TNygUzyV2FHZp7idkbyDyPHkgQIDAQABozMwMTAP BgNVHRMBAf8EBTADAQH/MBEGA1UdDgQKBAhHo6YEnS2W5TALBgNVHQ8EBAMCAQYw DQYJKoZIhvcNAQEMBQADggIBAIbX9Rko9qewUKpuPSM+Bu/nNHusyYUusKmiwn0k RT+tyNaTJ7XKjyygBDiD2ZrP7lcs7LEJE7LOfCQbZ+BEgszipWRLSzVsZ0Jvc7w4 uX7ARMh1/AVxp/udBcLlJdkssXVntDH3uiUMjp3JfGxK/HUFYKTNz7ufjl+dsiBA S2tuHacQHu+/YA/LN/1MI/pi431dgM2ubMfmp6STGHcfU9Z9qf914yTgT8uiYedm PtS0Ch0MFY46hQbG72xy/dRD0/2MqEOBWTjBhnwgh46oJIpGxAWtbaDVWBBTmZTy rIosVqZSSkw3OVW8wviueay5NoVuYVI+/TTqYWhlgYFM2xT5YI0EdQ8Q30PTJcdA X5vk0DB92gZB9O1m/jgRcyBZ2YB7FeFC1zqebGVfMXahE2XaJzuwEuisSLaZEQd+ LspikapRYfRnyit50o8hWl8WcI5UmJ/281kBba61pBJzn4KfF5/a7YOPI/1izjbe A8HRMKbTou+rXXV699ccLPfZ6WY6l5QpUNv8AgNf8jDXUTKcxC+dStkx8TUPfoOq HeK1xlFBa1ctIhmPO6cjuwN1nrv8+SCHzHBfjiBwLzo+Yg1f0uE2nUbWVbKYCi6c wFXS+x56a0p2KSYS9q+kp7ztMqFw0/mNiweBpX0GwI3xNb62YLJvHiOikcr5YI3m 6JPv -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIG4DCCBMigAwIBAgIINJotoYIGsrMwDQYJKoZIhvcNAQELBQAwggEMMQswCQYD VQQGEwJFUzEPMA0GA1UECAwGTUFEUklEMQ8wDQYDVQQHDAZNQURSSUQxOjA4BgNV BAsMMXNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2Fk ZHJlc3MxKTAnBgNVBAsMIENIQU1CRVJTIE9GIENPTU1FUkNFIFJPT1QgLSAyMDE2 MRIwEAYDVQQFEwlBODI3NDMyODcxGDAWBgNVBGEMD1ZBVEVTLUE4Mjc0MzI4NzEb MBkGA1UECgwSQUMgQ0FNRVJGSVJNQSBTLkEuMSkwJwYDVQQDDCBDSEFNQkVSUyBP RiBDT01NRVJDRSBST09UIC0gMjAxNjAeFw0xNjA0MTQwNzM1NDhaFw00MDA0MDgw NzM1NDhaMIIBDDELMAkGA1UEBhMCRVMxDzANBgNVBAgMBk1BRFJJRDEPMA0GA1UE BwwGTUFEUklEMTowOAYDVQQLDDFzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5j YW1lcmZpcm1hLmNvbS9hZGRyZXNzMSkwJwYDVQQLDCBDSEFNQkVSUyBPRiBDT01N RVJDRSBST09UIC0gMjAxNjESMBAGA1UEBRMJQTgyNzQzMjg3MRgwFgYDVQRhDA9W QVRFUy1BODI3NDMyODcxGzAZBgNVBAoMEkFDIENBTUVSRklSTUEgUy5BLjEpMCcG A1UEAwwgQ0hBTUJFUlMgT0YgQ09NTUVSQ0UgUk9PVCAtIDIwMTYwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQDqxqSh1K2Zlsmf9bxQAPQsz/J46PIsAifW g4wEq9MOe1cgydSvZfSH3TAI185Bo3YK24pG5Kb97QjOcD/6EGB5TGuBVIBV5Od6 IbZ1mtxe9g6Z/PjC30GOL6vHW20cUFnA7eisgkL+ua8vDEFRnL0AbmRRsjvlNquV kRL7McdzrBzYZXY7zhtMTrAfIAb7ULT7m6F5jhaV45/rGEuEqzmTzTeD0Ol8CyeP 7UII6YZGMqyaJmlwYS0YvT9Q8J72aFBOaZVwwe2TqZdOKaK63cKfbkkIK6P6I/Ep XrB9MVmb7YzNpm74+PfYGOjaVulI8kB0fp7NIK8UJFnudzWFv0qZSql13bMm4wbO fW9LZKN2NBk+FG+FVDjiiy1AtWRmH1czHHDNw7QoWhQjXPy4vbP+OxJf9rmMHciU Clbbcn7vJwcNALS/fZk/TUWzm/cdGdBPBPrHc5SIfYsUKpng6ZmSCcbWAWu38NtD V2Ibx0RS4pdjus/qzmDmCuUYaC0zgHWgMAdo9tX3Eyw6sJ7oWFVujFZETUMXQQLM d9xfRQVZz81g07/S9uL01dyHcTMHGvVvtH89l/tfZPRODgBECenr7D5xGQQXOUhg uEv/XshlmSumMvJbhqid6CN0EHjvyyedMbpgi04GUOJQHQdgwkGMFbRbNxwK5QkZ cgSKPOMB2wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSeLmVP Plf1q32WxovfszVtSuieizAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQAD ggIBAAVpKoWXJlC6QjkckyzST1vRXUQm2m9pK7V7ntD0Si5Ix+x/n8pZerlE9z69 91BrUZ90/5AaQNCTeZIPiiNei6+BC9CLrWbgKtyaKb012GxAFElCPYkvupsrOLwa owu3iNetxhQM7nxJrK7s8j0YT4xtFF0Oqrffd6s7j2JOiwxlxhmOzcAMoXeqtN16 pxMF5jkYx5VkfgO2i5DB5V8AI5jmc9oR0hD/HlMiJ8fTAckvxTsybvDDOMoSZ7y6 Iym7xJVJWgbd1FqQ1BNt59XCfOJYBMDsxL2iPH7GI4F1fKtwXzSElfez1UeWT3HK eDIIILRCpEJr1SWcsifrwQ5HRAnhKw/QIzZuHLm6TqzM8AyUzkEPa90P1cjgF4ve Ol1Svul1JR26BQfaVhk8jdHX8VE22ZLvonhRBVi9UswKXm+v2tDlDNtswSPvOTF3 FwcAjPa6D3D5vL7h5H3hzER6pCHsRz+o1hWl7AGpyHDomGcdvVlUfqFXFTUHxXLJ Prcpho2f2jJ5MtzbqOUJ/+9WKv6TsY4qE+2toitrLwTezS+SktY+YLV4AZUHCKls 4xza++WbI1YgW+nQXMZKJDu847YiFiqEkv+o/pe/o53bYV7uGSos1+sNdlY4dX5J AJNXyfwjWvz08d8qnbCMafQQo1WdcDwi/wfWK7aZwJfQ9Cqg -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIG2DCCBMCgAwIBAgIILdIuUDCmXhMwDQYJKoZIhvcNAQELBQAwggEIMQswCQYD VQQGEwJFUzEPMA0GA1UECAwGTUFEUklEMQ8wDQYDVQQHDAZNQURSSUQxOjA4BgNV BAsMMXNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2Fk ZHJlc3MxJzAlBgNVBAsMHkdMT0JBTCBDSEFNQkVSU0lHTiBST09UIC0gMjAxNjES MBAGA1UEBRMJQTgyNzQzMjg3MRgwFgYDVQRhDA9WQVRFUy1BODI3NDMyODcxGzAZ BgNVBAoMEkFDIENBTUVSRklSTUEgUy5BLjEnMCUGA1UEAwweR0xPQkFMIENIQU1C RVJTSUdOIFJPT1QgLSAyMDE2MB4XDTE2MDQxNDA3NTAwNloXDTQwMDQwODA3NTAw NlowggEIMQswCQYDVQQGEwJFUzEPMA0GA1UECAwGTUFEUklEMQ8wDQYDVQQHDAZN QURSSUQxOjA4BgNVBAsMMXNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVy ZmlybWEuY29tL2FkZHJlc3MxJzAlBgNVBAsMHkdMT0JBTCBDSEFNQkVSU0lHTiBS T09UIC0gMjAxNjESMBAGA1UEBRMJQTgyNzQzMjg3MRgwFgYDVQRhDA9WQVRFUy1B ODI3NDMyODcxGzAZBgNVBAoMEkFDIENBTUVSRklSTUEgUy5BLjEnMCUGA1UEAwwe R0xPQkFMIENIQU1CRVJTSUdOIFJPT1QgLSAyMDE2MIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEA0GvnniIrU3YVVa9MSsBta/v5hEQFoX1gzgXsnphz+luE BzH3/z1rx35WBmKlXJaW0/FeWX7rMRy/d1cwVO8exczEsurb5orQ9CiEyLBILSyW bfsiqDWOvt5wFRD5ZkFGFqBDZD+NSvOAMc+TgH6a26Wvj2ws/Q7vHHncD6JuhFwi iQ5ELkiolHPsOTKRHOIUvX1l5nL+W+dUdS99DuLGymkuXqIO1eiF3j9rf6WCsEZ9 XZ5xuhS06+3HwhRkDFhuT5U2YTZFYDZmGEuVGj5YrIsmHiXm+pUA+60SnvoSYb4a 3qZ86av/15SJckL8u0UR7D9w/BnEmuqXbqzkOAQ74T8BKHGj4q5DZHgWmQJav9fE 77W31cNYgUGG5LKMAKWImJjrCedYMWgx3u3iSTXz0rNX3MRCn/0879D1KzluYa56 4cd6PW0XMGwCrInWWoScKcCeEI64IDYzyoAraH82dWUV+MPa/3Gi/O2bd9wZ+vHI tgX05XCSqcjduLAaVVuR3LjlmrUDwK22rvGZe0u1iQ7eZAtkflTup8OKmBnF/DwT CEU+35/7x32xoII2FD3AYwABZsTk8Jk7HlF4XbkXPFiTFa+o9SUgGY0jPRI8Qusv XUKO8jCoJVrm+vdPbb4mWPWPf/eK+LNuwxvyMYU2cY79O9bmMDXLJY1liVeoM5UC AwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU6JvNfoZim3pNjACX OYXPHHiQcDowDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQB7/SrD yxspAhAJresusytt2Uug2zWY9Y8Cp9NCC0Org7V3R4hGhd3Rth59mBuMcb6qyPDv xmotVphS6JaJ+9XqAN/+5iLKp7k+ZUR1w4q/i3eJw2pX+rzI4RDe8dqFJ/HtB//V wkLUomEv34hx4zTmZ2SbxnoZ6znv8+oEqHRpTIC1/K29DQj0yO8oJ4LK3ejzuldn ouopwZnhdmb59nhdnD7w9s+hGTTT8TwzocyCMrZI44M+D79nlcGimXhCQ/cDTRNX b91x3Rbz+3k4G2KapM1eUN4RIJCKIpir2kZ6TDTRSN3ZZmViVAXZdJlndFexOi4Y sK6snz8u6x+ynM2O+Nt4jtQGz6OTMWt/7VJyt4vPKG/J+VRPAdQ6hugu+uHQJYTj FvyMjSTjZMwqjLJgU59ZkkUJlFuoEIUyy3fyjpWKRHLPbhfeRL0Krv0mtj15Zj1N vH4yQ13b4GW1KGm6fJ4ySo/qerA9Fl39PvobBPgQNXjM7cHZLb9r0u/pn8Bbj+q+ etEx5wY9rYSr7DvxEsd/8fhGLwl4l8AnPbE/cSOLGqdc5hYlDiZNuQ5Wp1KkOAmv SQX+f84/wvzm5EqUJ+VTxIg06wJXvM6OK613U3JAu4UWVRkvg3aVo3Y5qLL0faTb AEJ6oHuOGQbkl81bPTq0XMBpHzJmvwifhJsiZQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEijCCAvKgAwIBAgINAJCud3YAAAAAVx3QbzANBgkqhkiG9w0BAQsFADBcMQsw CQYDVQQGEwJTSTEcMBoGA1UEChMTUmVwdWJsaWthIFNsb3ZlbmlqYTEXMBUGA1UE YRMOVkFUU0ktMTc2NTk5NTcxFjAUBgNVBAMTDVNJLVRSVVNUIFJvb3QwHhcNMTYw NDI1MDczODE3WhcNMzcxMjI1MDgwODE3WjBcMQswCQYDVQQGEwJTSTEcMBoGA1UE ChMTUmVwdWJsaWthIFNsb3ZlbmlqYTEXMBUGA1UEYRMOVkFUU0ktMTc2NTk5NTcx FjAUBgNVBAMTDVNJLVRSVVNUIFJvb3QwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAw ggGKAoIBgQDTy5wtwuAwQ2UxJP9LsDjZqVPXNdHbt0uTtHKN8cuV0lMrdJsymqQv PgIG3a9wFaGqzxGHimZ7y8wdcERcj6zK5sNbJ7SNo44Qv25UdAhwiiPoysd0xGaR IN1L6KWEdaWYlYKLG+EgJAdGqwxlNkBni3XuqdmRKRvtby1FwtbiYAGx8045Kztv P4W+CPZTK3uiyUWhRIGAZppgOhvEvgzMMBB/ETY4SuaboZZTnJTMEcYETKJVS/+A 4a+MHDX8uZM33/ldPdzrDSdsRMlZZitWb/8EG/f1acNdwxj+vafZZC+in2DZcmw9 PHXyJSeYLjq4yd1Ndb2rsCJhWAE3KKYgnS5gXPuQvEZDuP5t2MBmIiRrNHgi5bni WOlIOO5MvQF7bj5A6tHCCkKTZ8MmLz8HW8+v4x3oOuJl4YSRP/VmAP2qM0ZC7BY+ 0hNlLw4JU/bkKnUUnBkzFppF4dtXz8841Kf37VhD5A6YXMTgMT+UpG9LSqLVSo0m qR1kJQg1DecCAwEAAaNLMEkwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AQYwEwYDVR0jBAwwCoAITKPDaF4IAmMwEQYDVR0OBAoECEyjw2heCAJjMA0GCSqG SIb3DQEBCwUAA4IBgQAmI4W7XUEZbpKDiu4BiwQ1GX+rj9zWM8H5eZZeI/Xwzt3q 22E7Wq/dWOlCiUDv+dlnEX9N8e3pEXuxQQ/tpNIWtu/B/Yv2ESss7/wHBkYMzwIL 7Tvejwm5M6smgFREQmXX56/NUA7KyIihEpwqlTs+VDxIc/Z8eNSb/5P3ReQphGP8 +n4a51zgclewL3gdMMYT/YhfsWWI2l6XE4F7/h7Pe79XMMFwkkOmmfBVn5jFI0K9 dBwxjhKl2UVqKlrIWM291t0+NQsZfwMczgcPh0WTFaFrvTQc4N711LjlkRxLBbUn JrzP0QmYFsbh8VVLOntt3sZntsE3LZ+ojlnHt6bF798W4u3esrfzojakKDI6CpTL P17+blntujayk9bGwxn+9Zl460dH5a1Ceuy8e8kuQU5NDwQOikszh9zxdnxaGIyc ChLXorPChYeubTFQYjIhoGgWX5Q1dFUp0nGBCErh112qVAGzG3xZrr6sDMq4QGRn W53qBgYR1tAwcx7jvCs= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF7DCCA9SgAwIBAgIIAlQaqVDXzh8wDQYJKoZIhvcNAQELBQAwYTELMAkGA1UE BhMCQkUxJDAiBgNVBAoMG1pFVEVTIFNBIChWQVRCRS0wNDA4NDI1NjI2KTEMMAoG A1UEBRMDMDAxMR4wHAYDVQQDDBVaRVRFUyBUU1AgUk9PVCBDQSAwMDEwHhcNMTYw NTIwMTMyMzM4WhcNMzYwNTIwMTMyMzM4WjBhMQswCQYDVQQGEwJCRTEkMCIGA1UE CgwbWkVURVMgU0EgKFZBVEJFLTA0MDg0MjU2MjYpMQwwCgYDVQQFEwMwMDExHjAc BgNVBAMMFVpFVEVTIFRTUCBST09UIENBIDAwMTCCAiIwDQYJKoZIhvcNAQEBBQAD ggIPADCCAgoCggIBAKv5lg6EKHY1gSpWPwLt1fFwkQ5AlyJcu5bmmh4OPCuZPC9r NGGrB8xKJhVlngsozAA4D1v2rEZMxVwiiI4j1lYoXnXixE9S4zkEczk55k/386my IOoMJ9LH9HRzO+wkzmFsGpXb3FVCsRaUMfmmfIwU+DiifaC1OZzX1l+VL4VzUb+s qYgcHMkybDgAw6KwK9aPsobKujk4bGeDykeHV4udVqR/dk1IFRazwJeKwgz6ZLAg Q1aMaofDLSEXPl7gCKoat6qEPVYjK4Mx49MC2RIDBcI5r29TVhcDqyMcevC8CheV lyaB73ggPebf9Nq+jl9f0R79mXz3IW1ctwSWYsPTbh3K9++mRZNT3yZ75NRE121/ sFSZfrYn4sO+SmdCBa5qSvLulwZdZ56Bvl/oAFpUSrZM2RUuCPZCGiUZPiuBe1rc GfRqJwLdj5QCl+zilge0VubkLu/dLBaFCPoc9wCWfg7koPopgJC2RFN9O3UV71lG 4crc2JcbkElDly5YBXK0XTEGfTnhdP8aTE2VMuiNpa/0PHv/IBzL8LD3MvPmEsWh 1+SSGelJZ8A8f5u4gt4E8RVX1rAJHjk6a6bi+KafIXCZqLBZeRK6SEbm9XLMzNQP s7dMw6PfLpd4yF97KyEitT6yHNlrQ1GL2yBJjtpqEzQLO071a46HG07GSgArAgMB AAGjgacwgaQwHQYDVR0OBBYEFDi8XDBU3OK7IO/ub0GgMW5c/Yt1MA8GA1UdEwEB /wQFMAMBAf8wHwYDVR0jBBgwFoAUOLxcMFTc4rsg7+5vQaAxblz9i3UwQQYDVR0g BDowODA2BgRVHSAAMC4wLAYIKwYBBQUHAgEWIGh0dHBzOi8vcmVwb3NpdG9yeS50 c3AuemV0ZXMuY29tMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEA nDEKHd7KpKBSsJYq4Pws5aF23BQ4ZYazLtWll/NzYK5GaHWHsTPIEo3ZKaPqH71u /ronUIHhcWzOqzCcJppRcXBnH9FEpxQ0zUbdK+MOZb3GTkNoU7K4sT3wZD0Hh7H5 hzIEepbkQrswKMeaXStrx1AKIbaGIvYSrS4V8LtTqTDKLesCoZRnYxHYt+bzpwsG H5J5ofKrU3s/o0gITPtEAAP/yQDCbMJKxYbEs+pZXA595T+2qU+S4xEEXbd3xjXD sjFz2nfXP38QGa0AIt1DyOASfkSYOFHSOMi2QxpMUV2cOovIPHm43LAe693l5p5E m+lQPcsRvFX+x3RlZQgNpKp3PRwTtpyfFSr5TuE0gnA2c9I0GYRV8w3AT43/Vhaa W2US8DJBnBtYv72vMhB21y0PxTdx5hr9Mea0Nhhs+0v1qjWwbFAt51siSuD6nTkg QcYuACXkkd+bONMFm5z9BGiRuA6CXNg192LcyWAFi5XMP3zrj8b9mp+pbzIBVJpk pN3lxUVe6lXt4UPLreIebgqejjLk4668AdBTBA6dQk02+5nlGukH1FPwRQdCE8dr IT6Et/fFiVdTH/jzTlFb/mcyw1n2kRmIDYBs4d5FCkaZej/MPvAgbPi8z653LPtu 9QsRdouZzq6OM5F4CqUMJLNTD2sR6bOwHWQBLpQdIdU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGbzCCBFegAwIBAgIQQxwoxnQP7SVXRJ/y/Q5eFDANBgkqhkiG9w0BAQsFADB7 MQswCQYDVQQGEwJDTzFHMEUGA1UECgw+U29jaWVkYWQgQ2FtZXJhbCBkZSBDZXJ0 aWZpY2FjacOzbiBEaWdpdGFsIC0gQ2VydGljw6FtYXJhIFMuQS4xIzAhBgNVBAMM GkFDIFJhw616IENlcnRpY8OhbWFyYSBTLkEuMB4XDTE2MDUyNDE4Mzk0NloXDTMx MDUyNDE4Mzk0NlowezELMAkGA1UEBhMCQ08xRzBFBgNVBAoMPlNvY2llZGFkIENh bWVyYWwgZGUgQ2VydGlmaWNhY2nDs24gRGlnaXRhbCAtIENlcnRpY8OhbWFyYSBT LkEuMSMwIQYDVQQDDBpBQyBSYcOteiBDZXJ0aWPDoW1hcmEgUy5BLjCCAiIwDQYJ KoZIhvcNAQEBBQADggIPADCCAgoCggIBAKtriaNTzEgjCPvDz1GWCC64CHptPJAX hqnp7S4TNEey0HDcyTzQjcruSxer0IWwpyMEy6ii/OV120DKYomPUJ4BPSZbGIQc y3w3t33s039zGbBqstiIii1FdKj3s7jA1NrNIol0TVoVOXMYdE+165mnwR6ItMKT kGOX86enErIJIgcz2ZHNDpwfDiDH7rszjY/C0linX/1lN+KIwtiPhnVe+S2nhzPy eDcvi7wdhjc5sZTy2LxKnIMYWgb889TUuowVCSXw+baNBH4XEjNrV0hMT9smHuvM kOeL+Wh8cA+jKtA6ON83l+Jb3oBh04DYkYNCWkwEiWgRPKxfaIBBzGBCzg1aKgwP mzDApvCG26tJ15dtSIv5A8BSZ5sS98LyLphlQtnWmuPQGTEMrYfVVwJ6MOiGJvuP I4pUh+S/PO7rw3VIXx45b4FibMUtxBdUGbc3jZw3kcj2C9XqY2+DrDjC8z/emvvh I2HwyCbLNsih8zCPpKOiod1Ts97wmjIfg5F5MMGpH1ObU6IVUz/dnbMQO0h9iQ/8 7QP1+yVkdQ4XGQ2PABZneXpA/C1ZB9mQ+pqtPdyAiuZcNaJnTBFrsfiAZAAtbyJh xaxLJuVaEIKbpIN7NPeeiZEgl463Qsdmw9DppNb1II3Ew5WsRAqdW3M8Jj0vSr6n yacQHvufUGnzAgMBAAGjge4wgeswHQYDVR0OBBYEFNEJ0OnXznl0VPk6MLP0bSwD AxtoMIGoBgNVHSAEgaAwgZ0wgZoGBFUdIAAwgZEwMwYIKwYBBQUHAgEWJ2h0dHBz Oi8vd2ViLmNlcnRpY2FtYXJhLmNvbS9tYXJjby1sZWdhbDBaBggrBgEFBQcCAjBO GkxMaW1pdGFjaW9uZXMgZGUgZ2FyYW507WFzIGRlIGVzdGUgY2VydGlmaWNhZG8g c2UgcHVlZGVuIGVuY29udHJhciBlbiBsYSBEUEMuMA8GA1UdEwEB/wQFMAMBAf8w DgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQBRG5A+g1oa6Gpwpa1w /hCCYTCtjMO1xtjTMRLJH3lTFeJDx+EO8T1IzmgLFXlt4XaHf3Bm7fUPgqS8ce+x K8yxuusqqWAfQ4C+knJCLp+h/xgIsV5d9WzOKNvAbJrNh16W6cjvNZe6ZKq3fkVA ibBDg0574bT0dglLzFY+IUmyxp9j293wtg8X9bpMcI3VJwDJQ1QPZqq6rrHZdu4D ke2YtxobopZQblV/zV4Y0Gcbv/T6ctm72vvemqpRLgW6ztpqbRhoJmiChTTtTXna mnYN9PHUw/uxKnTskFLjDV31SVhUJwAwl6AjAWyJvx0A8f38GayfOymow4HNknH4 1+Wx2hs6F49T2qauAc6ynhrNCWLPddTXZ1Cin1n2hPPHJzGeqh4mS7oOiqzp9eNc HaEqNzm7NG4zltVxpUM+NjSH/5Kiq+kl4NlRd1Sqe0E0hljxquU+kt7INBCThD8m Rb1Sxjx29yEcruDhxaNT8gmffROeqfOyWYIUlE7fdqqD6SjaiohU+xRxqlA7viT9 xD5E+Jhk82qPYnWwrEdl9psiOiHhtVdBVsUk1hmSd3CwrBf0LpUQThIwmahURWEv N2/6iVdGMvRb6ZvtCSkvla6U4oeqHmpx6W8bOe38fNQNpk4jIjb5Zc9C8ByxM500 1YkkaeYXaKOZ73pcL/0gvXeZYA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFyDCCA7CgAwIBAgIQR0ORJD/Oww1XSChr7oBdqzANBgkqhkiG9w0BAQsFADBd MQswCQYDVQQGEwJFUzESMBAGA1UEBRMJUTI4NjMwMDZJMScwJQYDVQQKDB5DT05T RUpPIEdFTkVSQUwgREUgTEEgQUJPR0FDSUExETAPBgNVBAMMCEFDQSBST09UMB4X DTE2MDUyNzEwNTg1MVoXDTQxMDUyNzEwNTg1MVowXTELMAkGA1UEBhMCRVMxEjAQ BgNVBAUTCVEyODYzMDA2STEnMCUGA1UECgweQ09OU0VKTyBHRU5FUkFMIERFIExB IEFCT0dBQ0lBMREwDwYDVQQDDAhBQ0EgUk9PVDCCAiIwDQYJKoZIhvcNAQEBBQAD ggIPADCCAgoCggIBALkrXvU+uokenfXRE8+7o1666d85cmSYUodppbbe4b+URb7F +KRTZxVQ3FJPKnYsLo0gaozmXbnZaL6RG7ppAxitGE42oqxGqyD4A2qqrXnV3x3B 7kVvIXT5TbGxPZA7PDKA7f8Vz1HK16SHLqrlDrbRelrHufhRu9mU3T7Ghk4K/juJ 8vhuJM6RA1gFEkrdUKtBes7tqR8RUx6lE9th8PWqgN50eR2k4ynW++D8l9qiuKsi PmWwIcTlxRBEh7Lj4CqCLn3m9LikEyXzd2BfY1OuLrGdimt2ezpxvZKBNrCcgvH3 xYkoXf+8QgazCGpPYc2kLZDTObh3/8jHo3m7A7mRAwE0Etgwi7aMAsrkSOw4KjJM bcp2KFqGCrrUII6voF8gLWKciPnxFW1bvbEDUMA/NteuP1HRyuNYZkTmo5t3LjH6 2X8ixAVM63QbXGN6pgKTfkMOdhQPTW8ylYiAklKXFPU8/JQH02wpBZVGD+Rx4X/4 bRQSgpK181M+mRGXR3ZKCXLu1MOWCaza//FLS7bXJc8eTJcmCzS7tpTxLGRxX4ny FTs3pwLkDU9IiTOjjGh4MVFnChnbtOJ0Lz1683cAn3ESY/9zKmRpVOysOq7a8lhj NH74PF7AQjql27Oo1FrBTli4abasgmLb0fsaQyEi/B31nE9OO+WN/3ZaI15bAgMB AAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O BBYEFBpV5BUx4jGbEdSIcXoAPXAoBb/NMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkG CCsGAQUFBwIBFh1odHRwOi8vd3d3LmFjYWJvZ2FjaWEub3JnL2RvYzANBgkqhkiG 9w0BAQsFAAOCAgEAezDKVYbTr+4a17iVmOz5O92QE6OckkWgkolpoXGRvHGFh6At MAnkwlM99Km3aC1Nmc2kz547kJ2aCikNKkLBPVtrQILFixOxQWePvqR34MB25PO2 KVYs73FPwmTx2rQLytA5X1OygwH7sn3Zg3R6NdDBXY+b917nUt/uqjeTq9k9fR7x vRzb6HXduFtM4xaj9nWIDo88wwts22BZ5AWrKEb3Zmkld97KSjPYWF57j5rPUo49 bf3Rsr0+eVeGHkQcB030whCqeMvzURcNdj2NbmhJ6e8HSdG4Fsl5ncyuCwVHev2Y rDGhkFqHYvn4q2Ja4CF20GhC6By+coHwxmd9fnQ81VVvj6VolhHxytMwF71GtjGv cOmkhDdXugk8LtkLE1YHPpXEtXAvk8Kur4FdRhQw+67F85r3QXqx3ksW2UV1RwJ8 FB7VsTugLEG1m0t7o4PwuczOHpS3Xi4jBpWRHDhHHO3EeA6kD/wbfNbya9CKW+qW 8zHUXmrElLgwn5XhB4m4iNInhaRhdOWoRDF6IHXo+Njrs0+q/1M/lu3qu/xRQKYr 7CSh+/lEjSPnppcAD8ukar9QoMpxomyub9/Zg4Jm3FNdr/pU94P/qz+Jlae0bfMP Cg1IMy+BKcdLBcTGV3SEw5g2/++FMqtinBPRIoexvpjbdJqP6sLWk3lFIMM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDgDCCAmigAwIBAgIDDN+bMA0GCSqGSIb3DQEBCwUAMGgxCzAJBgNVBAYTAlNJ MRQwEgYDVQQKEwtIYWxjb20gZC5kLjEXMBUGA1UEYRMOVkFUU0ktNDMzNTMxMjYx KjAoBgNVBAMTIUhhbGNvbSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0x NjA2MTAwNzA3NTBaFw0zNjA2MTAwNzA3NTBaMGgxCzAJBgNVBAYTAlNJMRQwEgYD VQQKEwtIYWxjb20gZC5kLjEXMBUGA1UEYRMOVkFUU0ktNDMzNTMxMjYxKjAoBgNV BAMTIUhhbGNvbSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eTCCASIwDQYJKoZI hvcNAQEBBQADggEPADCCAQoCggEBAOlSpsYa72O7rYH0kLJajw3VFjO0HBj7y4kq MLtlgcTh+wKplAd25dcV5HpkEIDqPNCzoq2uHB/qu4FhmNT5jWmVxEUuAwnKhvpc WhEXQDA+8MZjCcnxjUGlVg0FZGlLWKwqKZa7QDMWNEtnbNfxtEal6lmoQ2gPjDgq qjz2RAOG+IrbRSErKR4St/qlZUHeBghYcJU+9EzZ6w8pqZGKnq3KEvXlleY42Rqm i5xPpkgTEKV5RL1qOyn1FndAy36bXN++i+vnoBlvnxU/J54psfUN/F9HojzdLgsC +/SN6uwMsfm0Baz5j6k9biwdOZ/QTp9OyGqegANh3M/4bZTLD88CAwEAAaMzMDEw DwYDVR0TAQH/BAUwAwEB/zARBgNVHQ4ECgQIQq6mQ8eYKLAwCwYDVR0PBAQDAgEG MA0GCSqGSIb3DQEBCwUAA4IBAQBSuXnQ22P+GYH7DPnB5VBZyp2y+1wz0Dioq7Ua TlMldSLTSb/Kgc/T4XujkUZ1yhrr2fVdvHuGNf2Bl5yE1yaYIvyxNdCplbZ8/+SX tEB+SV1oyOLUOXUnTwORsjFXv4bXbcpxACI30DtYJFCgnIyaiY71KEZs5xbtsIGr 9EYmr6boGkV3cBaSsntxcdz330lnwDMIDi5TwXerx0qRTBLv5w4J5XUxIK5u/FqK gJwQsNuoSszzK9w2NKb3qQtnnZDLPSafdc1MyR0GCnWLUsCB8NEmrMySphScXDwW QvuTzAKoE/PargrDuBX0sNDU4BYgT6xQmHgmlB5o65Ry/veL -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICODCCAb6gAwIBAgIJANZdm7N4gS7rMAoGCCqGSM49BAMDMGExCzAJBgNVBAYT AkpQMSUwIwYDVQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMSswKQYD VQQDEyJTZWN1cml0eSBDb21tdW5pY2F0aW9uIEVDQyBSb290Q0ExMB4XDTE2MDYx NjA1MTUyOFoXDTM4MDExODA1MTUyOFowYTELMAkGA1UEBhMCSlAxJTAjBgNVBAoT HFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKzApBgNVBAMTIlNlY3VyaXR5 IENvbW11bmljYXRpb24gRUNDIFJvb3RDQTEwdjAQBgcqhkjOPQIBBgUrgQQAIgNi AASkpW9gAwPDvTH00xecK4R1rOX9PVdu12O/5gSJko6BnOPpR27KkBLIE+Cnnfdl dB9sELLo5OnvbYUymUSxXv3MdhDYW72ixvnWQuRXdtyQwjWpS4g8EkdtXP9JTxpK ULGjQjBAMB0GA1UdDgQWBBSGHOf+LaVKiwj+KBH6vqNm+GBZLzAOBgNVHQ8BAf8E BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjAVXUI9/Lbu 9zuxNuie9sRGKEkz0FhDKmMpzE2xtHqiuQ04pV1IKv3LsnNdo4gIxwwCMQDAqy0O be0YottT6SXbVQjgUMzfRGEWgqtJsLKB7HOHeLRMsmIbEvoWTSVLY70eN9k= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFfzCCA2egAwIBAgIJAOF8N0D9G/5nMA0GCSqGSIb3DQEBDAUAMF0xCzAJBgNV BAYTAkpQMSUwIwYDVQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMScw JQYDVQQDEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTMwHhcNMTYwNjE2 MDYxNzE2WhcNMzgwMTE4MDYxNzE2WjBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UEAxMeU2VjdXJpdHkg Q29tbXVuaWNhdGlvbiBSb290Q0EzMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC CgKCAgEA48lySfcw3gl8qUCBWNO0Ot26YQ+TUG5pPDXC7ltzkBtnTCHsXzW7OT4r CmDvu20rhvtxosis5FaU+cmvsXLUIKx00rgVrVH+hXShuRD+BYD5UpOzQD11EKzA lrenfna84xtSGc4RHwsENPXY9Wk8d/Nk9A2qhd7gCVAEF5aEt8iKvE1y/By7z/MG TfmfZPd+pmaGNXHIEYBMwXFAWB6+oHP2/D5Q4eAvJj1+XCO1eXDe+uDRpdYMQXF7 9+qMHIjH7Iv10S9VlkZ8WjtYO/u62C21Jdp6Ts9EriGmnpjKIG58u4iFW/vAEGK7 8vknR+/RiTlDxN/e4UG/VHMgly1s2vPUB6PmudhvrvyMGS7TZ2crldtYXLVqAvO4 g160a75BflcJdURQVc1aEWEhCmHCqYj9E7wtiS/NYeCVvsq1e+F7NGcLH7YMx3we GVPKp7FKFSBWFHA9K4IsD50VHUeAR/94mQ4xr28+j+2GaR57GIgUssL8gjMunEst +3A7caoreyYn8xrC3PsXuKHqy6C0rtOUfnrQq8PsOC0RLoi/1D+tEjtCrI8Cbn3M 0V9hvqG8OmpI6iZVIhZdXw3/JzOfGAN0iltSIEdrRU0id4xVJ/CvHozJgyJUt5rQ T9nO/NkuHJYosQLTA70lUhw0Zk8jq/R3gpYd0VcwCBEF/VfR2ccCAwEAAaNCMEAw HQYDVR0OBBYEFGQUfPxYchamCik0FW8qy7z8r6irMA4GA1UdDwEB/wQEAwIBBjAP BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQDcAiMI4u8hOscNtybS YpOnpSNyByCCYN8Y11StaSWSntkUz5m5UoHPrmyKO1o5yGwBQ8IibQLwYs1OY0PA FNr0Y/Dq9HHuTofjcan0yVflLl8cebsjqodEV+m9NU1Bu0soo5iyG9kLFwfl9+qd 9XbXv8S2gVj/yP9kaWJ5rW4OH3/uHWnlt3Jxs/6lATWUVCvAUm2PVcTJ0rjLyjQI UYWg9by0F1jqClx6vWPGOi//lkkZhOpn2ASxYfQAW0q3nHE3GYV5v4GwxxMOdnE+ OoAGrgYWp421wsTL/0ClXI2lyTrtcoHKXJg80jQDdwj98ClZXSEIx2C/pHF7uNke gr4Jr2VvKKu/S7XuPghHJ6APbw+LP6yVGPO5DtxnVW5inkYO0QR4ynKudtml+LLf iAlhi+8kTtFZP1rUPcmTPCtk9YENFpb3ksP+MW/oKjJ0DvRMmEoYDjBU1cXrvMUV nuiZIesnKwkK2/HmcBhWuwzkvvnoEKQTkrgc4NtnHVMDpCKn3F2SEDzq//wbEBrD 2NCcnWXL0CsnMQMeNuE9dnUM/0Umud1RvCPHX9jYhxBAEg09ODfnRDwYwFMJZI// 1ZqmfHAuc1Uh6N//g7kdPjIe1qZ9LPFm6Vwdp6POXiUyK+OVrCoHzrQoeIY8Laad TdJ0MN1kURXbg4NR16/9M51NZg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWjCCA0KgAwIBAgIQbkepxUtHDA3sM9CJuRz04TANBgkqhkiG9w0BAQwFADBH MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIy MDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNl cnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaM f/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vX mX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7 zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0P fyblqAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtc vfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4 Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUsp zBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOO Rc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYW k70paDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+ DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgF lQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBADiW Cu49tJYeX++dnAsznyvgyv3SjgofQXSlfKqE1OXyHuY3UjKcC9FhHb8owbZEKTV1 d5iyfNm9dKyKaOOpMQkpAWBz40d8U6iQSifvS9efk+eCNs6aaAyC58/UEBZvXw6Z XPYfcX3v73svfuo21pdwCxXu11xWajOl40k4DLh9+42FpLFZXvRq4d2h9mREruZR gyFmxhE+885H7pwoHyXa/6xmld01D1zvICxi/ZG6qcz8WpyTgYMpl0p8WnK0OdC3 d8t5/Wk6kjftbjhlRn7pYL15iJdfOBL07q9bgsiG1eGZbYwE8na6SfZu6W0eX6Dv J4J2QPim01hcDyxC2kLGe4g0x8HYRZvBPsVhHdljUEn2NIVq4BjFbkerQUIpm/Zg DdIx02OYI5NaAIFItO/Nis3Jz5nu2Z6qNuFoS3FJFDYoOj0dzpqPJeaAcWErtXvM +SUWgeExX6GjfhaknBZqlxi9dnKlC54dNuYvoS++cJEPqOba+MSSQGwlfnuzCdyy F62ARPBopY+Udf90WuioAnwMCeKpSwughQtiue+hMZL77/ZRBIls6Kl0obsXs7X9 SQ98POyDGCBDTtWTurQ0sR8WNh8M5mQ5Fkzc4P4dyKliPUDqysU0ArSuiYgzNdws E3PYJ/HQcu51OyLemGhmW/HGY0dVHLqlCFF1pkgl -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICCjCCAZGgAwIBAgIQbkepyIuUtui7OyrYorLBmTAKBggqhkjOPQQDAzBHMQsw CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU MBIGA1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQA IgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzu hXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/l xKvRHYqjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud DgQWBBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNnADBkAjBqUFJ0 CMRw3J5QdCHojXohw0+WbhXRIjVhLfoIN+4Zba3bssx9BzT1YBkstTTZbyACMANx sbqjYAuG7ZoIapVon+Kz4ZNkfF6Tpt95LY2F45TPI11xzPKwTdb+mciUqXWi4w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWjCCA0KgAwIBAgIQbkepxlqz5yDFMJo/aFLybzANBgkqhkiG9w0BAQwFADBH MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIy MDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNl cnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3Lv CvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3Kg GjSY6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9Bu XvAuMC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOd re7kRXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXu PuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1 mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K 8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqj x5RWIr9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsR nTKaG73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0 kzCqgc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9Ok twIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBALZp 8KZ3/p7uC4Gt4cCpx/k1HUCCq+YEtN/L9x0Pg/B+E02NjO7jMyLDOfxA325BS0JT vhaI8dI4XsRomRyYUpOM52jtG2pzegVATX9lO9ZY8c6DR2Dj/5epnGB3GFW1fgiT z9D2PGcDFWEJ+YF59exTpJ/JjwGLc8R3dtyDovUMSRqodt6Sm2T4syzFJ9MHwAiA pJiS4wGWAqoC7o87xdFtCjMwc3i5T1QWvwsHoaRc5svJXISPD+AVdyx+Jn7axEvb pxZ3B7DNdehyQtaVhJ2Gg/LkkM0JR9SLA3DaWsYDQvTtN6LwG1BUSw7YhN4ZKJmB R64JGz9I0cNv4rBgF/XuIwKl2gBbbZCr7qLpGzvpx0QnRY5rn/WkhLx3+WuXrD5R RaIRpsyF7gpo8j5QOHokYh4XIDdtak23CZvJ/KRY9bb7nE4Yu5UC56GtmwfuNmsk 0jmGwZODUNKBRqhfYlcsu2xkiAhu7xNUX90txGdj08+JN7+dIPT7eoOboB6BAFDC 5AwiWVIQ7UNWhwD4FFKnHYuTjKJNRn8nxnGbJN7k2oaLDX5rIMHAnuFl2GqjpuiF izoHCBy69Y9Vmhh1fuXsgWbRIXOhNUQLgD1bnF5vKheW0YMjiGZt5obicDIvUiLn yOd/xCxgXS/Dr55FBcOEArf9LAhST4Ldo/DUhgkC -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICDDCCAZGgAwIBAgIQbkepx2ypcyRAiQ8DVd2NHTAKBggqhkjOPQQDAzBHMQsw CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU MBIGA1UEAxMLR1RTIFJvb3QgUjMwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjOPQIBBgUrgQQA IgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout 736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2A DDL24CejQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud DgQWBBTB8Sa6oC2uhYHP0/EqEr24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEAgFuk fCPAlaUs3L6JbyO5o91lAFJekazInXJ0glMLfalAvWhgxeG4VDvBNhcl2MG9AjEA njWSdIUlUfUk7GRSJFClH9voy8l27OyCbvWFGFPouOOaKaqW04MjyaR7YbPMAuhd -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF4zCCA8ugAwIBAgIEV8fs9DANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJT RzEYMBYGA1UEChMPTmV0cnVzdCBQdGUgTHRkMSYwJAYDVQQLEx1OZXRydXN0IENl cnRpZmljYXRlIEF1dGhvcml0eTEaMBgGA1UEAxMRTmV0cnVzdCBSb290IENBIDIw HhcNMTYwOTAxMDgyNTE3WhcNNDEwOTAxMDg1NTE3WjBrMQswCQYDVQQGEwJTRzEY MBYGA1UEChMPTmV0cnVzdCBQdGUgTHRkMSYwJAYDVQQLEx1OZXRydXN0IENlcnRp ZmljYXRlIEF1dGhvcml0eTEaMBgGA1UEAxMRTmV0cnVzdCBSb290IENBIDIwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDV39ONmRdqmz3gsGnbtAXMvqUg +E8NB7MZPJeDPey8uVwMrKIDZKN/DHcT5siHq1IYTzDv6g7dgveVDzCKwBlQvGBl odwRxn8W8RuY5CJXUUKMynCWXG4NuY9naloFm98ePzjjqiVGwZwrkn/0grEjPN1s Z2ABVPLkqhD9o4p3JyGe1j3dRlwFPxgIFgplyAxNT2Y9XhZfFw8O/8EXC+cid18a C3hpp8oGj17F30CzDvjg12g+cUHJn41h60uZ4K8zAHetxBZZZgg2p0rkUixZP3t8 OEPkC6PT5Yl4U+ZrvPUnMOggNg6xDI4OFMhUNwd6rujTtsBGTMe1MS51/FHyqmz4 GKsmhWC/ELnDQRNf9HnBCfaRrPeOxY9INakW3R7gX4XzGrM/gVvRfkLu5BtnRGy5 wen7kHQ/lE6TybTpfUJLHfCnlptIfaKQXLQUcCCpCASL0nyy0glMI2ypMZPWKYFF LsPkqqbvvZvxy64Ct2RdgD1BTYlLi5qct4FvX9xoU4aKcXTSVxcyg77V9Hrbmu4N CtVjq9QR5cxdbT7Bj/SPTl0SJkTPLX1XekED2c0eOC8Q1JShNXI6Yd7uQ4tIKdJ2 4S1RLtS+vIDb/02LXw0wraMwpTDr1SRnljz6gW249RiBzMW2QgfzvITmHF6D1Gka uELq29THck1NpZm/owIDAQABo4GOMIGLMA8GA1UdEwEB/wQFMAMBAf8wKwYDVR0Q BCQwIoAPMjAxNjA5MDEwODI1MTdagQ8yMDQxMDkwMTA4NTUxN1owCwYDVR0PBAQD AgEGMB8GA1UdIwQYMBaAFDofR9lvhhjpKfr+Oc7L7YrJVlUrMB0GA1UdDgQWBBQ6 H0fZb4YY6Sn6/jnOy+2KyVZVKzANBgkqhkiG9w0BAQsFAAOCAgEARbJm3IEyIRyA mmkJ9aaUVVkB93asquqINx6sVfVKH26JV6OiBuudmCkasa0EVtruWDtoKm7j+QSP KlKbW+wQ/kwors+qFCzeFgJAU/3XXGAZ5UWWkuzjHhDf+RtK1aS/opcp20BBb9qu 7AmBukLwJDN+wFVssEd2Yo1Y6oG5FpkTBxou/xUqrWW7u9JNjCNVuxYo9SkZnsn8 avw+o+4XAgwTNJkvreeu4kA8dgxKsYQ5Ke3DPbiox5ZA/rK8t3LsoU++Pnf4fY7o Dqa5IsPkt5FkD/2RjaWoL4POYf1Z3mNpo4YwbsXubM+272ZcXvZ1Uf2YSCM4yb/p dQb9cWwhf/zJGceoAMYqXACd+vLkc0i1eIteq+l07Cvjph38Kdbhd1GXikEwzNHM k+rJT8V+caOm2Whsbn9Duxa9RbwBQp4O5x/Zn9q+GDfH1COy7jIMy2/owbhGasW4 BzI5zUq+w757LqLd8qtL2qbOkF49c35RlNLeL8dxFDaRV/VdpMvtxgIxaML7RfVa c/p7oT+o+W3NN9/APyjxvZKAuaCZo5JXcuXrsgXOzEYbobD3w4j1CCR1ZIc/K9MB Z1KPSTADjsdBUW2EmR4blEU+HkRHxSnM+gZp+Usn3GSkFkFrZuPN+c1+9a8nLZ3P 7naLqfk3x/LtOfB6wiMDtoXZPJRBvNM= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIEIjCCAwqgAwIBAgIUKeuSM0ZPMkH/gxkAqa3E2fjj4n8wDQYJKoZIhvcNAQEL BQAwcTELMAkGA1UEBhMCQVUxDDAKBgNVBAoTA0dPVjEMMAoGA1UECxMDRG9EMQww CgYDVQQLEwNQS0kxDDAKBgNVBAsTA0NBczEqMCgGA1UEAxMhQXVzdHJhbGlhbiBE ZWZlbmNlIFB1YmxpYyBSb290IENBMB4XDTE2MTEyODIyMjUyOFoXDTM2MTEyODIy MTM0OFowcTELMAkGA1UEBhMCQVUxDDAKBgNVBAoTA0dPVjEMMAoGA1UECxMDRG9E MQwwCgYDVQQLEwNQS0kxDDAKBgNVBAsTA0NBczEqMCgGA1UEAxMhQXVzdHJhbGlh biBEZWZlbmNlIFB1YmxpYyBSb290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEA005UBBvQ9JuduCOH4CDHnpixcXoGkC7irUj+kwVs7Ia/KECFs0x5 70dTmBAeVO59eLgYEwxEUv3QgaqTCCM5vl8Pa90ll/MBQt/UgQDEUL56iS0Zr3NK P8w6wL+iqMUV9z58QXSCay53ZuJqpZGIbgYxp68L5lrgrn1ary9H0PL7hHOcRqEe hERRxF8u2pACX4HfEQ7S+7s6F3Oj8o1jqk//cnplYoNaKjzyzSwjjc/rIR+/1ANX 9TcWDF7lVxHCqPr/bDnyPVLmtXnAW+Ky6mMgDA6lKl4S4eavX4t8oK05NTWYX/Gv ONAm0029Ynd1Pa9rFIZ7WvYhj9bq4qcOrQIDAQABo4GxMIGuMA8GA1UdEwEB/wQF MAMBAf8wSwYDVR0gBEQwQjAGBgRVHSAAMDgGCSokAYJOAQEBBzArMCkGCCsGAQUF BwIBFh1odHRwOi8vY3JsLmRlZmVuY2UuZ292LmF1L3BraTAOBgNVHQ8BAf8EBAMC AcYwHwYDVR0jBBgwFoAUrJnhAi/oXEtBtzS4HumbgzYNlLQwHQYDVR0OBBYEFKyZ 4QIv6FxLQbc0uB7pm4M2DZS0MA0GCSqGSIb3DQEBCwUAA4IBAQB4vIFK2DpXu70m v+oqKPCIivJQTJBn2kv1uBQIutt/cqiaWbzxHImo9DoDEFQTel3G2ro+D4jVatMb ly1iYTpv+QCvcgZz7BDAYR7MXE8ZMkY4wd0/0jcapY6GoPAJzDXWGQJ8zTn89/kf 55R5Tj23+JdOO0RqzZSwufd+4uP5mX/F06ZQtEn7Fn5OQSzPPsd5QLqBGCYI+cWd 49jxbxxoP2pbdxdSowbeGcJLbqKV/NUIvyy1aTVR4+PfTxopbYN4PTgkygI/VBDh s2Th1Zre8zf2MxC1drOr18kfUzqtVUEcSMk2nof/ddxp0K/ZelfGyrFD/DmB/Nx6 o5qlmFBU -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICvjCCAiCgAwIBAgIEBfXhATAKBggqhkjOPQQDBDB4MQswCQYDVQQGEwJDWjEt MCsGA1UECgwkUHJ2bsOtIGNlcnRpZmlrYcSNbsOtIGF1dG9yaXRhLCBhLnMuMRcw FQYDVQRhDA5OVFJDWi0yNjQzOTM5NTEhMB8GA1UEAwwYSS5DQSBSb290IENBL0VD QyAxMi8yMDE2MB4XDTE2MTIwNzExMDAwMFoXDTQxMTIwNzExMDAwMFoweDELMAkG A1UEBhMCQ1oxLTArBgNVBAoMJFBydm7DrSBjZXJ0aWZpa2HEjW7DrSBhdXRvcml0 YSwgYS5zLjEXMBUGA1UEYQwOTlRSQ1otMjY0MzkzOTUxITAfBgNVBAMMGEkuQ0Eg Um9vdCBDQS9FQ0MgMTIvMjAxNjCBmzAQBgcqhkjOPQIBBgUrgQQAIwOBhgAEAftR Bb2dghxXs6Ux+c+wN9n65c7jLZWUzLty376ONIGEtyRBKRZ6cJRb0nPN7MahIa1r p+62J9aNMH5pabDyMw/aAagmk+jmrpgBSfOx97Rn4Ykjru9oJMYpeC2IoDlPQ9vB 3/JU/EF6lzO/10wdL1vKoOR1BmkYFu6f6wziidk9tmfQo1UwUzAOBgNVHQ8BAf8E BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUddg3MwTzndDrHQqP5+Ym zNBNKyowEQYDVR0gBAowCDAGBgRVHSAAMAoGCCqGSM49BAMEA4GLADCBhwJBGieo oGlHxjtDibWSwrV99tHrZTmU4EsvGb4vctlUlmnhRwEBp4tsf8PF8Ra2TbowhgS0 y/N0XUH9Dn0I7ein2l0CQgGGuyiX8t/fYzue3h+GvevqS0lw2n4E8ea5yLUKNM0A B2eYVTxHkwWvbgOgl8nwCtsTSq1HleJIspSWOPt9F3Mf0g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNV BAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04g Uk9PVCBDQSBHMjAeFw0xNzAyMDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJ BgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJ R04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDF dRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05N0Iw vlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZ uIt4ImfkabBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhp n+Sc8CnTXPnGFiWeI8MgwT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKs cpc/I1mbySKEwQdPzH/iV8oScLumZfNpdWO9lfsbl83kqK/20U6o2YpxJM02PbyW xPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91QqhngLjYl/rNUssuHLoPj1P rCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732jcZZroiF DsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fx DTvf95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgy LcsUDFDYg2WD7rlcz8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6C eWRgKRM+o/1Pcmqr4tTluCRVLERLiohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB /wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSCIS1mxteg4BXrzkwJ d8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOBywaK8SJJ6ejq kX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQl qiCA2ClV9+BB/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0 OJD7uNGzcgbJceaBxXntC6Z58hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+c NywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5BiKDUyUM/FHE5r7iOZULJK2v0ZXk ltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklWatKcsWMy5WHgUyIO pwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tUSxfj 03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZk PuXaTH4MNMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE 1LlSVHJ7liXMvGnjSG4N0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MX QRBdJ3NghVdJIgc= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDZDCCAkygAwIBAgILMaXzypDqI6zSnr0wDQYJKoZIhvcNAQELBQAwPjELMAkG A1UEBhMCSlAxDjAMBgNVBAoTBUxHUEtJMR8wHQYDVQQDExZBcHBsaWNhdGlvbiBD QSBHNCBSb290MB4XDTE3MDIxNTE1MDAwMFoXDTM3MDIxNTE0NTk1OVowPjELMAkG A1UEBhMCSlAxDjAMBgNVBAoTBUxHUEtJMR8wHQYDVQQDExZBcHBsaWNhdGlvbiBD QSBHNCBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr6vH5Yym WJ4v1gXJkXcwvt4a1A5jYtHMLbHRhjiNHYVmU5+qQWXgWNLlKb6UqJWTPF9qxZuf NOhtwcbTp+VDoBIwwDk0YAyL9Gj1SN/pjhyuSKe7qj14t+JJu8EjBFobkAHFfatK AaHCk2rShbO253bra2846yBSMJUI9fks7sjAdbkB7cE3VjBcnX9kwspAILmVhbyl B30Mvi6h3cYm6SopbJ8omClR6HYTG+8uCzdaM57AJWeqDy2o1JImOAGn0GIYLiI4 OHgLulKZoXwmArHixeLezooCRISio+mLiGMxyS84AOnEAk0eIycSSNwRsfDS4g4w Ga8DoQezNZQipQIDAQABo2MwYTAdBgNVHQ4EFgQUbtwKNR8gwuih030FTk9MYOWk xGcwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU btwKNR8gwuih030FTk9MYOWkxGcwDQYJKoZIhvcNAQELBQADggEBAFUz1UC3Gn5P 3HSDDkS6P71SlciTliPyAbkU68oSdM1hiDSvTV70WYqrHtjjWcEe+DC1QMa7uK/R 7T9sqnOYguSYNK6SQQ5ZNhq6UBwW9Bc6LBvil2+yr9Ha3hRS34A8x089h566lb14 vFU8ifYuJtUV5dBAEsWzcT9sZh+j/Eu1TuJu3IAHw/koFHv3XhZqQ6eukQEfT2Wp SLPObhoGIaTTMYiIpUkRgmvruZ1g/p/+xff4f6s37q/nWEa6CeRdOadLBNgDAslg Kl5VaRELYHiBevRx9Y9Gro8EqJccgIkjY9v+66YXDlm2LrmG619ebN2B56swgSOQ J7H3K5A5C7g= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQsw CQYDVQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91 bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwg Um9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRaFw00MjA1MDkwOTU4MzNaMG0xCzAJ BgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQLExlPSVNURSBGb3Vu ZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2JhbCBS b290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4ni eUqjFqdrVCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4W p2OQ0jnUsYd4XxiWD1AbNTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8E BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7T rYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0EAwMDaAAwZQIwJsdpW9zV 57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtkAjEA2zQg Mgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNV BAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UE CgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2Vy dGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMB4XDTE3MDUzMTE4MTQzN1oXDTQy MDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQIDAVUZXhhczEQMA4G A1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYDVQQD DC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvq M0fNTPl9fb69LT3w23jhhqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssuf OePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7wcXHswxzpY6IXFJ3vG2fThVUCAtZJycxa 4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTOZw+oz12WGQvE43LrrdF9 HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+B6KjBSYR aZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcA b9ZhCBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQ Gp8hLH94t2S42Oim9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQV PWKchjgGAGYS5Fl2WlPAApiiECtoRHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMO pgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+SlmJuwgUHfbSguPvuUCYHBBXtSu UDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48+qvWBkofZ6aY MBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa4 9QaAJadz20ZpqJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBW s47LCp1Jjr+kxJG7ZhcFUZh1++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5 Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nxY/hoLVUE0fKNsKTPvDxeH3jnpaAg cLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2GguDKBAdRUNf/ktUM 79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDzOFSz /bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXt ll9ldDz7CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEm Kf7GUmG6sXP/wwyc5WxqlD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKK QbNmC1r7fSOl8hqw/96bg5Qu0T/fkreRrwU7ZcegbLHNYhLDkBvjJc40vG93drEQ w/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1hlMYegouCRw2n5H9gooi S9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX9hwJ1C07 mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQEL BQAwbzELMAkGA1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJ SG9uZyBLb25nMRYwFAYDVQQKEw1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25n a29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2MDMwMjI5NDZaFw00MjA2MDMwMjI5 NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtvbmcxEjAQBgNVBAcT CUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMXSG9u Z2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK AoICAQCziNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFO dem1p+/l6TWZ5Mwc50tfjTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mI VoBc+L0sPOFMV4i707mV78vH9toxdCim5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV 9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOesL4jpNrcyCse2m5FHomY 2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj0mRiikKY vLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+Tt bNe/JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZb x39ri1UbSsUgYT2uy1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+ l2oBlKN8W4UdKjk60FSh0Tlxnf0h+bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YK TE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsGxVd7GYYKecsAyVKvQv83j+Gj Hno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwIDAQABo2MwYTAP BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEw DQYJKoZIhvcNAQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG 7BJ8dNVI0lkUmcDrudHr9EgwW62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCk MpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWldy8joRTnU+kLBEUx3XZL7av9YROXr gZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov+BS5gLNdTaqX4fnk GMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDceqFS 3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJm Ozj/2ZQw9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+ l6mc1X5VTMbeRRAc6uk7nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6c JfTzPV4e0hz5sy229zdcxsshTrD3mUcYhcErulWuBurQB7Lcq9CClnXO0lD+mefP L5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB60PZ2Pierc+xYw5F9KBa LJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fqdBb9HxEG mpv0 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEM BQAwaTELMAkGA1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRG T1JNIENvcnAuMTIwMAYDVQQDDClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0 aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4NDJaFw0zNzA4MTgyMzU5NTlaMGkx CzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVTUyBQTEFURk9STSBD b3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlvbiBB dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVA iQqrDZBbUGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH 38dq6SZeWYp34+hInDEW+j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lE HoSTGEq0n+USZGnQJoViAbbJAh2+g1G7XNr4rRVqmfeSVPc0W+m/6imBEtRTkZaz kVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2aacp+yPOiNgSnABIqKYP szuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4Yb8Obtoq vC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHf nZ3zVHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaG YQ5fG8Ir4ozVu53BA0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo 0es+nPxdGoMuK8u180SdOqcXYZaicdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3a CJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejyYhbLgGvtPe31HzClrkvJE+2K AQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNVHQ4EFgQU0p+I 36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoN qo0hV4/GPnrK21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatj cu3cvuzHV+YwIHHW1xDBE1UBjCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm +LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bxhYTeodoS76TiEJd6eN4MUZeoIUCL hr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTgE34h5prCy8VCZLQe lHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTHD8z7 p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8 piKCk5XQA76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLR LBT/DShycpWbXgnbiUSYqqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX 5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oGI/hGoiLtk/bdmuYqh7GYVPEi92tF4+KO dh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmgkpzNNIaRkPpkUZ3+/uul 9XXeifdy -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNV BAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRk LjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJv b3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZaFw00MjA4MjIxMjA3MDZaMHExCzAJ BgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMg THRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25v IFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtv xie+RJCxs1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+H Wyx7xf58etqjYzBhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G A1UdDgQWBBSHERUI0arBeAyxr87GyZDvvzAEwDAfBgNVHSMEGDAWgBSHERUI0arB eAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEAtVfd14pVCzbhhkT61Nlo jbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxOsvxyqltZ +efcMQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQsw CQYDVQQGEwJVUzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28x ITAfBgNVBAoMGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1 c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMx OTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJVUzERMA8GA1UECAwI SWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2ZSBI b2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZp Y2F0aW9uIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB ALldUShLPDeS0YLOvR29zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0Xzn swuvCAAJWX/NKSqIk4cXGIDtiLK0thAfLdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu 7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4BqstTnoApTAbqOl5F2brz8 1Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9oWN0EACyW 80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotP JqX+OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1l RtzuzWniTY+HKE40Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfw hI0Vcnyh78zyiGG69Gm7DIwLdVcEuE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10 coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm+9jaJXLE9gCxInm943xZYkqc BW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqjifLJS3tBEW1n twiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1Ud DwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W 0OhUKDtkLSGm+J1WE2pIPU/HPinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfe uyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0HZJDmHvUqoai7PF35owgLEQzxPy0Q lG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla4gt5kNdXElE1GYhB aCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5RvbbE sLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPT MaCm/zjdzyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qe qu5AvzSxnI9O4fKSTx+O856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxh VicGaeVyQYHTtgGJoC86cnn+OjC/QezHYj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8 h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu3R3y4G5OBVixwJAWKqQ9 EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP29FpHOTK yeC2nOnOcXHebD8WpHk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYD VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x NzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYDVQQGEwJVUzERMA8G A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF Q0MgUDI1NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqG SM49AwEHA0IABH77bOYj43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoN FWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqmP62jQzBBMA8GA1UdEwEB/wQFMAMBAf8w DwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt0UrrdaVKEJmzsaGLSvcw CgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjzRM4q3wgh DDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYD VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAf BgNVBAoTGFRydXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3 YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0x NzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYDVQQGEwJVUzERMA8G A1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0 d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBF Q0MgUDM4NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuB BAAiA2IABGvaDXU1CDFHBa5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJ j9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr/TklZvFe/oyujUF5nQlgziip04pt89ZF 1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNVHQ8BAf8EBQMDBwYAMB0G A1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNnADBkAjA3 AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsC MGclCrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVu Sw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGUTCCBDmgAwIBAgIQaF3MJjngI2bkSp1k044ENTANBgkqhkiG9w0BAQsFADCB sTELMAkGA1UEBhMCWkExEDAOBgNVBAgMB0dhdXRlbmcxFTATBgNVBAcMDEpvaGFu bmVzYnVyZzEdMBsGA1UECgwUVHJ1c3RGYWN0b3J5KFB0eSlMdGQxJDAiBgNVBAsM G1RydXN0RmFjdG9yeSBQS0kgT3BlcmF0aW9uczE0MDIGA1UEAwwrVHJ1c3RGYWN0 b3J5IFNTTCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xNzEyMDUxMDU5 MjlaFw00NzExMjgxMDU5MjlaMIGxMQswCQYDVQQGEwJaQTEQMA4GA1UECAwHR2F1 dGVuZzEVMBMGA1UEBwwMSm9oYW5uZXNidXJnMR0wGwYDVQQKDBRUcnVzdEZhY3Rv cnkoUHR5KUx0ZDEkMCIGA1UECwwbVHJ1c3RGYWN0b3J5IFBLSSBPcGVyYXRpb25z MTQwMgYDVQQDDCtUcnVzdEZhY3RvcnkgU1NMIFJvb3QgQ2VydGlmaWNhdGUgQXV0 aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAldFHKO7wVLzr vdWrBn4lpAOq/WB6zje5adopeXdsPX+CNMJd/kKkDUFaANKDpGptweXIUWL6a9XG R9w4bhGQjGgVz+m6WOaai4WBEC3P51NJ6aM3Igy8dLK2JVIRz6IhPImg16QdIxBr HVk7N/RdNjhAtXVCry0aB7yNYxTYSvgime/AWklvq5I/S+ykahg/US7TIOdPLoMG Ol5/FYvP+jUuU7lqGs+n+Dy5yXMXOv2tDVjNknXqP/+5hvP+1aD1Zepj1vqGEbR0 1bVYhKotXUoXvuymJNegvbcYOBZnbhGFW19gUovRz+VC0Jxe9Y6FvfKGbKhV3Osd ev2sKPDE0sepB9ddPhdWlEbum8rEsIwaatfPm86mTC2A+J3xI0CaQCs4VR41A911 2zHUToonb5eOnMx2mR1WrjJMF9kZr6ikzAvKAnUBTj28FPSqO5vQT7fn/lrEztYM czOsqc0six0NIflh5qF24q7wdEkB/DnfqBOSyGOJXrUQ8R0h9tMY+3dMaeJqzOB5 rE6bZM/o4vMiooeenhskDHFm5el25GRUm80N9lF9u58AWh50tNCrjR2rCO8rwtu9 g2HXyWS8D24XxjLfDPOmXu7sIAwqz3pFUHsY1vsSduGvWR+B2jSCNkW/kslVpdZ1 BlmHm6SD3q14eWw8qI+d7lzsPOOJoisCAwEAAaNjMGEwHQYDVR0OBBYEFEI6XjZa 3Buq0KLq9fFEf3Qlc+m9MB8GA1UdIwQYMBaAFEI6XjZa3Buq0KLq9fFEf3Qlc+m9 MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA A4ICAQAGOiJqHPwbet1ov9VKqL4LYthqZ0k0YBxbs+0lvjYOIFd1A4foZnesd9V3 YZRt6HRxVGv0/Lbyi4pnXx0ECD/+gSDtjzzXR3ZYQtFqxzF0fjRNpntFUXAT+EZE R88N2pYUxoJWPoUa6LKln3/ND2yDguIYB9xmXIrKXaiEL1SMg/DFPEAgMuJP6Fbr lcLkxlD+IuivAVIrla6GVpWnex7GN+419vf7NtDgKt0wMsNtFCXHVdJrI2+QKgpj lnpm6N2Asnn/k2htD7EUU+XOe0zQwSMLOoPkzI773C7ZdFLgUL26Sfh2NBYfaSv0 KIYdTDQVF9p0qHCWXT/CHccEh1Wia7Gy9TVWYru79UfsgrRmahNIeFRjz1+A7JhG xEnJ9KQrlSXHwKPbVly9qva5N+LaROUNS4d5naadH60P/c7pZq3xBJRVSNerJ5Zh Vfk23TXfiFY19mqxk1hYZSq0pd0PTYsHGb2CqnW0QsxVWd6nciiBfqyrG+yAHJhX EhnftyYpMdL6kA1cHjAvKoYuRWPVnuV8cH8CZS4Z9AFG3ty4V52+eT5Ufy6DTnLF zVlhPfegtpOUa10JMCZzOFb8V3iH7+04wg1WMISJmxaOegi1fyYSw1D1Gyqyb5A4 NuA1EUzZHh774biMRaxg4fm1uey/wQl6KSXD6SHL0O+DrCI8aA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGWDCCBECgAwIBAgIRAI5ZQFi3WJ+9F4SSs8w6x5MwDQYJKoZIhvcNAQELBQAw gbQxCzAJBgNVBAYTAlpBMRAwDgYDVQQIDAdHYXV0ZW5nMRUwEwYDVQQHDAxKb2hh bm5lc2J1cmcxHTAbBgNVBAoMFFRydXN0RmFjdG9yeShQdHkpTHRkMSQwIgYDVQQL DBtUcnVzdEZhY3RvcnkgUEtJIE9wZXJhdGlvbnMxNzA1BgNVBAMMLlRydXN0RmFj dG9yeSBDbGllbnQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMTcxMjA1 MTE0ODM2WhcNNDcxMTI4MTE0ODM2WjCBtDELMAkGA1UEBhMCWkExEDAOBgNVBAgM B0dhdXRlbmcxFTATBgNVBAcMDEpvaGFubmVzYnVyZzEdMBsGA1UECgwUVHJ1c3RG YWN0b3J5KFB0eSlMdGQxJDAiBgNVBAsMG1RydXN0RmFjdG9yeSBQS0kgT3BlcmF0 aW9uczE3MDUGA1UEAwwuVHJ1c3RGYWN0b3J5IENsaWVudCBSb290IENlcnRpZmlj YXRlIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAOrA ZChzgke2wM6tiNzS4e5IUvMQ504IhuAv7zgmShfwe0MbqlFNIjIHU3YKt2Cxqj9H Gkv+mMrz1KhbeN6Tnvw0JXSQ6BbmnWNVPn9Vc6YSb/eoc82WkjGutMQBSF0Rf/Z9 gr5dDemjK+sxLjnmWkqe3AZsKJj2cfzwWkL2u8BBJub5z0Gg+H5swZPF42Pn9pRC JNhrZ9HndRsAjgoEJ8fgGze7XuAuyaUEcw369dY4pKTWBpYWK4AQd9D3afFpkqmq /MMhtv0TMQk4/8P1b+NHsyHo9mXUuNNbLnzdCk+6Sd9qj7BCbLZHaa6zaWuYKGLz /Hf3H3Y0Rji3Ixe51C3aVxgDCaVVnaHyDAC8JTlih9FAB8AOy87UC3pQke+QJw7Y VwCIkuIXyWnBNR6kb8CphjQ3RFK8Q7J9iY+lo1nA0DiMp8tW/RlbwZW15UC9+YLE ySLUMp2Fo+9KdKcVBj5wIkgrDCOs0GJcuXz3hdmN+MXTl49e6vAM0LGaCE+ZBoHk Gil8pPoWJ5tzUanFJPYlGKizMtdK59Na2ZvCMjsEho1Yc1WQLmhISVQ6O+4loJni XANmU8xu1A0RHXmq1PFlC4/NT1QBEAw/XY0AZDQfBiDsodaSC8m+tmKHVAn8/hpz eSERZVye1bOQxaSWviOrfYFZ8TqbV69dgW760UuxAgMBAAGjYzBhMB0GA1UdDgQW BBQ8tpw4Wuy11CILQL5jDwiLKO4MGTAfBgNVHSMEGDAWgBQ8tpw4Wuy11CILQL5j DwiLKO4MGTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG 9w0BAQsFAAOCAgEABTcWLooTAcR8JmnoMwVS/QhaghKNzwoTWXg3usVEFzriFT/z j8zcVy0Toz7leLsrkZ0+4UJsVXVuaCyUP5uCN/w8L34cZvFVyYPSiMCbrJP+2WAv OlMkv7UvVV9hs1NPBNtuNqsdLyjD1SK7GKQnHiun0XxRfoIrd/91dZuJgefQwdvz Gb9LbAcSBA7iBgspSGY6NSbUveEFdCGK9cbPFlArFMVk6hb8TSFVjCjvHMzqEJtN GKqOTdwBxkVN8cdu+0eApzDHJ/ytCoGb91ZV2rsflfdfEHgji6OgZVAEY/M+QXOH FNxagyc40CMPpegsjhYmmevld5V+6Y+Fj0EUkP88icflXIrXYwxpc6U4HW2pYxyV f/filBDQ7VagR6FAJR+5sry6as1eNoAOslWLPEvmgcHKJ2nfsy44/L+zqh2ybSBS 3Iw/G4N6rBt506ToKTAU73iM6T5Y4tnP9XvTYbkcATaw7DCIW5+zGDpG+hbly4S4 OQSXTiQAR10g84zxpG8yA+BKZeWMuhXUVFi8sVB6cC6sQwoN5qbwIi5fShoAbHGT 2xpk7hlxfQW2mIzfgN2KqDooNUMU/vMEOo8hOA9OE4OO39v72drg5fdGPO/a6G5M ngH6MmW7UhMgaTubG3+TzzAzjrOKI/wH02lgEvdEvQMvqPBHFXcn2GG3kLU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGDjCCA/agAwIBAgIDAw1AMA0GCSqGSIb3DQEBDQUAMIGWMQswCQYDVQQGEwJG STEhMB8GA1UECgwYVmFlc3RvcmVraXN0ZXJpa2Vza3VzIENBMSkwJwYDVQQLDCBD ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBTZXJ2aWNlczEZMBcGA1UECwwQVmFybWVu bmVwYWx2ZWx1dDEeMBwGA1UEAwwVVlJLIEdvdi4gUm9vdCBDQSAtIEcyMB4XDTE3 MTIxNDA4NTAzMVoXDTM4MTIxMzA4NTAzMVowgZYxCzAJBgNVBAYTAkZJMSEwHwYD VQQKDBhWYWVzdG9yZWtpc3RlcmlrZXNrdXMgQ0ExKTAnBgNVBAsMIENlcnRpZmlj YXRpb24gQXV0aG9yaXR5IFNlcnZpY2VzMRkwFwYDVQQLDBBWYXJtZW5uZXBhbHZl bHV0MR4wHAYDVQQDDBVWUksgR292LiBSb290IENBIC0gRzIwggIiMA0GCSqGSIb3 DQEBAQUAA4ICDwAwggIKAoICAQC/1gBKiQ4vIztyf3MgZaBfFsV7XlwG+WZzIIL1 YpYXlFH+mzXo8g5ffyGVHGLA5PmCeFzvVcDH/A1587ZMgjYKsEv8LWGmC4i4T7kF rgbMCdN7Sg1oiRNFAKOdXOZ+pR7nBi/wa0WkotSbh8qYZWDrWsyileyTW0qldn1f ddItlUd6abFziKxlJHkgf4iGRWQS6BTHOJCXHPFB97jgN/+2tcwxWswo/4SoU1ZY ct1jwDtHHYxWQ95UxwjMP3rowgPKNLyFlefD0SDS9Eor8envfXpbtQRgUgR4nejn KUNuOwEA2CrMBiYCaoQ/8wiqPhT99/eOuYAwQqUFfM3zoYQieBFBCdWMgAtOWI2Y 1HM9FfdtmT3khPNHPC9rmRSEITucVmVS9Y+rDaljgsw5UrHqp1njo8APeT7olT5G rLnduFeF9pf/nrMI5jdW3vymMziNvw1rlqaL6XBKt2dEqIkukOaXi+5vnKxzRftp OP1W+AXroxHMyPLyxLD41xn4BuaWYH3U5Lbz1JsZX98xg8644HWWKW08L+hZwEqf uuz6k/aRby0kFJIrvq2dCFg14WEqE9/Y0HzxVvNrdC3E4+6AYSyrCl1VSUthr5VO sbdS1pnT7yTQHAZImhvCF5yy5ov9LXKxlzwYSVFWfFXkEr5QiR1pKBlIw9oigang 4AWqvQIDAQABo2MwYTAfBgNVHSMEGDAWgBTRpwgWB57pvU7T1yBTllkGJ9eITTAd BgNVHQ4EFgQU0acIFgee6b1O09cgU5ZZBifXiE0wDgYDVR0PAQH/BAQDAgEGMA8G A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQENBQADggIBAC1Qj8Fm74llE8N41MzM Wpdv7I9gVN5zZLcN6OE7pazPhbaWOUxEpDtZNwyAQBYzcnRI4IQloxstDQDhM2DC wV92D7OiS3DFJkDNEPpY9IFTj67cJ0iFlaaizkpCGb+VNSBk30JqZnUNVltLdZY1 U4McUKDlx5Sdy9ayPZNKy5SQcchvb2GbbvHQiOvEbz6DNEBUmEf9TMzKHI2D4DFt MDWz3yTEjTbdwNT8WYaso/BQvhhKQHhXoI3cDZK1yZZspzldPryuK9pxVj3RJ1Sq tAZ82MA8bcWd8jxVvvFhDtgc0ah9b9izF0K31RJlJs77lIXGbG1a5W58gD07m84v o/i98pIiXG4NeggKPlzd0//2F9YlZ8H7hnxUV2pzUr0HpUkF2RGLlUby3GIGiqyB BFfJuFRGGInEaB8VHpUCWKrEYZ8uD0TbTAGCaJX7Mf/QwgROfUex95nN5Q7CjBcS RJaCPZGYGpe2Z0Fw0o680WIgdoAS7Q65+Z8miUzXT2upbqXB+rsEE11mR46JqCqx 9l8XFtz9WRJuJ23dvej9xxF98vVWz6p+0P8TIoVi+UfqaO0Pk9hYYcrPdeMUZSfg En8jHtbtDz69AVvmFCYjXeAER3QlrMGVM6gzYCmdnYZj9dC9LxYRJtOZKY+Clnpc r/xS7vOO+Qq8VUHSmfQbp31m -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkG A1UEBhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEg SW5jMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAw MFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2ln biBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNpZ24gUm9v dCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+upufGZ BczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZ HdPIWoU/Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH 3DspVpNqs8FqOp099cGXOFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvH GPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4VI5b2P/AgNBbeCsbEBEV5f6f9vtKppa+c xSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleoomslMuoaJuvimUnzYnu3Yy1 aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+XJGFehiq TbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL BQADggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87 /kOXSTKZEhVb3xEp/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4 kqNPEjE2NuLe/gDEo2APJ62gsIq1NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrG YQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9wC68AivTxEDkigcxHpvOJpkT +xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQBmIMMMAVSKeo WXzhriKi4gp6D/piq1JM4fHfyr6DDUI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFlTCCA32gAwIBAgILAIZNvw/jXtd9jtgwDQYJKoZIhvcNAQEMBQAwZzELMAkG A1UEBhMCSU4xEzARBgNVBAsTCmVtU2lnbiBQS0kxJTAjBgNVBAoTHGVNdWRocmEg VGVjaG5vbG9naWVzIExpbWl0ZWQxHDAaBgNVBAMTE2VtU2lnbiBSb290IENBIC0g RzIwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBnMQswCQYDVQQGEwJJ TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s b2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBHMjCCAiIw DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMNwGIWW2kHfHK+sXTNwxF07K+IV ySTuyFM2r1v002wUfcdT+zs5OM5QbMYFFnedXQI6gCFLsjKrcaej48Zt37OyEb3i aPs7CsP4kAyTwzKH9aZe6gXYHrJq40/ZVMNcQVI2PcIp40B/SAN2gUZ+ZaUtIOvV jEx26/ebNaXRIsthlkOG/caB+QRwDw1tl7338Zlv0M2oTBUy4B3e7dGP5pgXH71M jqHPCoNo+xv9f0NTBT+hUDa8h8wUtcGQq9CDeJTpjWcD2bP2AMdVG6oVpMAUeUzo cCyglvtFdUMjggxBbw4qhau1HXPG8Ot9hwL7ZMi8tkTzrvUIxxb8G9LF/7kKeCE7 tGZaVzDTnXuifl3msR4ErHsQ4P7lVu2AIjIAhrAXoedDidb7pMcf7TABdrYUT1Jo G/AiK+J9jO6GTjeADD4LMDSBZhHMuBK/PJ/g0kGBt+/C1L+/HURzQhJkMlRnM6Rv XoCtfKopSlns5trZmTi971Wjbn88QXP61lGpBCUPwCjs7rpOYvSUJtI+lcbF+37q kIqOXYkVT3cupDSpw+H89kFtj5GKY+Xny4LxY+3IvDIRiyd6ky1DPj713DI0yqve EpsIr3A0PdwuyUI7CS1jg0NnGFT6Xxyr0xB+VDt83FJYW8v16k2pbaQ4kVxA3aXd X9dZYyVR1S59KM75AgMBAAGjQjBAMB0GA1UdDgQWBBTt7E1FYRgo57MjKBEcTaUn DV7s9DAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B AQwFAAOCAgEACFC/ilQg8KTCVBxFJW/sazomkS0kNYbEIZg4B3obqwsJ7SX98z8Z gfzBpz0nYClwwJjWbFN1R2zY8pCEot6/dgmA8Vbq0GxhwPM5YN/SZquNyRIxO3cU dlAcwf+vSezdVCf9wOzvSAF3q0a5ljvbdbNJNpfScQVp7UUd5sBsZk8jXO1KQ/go /Vf/GDPnrIFmxpAIGE3sgnO8lAv9FzUaAeuv7HWe47xN9J7+bQzF93yHuIXACPTL pQHhg2zMv5C7BAbuDHfbj1Cu294Z832yhSfBcziWGskOvl3es2EcHytbS9c9P+0z Mpka7zGC1FHrvLb/FoduH86TeZt0QjZ6pcplNzoaxDnDvzTJ6CC2Eny+qH/APFCu VUv5/wjwF+HPm8Pup2ARj9cEp92+0qcerfHacNq5hMeGZdbA/dzdUR/5z5zXdxAk nl8mcfGb0eMNSTXQmmB/i4AecNnr72uYjzlaXUGYN7Nrb6XouG0pnh0/BBtWWp0U ShIPpWEAqs7RJBj6+1ZUYXZ4ObrCw962DxhN2p19Hxw9LtuUUcLqqTPrFXYvwO4t ouj7KJnAkaTUfXGdEaFVtFig1EA30WzJY2X1vAQ7hVnniCjgaXAGqjsU6sklNM9n xDx5rFCCCEtj9Kh8UHjGK2QqgP5kwgttjOApQMaCoezMfK4KD7WpOXU= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQG EwJVUzETMBEGA1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMx IDAeBgNVBAMTF2VtU2lnbiBFQ0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAw MFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UEBhMCVVMxEzARBgNVBAsTCmVtU2ln biBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQDExdlbVNpZ24gRUND IFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd6bci MK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4Ojavti sIGJAnB9SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0O BBYEFPtaSNCAIEDyqOkAB2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB Af8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQC02C8Cif22TGK6Q04ThHK1rt0c 3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwUZOR8loMRnLDRWmFLpg9J 0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO 8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH 6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx iN66zB+Afko= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0 WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD +JbNR6iC8hZVdyR+EhCVBCyj -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcjCCA1qgAwIBAgIKLwq3aw3LSq8nWDANBgkqhkiG9w0BAQwFADBWMQswCQYD VQQGEwJVUzETMBEGA1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJ bmMxHDAaBgNVBAMTE2VtU2lnbiBSb290IENBIC0gQzIwHhcNMTgwMjE4MTgzMDAw WhcNNDMwMjE4MTgzMDAwWjBWMQswCQYDVQQGEwJVUzETMBEGA1UECxMKZW1TaWdu IFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxHDAaBgNVBAMTE2VtU2lnbiBSb290 IENBIC0gQzIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCMfX1lA+Tb mh9YInmRgOW97IVx4LUJf2DRZfs837Jrml+py64aVnYgWO4t6C78fgjfS7jX+c4T inIzEquWcI+zi0fd4Sc8NDf7JONp27VWX0qwUYqzLDRCt+s7zpLcfx1ky0zVIJj6 L06uPyK3kIr9+YAsrVj+39utm6e2MBQsRNstSI3fCQYAGvoQTQ8fULauTqNWaYAk NYFe6HUHHQPp2u1Ua00odMXiD5oRFxLcDnGAcE1I/9E9mLCdkggXijYUmico7+Xw ZeFoPhva6eIJ5p03Lt3Du5W3EcHR0cJmmY1pyeA36JaXKWRNM9IRjYMVNCcp4jhB 2tIYiZ+LVk8bwQ9/1c23txmv3u97taZlV22NF4ttS1qq3J+MOp0oGULBzpKfRx0q GVqbPukQNGAjOLIN8KDNQNzbR1iAl2d8H+MSoicBo4Aid8TjLWcNv48oCWL53ZrF BMTDjaIA6frG1t4IpbnHadA7qCJJe2qpJN6n2eQKAUn6UiQDHPsSqNBlcUhQ4Y/0 Y0mU5rghm2OB9rXQS1Fb1JRCfJMNnJIm5AUB2+2RWzq5Tgz7SbSho8NsZk0UbQnF xciqQ9uoVTAsK14Sk9oG8Q3zfsM08cdPoRb0WlIZklR6mKD7L8nH/zfGu8PIJv94 GGB9RZ9U4A69r3ePmy8MvrzfNxHKtH6svwIDAQABo0IwQDAdBgNVHQ4EFgQUs/eK pNYPiABZ6FEXT9V+7IYigZ0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB Af8wDQYJKoZIhvcNAQEMBQADggIBADQlpiWM0cv2nZ0H5jVsBq0x2q62Q0LwqATs CFvyub7gxNCytRuoA8stmPOEu/lg8Igxj4FIjoyhIrWUVxyiLU7No4P+WjEUOwUT xIpkEOtvGUQ9fiOlcGHtIZDNBlZq7WpktXAxeV55RPPsor26p2FNAMRFfZQh0sLX hKgk8iulSSggqx8ezgPye63FaiYEi4c/dzRj3HOCnsZiwZZU02df5YpNFjxSwZvE 41cjGpsrpWMfQFI2s53RbeXp47lSAxYE4NzjBFMe+EwFuEveBCJBEAH5rvYu3pi2 orsJ424TqWEQV1tCsCkQz+Yq/Okal7yHAkKDeOXcP7oN4A+TdXc2pdqxuVCnBO0R mWz2JpGSSeJjiTk/OPwRsPNWtwG/KXL04o2ta3jiPpJuICVtWDAc9R3auBEgJl5r ShRmBdszG0LmzsHuZPCFSYC15RBDCOBsa8bDRJ8pBFU2Wi/CVXCACEuavgoveA4F a5bt38o0PWxsBP+MpocCdVtDMqzQhxy9IohKuXWAGresoIvKDg3xFk6rBOrjfVwJ elwi/xAisojHPJVQv9W1zVIoHp+EQg/4MQC21NbIX2RoioB+V3hK439b/w7deU8x 2M8cl1OG0nPfbnARl5GPM7vJgi470jto4SeMg6HMAW3Egb56tQcNLwI9U8mZnNvR gUMrkAgL -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDIzCCAqigAwIBAgIQFJgmZtx8zY9AU2d7uZnshTAKBggqhkjOPQQDAzCBlDEL MAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1v bmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE+MDwGA1UEAxM1TWlj cm9zb2Z0IEVDQyBQcm9kdWN0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIw MTgwHhcNMTgwMjI3MjA0MjA4WhcNNDMwMjI3MjA1MDQ2WjCBlDELMAkGA1UEBhMC VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE+MDwGA1UEAxM1TWljcm9zb2Z0IEVD QyBQcm9kdWN0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTgwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAATHERYqdh1Wjr65YmXUw8608MMw7I9t1245vMhJq6u4 40N41YEGXe/HfZ/O1rOQdd4MsJDeI7rI0T5n4BmpG4YxHl80Le4X/RX7fieKMqHq yY/JfhjLLzssSHp9pvQBB6yjgbwwgbkwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB /wQFMAMBAf8wHQYDVR0OBBYEFEPvcIe4nb/siBncxsRrdQ11NDMIMBAGCSsGAQQB gjcVAQQDAgEAMGUGA1UdIAReMFwwBgYEVR0gADBSBgwrBgEEAYI3TIN9AQEwQjBA BggrBgEFBQcCARY0aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9Eb2Nz L1JlcG9zaXRvcnkuaHRtADAKBggqhkjOPQQDAwNpADBmAjEAocBJRF0yVSfMPpBu JSKdJFubUTXHkUlJKqP5b08czd2c4bVXyZ7CIkWbBhVwHEW/AjEAxdMo63LHPrCs Jwl/Yj1geeWS8UUquaUC5GC7/nornGCntZkU8rC+8LsFllZWj8Fo -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIDFzCCAp6gAwIBAgIQFTh14WR+0bBHtO+vQRKCRTAKBggqhkjOPQQDAzCBjzEL MAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1v bmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE5MDcGA1UEAxMwTWlj cm9zb2Z0IEVDQyBUUyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE4MB4X DTE4MDIyNzIwNTEzNFoXDTQzMDIyNzIxMDAxMlowgY8xCzAJBgNVBAYTAlVTMRMw EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN aWNyb3NvZnQgQ29ycG9yYXRpb24xOTA3BgNVBAMTME1pY3Jvc29mdCBFQ0MgVFMg Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxODB2MBAGByqGSM49AgEGBSuB BAAiA2IABN7Nu3Ag8SUgtJTo17Q7D26H3ausz01AL4Eza1kJGNaHDSYjnLSNlZ12 n6W5BkLmrTayxLOuejwI1cudOl5FIWwL4yD1m8LdRDPjQrnq8ihCkqr+DAfKihOZ O2IA7drzNaOBvDCBuTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAd BgNVHQ4EFgQU6EfIQpqwna5vCyg7mBWP47HogLIwEAYJKwYBBAGCNxUBBAMCAQAw ZQYDVR0gBF4wXDAGBgRVHSAAMFIGDCsGAQQBgjdMg30BATBCMEAGCCsGAQUFBwIB FjRodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9y eS5odG0AMAoGCCqGSM49BAMDA2cAMGQCMBSGUMAmGuvqoRR3OlvfYzmlM8dQQNVr NWsPtN99VrnhpZ14GYKhQ24a11ijVQNC2wIwGJS0HjqNZPoMJxuHE0rStzoAlMby 5WO/r+P63JPV50aaa4FpPgLfUQ2PKHFBiZEv -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFwDCCA6igAwIBAgIQHr9ZULjJgDdMBvfrVU+17TANBgkqhkiG9w0BAQ0FADB6 MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu MScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxHzAdBgNV BAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwHhcNMTgwMzE2MTIxMDEzWhcNNDMw MzE2MTIxMDEzWjB6MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEg U3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRo b3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwggIiMA0GCSqG SIb3DQEBAQUAA4ICDwAwggIKAoICAQDRLY67tzbqbTeRn06TpwXkKQMlzhyC93yZ n0EGze2jusDbCSzBfN8pfktlL5On1AFrAygYo9idBcEq2EXxkd7fO9CAAozPOA/q p1x4EaTByIVcJdPTsuclzxFUl6s1wB52HO8AU5853BSlLCIls3Jy/I2z5T4IHhQq NwuIPMqw9MjCoa68wb4pZ1Xi/K1ZXP69VyywkI3C7Te2fJmItdUDmj0VDT06qKhF 8JVOJVkdzZhpu9PMMsmN74H+rX2Ju7pgE8pllWeg8xn2A1bUatMn4qGtg/BKEiJ3 HAVz4hlxQsDsdUaakFjgao4rpUYwBI4Zshfjvqm6f1bxJAPXsiEodg42MEx51UGa mqi4NboMOvJEGyCI98Ul1z3G4z5D3Yf+xOr1Uz5MZf87Sst4WmsXXw3Hw09Omiqi 7VdNIuJGmj8PkTQkfVXjjJU30xrwCSss0smNtA0Aq2cpKNgB9RkEth2+dv5yXMSF ytKAQd8FqKPVhJBPC/PgP5sZ0jeJP/J7UhyM9uH3PAeXjA6iWYEMspA90+NZRu0P qafegGtaqge2Gcu8V/OXIXoMsSt0Puvap2ctTMSYnjYJdmZm/Bo/6khUHL4wvYBQ v3y1zgD2DGHZ5yQD4OMBgQ692IU0iL2yNqh7XAjlRICMb/gv1SHKHRzQ+8S1h9E6 Tsd2tTVItQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSM+xx1 vALTn04uSNn5YFSqxLNP+jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQAD ggIBAEii1QALLtA/vBzVtVRJHlpr9OTy4EA34MwUe7nJ+jW1dReTagVphZzNTxl4 WxmB82M+w85bj/UvXgF2Ez8sALnNllI5SW0ETsXpD4YN4fqzX4IS8TrOZgYkNCvo zMrnadyHncI013nR03e4qllY/p0m+jiGPp2Kh2RX5Rc64vmNueMzeMGQ2Ljdt4NR 5MTMI9UGfOZR0800McD2RrsLrfw9EAUqO0qRJe6M1ISHgCq8CYyqOhNf6DR5UMEQ GfnTKB7U0VEwKbOukGfWHwpjscWpxkIxYxeU72nLL/qMFH3EQxiJ2fAyQOaA4kZf 5ePBAFmo+eggvIksDkc0C+pXwlM2/KfUrzHN/gLldfq5Jwn58/U7yn2fqSLLiMmq 0Uc9NneoWWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7D P78v3DSk+yshzWePS/Tj6tQ/50+6uaWTRRxmHyH6ZF5v4HaUMst19W7l9o/HuKTM qJZ9ZPskWkoDbGs4xugDQ5r3V7mzKWmTOPQD8rv7gmsHINFSH5pkAnuYZttcTVoP 0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZckbxJF0WddCajJFdr60qZf E2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICZTCCAeugAwIBAgIQeI8nXIESUiClBNAt3bpz9DAKBggqhkjOPQQDAzB0MQsw CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScw JQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAXBgNVBAMT EENlcnR1bSBFQy0zODQgQ0EwHhcNMTgwMzI2MDcyNDU0WhcNNDMwMzI2MDcyNDU0 WjB0MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBT LkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGTAX BgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATE KI6rGFtqvm5kN2PkzeyrOvfMobgOgknXhimfoZTy42B4mIF4Bk3y7JoOV2CDn7Tm Fy8as10CW4kjPMIRBSqniBMY81CE1700LCeJVf/OTOffph8oxPBUw7l8t1Ot68Kj QjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI0GZnQkdjrzife81r1HfS+8 EF9LMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjADVS2m5hjEfO/J UG7BJw+ch69u1RsIGL2SKcHvlJF40jocVYli5RsJHrpka/F2tNQCMQC0QoSZ/6vn nvuRlydd3LBbMHHOXjgaatkl5+r3YZJW+OraNsKHZZYuciUvf9/DE8k= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFajCCA1KgAwIBAgIQEoG5GPN5OkKTzpFYYeTtXDANBgkqhkiG9w0BAQsFADBP MSUwIwYDVQQDDBxEaWdpZGVudGl0eSBTZXJ2aWNlcyBSb290IENBMRkwFwYDVQQK DBBEaWdpZGVudGl0eSBCLlYuMQswCQYDVQQGEwJOTDAeFw0xODA3MTAxMDA1NDJa Fw00MzA3MDQxMDA1NDJaME8xJTAjBgNVBAMMHERpZ2lkZW50aXR5IFNlcnZpY2Vz IFJvb3QgQ0ExGTAXBgNVBAoMEERpZ2lkZW50aXR5IEIuVi4xCzAJBgNVBAYTAk5M MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAkgc7BfM91cHK5ubHBvp5 qD9oZ0R3M2TDH13YclmDY8+TzKWTEwFBxAoPps9nGjI0oLpAnEe+QqzeGwdcSCMz Up0p87dcxjVCaoZ0Z8jJmhNVk1BfRi9AKfCmnnx7WlTaiiryAZtKje7PbBBF9fAg ETq9jlh6mEKXkwNiDzx8YSia2lVNJMB8zwvL2R3ZzWm6i82ONMX0dVdGK4KNbjzl CJV6b0qLfeOEf35CKtmxIaAm4po4F7Gq3TLkTKar+cQmB14GlbnPrZ/J/8sj0jno JEiIErHVz7TE7D2L/nVvxxFyEui62prSfXFrXtmMfjGG31jdLJlKrLAtzcrcYC9r MKJaizzLGzD8ETNJSdlW1ugh3rS6PHrXGCUegPaL5gWXddR0aIVDCnSLHLEtuZ8E 2KGX1KY0UsyNMoStie3m+EWMc5wdNeYO562Y90nJCpmWUKIujX/uqRoeqawntsxZ y0qS6PLXjqeNXU7VdQeg1Hgj2bUfWuOxQBqg8X5taMR8OVq+StI1k/VmNNb9C5Sq mK6iLS5AcsCrrgBzijeIevxCmoXderIy/t3EhjSEf3saacC3PrST3Aax4Bjifoey KMXVaU7xy8PTUjwFIZzZZawZq/+xZSw4emoEM6esnyguzsJMk5jwwgGqkBhH07or MKnNaVXYH2M8NzM8Ze/v5x0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQUwnhnF2uPPk6xWJaOekLaZz/EF10wDgYDVR0PAQH/BAQDAgEGMA0GCSqG SIb3DQEBCwUAA4ICAQAeojNQBng8utKsHlJ2xUc7zr06qqTAr7Vcp3Us4yBks7WF VwnfPpPPlgYyHtZOMxc/6KIIuV2qgC6d71JeFw/gB3yJ40EY7YxUrlayfECIFit8 xUWuwuZPNvhz/bQOmUBJha8hvhKT0/5mQPzRU6Alf512EWBIMEydrInciCS/olMz sYrL4t5hQ3h/euHtJI58CL80zjOUdXNu9M8oMt+9IhjNIbykHN6wpP+OGiPHX3RT ebYAe2wyf1ztO3GwGgTiDuOjb39TvWZ/tbkfG6xz05NSo1kDOK1bZ2hiGifJ9r1/ Ha2dMHYUWDvzMKpCeUcQs3/ZOsrZmUpHnFuEEp9l+MeAtfQ/HNBeWfx4RIGniT6I XZKWsXRipuzpYnVbzelCESyLFCKaB4wG5IOoyleSWQZosjk6mlEIReIGA+U2T4he lL0UPK9V+DJ1M1/LUbsSGUZlAXNBZgWMvxhL/zk5j27g4lnW8Jy8DD46eIFPJFna RErXT7avmuxE9Xeb28MjkPZGGL2/L9F+KEAUMX26IAV4pHbdFg4KeqxpRv7wAe5q 0m0OjjsVLnwjj3fh5X38GAOU3iGUJttGiVT4I7NYK/4v9vSWG5NlrXkDLMTfITh0 5Jod9kVHOXLVcV37vghtFtWot2FjKqcowAemtd6V7ZKqbPvNXE1ZWuZdIJuGlw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIHMDCCBRigAwIBAgICD6AwDQYJKoZIhvcNAQENBQAwZTELMAkGA1UEBhMCQ1ox FzAVBgNVBGETDk5UUkNaLTQ3MTE0OTgzMR0wGwYDVQQKDBTEjGVza8OhIHBvxaF0 YSwgcy5wLjEeMBwGA1UEAxMVUG9zdFNpZ251bSBSb290IFFDQSA0MB4XDTE4MDcy NjA5NTYwOFoXDTM4MDcyNjA5NTYwOFowZTELMAkGA1UEBhMCQ1oxFzAVBgNVBGET Dk5UUkNaLTQ3MTE0OTgzMR0wGwYDVQQKDBTEjGVza8OhIHBvxaF0YSwgcy5wLjEe MBwGA1UEAxMVUG9zdFNpZ251bSBSb290IFFDQSA0MIICIjANBgkqhkiG9w0BAQEF AAOCAg8AMIICCgKCAgEAxmaNgqB+vosiJXgQwAiLmhl/1a0AFA5k3t4hcB3IYUL6 VRyLnjvonYJHfLuOAn6dS9zi++i3PZkRqB1xHkfCJNFClXxk4tfbmhDeTJ6mQjx+ fu2wywPtxrtd/Dn0xO6Kc7Mb/ffwaFSSh6f0bZt61RLov4JPNKOvhq9qjOQgjGZy rBGIle60IppJm8bl0A5bmRL4FQygNwIascskyl0Vy69LHx4CNUIwtgN7b1s++leV NpETeLFpCtPdLoxEswg/kJuMRf8XaBZmGJIYSArCKIVYyC/gO7PRUmiwv2yLYdm7 9xvCd1xoIXHqPd23bqQs4vr5O0QzmYjU6kZbuLV8GIBuVFOH35tjtOUxMrZ+2Dja yuNcNc7OGnAoofqXvD5dfp5snqP+ZZYlVPXi9Y+N5e4PLt0rdud+uiLDW27ekSXR hvJMBxJxSb8XFgKPUbMnatCNTmtFaD9nfv5Uhlx7kfn2XzO61rnzuf2CcgSlNiT7 TQSXepGBIPjg+5QYJlhacazdL7JHdUTjJqYVbnA/Zje68lzDMfL1wDSMExh2HWGL VGJZj6inVKBZB+4suo7FtdqyzT9AmVW9a1ekPlk7g/s93freyoA/EIwHy/Hvosk7 VivLdYwU8IdUbX8JMA1QaxVgkMe6F7A7EKvFujf1L/nAnPt5CC0A2niFS+XBMikC AwEAAaOCAegwggHkMIGlBgNVHR8EgZ0wgZowMaAvoC2GK2h0dHA6Ly9jcmwucG9z dHNpZ251bS5jei9jcmwvcHNyb290cWNhNC5jcmwwMqAwoC6GLGh0dHA6Ly9jcmwy LnBvc3RzaWdudW0uY3ovY3JsL3Bzcm9vdHFjYTQuY3JsMDGgL6AthitodHRwOi8v Y3JsLnBvc3RzaWdudW0uZXUvY3JsL3Bzcm9vdHFjYTQuY3JsMIHVBgNVHSAEgc0w gcowgccGBFUdIAAwgb4wgbsGCCsGAQUFBwICMIGuGoGrVGVudG8gY2VydGlmaWth dCBwcm8gZWxla3Ryb25pY2tvdSBwZWNldCBieWwgdnlkYW4gdiBzb3VsYWR1IHMg bmFyaXplbmltIEVVIGMuIDkxMC8yMDE0LlRoaXMgaXMgYSBjZXJ0aWZpY2F0ZSBm b3IgZWxlY3Ryb25pYyBzZWFsIGFjY29yZGluZyB0byBSZWd1bGF0aW9uIChFVSkg Tm8gOTEwLzIwMTQuMBIGA1UdEwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEG MB8GA1UdIwQYMBaAFJMYNh+paXBRNapPP6yNUH4mBSkKMB0GA1UdDgQWBBSTGDYf qWlwUTWqTz+sjVB+JgUpCjANBgkqhkiG9w0BAQ0FAAOCAgEAO01Radk3mUuojS9G +JksIhH6qWebQZg0UpN2v5H22JEI+HfBat2ept+TMmB9o9D51rhRoC8Y85yS0WB9 JJCMauZcF77PjF2LTT4pO/bvEgI3ahrjf63iJiTNHFNztqyzKuOBGNAqQ2S0bV9a GNcAqvSbF7gJbyDE/74EFz9Qq0BHnmQJH4xQN3uzGJPM8XkRvxRgj+SD/tXnqGGI PWurj4J6GGBsIfr6ecYReq9B2syPC9E4uB8qFfvEQunA9NJ2mLLoCqtTICU3/t95 IvUVOBl1o6q+QmYEfmUg2qJuIBbtXb5WhQ5hkRfIBFlQ8upyZQZaXXqlmJmjZJzk dNk7hstyRP7BhVdgyCyHZtBTX2p+cEO644M0fzw58ORo0s1zvG/tooRm9tWg+5ry hLmG2Xcrll4V+QxjFgmG8wFakq2AqNq4W7PxDHiAl/xqnh/kNgwkI+7VoTHrdqrz CSbyAwzjDd9T2kgRxQG8U6vfuEt84iNtySCdmp6pWPNPkfjNOGCQEv7GamcUlHw4 11SfvD70YnW5nxgNdmqxcDcUtxzGngcXtFa/qAjxWR7TS25ESNkzzKAZELQs9ORy DLQkgzbYhCLdvDolc33xA0+Ge1bjzpH6PbpGDZxmWKTFM2ZJQQYNvWH7P55T3pbE 53TUes0DYl+ICmA+jPmN4YzcGrI= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFdDCCA1ygAwIBAgIPAWdfJ9b+euPkrL4JWwWeMA0GCSqGSIb3DQEBCwUAMEQx CzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UE AwwQVGVsaWEgUm9vdCBDQSB2MjAeFw0xODExMjkxMTU1NTRaFw00MzExMjkxMTU1 NTRaMEQxCzAJBgNVBAYTAkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZ MBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2MjCCAiIwDQYJKoZIhvcNAQEBBQADggIP ADCCAgoCggIBALLQPwe84nvQa5n44ndp586dpAO8gm2h/oFlH0wnrI4AuhZ76zBq AMCzdGh+sq/H1WKzej9Qyow2RCRj0jbpDIX2Q3bVTKFgcmfiKDOlyzG4OiIjNLh9 vVYiQJ3q9HsDrWj8soFPmNB06o3lfc1jw6P23pLCWBnglrvFxKk9pXSW/q/5iaq9 lRdU2HhE8Qx3FZLgmEKnpNaqIJLNwaCzlrI6hEKNfdWV5Nbb6WLEWLN5xYzTNTOD n3WhUidhOPFZPY5Q4L15POdslv5e2QJltI5c0BE0312/UqeBAMN/mUWZFdUXyApT 7GPzmX3MaRKGwhfwAZ6/hLzRUssbkmbOpFPlob/E2wnW5olWK8jjfN7j/4nlNW4o 6GwLI1GpJQXrSPjdscr6bAhR77cYbETKJuFzxokGgeWKrLDiKca5JLNrRBH0pUPC TEPlcDaMtjNXepUugqD0XBCzYYP2AgWGLnwtbNwDRm41k9V6lS/eINhbfpSQBGq6 WT0EBXWdN6IOLj3rwaRSg/7Qa9RmjtzG6RJOHSpXqhC8fF6CfaamyfItufUXJ63R DolUK5X6wK0dmBR4M0KGCqlztft0DbcbMBnEWg4cJ7faGND/isgFuvGqHKI3t+ZI pEYslOqodmJHixBTB0hXbOKSTbauBcvcwUpej6w9GU7C7WB1K9vBykLVAgMBAAGj YzBhMB8GA1UdIwQYMBaAFHKs5DN5qkWH9v2sHZ7Wxy+G2CQ5MB0GA1UdDgQWBBRy rOQzeapFh/b9rB2e1scvhtgkOTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw AwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAoDtZpwmUPjaE0n4vOaWWl/oRrfxn83EJ 8rKJhGdEr7nv7ZbsnGTbMjBvZ5qsfl+yqwE2foH65IRe0qw24GtixX1LDoJt0nZi 0f6X+J8wfBj5tFJ3gh1229MdqfDBmgC9bXXYfef6xzijnHDoRnkDry5023X4blMM A8iZGok1GTzTyVR8qPAs5m4HeW9q4ebqkYJpCh3DflminmtGFZhb069GHWLIzoBS SRE/yQQSwxN8PzuKlts8oB4KtItUsiRnDe+Cy748fdHif64W1lZYudogsYMVoe+K TTJvQS8TUoKU1xrBeKJR3Stwbbca+few4GeXVtt8YVMJAygCQMez2P2ccGrGKMOF 6eLtGpOg3kuYooQ+BXcBlj37tCAPnHICehIv1aO6UXivKitEZU61/Qrowc15h2Er 3oBXRb9n8ZuRXqWk7FlIEA04x7D6w0RtBPV4UBySllva9bguulvP5fBqnUsvWHMt Ty3EHD70sz+rFQ47GUGKpMFXEmZxTPpT41frYpUJnlTd0cI8Vzy9OK2YZLe4A5pT VmBds9hCG1xLEooc6+t9xnppxyd/pPiL8uSUZodL6ZQHCRJ5irLrdATczvREWeAW ysUsWNc8e89ihmpQfTU2Zqf7N+cox9jQraVplI/owd8k+BsHMYeB2F326CjYSlKA rBPuUBQemMc= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICbjCCAfOgAwIBAgIQYvYybOXE42hcG2LdnC6dlTAKBggqhkjOPQQDAzB4MQsw CQYDVQQGEwJFUzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVzMRgw FgYDVQRhDA9WQVRFUy1RMjgyNjAwNEoxLDAqBgNVBAMMI0FDIFJBSVogRk5NVC1S Q00gU0VSVklET1JFUyBTRUdVUk9TMB4XDTE4MTIyMDA5MzczM1oXDTQzMTIyMDA5 MzczM1oweDELMAkGA1UEBhMCRVMxETAPBgNVBAoMCEZOTVQtUkNNMQ4wDAYDVQQL DAVDZXJlczEYMBYGA1UEYQwPVkFURVMtUTI4MjYwMDRKMSwwKgYDVQQDDCNBQyBS QUlaIEZOTVQtUkNNIFNFUlZJRE9SRVMgU0VHVVJPUzB2MBAGByqGSM49AgEGBSuB BAAiA2IABPa6V1PIyqvfNkpSIeSX0oNnnvBlUdBeh8dHsVnyV0ebAAKTRBdp20LH sbI6GA60XYyzZl2hNPk2LEnb80b8s0RpRBNm/dfF/a82Tc4DTQdxz69qBdKiQ1oK Um8BA06Oi6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD VR0OBBYEFAG5L++/EYZg8k/QQW6rcx/n0m5JMAoGCCqGSM49BAMDA2kAMGYCMQCu SuMrQMN0EfKVrRYj3k4MGuZdpSRea0R7/DjiT8ucRRcRTBQnJlU5dUoDzBOQn5IC MQD6SmxgiHPz7riYYqnOK8LZiqZwMR2vsJRM60/G49HzYqc8/5MuB1xJAWdpEgJy v+c= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFajCCA1KgAwIBAgIQLd2szmKXlKFD6LDNdmpeYDANBgkqhkiG9w0BAQsFADBP MQswCQYDVQQGEwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0 ZC4xGzAZBgNVBAMMEkhpUEtJIFJvb3QgQ0EgLSBHMTAeFw0xOTAyMjIwOTQ2MDRa Fw0zNzEyMzExNTU5NTlaME8xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3 YSBUZWxlY29tIENvLiwgTHRkLjEbMBkGA1UEAwwSSGlQS0kgUm9vdCBDQSAtIEcx MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9B5/UnMyDHPkvRN0o9Qw qNCuS9i233VHZvR85zkEHmpwINJaR3JnVfSl6J3VHiGh8Ge6zCFovkRTv4354twv Vcg3Px+kwJyz5HdcoEb+d/oaoDjq7Zpy3iu9lFc6uux55199QmQ5eiY29yTw1S+6 lZgRZq2XNdZ1AYDgr/SEYYwNHl98h5ZeQa/rh+r4XfEuiAU+TCK72h8q3VJGZDnz Qs7ZngyzsHeXZJzA9KMuH5UHsBffMNsAGJZMoYFL3QRtU6M9/Aes1MU3guvklQgZ KILSQjqj2FPseYlgSGDIcpJQ3AOPgz+yQlda22rpEZfdhSi8MEyr48KxRURHH+CK FgeW0iEPU8DtqX7UTuybCeyvQqww1r/REEXgphaypcXTT3OUM3ECoWqj1jOXTyFj HluP2cFeRXF3D4FdXyGarYPM+l7WjSNfGz1BryB1ZlpK9p/7qxj3ccC2HTHsOyDr y+K49a6SsvfhhEvyovKTmiKe0xRvNlS9H15ZFblzqMF8b3ti6RZsR1pl8w4Rm0bZ /W3c1pzAtH2lsN0/Vm+h+fbkEkj9Bn8SV7apI09bA8PgcSojt/ewsTu8mL3WmKgM a/aOEmem8rJY5AIJEzypuxC00jBF8ez3ABHfZfjcK0NVvxaXxA/VLGGEqnKG/uY6 fsI/fe78LxQ+5oXdUG+3Se0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQU8ncX+l6o/vY9cdVouslGDDjYr7AwDgYDVR0PAQH/BAQDAgGGMA0GCSqG SIb3DQEBCwUAA4ICAQBQUfB13HAE4/+qddRxosuej6ip0691x1TPOhwEmSKsxBHi 7zNKpiMdDg1H2DfHb680f0+BazVP6XKlMeJ45/dOlBhbQH3PayFUhuaVevvGyuqc SE5XCV0vrPSltJczWNWseanMX/mF+lLFjfiRFOs6DRfQUsJ748JzjkZ4Bjgs6Fza ZsT0pPBWGTMpWmWSBUdGSquEwx4noR8RkpkndZMPvDY7l1ePJlsMu5wP1G4wB9Tc XzZoZjmDlicmisjEOf6aIW/Vcobpf2Lll07QJNBAsNB1CI69aO4I1258EHBGG3zg iLKecoaZAeO/n0kZtCW+VmWuF2PlHt/o/0elv+EmBYTksMCv5wiZqAxeJoBF1Pho L5aPruJKHJwWDBNvOIf2u8g0X5IDUXlwpt/L9ZlNec1OvFefQ05rLisY+GpzjLrF Ne85akEez3GoorKGB1s6yeHvP2UEgEcyRHCVTjFnanRbEEV16rCf0OY1/k6fi8wr kkVbbiVghUbN0aqwdmaTd5a+g744tiROJgvM7XpWGuDpWsZkrUx6AEhEL7lAuxM+ vhV4nYWBSipX3tUZQ9rbyltHhoMLP7YNdnhzeSJesYAfz77RP1YQmCuVh6EfnWQU YDksswBVLuT1sw5XxJFBAJw/6KXf6vb/yPCtbVKoF6ubYfwSUTXkJf2vqmqGOQ== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud 316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo 0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE +cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC 4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti 2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP 4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ 7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 +RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQEL BQAwYTELMAkGA1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUg Q2VydGlmaWNhdGlvbiBFbGVjdHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJv b3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQwNDI2MDg1NzU2WjBhMQswCQYDVQQG EwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBDZXJ0aWZpY2F0aW9u IEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIwDQYJ KoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZ n56eY+hz2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd 2JQDoOw05TDENX37Jk0bbjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgF VwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZ GoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAdgjH8KcwAWJeRTIAAHDOF li/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViWVSHbhlnU r8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2 eY8fTpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIb MlEsPvLfe/ZdeikZjuXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISg jwBUFfyRbVinljvrS5YnzWuioYasDXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB 7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwSVXAkPcvCFDVDXSdOvsC9qnyW 5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI04Y+oXNZtPdE ITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0 90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+z xiD2BkewhpMl0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYu QEkHDVneixCwSQXi/5E/S7fdAo74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4 FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRYYdZ2vyJ/0Adqp2RT8JeNnYA/u8EH 22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJpadbGNjHh/PqAulxP xOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65xxBzn dFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5 Xc0yGYuPjCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7b nV2UqL1g52KAdoGDDIzMMEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQ CvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9zZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZH u/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3rAZ3r2OvEhJn7wAzMMujj d9qDRIueVSjAi1jTkD5OGwDxFa2DK5o= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGrDCCBJSgAwIBAgIJANLVi0S/gZNCMA0GCSqGSIb3DQEBDQUAMIGYMQswCQYD VQQGEwJCUjETMBEGA1UECgwKSUNQLUJyYXNpbDE9MDsGA1UECww0SW5zdGl0dXRv IE5hY2lvbmFsIGRlIFRlY25vbG9naWEgZGEgSW5mb3JtYWNhbyAtIElUSTE1MDMG A1UEAwwsQXV0b3JpZGFkZSBDZXJ0aWZpY2Fkb3JhIFJhaXogQnJhc2lsZWlyYSB2 MTAwHhcNMTkwNzAxMTkxNTU5WhcNMzIwNzAxMTIwMDU5WjCBmDELMAkGA1UEBhMC QlIxEzARBgNVBAoMCklDUC1CcmFzaWwxPTA7BgNVBAsMNEluc3RpdHV0byBOYWNp b25hbCBkZSBUZWNub2xvZ2lhIGRhIEluZm9ybWFjYW8gLSBJVEkxNTAzBgNVBAMM LEF1dG9yaWRhZGUgQ2VydGlmaWNhZG9yYSBSYWl6IEJyYXNpbGVpcmEgdjEwMIIC IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAk3AxKl1ZtP0pNyjChqO7qNkn +/sClZeqiV/Kd7KnnbkDbI2y3VWcUG7feCE/deIxot6GH6JXncRG794UZl+4doD0 D0/cEwBd4DvrDSZm0RT40xhmYYOTxZDJxv+coTHdmsT5aNmSkktfjzYX4HQHh/7M em+kTOpT/3E4K6B7KVs9HkOT7nXx5yU1qYbVWqI0qpJM9mOTSFx8C9HiKcHvLCvt 1ioXKPAmFuHPkayOcXP2MXeb+VRNjWKU4E+L2t5uZPKVx1M/9i1DztlLb4K8OfYg GaPDUSF1sxnoGk5qZHLleO6KjCpmuQepmgsBvxi2YNO7X2YUwQQx1AXNSolgtkAR 5gt+1WzxhbFUhItQqlhqxgWHefLmiT5T/Ctz/P2v+zSO4efkkIzsi1iwD+ypZvM2 lnIvB24RcSN6jzmCahLPX4CwjwIK6JsSoMVxIhpZHCguUP4LXqP8IWUZ6WgS/4zB 7B9E0EICl2rM1PRy+6ulv+ZOW256e8a0pijUB+hXM1msUq9L92476FAAX8va3sP7 +Uut94+bGHmubcTLImWUPrxNT7QyrvE3FyHicfiHioeFL2oV4cXTLZrEq2wS8R4P KPdSzNn5Z9e2uMEGYQaSNO+OwvVycpIhOBOqrm12wJ9ZhWKtM5UOo34/o37r5ZBI TYXAGbhqQDB9mWXwH+0CAwEAAaOB9jCB8zBOBgNVHSAERzBFMEMGBWBMAQEAMDow OAYIKwYBBQUHAgEWLGh0dHA6Ly9hY3JhaXouaWNwYnJhc2lsLmdvdi5ici9EUENh Y3JhaXoucGRmMEAGA1UdHwQ5MDcwNaAzoDGGL2h0dHA6Ly9hY3JhaXouaWNwYnJh c2lsLmdvdi5ici9MQ1JhY3JhaXp2MTAuY3JsMB8GA1UdIwQYMBaAFHTzfv/8n1N6 8Xzrqz6kptoYukVjMB0GA1UdDgQWBBR0837//J9TevF866s+pKbaGLpFYzAPBgNV HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQ0FAAOCAgEA eCNhBSuy/Ih/T+1VOtAJju85SrtoE3vET1qXASpmjQllDHG/ph7VFNRAkC+gha+B CbjoA5oJ/8wwl+Qdp1KGz6nXXFTLx3osU+kjm0srmBf9nyXHPqvFyvBeB0A7sYb7 TmII9GKD20oCxsdkccR/oE/JuTaNnGq0GYZ2aDb5v62uLi21Y6P9UBiTxZqQ4ojW ET6kXNjlK238jpXv17FR8Sg3VusCvX7Q8eJkavvHHZDeWck2fSA+ycAc2JeL2Z0B MSxGWpH32WM9J8+6XqCJUXHiWEV0zCE8wDYiYC+047pTxQI/gB/FcU7jvylh98DJ kQPHd/Tp6Og3ynlDA9n9uBbxYHVRZs9vsZ/7xTFaxRe+zk8dhgKgZ/3RrcMFB570 2t8LFbyuUE/kQVY6rZ0QJ9qMWQ7VPLRwRhiMeU3k8WDJb/tBbOXHBqldTbWyQ+mp MEDWhbrzE/IED82wAuO23Tb05cYk2xC7+Izef8fSc3XdJDuPSbcDpWukzyCDtSEH isLiGEtIbYRiPsF3czlQPsnIEVoTTCWxHCH1zYR6zScSv18Qh69qVe2J40K5jZoP GEOhq/oKhVJQAdvAFW5Odp7mF3Tk9nivjjsctJSxY26LFiV5GRV+07SSse4ti0aO jO5PLg5SWjfcOtBG2rz02EIvQAmLcb0kGBtfdj0lW/w= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFrjCCA5agAwIBAgIQTU0GyxRpCYdFVPhZfRsTHzANBgkqhkiG9w0BAQwFADBo MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTkw NwYDVQQDEzBNaWNyb3NvZnQgRVYgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9y aXR5IDIwMTcwHhcNMTkxMjE4MjE1OTU1WhcNNDIwNzE4MjIwOTA5WjBoMQswCQYD VQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTkwNwYDVQQD EzBNaWNyb3NvZnQgRVYgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIw MTcwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnBEElv+W+kRZBP974 tJEqQz+ojQ/+yJoYdk5gKLn2wHFl1ZWUJ0tNbWz3UyDWoejCgDZgsk00WGAn31g0 n//s3oer16r86HNpME/ETik0DX1EnUrTdW7nwiH9Wt2HpytMyvZ4pIYmAquN3YlA a3bp9rXaYRWHQ121o5OCR7FSoeqXusIF3iHhkHsnnDc1Asv/6xcAh5ZsRDT+57hQ u/lGT5oxj9hUX0bwXsolRDXkVfyUZElNLf4SJvd+chZnmP6SpQM/cFksZF1qY52R vyM7SlWascEkJY1OsB/1Mcgblc5QzEvRLGQYlNEoZ4sKpNkbGJuvqkfGJp4KMTVa xjmjLN2bQ4/69I31I8cHT4lpmtYDbdNjWSDoM5lZsFAp3InvUVRaqNjuLH2q2YmS Ybj/qksMvFP7JkbdUZ08XGAJwAFrWa2DVltT+6CrJq3M2f8+eC47a/NFwQPowpNc /pULklyLnLSz3DpGrAC4JwkKPjXuQwbvGpQIKeZR8eMu+kTjJc2LF/vGywr6DLIe 9G4OC4A76V8CiYu3NL4kjrw961XJsZ0rJTHK/Pct5msBdo7smgDXboc+vBK4JcvJ YQ+FGBGrJ4NMSS4s/Fexx+nCB87rt7XD3ZbvBxLQ3v6EwOeOd/PReLA2XuSALf7a 7VIFAql1sYNS4JfynxdCP+7aawIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYD VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUQcr/FrIJTtwkyEvkXBYlmfgm7zswEAYJ KwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAC1YTkfwguUdJ8YbuW3t X1mCYhL6dtmBHxLZnLeD3FXZqByzi1fFOfDYilOsAfQCwSmQ/6Nq+Hednq2lqa6v nYI9DZieatYYmzsZ8UFERMg2J+b46a4L+todIk0EQgw0VOA/KWxA+NAfDIZYaA/E rhnSj9y7a20ineb2OhYNAfiIyx5OnH51GsV1+xbhGMZ7QqF+EnlVyj7bWYoz6kSi W44lq0p9cGFu+pVe4AkvSKUpav7u6ZpRw7wgKvQio0YtiRKNX4TklUIQTcIXpN9L 5ictFU1zwIgmsva2FLC5hyygu8NwG7csQNNrR944iyIv9Z2we3w/7A+IB8mdU4qp OR+ZiPuuzZa65vPnJzsJaeo5EwkWkQl+rK7c1687JNZMvajZcEinTT8QKkEcIbUO lHz/lRH0SqWFwJL2phWkjg5z56jkjLfT+11nZvR9JiQcwDI/VxmaE1Guvb8Ni5ov NJyhw1pxlqBJIEnMJQiyZ0NK7PAMZoHJMk5i/hadIXsfKeTKhZ1PdJUog1wGjrok MJB+eh6bMEolGD5LS/WTb5sIBBC8Lc7tHXfb8MenfbVDoqCjAImo4NBPff0RZlHn 4/fCQT2Aj65qhHRld+35vpsCJvhkhEzfWHlrsGaE1q3UuwZIc8gpnSVNAuPf2sDm QJ6n4aMj2Bwn9Q/eyd3Sp9+5 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICYDCCAeWgAwIBAgIQIq9OUsJgV5tEnB3eAcjjGTAKBggqhkjOPQQDAzBoMQsw CQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTkwNwYD VQQDEzBNaWNyb3NvZnQgRVYgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 IDIwMTcwHhcNMTkxMjE4MjIyMjE2WhcNNDIwNzE4MjIzMTQzWjBoMQswCQYDVQQG EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTkwNwYDVQQDEzBN aWNyb3NvZnQgRVYgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ03fnXAKvhJipfmjaLsLzJwAVzzNXWIcv2 V/zPtYu95jSvILE9SarFZ1WC4e0w3WvtRct8LaGsrpcfP3ZgUX+syov/pZtB5dNK 8iXVAdv9+2xOyZaj3fdTNCt3HjFWwr+jVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNV HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRj5/TgIgrMsBbPtD8G9YPw3/lbkTAQBgkr BgEEAYI3FQEEAwIBADAKBggqhkjOPQQDAwNpADBmAjEA3TGYWPADxBwh+2rbOUzD Anq/uihpq4VbMUHY1MPLetkIGBcb+TU8qn4nCY74Y0fdAjEA/KDz7stlJkG4kFf+ U7+zxDvxU2jguTkFYMMbg+BWru/2RuJCYMC7U17GrFLHX4rt -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBl MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw NAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5 IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQG EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1N aWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZ Nt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0 ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1 HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztm gGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XFWvLJ jEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUc aDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaG YaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6 W6IYZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4K UGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH +FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+Q W5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/ BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZC LgLNFgVZJ8og6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OC gMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6 tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nh SnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2 TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3 pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGR xpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiApp GWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9 dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hN AHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D5KbvtwEwXlGjefVwaaZB RA+GsCyRxj3qrg+E -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQsw CQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYD VQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIw MTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4MjMxNjA0WjBlMQswCQYDVQQGEwJV UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNy b3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQBgcq hkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZR ogPZnZH6thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYb hGBKia/teQ87zvH2RPUBeMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8E BTADAQH/MB0GA1UdDgQWBBTIy5lycFIM+Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3 FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlfXu5gKcs68tvWMoQZP3zV L8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaReNtUjGUB iudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkG A1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkw FwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYx MDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAhBgNVBAoTGmUtY29tbWVyY2UgbW9u aXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAyMDIwMIICIjANBgkq hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWiD59b RatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9Z YybNpyrOVPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3 QWPKzv9pj2gOlTblzLmMCcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPw yJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCmfecqQjuCgGOlYx8ZzHyyZqjC0203b+J+ BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKAA1GqtH6qRNdDYfOiaxaJ SaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9ORJitHHmkH r96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj0 4KlGDfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9Me dKZssCz3AwyIDMvUclOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIw q7ejMZdnrY8XD2zHc+0klGvIg5rQmjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2 nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC AQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1UdIwQYMBaAFNwu H9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJC XtzoRlgHNQIw4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd 6IwPS3BD0IL/qMy/pJTAvoe9iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf +I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS8cE54+X1+NZK3TTN+2/BT+MAi1bi kvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2HcqtbepBEX4tdJP7 wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxSvTOB TI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6C MUO+1918oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn 4rnvyOL2NSl6dPrFf4IFYqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+I aFvowdlxfv1k7/9nR4hYJS8+hge9+6jlgqispdNpQ80xiEmEU5LAsTkbOYMBMMTy qfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFhDCCA2ygAwIBAgIQdlP+ufXH2+qLpHjUPj1r9jANBgkqhkiG9w0BAQwFADBc MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEyMDAGA1UE AxMpR2xvYmFsU2lnbiBDbGllbnQgQXV0aGVudGljYXRpb24gUm9vdCBSNDUwHhcN MjAwMzE4MDAwMDAwWhcNNDUwMzE4MDAwMDAwWjBcMQswCQYDVQQGEwJCRTEZMBcG A1UEChMQR2xvYmFsU2lnbiBudi1zYTEyMDAGA1UEAxMpR2xvYmFsU2lnbiBDbGll bnQgQXV0aGVudGljYXRpb24gUm9vdCBSNDUwggIiMA0GCSqGSIb3DQEBAQUAA4IC DwAwggIKAoICAQC+PrPi5LejQfhLmafaJmRr7a5Jg1F9bGDgnwvvOzrGtOhJO81t pD4a1cpj6oN3AOJavVZsfIHB8NvmWtGbfW0ilijsmuO6t122ET7kesa4Gs8FIeko N2X05Mmt5l0kL0iGPt9vFc4qsqVe3JUEkuV4JvjfXDXhv4ZTZZPLGJjj2ewyDcoK 8P9VeTgfXcyd7c4VtlifTlrgsdNJFBisCGDmz8N9Io5vJnlDcWbmR4+ENqZsAFJ2 tERfGu8ixAY2guMcVpo9UvdTBFEoINGzdC0tYjcpw2S45fqp9UCl/msU4f1zGZoh I7HnzIajHCRItWw8IX8XU+lkriUXLPa7RJ44Z+9Ju1ty0xXdNRMfVUajRkmagvXP fNHseYLOSCvdVvoZrSW4i7Zw14Kj5z2vbkGmPWDOeU9qxMkmOUS9Aa8dYXH29fE1 RiceAxngMXlscVHfw3ZlIpUe02tpvBBZGJFX4p9i6QuOtoeP4b+DzUpYshDd7uP8 DxwBYH72OGpccrl5Hd3XQ0cd7u3v/Mis+1Ihf4OGa7zu6XZ+VQt8nt5kREQUrqrn JSowNhrxJ0Pwrf6jRddHyYF2IlzOjv3qDkEjuPjE9s1ljMt2mjytaoHEUb6tlA2M F5EoASwechJUUUKk6ywPlFQsJTuTwzGGZIahbEjmvVBWzFCnashetvqFrwIDAQAB o0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU dYKqG7azjRmP/Kl5zD8CmvRPy9kwDQYJKoZIhvcNAQEMBQADggIBAAFMXi+4f3I1 vLUYMIB9N3yRb3r0PK0gVhu4qTP3/qhcVFg5VTwz0Hq5NPyNVg3uAaYnG3EvtZp3 RYcE2I9bA3IDOSQdD3iQxcb1+H/6kKkiGw1nxrZSUPSdqOmgxHV6k9qxpWrtDEfO oE6qcrTE5593kWX2awznDQdhCoevRhDV1ACrtbruRdFn5vd4n/l6wsennGwLXQ7F yz/6I9G7n+o1Asg3NUEfmt0cRLqASoDZTgmV0j6yMJI0nO2dID8TDec2vQpRDMNq V4rp2V2votwv1Za8xwjov6IV61QzYeVtzz31iZDiTY+cQL8Ug/KkNnol3njRCY2e hQevcgRUIV0n7eVCEcs61mOs79L7fWrKhIHjCjJbkMDEjZKsCEsK39dW3NtmjHJe PchOl6vLAaC2mLNXgDHvEU5AgmILem7K9SV7Wf/jvp/+/OpA6RogYKyGS6DBqUqx qtTyM/4TObvvrhf5NssQ+3e64ulbA4fxaNzHlhVZ8jhUB0//AtQ48HBooCemDmQR Kom2nr2CykmaRxG8u5h200NwxYhZ/M7nyxAhelShHb3N9+FOsxct6yTGx0pc2pgj i7Jl0l/HfPkqK6VeDVBy1a7c+0iLhWcyQIF+CvIJTXicyU1ozvrhsfzZQf7mCfEi ksRCXNTngVc4/6oai4r3z4f34t95em4E -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICIjCCAamgAwIBAgIQdlP+rhgmQ29p9RzCdxbyXjAKBggqhkjOPQQDAzBTMQsw CQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEpMCcGA1UEAxMg R2xvYmFsU2lnbiBDb2RlIFNpZ25pbmcgUm9vdCBFNDUwHhcNMjAwMzE4MDAwMDAw WhcNNDUwMzE4MDAwMDAwWjBTMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFs U2lnbiBudi1zYTEpMCcGA1UEAxMgR2xvYmFsU2lnbiBDb2RlIFNpZ25pbmcgUm9v dCBFNDUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR2GW0DtfWEI6syai5h3YQlL+/o eSeJg8ODdfO2eGoIbaKtISoCkAbsmkCceoaRuViFyCiaLgv34nap37K9qcPpKRl5 CLJQ0MLFnQphDONdNwZKXP6EvcCAhPpLVSPg4j6jQjBAMA4GA1UdDwEB/wQEAwIB hjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSnn93TVM3b+Gy/JmwO5Ndbb4DM QjAKBggqhkjOPQQDAwNnADBkAjBsjFa2xTeuLZAreO2xHkYI0sNKKO94GQiOJDRG T4dxYV+pEUpvMqsc0VJ7qjrq5ZoCMFUrdy/O+D+baEra16hLRQ1+smv2bNqxFeK8 SBl3i1fBXRTXQQDMJlLQILgZT5bnmg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcjCCA1qgAwIBAgIQdlP+uT3Z5+kmMqzWCr6sODANBgkqhkiG9w0BAQwFADBT MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEpMCcGA1UE AxMgR2xvYmFsU2lnbiBUaW1lc3RhbXBpbmcgUm9vdCBSNDUwHhcNMjAwMzE4MDAw MDAwWhcNNDUwMzE4MDAwMDAwWjBTMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xv YmFsU2lnbiBudi1zYTEpMCcGA1UEAxMgR2xvYmFsU2lnbiBUaW1lc3RhbXBpbmcg Um9vdCBSNDUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC6dDPsJ9wS OCEbxdNhKNZavE/fi8yRhEMkV7xkIbw7HB89T4ytB7fzxdcC6REUgpqqtJRyO3EN Gu9oa4V5jq9m6liYDbrBfHnS/82zbzFF0AV0BAByaid+uDc/Oojtl4P1qzVND59Z O/Uv31nFfKUydmCWyO3u+AR+GVFyqL9EQXq8ex47AJu8uuCWv5D+jZvDcosAEvgg OmA498HMhYr7h3kuoSsg5sughZEjtsQoB1Qo3uwQMU+K8s0UHx7dVRzqKDFM+SFq qM3zlmf6AUGbzQ8LaH+73vFD6hflsNxwIrNpNll0a8bliSp85QuBXas/j7jRdnLz fKKp4pdBv8yMRf5hyfZsBwsABOgVI0+CKi3278P6ETZIodH9ejk6NF2jLA6bd1Ag NEDdsQMxrV/pYodzlgNh95Sw2VxsT+cUxeHxew0jnM1wjB1q3kotiyq720IUBQeq +xTcMdP2H2zLvmhmRHBNbRf5cesFc46RknXraFwe9kRhGCli3RdmiOwouklv2z53 /rkxH3UcGKKmR73Y7kiFO/2z4g8/KpjGmvqCb7GlpYYdWjr6pGx0D3dSYWp/hyne OZuL7rNFYDAklxUSKoUwkyaslqYt6HBtC6kyrSybKAp2QvJVYVGYlN7t9sUXbzwV ELAOrbDexRb0ZdHML1pWCM+ZxPBVkcIseQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC AYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQURrIcd+F7FfClOaFw3tHELupt st4wDQYJKoZIhvcNAQEMBQADggIBABZ8CmdKAzyTKj4cT0ZVsJqeiOHrU/1MVXmE +Wy2n6kKtomrVAcUFkpJWvS4LoYUxH4ZifmHiLzsstKVjOAM+fKUZqaYVxuh39Fx fYy1+HEC3RO2vvqwMcMsZ+saGA4aTdwszzFcYSipneNqLK5QSw460Gn7ijRE335L jhqQCdox2sovpff0Nyw1DRpizTx7PFZ3ZZVclHNwn2EvaWQjHUx5B8IXfDrtqm1x AxRiRcy3PlTYUXFC6juSQqUvVIGjsAxWWFa75JjuZscR+ahFF+JlKore4qjOxS32 9c6t8OMKCd1Te2ypbIZ+od42NQAPX4D9RbtxZkPURCzQuwFOmZ4+TeFeVh8FeoId ssstpTO5OeXEt9pC4b3QlEKA+hiUO5NDqMiUOm1+nfxPoMLT5aWqECZvBiJb4AHi Sr8Z5USesK2rGdLN60fEYoHs8MJ6jUz9wiW3vCxwjqqtUvQUPKp4HQTTydUlgqda y4x8H1cCO4cbyNf5VBodyhpLJ7HiSu/nmkAUT6U8n9WjvpQ1nMLXPyjupBcrQ71k p9ev6VPnp3cexRIbMeJLxn+eHO6jOpRQXaZQBlJeRQMrtADgwe3YDcGuu0kJgYJa QkOvmWO4FNE8i93V8FTtcmfC9so+NYSHgA1SlVBB1rINGUAvthNN97Fg1HbFVzlu WqJeCnnc -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcDCCA1igAwIBAgIQdlP+qExQq5+NMrUdA49X3DANBgkqhkiG9w0BAQwFADBS MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEoMCYGA1UE AxMfR2xvYmFsU2lnbiBTZWN1cmUgTWFpbCBSb290IFI0NTAeFw0yMDAzMTgwMDAw MDBaFw00NTAzMTgwMDAwMDBaMFIxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9i YWxTaWduIG52LXNhMSgwJgYDVQQDEx9HbG9iYWxTaWduIFNlY3VyZSBNYWlsIFJv b3QgUjQ1MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3HnMbQb5bbvg VgRsf+B1zC0FSehL3FTsW3eVcr9/Yp2FqYokUF9T5dt0b6QpWxMqCa2axS/C93Y7 oUVGqkPmJP4rsG8ycBlGWnkmL/w9fV9ky1fMYWGo2ZVu45Wgbn9HEhjW7wPJ+4r6 mr2CFalVd0sRT1nga8Nx8wzYVNWBaD4TuRUuh4o8RCc2YiRu+CwFcjBhvUKRI8Sd JafZVJoUozGtgHkMp2NsmKOsV0czH2WW4dDSNdr5cfehpiW1QV3fPmDY0fafpfK4 zBOqj/mybuGDLZPdPoUa3eixXCYBy0mF/PzS1H+FYoZ0+cvsNSKiDDCPO6t561by +kLz7fkfRYlAKa3qknTqUv1WtCvaou11wm6rzlKQS/be8EmPmkjUiBltRebMjLnd ZGBgAkD4uc+8WOs9hbnGCtOcB2aPxxg5I0bhPB6jL1Bhkgs9K2zxo0c4V5GrDY/G nU0E0iZSXOWl/SotFioBaeepfeE2t7Eqxdmxjb25i87Mi6E+C0jNUJU0xNgIWdhr JvS+9dQiFwBXya6bBDAznwv731aiyW5Udtqxl2InWQ8RiiIbZJY/qPG3JEqNPFN8 bYN2PbImSHP1RBYBLQkqjhaWUNBzBl27IkiCTApGWj+A/1zy8pqsLAjg1urwEjiB T6YQ7UarzBacC89kppkChURnRq39TecCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGG MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKCTFShu7o8IsjXGnmJ5dKexDit7 MA0GCSqGSIb3DQEBDAUAA4ICAQBFCvjRXKxigdAE17b/V1GJCwzL3iRlN/urnu1m 9OoMGWmJuBmxMFa02fb3vsaul8tF9hGMOjBkTMGfWcBGQggGR2QXeOCVBwbWjKKs qdk/03tWT/zEhyjftisWI8CfH1vj1kReIk8jBIw1FrV5B4ZcL5fi9ghkptzbqIrj pHt3DdEpkyggtFOjS05f3sH2dSP8Hzx4T3AxeC+iNVRxBKzIxG3D9pGx/s3uRG6B 9kDFPioBv6tMsQM/DRHkD9Ik4yKIm59fRz1RSeAJN34XITF2t2dxSChLJdcQ6J9h WRbFPjJOHwzOo8wP5McRByIvOAjdW5frQmxZmpruetCd38XbCUMuCqoZPWvoajB6 V+a/s2o5qY/j8U9laLa9nyiPoRZaCVA6Mi4dL0QRQqYA5jGY/y2hD+akYFbPedey Ttew+m4MVyPHzh+lsUxtGUmeDn9wj3E/WCifdd1h4Dq3Obbul9Q1UfuLSWDIPGau l+6NJllXu3jwelAwCbBgqp9O3Mk+HjrcYpMzsDpUdG8sMUXRaxEyamh29j32ahNe JJjn6h2az3iCB2D3TRDTgZpFjZ6vm9yAx0OylWikww7oCkcVv1Qz3AHn1aYec9h6 sr8vreNVMJ7fDkG84BH1oQyoIuHjAKNOcHyS4wTRekKKdZBZ45vRTKJkvXN5m2/y s8H2PA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFejCCA2KgAwIBAgIQdlP+sEyg1XHyFLOOLH8XQTANBgkqhkiG9w0BAQwFADBX MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEtMCsGA1UE AxMkR2xvYmFsU2lnbiBEb2N1bWVudCBTaWduaW5nIFJvb3QgUjQ1MB4XDTIwMDMx ODAwMDAwMFoXDTQ1MDMxODAwMDAwMFowVzELMAkGA1UEBhMCQkUxGTAXBgNVBAoT EEdsb2JhbFNpZ24gbnYtc2ExLTArBgNVBAMTJEdsb2JhbFNpZ24gRG9jdW1lbnQg U2lnbmluZyBSb290IFI0NTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB AKPQGKqmJaBoxSoYFVYt/dBLfaEecm4xsZ0STDc8LAzKutUukiBLkultAJxEbzgX 7xlg8skghJR6OwgNa0hl/NAeJPXU3NpHUphO342nitTllKh8siw4i+XSLZwAGTM3 irhsZWIblOjjm6R1ay2AGh0b5i+n7HHq6wQPsanAk1JhIC29UptoWDRLa0tbPm1y 1jjYlUGTTnn9T9W1/MiApVkIN+iyet62eQxB4PFg1i7y5KFN2BOrz45kW3zc5jEp Hg2Qtjjo0PY6TTDHePklFWfhz3/3k5B/3kD6aYt9oENfRfnCS5d/UWEuC2LOYNoN X3bMlJwd2IXs70V+vuoq0D8UjWkgfgxW/epp9KlEweatJ/9Ycah9LzufHn/ZcgXo kSSAGtQheY4uWvr5j7AQKDCNquDyk9s9cVGrs553LgaAN4oLTg+YejcboM1JpUEQ hMOfUG0vKI4u88+2x1SBbiychxEN7eP1hIsr/hSQu0ooVDRMZ/viKnN2JpFfx9o/ Np/aJy8nDcDHOf7b4/k2aYKAvfXB8aAz7od2H4gJft3oQbS+DxCkBuXt4Qh7JfdH B7wqJQ8xOpGoqhMzkK8Op2DWgn1nTTQW4We7eeuCMEa0APhZuw78sxCRRSPY8TFC BLFgZ6hjg7KsP5/3GBiETFGFZpoqHNLbKbmbG0Ma6jPtAgMBAAGjQjBAMA4GA1Ud DwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQHQVdLz+EcFlPV veuDbMyLKSGEvzANBgkqhkiG9w0BAQwFAAOCAgEAJJwyIaZykDsC3f64SqaO8Dew W/8uP7Enbtl+nvSPX36/u4OFcMSKj0ZdxgpRKQLIxqBD/cICE/I6IZLdRpXDdLg8 VyIBhGhns1Beem4spPSj9QsM+VoNR4VFGk+bTNGokfOJqj5JqvWEsRe0S+ZeaRT9 RBsK/yDOCP70ZXKtxSJc3PKljMXcHWzb95anN2oaMLxrWTDjDUjxuGS5F5XG5J+D prLujbvhniXMwFaoAQeRa6Qu6hPr2/FJb+U7OpYn/kRQ4Qw0qxgQwaZwieJSyB2/ YtY0guX+x5gAYRCAdyd8rF1yQrgiD3Ig9wpH0FUGVU/vZG2z/DrgoVZPZ8lFVMQT IfurtfoxGlsGaU463x4gvCB/sCt0MtaodrM6PgseIETeh6b3UgsLjxT4MQOq6hHJ 2ZVGwIS72OsrLwpQxDgjf2+zv8Mnt/VMhwFzSQflwIyt7MeBQo/bXWsO2yHystfX kieXNu3GS19zR7kMuA3cSUtFsr8xjuFVhCfpWBoxwg4m01/Ri70gXXHfl2Hd35XJ 4Msv20ScC3QKfRuKtE+MKJZM6CnLilxY8bg9bsLd2myyB6mr6NHR0niwPtPFaY13 54Rk+LFW8fsZ0Yhmbz0bZcglRTwfdDseHDjr8aMsUsG/6CH0Lo4yg58V6vQNo5RH Rn7JhIJYRobXTF+4bZk= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICITCCAaegAwIBAgIQdlP+qicdlUZd1vGe5biQCjAKBggqhkjOPQQDAzBSMQsw CQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEoMCYGA1UEAxMf R2xvYmFsU2lnbiBTZWN1cmUgTWFpbCBSb290IEU0NTAeFw0yMDAzMTgwMDAwMDBa Fw00NTAzMTgwMDAwMDBaMFIxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxT aWduIG52LXNhMSgwJgYDVQQDEx9HbG9iYWxTaWduIFNlY3VyZSBNYWlsIFJvb3Qg RTQ1MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+XmLgUc3iZY/RUlQfxomC5Myfi7A wKcImsNuj5s+CyLsN1O3b4qwvCc3S22pRjvZH/+loUS7LXO/nkEHXFObUQg6Wrtv OMcWkXjCShNpHYLfWi8AiJaiLhx0+Z1+ZjeKo0IwQDAOBgNVHQ8BAf8EBAMCAYYw DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3xNei1/CQAL9VreUTLYe1aaxFJYw CgYIKoZIzj0EAwMDaAAwZQIwE7C+13EgPuSrnM42En1fTB8qtWlFM1/TLVqy5IjH 3go2QjJ5naZruuH5RCp7isMSAjEAoGYcToedh8ntmUwbCu4tYMM3xx3NtXKw2cbv vPL/P/BS3QjnqmR5w+RpV5EvpMt8 -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFcjCCA1qgAwIBAgIQdlP+rHVGSJP15ddKSDpO+DANBgkqhkiG9w0BAQwFADBT MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEpMCcGA1UE AxMgR2xvYmFsU2lnbiBDb2RlIFNpZ25pbmcgUm9vdCBSNDUwHhcNMjAwMzE4MDAw MDAwWhcNNDUwMzE4MDAwMDAwWjBTMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xv YmFsU2lnbiBudi1zYTEpMCcGA1UEAxMgR2xvYmFsU2lnbiBDb2RlIFNpZ25pbmcg Um9vdCBSNDUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2LcUw3Xro q5A9A3KwOkuZFmGy5f+lZx03HOV+7JODqoT1o0ObmEWKuGNXXZsAiAQl6fhokkuC 2EvJSgPzqH9qj4phJ72hRND99T8iwqNPkY2zBbIogpFd+1mIBQuXBsKY+CynMyTu UDpBzPCgsHsdTdKoWDiW6d/5G5G7ixAs0sdDHaIJdKGAr3vmMwoMWWuOvPSrWpd7 f65V+4TwgP6ETNfiur3EdaFvvWEQdESymAfidKv/aNxsJj7pH+XgBIetMNMMjQN8 VbgWcFwkeCAl62dniKu6TjSYa3AR3jjK1L6hwJzh3x4CAdg74WdDhLbP/HS3L4Sj v7oJNz1nbLFFXBlhq0GD9awd63cNRkdzzr+9lZXtnSuIEP76WOinV+Gzz6ha6Qcl mxLEnoByPZPcjJTfO0TmJoD80sMD8IwM0kXWLuePmJ7mBO5Cbmd+QhZxYucE+WDG ZKG2nIEhTivGbWiUhsaZdHNnMXqR8tSMeW58prt+Rm9NxYUSK8+aIkQIqIU3zgdh VwYXEiTAxDFzoZg1V0d+EDpF2S2kUZCYqaAHN8RlGqocaxZ396eX7D8ZMJlvMfvq QLLn0sT6ydDwUHZ0WfqNbRcyvvjpfgP054d1mtRKkSyFAxMCK0KA8olqNs/ITKDO nvjLja0Wp9Pe1ZsYp8aSOvGCY/EuDiRk3wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC AYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUHwC/RoAK/Hg5t6W0Q9lWULvO ljswDQYJKoZIhvcNAQEMBQADggIBAF4runSXNERfdkgoQIST7gFu6aGz1oAl5nvk vAmRPQ/8dq3X1DAgu49g0JHWHPKc73gaK5QyAsEkllJSAtDz0fzymzlumeEfjkNB fZoeW8ldmoT8JuaH83RyJq2kG9k9O2pSoDwJHi8ee7MztEXH96yxr5NgrXauuLIV eOuDauv/20arJOXuAvqQH1nAL13Wt12kXBC3clP4QU7M+ngaJUrK/oViQ2HDtDeq gdL01joPvY1ZfjBH3itr5yFQM1/UZ5vUuGefPCeZA/+FQ45zEsogzehh1bFm3BfW OW0P288jN6GCiU4caz/WoM2qB50+Qiaq1wzu+ke/GlJ+0XWB08mKYhdtT4igIaAm Pq9t2WIwH+mYKK5ujdWOTHJmk4CNKuNVx2BnkEJWXCJRD7PcTjnuTd3ZHXgQVDtu 0JdvA7UesiNzxhKymmTQ/JWFJKj/36Gw3JFArt8JM6u53ZK38cyRdDtp62eXG5C/ 58egb3G7V7+3j1rtekBqFs2AhC0v4QLUJJRDsxX8DCsb/XFv/Mu8dRc6XoPSybMv G9WcjX9U/n5+5Fajh6ed4VlSlEGPbVu+hpWa/xp23UDSUUpwtB8zYyN3P+wnHlnk CIftNIJKDz/+oB3B9WdzRYZ49Kop6SeHxhnbxhMUwzlJh02gl+BlE/Wdd1bp2rNY xzrywM2C -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICNDCCAbugAwIBAgIQdlP+urId2CfpaRai64G+WDAKBggqhkjOPQQDAzBcMQsw CQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEyMDAGA1UEAxMp R2xvYmFsU2lnbiBDbGllbnQgQXV0aGVudGljYXRpb24gUm9vdCBFNDUwHhcNMjAw MzE4MDAwMDAwWhcNNDUwMzE4MDAwMDAwWjBcMQswCQYDVQQGEwJCRTEZMBcGA1UE ChMQR2xvYmFsU2lnbiBudi1zYTEyMDAGA1UEAxMpR2xvYmFsU2lnbiBDbGllbnQg QXV0aGVudGljYXRpb24gUm9vdCBFNDUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATM zLQ6uxpN+J2RxHeB7RZ/AxF/uOlwhEiWQQmDYF30JJMqMh5eB/tHpIcqJNhXjFzZ qN8ReH+2RNXdr9UB2SY0X30xyMHu49a5/o+TAnCib2A7GXO1i3QKe51CF7wtPqej QjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS1 g9ZwBorGnYaCd9WpBWU2E/HGSDAKBggqhkjOPQQDAwNnADBkAjBmpdF/fTQJFg4O ++53h4FKndiAh6BkaMtftnRYrMuymOKSEoktHT2xVGj4kvGNTkoCMBRVMnt2ZnSR ayTUWpTi5WqA9np9zULzWHhjwekCe1TdHAEVncu/BBhVQCT6IvLZXg== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICLDCCAbGgAwIBAgIQdlP+sK9LdZCiGuSi1fJ2tTAKBggqhkjOPQQDAzBXMQsw CQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEtMCsGA1UEAxMk R2xvYmFsU2lnbiBEb2N1bWVudCBTaWduaW5nIFJvb3QgRTQ1MB4XDTIwMDMxODAw MDAwMFoXDTQ1MDMxODAwMDAwMFowVzELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEds b2JhbFNpZ24gbnYtc2ExLTArBgNVBAMTJEdsb2JhbFNpZ24gRG9jdW1lbnQgU2ln bmluZyBSb290IEU0NTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIblQ9C7AGVe1koK Y4WeRQ+GIzJQVUljapzO96/0fiD5gDJbbrDv8sekLPtqWZAGdrcXjA51RDqAfMjc Aj3yzqGes0tyy8aM/cLJqoyuM1zqeUvcachWpDwoQXB0jmoaSKNCMEAwDgYDVR0P AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFGGZArQQ/xA823ra bDpwJANg8eeOMAoGCCqGSM49BAMDA2kAMGYCMQCP9ck/sU7z99GdtLoPPQqXJxCT 8lB8IonajNTKqWMkJiqLY4JjVMc08NGeehgLp+oCMQCxNY9K8vsmBsHTDY9i0bDE oF3pk9ZhxOGhuVyo9fFnXqIpN8JLxmdy/oyQ+SSAd7c= -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00 MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW +1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9 ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1 /q4AaOeMSQ+2b1tbFfLn -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS 7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp 0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 DXZDjC5Ty3zfDBeWUA== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+ ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0 2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv /PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+ p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQsw CQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcN MjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYG A1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA IgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccC WvkEN/U0NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+ 6xnOQ6OjQjBAMB0GA1UdDgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8B Af8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNnADBkAjAn7qRa qCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RHlAFWovgzJQxC36oCMB3q 4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21USAGKcw== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3 IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu +Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt 8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52 gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5 dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65 LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp 0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIGlTCCBH2gAwIBAgIRANJ/u8HeNZ5SFq1hSVhgmcQwDQYJKoZIhvcNAQEMBQAw gYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtK ZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYD VQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIx MDMyMjAwMDAwMFoXDTM4MDExODIzNTk1OVowXzELMAkGA1UEBhMCR0IxGDAWBgNV BAoTD1NlY3RpZ28gTGltaXRlZDE2MDQGA1UEAxMtU2VjdGlnbyBQdWJsaWMgU2Vy dmVyIEF1dGhlbnRpY2F0aW9uIFJvb3QgUjQ2MIICIjANBgkqhkiG9w0BAQEFAAOC Ag8AMIICCgKCAgEAk77VNlJ12AEjoBxHQknuY7a3If3EldVIKyZ8FFMQ2nn9K7ct pNQs+uoy3UnCub0PSD17WphUr55dMXRPB/xQId2kz2hPGxJjbSWZTCqZ80gwYfqB fB6nCErcPiscHxhMcao1jK34bug7StnllALWiYQTqm3ITzPMUJY3kjPcX4jnn1TZ SPCYQ9Zm/Z8XOEPFAVEL1+MjDxRdWxTnS77d9MjaAzfR1jmhIVEwg7Bt1zBOlluR 8HAkq79FgWRDDb0hOi886Z4NyyC1QifM2m+b7mQwkDnNk2WBITG1I1AzNyLjOO34 MTDMRf5i+dFdMnlCh99qzFYZQE3Oqrv5tXZJlPEn+JGlg+UGs2MOgNzgElWApjtm tDmHLcjw0NEU6eQNTQ72XVdyxTscR1ad4tX7gWGMzE2AkDRbt9cUddzYBEifwMEo iLTpHMqnsfFWt3tJTFnlIBWohAIp+jiUaZpJBo/NH3kUFxIMg3reH7GX7vmXeCik yESS6X0mBaZYcpt5E9gRX67FOGI0aLKGMI74kGGeMmz1BzbNokxu7Io27fLmmRVE cMN8vJw5wLTha/eDJSNX2RKA5UnwdQ/vjescm1QotCE8/HwK/+97a3X/ix2gGQWr +vgrgULoOLq7+6r9PeDzyt9Ol5cp7fMYVumllqy9w5CYsuD5otSmR0N8bc8CAwEA AaOCASAwggEcMB8GA1UdIwQYMBaAFFN5v1qqK0rPVIDh2JvAnfKyA2bLMB0GA1Ud DgQWBBRWc1hklfmSGrASKgRieaFAFYghSTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T AQH/BAUwAwEB/zAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwEQYDVR0g BAowCDAGBgRVHSAAMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRy dXN0LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDA1 BggrBgEFBQcBAQQpMCcwJQYIKwYBBQUHMAGGGWh0dHA6Ly9vY3NwLnVzZXJ0cnVz dC5jb20wDQYJKoZIhvcNAQEMBQADggIBADpvBIlq7bMU0cFDT/9P9+BsgCkRgQs0 S6Bf7vJSlWMHwby0VGvxCS0hrbi0K2BINZbEbsVsgpQq04431yyoVn3Hldorgq24 RldRDOOipEZDTFB9wC9HYt1thHF00XeG2C8KC1plwoEzKAIhPvefI/C3cT0CfTXJ uFjUbKIgSwjNjw6YHtLgoy/hd5+JLUlLco/gzFX/qWbT7tEquOMYpsNKWZj8TLqP q6zMiG4Na6feEZte6YPXGrMWlTWN341vDedc+yxQqSug79HJUQcOZs7KyDWztmae QxsPE49UV/8XwrfZtZaYyrs4FpD94Z4Q8dzXGL8+qEJjxgcza7W6PROaClubavd1 VKPm8+aCW77u7SxpR2TFGL6kPdxsKyFijpcunR5V79sUyROfNdzjrAcFWZXK8sbb 9FlnwuVG677JLv+ZVTX5AxLvW5OB4zt5uS+zB62wJ/Wv+jXGAttSAcJec4iFgCWH Rvdi/jJoSzRLa3nEzx6pFIzclSCnh0u1xCeLcUBypSiPga8W+6PkuoyQq8U9qs9E oxG5NvrvlyshwUS9yvcZRGw7Ljlx4jJH/BhIPR8kIBCQj1vna9TziZOrw1Of8hDU bHKFG9Pm8Dp2vbjz/2JH39qvxshPKVllGfq+5klPm7yZRUYTiCMAbqwNdL/nsqF2 Rnnyp58XRStJ -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQsw CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT U0wuY29tIFRMUyBFQ0MgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2 MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3QgQ0EgMjAyMjB2MBAG ByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWyJGYm acCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFN SeR7T5v15wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME GDAWgBSJjy+j6CugFFR781a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NW uCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp 15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w7deedWo1dlJF4AIxAMeN b0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5Zn6g6g== -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFiTCCA3GgAwIBAgIQb77arXO9CEDii02+1PdbkTANBgkqhkiG9w0BAQsFADBO MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQD DBxTU0wuY29tIFRMUyBSU0EgUm9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzQyMloX DTQ2MDgxOTE2MzQyMVowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw b3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgUlNBIFJvb3QgQ0EgMjAyMjCC AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANCkCXJPQIgSYT41I57u9nTP L3tYPc48DRAokC+X94xI2KDYJbFMsBFMF3NQ0CJKY7uB0ylu1bUJPiYYf7ISf5OY t6/wNr/y7hienDtSxUcZXXTzZGbVXcdotL8bHAajvI9AI7YexoS9UcQbOcGV0ins S657Lb85/bRi3pZ7QcacoOAGcvvwB5cJOYF0r/c0WRFXCsJbwST0MXMwgsadugL3 PnxEX4MN8/HdIGkWCVDi1FW24IBydm5MR7d1VVm0U3TZlMZBrViKMWYPHqIbKUBO L9975hYsLfy/7PO0+r4Y9ptJ1O4Fbtk085zx7AGL0SDGD6C1vBdOSHtRwvzpXGk3 R2azaPgVKPC506QVzFpPulJwoxJF3ca6TvvC0PeoUidtbnm1jPx7jMEWTO6Af77w dr5BUxIzrlo4QqvXDz5BjXYHMtWrifZOZ9mxQnUjbvPNQrL8VfVThxc7wDNY8VLS +YCk8OjwO4s4zKTGkH8PnP2L0aPP2oOnaclQNtVcBdIKQXTbYxE3waWglksejBYS d66UNHsef8JmAOSqg+qKkK3ONkRN0VHpvB/zagX9wHQfJRlAUW7qglFA35u5CCoG AtUjHBPW6dvbxrB6y3snm/vg1UYk7RBLY0ulBY+6uB0rpvqR4pJSvezrZ5dtmi2f gTIFZzL7SAg/2SW4BCUvAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0j BBgwFoAU+y437uOEeicuzRk1sTN8/9REQrkwHQYDVR0OBBYEFPsuN+7jhHonLs0Z NbEzfP/UREK5MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAjYlt hEUY8U+zoO9opMAdrDC8Z2awms22qyIZZtM7QbUQnRC6cm4pJCAcAZli05bg4vsM QtfhWsSWTVTNj8pDU/0quOr4ZcoBwq1gaAafORpR2eCNJvkLTqVTJXojpBzOCBvf R4iyrT7gJ4eLSYwfqUdYe5byiB0YrrPRpgqU+tvT5TgKa3kSM/tKWTcWQA673vWJ DPFs0/dRa1419dvAJuoSc06pkZCmF8NsLzjUo3KUQyxi4U5cMj29TH0ZR6LDSeeW P4+a0zvkEdiLA9z2tmBVGKaBUfPhqBVq6+AL8BQx1rmMRTqoENjwuSfr98t67wVy lrXEj5ZzxOhWc5y8aVFjvO9nHEMaX3cZHxj4HCUp+UmZKbaSPaKDN7EgkaibMOlq bLQjk2UEqxHzDh1TJElTHaE/nUiSEeJ9DU/1172iWD54nR4fK/4huxoTtrEoZP2w AgDHbICivRZQIA9ygV/MlP+7mea6kMvq+cYMwq7FGc4zoWtcu358NFcXrfA/rs3q r5nsLFR+jM4uElZI7xc7P0peYNLcdDa8pUNjyw9bowJWCZ4kLOGGgYz+qxcs+sji Mho6/4UIyYOf8kpIEFR3N+2ivEC+5BB09+Rbu7nzifmPQdjH5FCQNYA+HLhNkNPU 98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA= -----END CERTIFICATE----- ================================================ FILE: apps/checker/checker/dns.go ================================================ package checker import ( "context" "fmt" "net" "strings" "github.com/rs/zerolog/log" ) type DnsResponse struct { A []string `json:"a,omitempty"` AAAA []string `json:"aaaa,omitempty"` CNAME string `json:"cname,omitempty"` MX []string `json:"mx,omitempty"` NS []string `json:"ns,omitempty"` TXT []string `json:"txt,omitempty"` } func Dns(ctx context.Context, host string) (*DnsResponse, error) { logger:= log.Ctx(ctx).With().Str("monitor", host).Logger() ips, err := net.LookupIP(host) if err != nil { logger.Error().Err(err).Msg("DNS IP lookup failed") return nil, fmt.Errorf("failed to lookup IPs: %w", err) } A := []string{} AAAA := []string{} for _, ip := range ips { if ip.To4() != nil { A = append(A, ip.String()) } else { AAAA = append(AAAA, ip.String()) } } CNAME,err := lookupCNAME(host) if err != nil { logger.Error().Err(err).Msg("DNS CNAME record lookup failed") return nil, fmt.Errorf("failed to lookup CNAME record: %w", err) } MXRecords := lookupMX(host) NS,err := lookupNS(host) if err != nil { logger.Error().Err(err).Msg("DNS NS record lookup failed") return nil, fmt.Errorf("failed to lookup NS record: %w", err) } TXT := lookupTXT(host) response := &DnsResponse{ A: A, AAAA: AAAA, CNAME: CNAME, MX: MXRecords, NS: NS, TXT: TXT, } return response, nil } func lookupCNAME(domain string) (string, error) { cname, err := net.LookupCNAME(domain) if err != nil { return "", err } return cname, nil } func lookupMX(domain string) ([]string) { mx := []string{} mxRecords,_ := net.LookupMX(domain) for _, r := range mxRecords { mx = append(mx, fmt.Sprintf("%s:%d", r.Host, r.Pref)) } return mx } func lookupNS(domain string) ([]string, error) { hosts := []string{} isSubdomain := isSubdomain(domain) if isSubdomain { return hosts, nil } nsRecords, err := net.LookupNS(domain) if err != nil { return nil, err } for _, ns := range nsRecords { hosts = append(hosts, ns.Host) } return hosts, nil } func lookupTXT(domain string) ([]string) { records := []string{} txtRecords, err := net.LookupTXT(domain) if err != nil { return nil } for _, txt := range txtRecords { records = append(records, txt) } return records } func isSubdomain(domain string) bool { parent := strings.Split(domain, ".") if len(parent) < 3 { return false } return true } ================================================ FILE: apps/checker/checker/dns_test.go ================================================ package checker_test import ( "testing" "github.com/openstatushq/openstatus/apps/checker/checker" ) func TestPingDNS(t *testing.T) { ctx := t.Context() data, err := checker.Dns(ctx, "openstat.us") if err != nil { t.Errorf("Dns() error = %v", err) } if len(data.A) == 0 { t.Errorf("Dns() A records = %v", data.A) } } ================================================ FILE: apps/checker/checker/http.go ================================================ package checker import ( "bytes" "context" "crypto/tls" "encoding/base64" "errors" "fmt" "io" "net/http" "net/http/httptrace" "net/url" "strings" "time" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/rs/zerolog/log" ) type Timing struct { DnsStart int64 `json:"dnsStart"` DnsDone int64 `json:"dnsDone"` ConnectStart int64 `json:"connectStart"` ConnectDone int64 `json:"connectDone"` TlsHandshakeStart int64 `json:"tlsHandshakeStart"` TlsHandshakeDone int64 `json:"tlsHandshakeDone"` FirstByteStart int64 `json:"firstByteStart"` FirstByteDone int64 `json:"firstByteDone"` TransferStart int64 `json:"transferStart"` TransferDone int64 `json:"transferDone"` } type Response struct { Headers map[string]string `json:"headers,omitempty"` Body string `json:"body,omitempty"` Error string `json:"error,omitempty"` Region string `json:"region"` JobType string `json:"jobType"` Latency int64 `json:"latency"` Timestamp int64 `json:"timestamp"` Status int `json:"status,omitempty"` Timing Timing `json:"timing"` } // decodeBase64Body decodes a data URL base64 body if needed func decodeBase64Body(body string) ([]byte, error) { data := strings.Split(body, ",") if len(data) == 2 { return base64.StdEncoding.DecodeString(data[1]) } return nil, fmt.Errorf("invalid base64 data url format") } // FIXME: This should only return the TCP Timing Data; func Http(ctx context.Context, client *http.Client, inputData request.HttpCheckerRequest) (Response, error) { logger := log.Ctx(ctx).With().Str("monitor", inputData.URL).Logger() var bodyBytes []byte if inputData.Method == http.MethodPost { contentType := "" for _, header := range inputData.Headers { if header.Key == "Content-Type" { contentType = header.Value break } } if contentType == "application/octet-stream" { decoded, err := decodeBase64Body(inputData.Body) if err != nil { return Response{}, fmt.Errorf("error while decoding base64: %w", err) } bodyBytes = decoded } else { bodyBytes = []byte(inputData.Body) } } else { bodyBytes = []byte(inputData.Body) } req, err := http.NewRequestWithContext(ctx, inputData.Method, inputData.URL, bytes.NewReader(bodyBytes)) if err != nil { logger.Error().Err(err).Msg("error while creating req") return Response{}, fmt.Errorf("unable to create req: %w", err) } req.Header.Set("User-Agent", "OpenStatus/1.0") for _, header := range inputData.Headers { if header.Key != "" { req.Header.Set(header.Key, header.Value) } } // Maybe we should remove the default post to application JSON // Default POST Content-Type if inputData.Method == http.MethodPost && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } timing := Timing{} trace := &httptrace.ClientTrace{ DNSStart: func(_ httptrace.DNSStartInfo) { timing.DnsStart = time.Now().UTC().UnixMilli() }, DNSDone: func(_ httptrace.DNSDoneInfo) { timing.DnsDone = time.Now().UTC().UnixMilli() }, ConnectStart: func(_, _ string) { timing.ConnectStart = time.Now().UTC().UnixMilli() }, ConnectDone: func(_, _ string, _ error) { timing.ConnectDone = time.Now().UTC().UnixMilli() }, TLSHandshakeStart: func() { timing.TlsHandshakeStart = time.Now().UTC().UnixMilli() }, TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { timing.TlsHandshakeDone = time.Now().UTC().UnixMilli() }, GotConn: func(_ httptrace.GotConnInfo) { timing.FirstByteStart = time.Now().UTC().UnixMilli() }, GotFirstResponseByte: func() { timing.FirstByteDone = time.Now().UTC().UnixMilli() timing.TransferStart = time.Now().UTC().UnixMilli() }, } req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) start := time.Now() response, err := client.Do(req) latency := time.Since(start).Milliseconds() if err != nil { var urlErr *url.Error if errors.As(err, &urlErr) && urlErr.Timeout() { return Response{ Latency: latency, Timing: timing, Timestamp: start.UTC().UnixMilli(), Error: fmt.Sprintf("Timeout after %d ms", latency), }, nil } logger.Error().Err(err).Msg("error while pinging") return Response{}, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) timing.TransferDone = time.Now().UTC().UnixMilli() if err != nil { return Response{ Latency: latency, Timing: timing, Timestamp: start.UTC().UnixMilli(), Error: fmt.Sprintf("Cannot read response body: %s", err.Error()), }, err } headers := make(map[string]string) for key := range response.Header { headers[key] = response.Header.Get(key) } return Response{ Timestamp: start.UTC().UnixMilli(), Status: response.StatusCode, Headers: headers, Timing: timing, Latency: latency, Body: string(body), }, nil } ================================================ FILE: apps/checker/checker/http_test.go ================================================ package checker_test import ( "bytes" "context" "io" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" ) // RoundTripFunc . type RoundTripFunc func(req *http.Request) *http.Response // RoundTrip . func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } // NewTestClient returns *http.Client with Transport replaced to avoid making real calls func NewTestClient(fn RoundTripFunc) *http.Client { return &http.Client{ Transport: RoundTripFunc(fn), } } func Test_ping(t *testing.T) { type args struct { client *http.Client inputData request.HttpCheckerRequest } tests := []struct { name string args args want checker.Response wantErr bool }{ {name: "200", args: args{client: NewTestClient(func(req *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`OK`)), Header: make(http.Header), } }), inputData: request.HttpCheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { Key string `json:"key"` Value string `json:"value"` }{{Key: "", Value: ""}}}}, want: checker.Response{Status: 200, Body: "OK"}, wantErr: false}, {name: "200 with headers", args: args{client: NewTestClient(func(req *http.Request) *http.Response { assert.Equal(t, "Value", req.Header.Get("Test")) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`OK`)), Header: make(http.Header), } }), inputData: request.HttpCheckerRequest{URL: "https://openstat.us", CronTimestamp: 1, Headers: []struct { Key string `json:"key"` Value string `json:"value"` }{{Key: "Test", Value: "Value"}}}}, want: checker.Response{Status: 200, Body: "OK"}, wantErr: false}, {name: "500", args: args{client: NewTestClient(func(req *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewBufferString(`OK`)), Header: make(http.Header), } }), inputData: request.HttpCheckerRequest{URL: "https://openstat.us/500", CronTimestamp: 1}}, want: checker.Response{Status: 500, Body: "OK"}, wantErr: false}, {name: "Wrong url should return an error", args: args{client: &http.Client{}, inputData: request.HttpCheckerRequest{URL: "https://somethingthatwillfail.ed", CronTimestamp: 1}}, want: checker.Response{Status: 0}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := checker.Http(context.Background(), tt.args.client, tt.args.inputData) if (err != nil) != tt.wantErr { t.Errorf("Ping() error = %v, wantErr %v", err, tt.wantErr) return } if got.Status != tt.want.Status { t.Errorf("Ping() = %v, want %v", got, tt.want) } if got.Body != tt.want.Body { t.Errorf("Ping() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: apps/checker/checker/tcp.go ================================================ package checker import ( "fmt" "net" "strings" "time" ) type TCPData struct { WorkspaceID string `json:"workspaceId"` MonitorID string `json:"monitorId"` Timestamp int64 `json:"timestamp"` } type TCPResponseTiming struct { TCPStart int64 `json:"tcpStart"` TCPDone int64 `json:"tcpDone"` } type TCPResponse struct { Region string `json:"region"` ErrorMessage string `json:"errorMessage"` JobType string `json:"jobType"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` MonitorID int64 `json:"monitorId"` Timestamp int64 `json:"timestamp"` Latency int64 `json:"latency"` Timing TCPResponseTiming `json:"timing"` Error uint8 `json:"error,omitempty"` } func PingTCP(timeout int, url string) (TCPResponseTiming, error) { start := time.Now().UTC().UnixMilli() conn, err := net.DialTimeout("tcp", url, time.Duration(timeout)*time.Second) stop := time.Now().UTC().UnixMilli() if err != nil { if e := err.(*net.OpError).Timeout(); e { return TCPResponseTiming{}, fmt.Errorf("timeout after %d ms", timeout*1000) } if strings.Contains(err.Error(), "connection refused") { return TCPResponseTiming{}, fmt.Errorf("connection refused") } return TCPResponseTiming{}, fmt.Errorf("dial error: %w", err) } defer conn.Close() return TCPResponseTiming{TCPStart: start, TCPDone: stop}, nil } ================================================ FILE: apps/checker/checker/tcp_test.go ================================================ package checker_test import ( "testing" "github.com/openstatushq/openstatus/apps/checker/checker" ) func TestPingTcp(t *testing.T) { type args struct { url string timeout int } tests := []struct { name string args args want checker.TCPResponseTiming wantErr bool }{ {name: "will failed", args: args{url: "error", timeout: 60}, wantErr: true}, {name: "will be ok", args: args{url: "openstat.us:443", timeout: 60}, wantErr: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := checker.PingTCP(tt.args.timeout, tt.args.url) if (err != nil) != tt.wantErr { t.Errorf("PingTcp() error = %v, wantErr %v", err, tt.wantErr) return } if got.TCPStart == 0 && tt.wantErr == false { t.Errorf("PingTcp() = %v", got) return } if got.TCPDone == 0 && tt.wantErr == false { t.Errorf("PingTcp() = %v", got) return } }) } } ================================================ FILE: apps/checker/checker/update.go ================================================ package checker import ( "bytes" "context" "encoding/json" "fmt" "os" "strings" "github.com/rs/zerolog/log" "google.golang.org/api/option" "cloud.google.com/go/auth" cloudtasks "cloud.google.com/go/cloudtasks/apiv2" taskspb "cloud.google.com/go/cloudtasks/apiv2/cloudtaskspb" ) type UpdateData struct { MonitorId string `json:"monitorId"` Status string `json:"status"` Message string `json:"message,omitempty"` Region string `json:"region"` CronTimestamp int64 `json:"cronTimestamp"` StatusCode int `json:"statusCode,omitempty"` Latency int64 `json:"latency,omitempty"` } func UpdateStatus(ctx context.Context, updateData UpdateData) error { url := "https://openstatus-workflows.fly.dev/updateStatus" basic := "Basic " + os.Getenv("CRON_SECRET") payloadBuf := new(bytes.Buffer) c := os.Getenv("GCP_PRIVATE_KEY") c = strings.ReplaceAll(c, "\\n", "\n") opts := &auth.Options2LO{ Email: os.Getenv("GCP_CLIENT_EMAIL"), PrivateKey: []byte(c), PrivateKeyID: os.Getenv("GCP_PRIVATE_KEY_ID"), Scopes: []string{ "https://www.googleapis.com/auth/cloud-platform", }, TokenURL: "https://oauth2.googleapis.com/token", } tp, err := auth.New2LOTokenProvider(opts) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("error while creating token provider") return err } creds := auth.NewCredentials(&auth.CredentialsOptions{ TokenProvider: tp, }) client, err := cloudtasks.NewClient(ctx, option.WithAuthCredentials(creds)) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("error while creating cloud tasks client") } defer client.Close() if err := json.NewEncoder(payloadBuf).Encode(updateData); err != nil { log.Ctx(ctx).Error().Err(err).Msg("error while updating status") return err } projectID := os.Getenv("GCP_PROJECT_ID") queuePath := fmt.Sprintf("projects/%s/locations/europe-west1/queues/alerting", projectID) req := &taskspb.CreateTaskRequest{ Parent: queuePath, Task: &taskspb.Task{ // https://godoc.org/google.golang.org/genproto/googleapis/cloud/tasks/v2#HttpRequest MessageType: &taskspb.Task_HttpRequest{ HttpRequest: &taskspb.HttpRequest{ HttpMethod: taskspb.HttpMethod_POST, Url: url, Headers: map[string]string{"Authorization": basic, "Content-Type": "application/json"}, }, }, }, } // Add a payload message if one is present. req.Task.GetHttpRequest().Body = payloadBuf.Bytes() _, err = client.CreateTask(ctx, req) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("error while creating the cloud task") return fmt.Errorf("cloudtasks.CreateTask: %w", err) } return nil } ================================================ FILE: apps/checker/cmd/private/main.go ================================================ package main import ( "context" "fmt" "net/http" "os" "os/signal" "syscall" "time" "connectrpc.com/connect" "github.com/madflojo/tasks" "github.com/openstatushq/openstatus/apps/checker/pkg/job" "github.com/openstatushq/openstatus/apps/checker/pkg/scheduler" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) const ( configRefreshInterval = 10 * time.Minute ) func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Graceful shutdown on interrupt sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigChan cancel() }() fmt.Println("Launching openstatus private location checker") s := tasks.New() defer s.Stop() apiKey := getEnv("OPENSTATUS_KEY", "") monitorManager := scheduler.MonitorManager{ Client: getClient(apiKey), JobRunner: job.NewJobRunner(), Scheduler: s, } configTicker := time.NewTicker(configRefreshInterval) defer configTicker.Stop() monitorManager.UpdateMonitors(ctx) for { select { case <-ctx.Done(): return case <-configTicker.C: fmt.Println("fetching monitors") monitorManager.UpdateMonitors(ctx) } } } func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } func getClient(apiKey string) v1.PrivateLocationServiceClient { ingestUrl := getEnv("OPENSTATUS_INGEST_URL", "https://openstatus-private-location.fly.dev") client := v1.NewPrivateLocationServiceClient( http.DefaultClient, ingestUrl, connect.WithHTTPGet(), connect.WithInterceptors(NewAuthInterceptor(apiKey)), ) return client } func NewAuthInterceptor(token string) connect.UnaryInterceptorFunc { interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { return connect.UnaryFunc(func( ctx context.Context, req connect.AnyRequest, ) (connect.AnyResponse, error) { if req.Spec().IsClient { // Send a token with client requests. req.Header().Set("openstatus-token", token) } return next(ctx, req) }) } return connect.UnaryInterceptorFunc(interceptor) } ================================================ FILE: apps/checker/cmd/server/main.go ================================================ package main import ( "context" "errors" "fmt" "log/slog" "math/rand/v2" "net/http" "os" "os/signal" "syscall" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/handlers" "github.com/openstatushq/openstatus/apps/checker/pkg/logger" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/rs/zerolog/log" "go.opentelemetry.io/contrib/bridges/otelslog" // otelz "go.opentelemetry.io/contrib/bridges/otelzerolog" "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/attribute" otlploghttp "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" ) func shouldSample(event map[string]any) bool { statusCode, _ := event["status_code"].(int) durationMs, _ := event["duration_ms"].(int) // Always capture: server errors if statusCode >= 500 { return true } // Always capture: explicit errors if _, hasError := event["error"]; hasError { return true } // Always capture: slow requests (above p99 - 2s threshold) if durationMs > 2000 { return true } // Higher sampling for client errors (4xx) - 100% if statusCode >= 400 && statusCode < 500 { return true } // Random sample successful, fast requests at 20% return rand.Float64() < 0.2 } // MapToAttrs converts a map[string]any to a slice of slog.Attr func MapToAttrs(m map[string]any) []slog.Attr { attrs := make([]slog.Attr, 0, len(m)) for k, v := range m { attrs = append(attrs, toAttr(k, v)) } return attrs } func toAttr(key string, value any) slog.Attr { switch v := value.(type) { case string: return slog.String(key, v) case int: return slog.Int(key, v) case int64: return slog.Int64(key, v) case float64: return slog.Float64(key, v) case bool: return slog.Bool(key, v) case time.Time: return slog.Time(key, v) case time.Duration: return slog.Duration(key, v) case map[string]any: return slog.Group(key, mapToAny(v)...) default: return slog.Any(key, v) } } func mapToAny(m map[string]any) []any { args := make([]any, 0, len(m)*2) for k, v := range m { args = append(args, toAttr(k, v)) } return args } func Logger() gin.HandlerFunc { return func(c *gin.Context) { startTime := time.Now() // Generate or get request ID requestID := c.GetHeader("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } c.Set("requestId", requestID) // Build wide event context at request start event := map[string]any{ "timestamp": startTime.Format(time.RFC3339), "request_id": requestID, "method": c.Request.Method, "path": c.Request.URL.Path, "url": c.Request.Host + c.Request.URL.String(), "user_agent": c.GetHeader("User-Agent"), "content_type": c.GetHeader("Content-Type"), } c.Set("event", event) // Process request c.Next() // After request - capture response details duration := time.Since(startTime).Milliseconds() status := c.Writer.Status() event["status_code"] = status event["duration_ms"] = int(duration) // var requestErr error if len(c.Errors) > 0 { event["outcome"] = "error" lastErr := c.Errors.Last() event["error"] = map[string]any{ "type": "GinError", "message": lastErr.Error(), } } else { event["outcome"] = "success" } if shouldSample(event) { attrs := MapToAttrs(event) slog.LogAttrs(c.Request.Context(),slog.LevelInfo, "request done", attrs...) } log.Debug(). Int("status_code", status). Int64("duration_ms", duration). Str("request_id", requestID). Msg("Request completed") } } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { <-done cancel() }() // environment variables. var region string cronSecret := env("CRON_SECRET", "") tinyBirdToken := env("TINYBIRD_TOKEN", "") logLevel := env("LOG_LEVEL", "info") cloudProvider := env("CLOUD_PROVIDER", "fly") axiomToken := env("AXIOM_TOKEN", "") axiomDataset := env("AXIOM_DATASET", "dev") switch cloudProvider { case "fly": region = env("FLY_REGION", env("REGION", "local")) case "koyeb": region = fmt.Sprintf("koyeb_%s", env("KOYEB_REGION", env("REGION", "local"))) case "railway": region = fmt.Sprintf("railway_%s", env("RAILWAY_REPLICA_REGION", env("REGION", "local"))) default: log.Fatal().Msgf("unsupported cloud provider: %s", cloudProvider) } logger.Configure(logLevel) // Define resource with service name, version, and environment res := resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceNameKey.String("openstatus-checker"), semconv.ServiceVersionKey.String("1.0.0"), attribute.String("environment", "production"), attribute.String("cloud.provider", cloudProvider), attribute.String("cloud.region", region), ) // Set up OTLP log exporter for Axiom exporter, err := otlploghttp.New(ctx, otlploghttp.WithEndpointURL("https://eu-central-1.aws.edge.axiom.co/v1/logs"), otlploghttp.WithHeaders(map[string]string{ "Authorization": "Bearer " + axiomToken, "X-Axiom-Dataset": axiomDataset, }), ) if err != nil { log.Fatal().Err(err).Msg("failed to create OTLP exporter") } // Create log provider with resource and batch processor logProvider := sdklog.NewLoggerProvider( sdklog.WithResource(res), sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), ) defer logProvider.Shutdown(ctx) global.SetLoggerProvider(logProvider) slog.SetDefault(otelslog.NewLogger("openstatus-checker")) httpClient := &http.Client{ Timeout: 45 * time.Second, } defer httpClient.CloseIdleConnections() tinybirdClient := tinybird.NewClient(httpClient, tinyBirdToken) h := &handlers.Handler{ Secret: cronSecret, CloudProvider: cloudProvider, Region: region, TbClient: tinybirdClient, } router := gin.New() router.Use(gin.Recovery()) router.Use(Logger()) router.POST("/checker", h.HTTPCheckerHandler) router.POST("/checker/http", h.HTTPCheckerHandler) router.POST("/checker/tcp", h.TCPHandler) router.POST("/checker/dns", h.DNSHandler) router.POST("/ping/:region", h.PingRegionHandler) router.POST("/tcp/:region", h.TCPHandlerRegion) router.POST("/dns/:region", h.DNSHandlerRegion) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) }) httpServer := &http.Server{ Addr: fmt.Sprintf("0.0.0.0:%s", env("PORT", "8080")), Handler: router, } go func() { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Ctx(ctx).Error().Err(err).Msg("failed to start http server") cancel() } }() <-ctx.Done() if err := httpServer.Shutdown(ctx); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to shutdown http server") return } } func env(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } ================================================ FILE: apps/checker/fly.toml ================================================ # fly.toml app configuration file generated for openstatus-checker on 2023-11-30T20:23:20+01:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = "openstatus-checker" primary_region = "ams" [build] dockerfile = "./Dockerfile" [deploy] strategy = "canary" [env] PORT = "8080" [http_service] internal_port = 8080 force_https = true auto_stop_machines = "off" auto_start_machines = false processes = ["app"] [[vm]] cpu_kind = "shared" cpus = 2 memory_mb = 512 [[http_service.checks]] grace_period = "10s" interval = "15s" method = "GET" timeout = "5s" path = "/health" [http_service.concurrency] type = "requests" hard_limit = 1000 soft_limit = 500 ================================================ FILE: apps/checker/go.mod ================================================ module github.com/openstatushq/openstatus/apps/checker go 1.25.2 require ( cloud.google.com/go/auth v0.18.2 cloud.google.com/go/cloudtasks v1.13.7 connectrpc.com/connect v1.19.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/madflojo/tasks v1.2.1 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/bridges/otelslog v0.16.0 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 go.opentelemetry.io/otel/log v0.17.0 go.opentelemetry.io/otel/metric v1.41.0 go.opentelemetry.io/otel/sdk v1.41.0 go.opentelemetry.io/otel/sdk/log v0.17.0 go.opentelemetry.io/otel/sdk/metric v1.41.0 google.golang.org/api v0.269.0 google.golang.org/protobuf v1.36.11 ) require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: apps/checker/go.sum ================================================ cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/cloudtasks v1.13.7 h1:H2v8GEolNtMFfYzUpZBaZbydqU7drpyo99GtAgA+m4I= cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/madflojo/tasks v1.2.1 h1:0HMN1RCVf6yDjrlIbthkET1KCB+gxknQG3/SLO+HHj4= github.com/madflojo/tasks v1.2.1/go.mod h1:/WMv6u3Xb5eyy+aIM76ildaIT166GOxN/jya9oI7dyo= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.16.0 h1:ZtVk8SzgioZhBJmJoezi6Jl5uuXoNVLnZxcJCDTqSbM= go.opentelemetry.io/contrib/bridges/otelslog v0.16.0/go.mod h1:p0C45DA3hvvo+5hwDilrMIp43ddVBGmwWEHZft4pY6c= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 h1:GcSx2UgcMuQEu0vHq823xR5LCN3WqEx5yKhqDkv1pwY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0/go.mod h1:ctNT8t8Vzx9sb1oWAozighT3guWorr8xdCboBvkT5yg= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c= go.opentelemetry.io/otel/log v0.17.0 h1:blZWM4y7n+KSa9OywwGWyBMPpeVoCl/NCw+jMps8afM= go.opentelemetry.io/otel/log v0.17.0/go.mod h1:VXhjKYep6/laSgf/tjdh2SMAt18Z9XotBFBO0jxSE24= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk/log v0.17.0 h1:stWOgJB8bWieSlX4VO+gD7BrRZ/Dh1H/u7115amleGE= go.opentelemetry.io/otel/sdk/log v0.17.0/go.mod h1:LQKPUyHraLka2sRvNQ5+W456+sElomqR7VWpOnOefZg= go.opentelemetry.io/otel/sdk/log/logtest v0.17.0 h1:Z4S9W5piCH88itCkWDtX5ppRgO0UTkLXVK/6tPOMM2w= go.opentelemetry.io/otel/sdk/log/logtest v0.17.0/go.mod h1:d9iIX/BwLfu1BTPxO0wi4ucyCenCckfuf9LC0aJDjqM= go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4= google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: apps/checker/handlers/checker.go ================================================ package handlers import ( "encoding/json" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v4" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" "github.com/openstatushq/openstatus/apps/checker/request" ) type statusCode int func (s statusCode) IsSuccessful() bool { return s >= 200 && s < 300 } type PingData struct { ID string `json:"id"` WorkspaceID string `json:"workspaceId"` MonitorID string `json:"monitorId"` URL string `json:"url"` Method string `json:"method"` Region string `json:"region"` Message string `json:"message,omitempty"` Timing string `json:"timing,omitempty"` Headers string `json:"headers,omitempty"` Assertions string `json:"assertions"` Body string `json:"body,omitempty"` Trigger string `json:"trigger,omitempty"` RequestStatus string `json:"requestStatus,omitempty"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` Error uint8 `json:"error"` } func (h Handler) HTTPCheckerHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 dataSourceName := "ping_response__v8" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.CloudProvider == "fly" { // if the request has been routed to a wrong region, we forward it to the correct one. region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } var req request.HttpCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // We need a new client for each request to avoid connection reuse. requestClient := &http.Client{ Timeout: time.Duration(req.Timeout) * time.Millisecond, } // Configure redirect policy based on FollowRedirects setting if !req.FollowRedirects { requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } } else { // Explicitly limit the number of redirects to 10 (Go's default) requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return http.ErrUseLastResponse } return nil } } defer requestClient.CloseIdleConnections() // Might be a more efficient way to do it var i interface{} = req.RawAssertions jsonBytes, _ := json.Marshal(i) assertionAsString := string(jsonBytes) if assertionAsString == "null" { assertionAsString = "" } trigger := "cron" if req.Trigger != "" { trigger = req.Trigger } var called int var result checker.Response retry := defaultRetry if req.Retry != 0 { retry = int(req.Retry) } op := func() error { called++ res, err := checker.Http(ctx, requestClient, req) if err != nil { return fmt.Errorf("unable to ping: %w", err) } // In TB we need to store them as string timingAsString, err := json.Marshal(res.Timing) if err != nil { return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(res.Headers) if err != nil { return fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } id, err := uuid.NewV7() if err != nil { return fmt.Errorf("error while generating uuid %w", err) } var requestStatus = "" switch req.Status { case "active": requestStatus = "success" case "error": requestStatus = "error" case "degraded": requestStatus = "degraded" } data := PingData{ ID: id.String(), Latency: res.Latency, StatusCode: res.Status, MonitorID: req.MonitorID, Region: h.Region, WorkspaceID: req.WorkspaceID, Timestamp: res.Timestamp, CronTimestamp: req.CronTimestamp, URL: req.URL, Method: req.Method, Timing: string(timingAsString), Headers: string(headersAsString), Body: string(res.Body), Trigger: trigger, RequestStatus: requestStatus, } var isSuccessfull bool = true isSuccessfull, err = EvaluateHTTPAssertions(req.RawAssertions, data, res) if err != nil { return err } // let's retry at least once if the status code is not successful. if !isSuccessfull && called < retry { return fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } result = res result.Region = h.Region result.JobType = "http" // it's in error if not successful if isSuccessfull { data.Error = 0 if req.DegradedAfter != 0 && res.Latency > req.DegradedAfter { data.Body = res.Body } else { data.Body = "" } // Small trick to avoid sending the body at the moment to TB } else { data.Error = 1 result.Error = "Error" } data.Assertions = assertionAsString if !isSuccessfull && req.Status != "error" { // Q: Why here we do not check if the status was previously active? checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", StatusCode: res.Status, Region: h.Region, Message: res.Error, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "error" } // it's degraded if isSuccessfull && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter && req.Status != "degraded" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "degraded", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "degraded" } // it's active if isSuccessfull && req.DegradedAfter == 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "success" } // it's active if isSuccessfull && res.Latency < req.DegradedAfter && req.DegradedAfter != 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "success" } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } e, f := c.Get("event") if f { t := e.(map[string]any) t["checker"] = map[string]string{ "uri": req.URL, "workspace_id": req.WorkspaceID, "monitor_id":req.MonitorID, "trigger": trigger, "type": "http", } c.Set("event", t) } return nil } if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") return } data := PingData{ ID: id.String(), URL: req.URL, Method: req.Method, Region: h.Region, Message: err.Error(), CronTimestamp: req.CronTimestamp, Timestamp: req.CronTimestamp, MonitorID: req.MonitorID, WorkspaceID: req.WorkspaceID, Error: 1, Assertions: assertionAsString, Body: "", Trigger: trigger, RequestStatus: "error", } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } if req.Status != "error" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", Message: err.Error(), Region: h.Region, CronTimestamp: req.CronTimestamp, }) } } if req.OtelConfig.Endpoint != "" { otelOS.RecordHTTPMetrics(ctx, req, result, h.Region) } returnData := c.Query("data") if returnData == "true" { if len(result.Body) > 1024 { result.Body = result.Body[:1000] } c.JSON(http.StatusOK, result) return } c.JSON(http.StatusOK, nil) } func EvaluateHTTPAssertions(raw []json.RawMessage, data PingData, res checker.Response) (bool, error) { statusCode := statusCode(res.Status) if len(raw) == 0 { return statusCode.IsSuccessful(), nil } isSuccessful := true for _, a := range raw { var assert request.Assertion if err := json.Unmarshal(a, &assert); err != nil { return false, fmt.Errorf("unable to unmarshal assertion: %w", err) } switch assert.AssertionType { case request.AssertionHeader: var target assertions.HeaderTarget if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal HeaderTarget: %w", err) } isSuccessful = isSuccessful && target.HeaderEvaluate(data.Headers) case request.AssertionTextBody: var target assertions.StringTargetType if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal StringTargetType: %w", err) } isSuccessful = isSuccessful && target.StringEvaluate(data.Body) case request.AssertionStatus: var target assertions.StatusTarget if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal StatusTarget: %w", err) } isSuccessful = isSuccessful && target.StatusEvaluate(int64(res.Status)) case request.AssertionJsonBody: // TODO: Implement JSON body assertion default: fmt.Println("unknown assertion type: ", assert.AssertionType) // TODO: Handle unknown assertion type } } return isSuccessful, nil } ================================================ FILE: apps/checker/handlers/checker_test.go ================================================ package handlers_test import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/handlers" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/stretchr/testify/assert" ) func TestHandler_HTTPCheckerHandler(t *testing.T) { hclient := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusAccepted, Body: io.NopCloser(strings.NewReader(`Status Accepted`)), } })} client := tinybird.NewClient(hclient, "apiKey") t.Run("it should return 401 if there's no auth", func(t *testing.T) { region := "local" h := handlers.Handler{ TbClient: client, Secret: "", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.HTTPCheckerHandler) w := httptest.NewRecorder() data := request.HttpCheckerRequest{ URL: "https://www.openstatus.dev", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) router.ServeHTTP(w, req) assert.Equal(t, 401, w.Code) }) t.Run("it should return 400 if the payload is not ok", func(t *testing.T) { region := "local" h := handlers.Handler{ TbClient: client, Secret: "test", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.HTTPCheckerHandler) w := httptest.NewRecorder() data := request.PingRequest{ URL: "https://www.openstatus.dev", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) req.Header.Set("Authorization", "Basic test") router.ServeHTTP(w, req) assert.Equal(t, 400, w.Code) assert.Contains(t, w.Body.String(), "{\"error\":\"invalid request\"}") }) t.Run("it should return 200 if the payload is not ok", func(t *testing.T) { region := "local" httptest.NewRequest(http.MethodGet, "http://www.openstatus.dev", nil) httptest.NewRecorder() h := handlers.Handler{ TbClient: client, Secret: "test", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.HTTPCheckerHandler) w := httptest.NewRecorder() data := request.HttpCheckerRequest{ URL: "https://www.openstatus.dev", Method: "GET", Body: "", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) req.Header.Set("Authorization", "Basic test") router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) fmt.Println(w.Body.String()) }) } func TestEvaluateAssertions_raw(t *testing.T) { // Helper to marshal assertion marshal := func(a any) json.RawMessage { b, _ := json.Marshal(a) return b } // Success if no assertions and status code is 200 t.Run("no assertions, status code 200", func(t *testing.T) { raw := []json.RawMessage{} data := handlers.PingData{} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.True(t, ok) assert.NoError(t, err) }) // Header assertion success t.Run("header assertion success", func(t *testing.T) { assertion := request.Assertion{AssertionType: request.AssertionHeader} target := struct { request.Assertion Comparator request.StringComparator `json:"compare"` Key string `json:"key"` Target string `json:"target"` }{ assertion, request.StringContains, "X-Test", "ok", } rawMsg := marshal(target) raw := []json.RawMessage{rawMsg} data := handlers.PingData{Headers: `{"X-Test":"ok-value"}`} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.True(t, ok) assert.NoError(t, err) }) t.Run("header assertion failed", func(t *testing.T) { assertion := request.Assertion{AssertionType: request.AssertionHeader} target := struct { request.Assertion Comparator request.StringComparator `json:"compare"` Key string `json:"key"` Target string `json:"target"` }{ assertion, request.StringContains, "X-Test", "not-ok", } rawMsg := marshal(target) raw := []json.RawMessage{rawMsg} data := handlers.PingData{Headers: `{"X-Test":"ok-value"}`} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.False(t, ok) assert.NoError(t, err) }) // Text body assertion failure t.Run("text body assertion failure", func(t *testing.T) { assertion := request.Assertion{AssertionType: request.AssertionTextBody} target := struct { request.Assertion Comparator request.StringComparator `json:"compare"` Target string `json:"target"` }{ assertion, request.StringEquals, "fail", } rawMsg := marshal(target) raw := []json.RawMessage{rawMsg} data := handlers.PingData{Body: "ok"} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.False(t, ok) assert.NoError(t, err) }) // Text body assertion failure t.Run("text body assertion success", func(t *testing.T) { assertion := request.Assertion{AssertionType: request.AssertionTextBody} target := struct { request.Assertion Comparator request.StringComparator `json:"compare"` Target string `json:"target"` }{ assertion, request.StringEquals, "success", } rawMsg := marshal(target) raw := []json.RawMessage{rawMsg} data := handlers.PingData{Body: "success"} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.True(t, ok) assert.NoError(t, err) }) // Status assertion success t.Run("status assertion success", func(t *testing.T) { assertion := request.Assertion{AssertionType: request.AssertionStatus} target := struct { request.Assertion Comparator request.NumberComparator `json:"compare"` Target int64 `json:"target"` }{ assertion, request.NumberEquals, 200, } rawMsg := marshal(target) raw := []json.RawMessage{rawMsg} data := handlers.PingData{} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.True(t, ok) assert.NoError(t, err) }) // Malformed assertion t.Run("malformed assertion", func(t *testing.T) { raw := []json.RawMessage{[]byte(`{not valid json}`)} data := handlers.PingData{} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.False(t, ok) assert.Error(t, err) }) // Unknown assertion type t.Run("unknown assertion type", func(t *testing.T) { assertion := request.Assertion{AssertionType: "unknown"} rawMsg := marshal(assertion) raw := []json.RawMessage{rawMsg} data := handlers.PingData{} res := checker.Response{Status: 200} ok, err := handlers.EvaluateHTTPAssertions(raw, data, res) assert.True(t, ok) // Should not fail, just skip assert.NoError(t, err) }) } ================================================ FILE: apps/checker/handlers/dns.go ================================================ package handlers import ( "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/rs/zerolog/log" "github.com/cenkalti/backoff/v5" ) type DNSResponse struct { ID string `json:"id"` ErrorMessage string `json:"errorMessage"` Region string `json:"region"` Trigger string `json:"trigger"` URI string `json:"uri"` RequestStatus string `json:"requestStatus,omitempty"` Assertions string `json:"assertions"` Records map[string][]string `json:"records"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` MonitorID int64 `json:"monitorId"` Timestamp int64 `json:"timestamp"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` } func (h Handler) DNSHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 dataSourceName := "dns_response__v0" // Authorization check if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } // Fly region forwarding if h.CloudProvider == "fly" { region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } // Parse request var req request.DNSCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"}) return } monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid monitor id"}) return } trigger := req.Trigger if trigger == "" { trigger = "cron" } retry := defaultRetry if req.Retry != 0 { retry = int(req.Retry) } id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to generate UUID") return } statusMap := map[string]string{ "active": "success", "error": "error", "degraded": "degraded", } requestStatus := statusMap[req.Status] data := DNSResponse{ ID: id.String(), Region: h.Region, Trigger: trigger, URI: req.URI, WorkspaceID: workspaceId, MonitorID: monitorId, CronTimestamp: req.CronTimestamp, RequestStatus: requestStatus, Timestamp: time.Now().UTC().UnixMilli(), } var ( latency int64 isSuccessful = true called int ) op := func() (*checker.DnsResponse, error) { called++ log.Ctx(ctx).Debug().Msgf("performing dns check for %s (attempt %d/%d)", req.URI, called, retry) start := time.Now().UTC().UnixMilli() response, err := checker.Dns(ctx, req.URI) latency = time.Now().UTC().UnixMilli() - start if err != nil { log.Ctx(ctx).Error().Err(err).Msg("dns check failed") return nil, err } if len(req.RawAssertions) > 0 { log.Ctx(ctx).Debug().Msgf("evaluating %d dns assertions", len(req.RawAssertions)) isSuccessful, err = EvaluateDNSAssertions(req.RawAssertions, response) if err != nil { return response, backoff.Permanent(err) } } if !isSuccessful && called < retry { return nil, backoff.RetryAfter(1) } if !isSuccessful { log.Ctx(ctx).Debug().Msg("dns assertions failed") return response, backoff.Permanent(fmt.Errorf("assertion failed")) } return response, nil } result, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) data.Latency = latency if result != nil { data.Records = FormatDNSResult(result) } if len(req.RawAssertions) > 0 { if j, err := json.Marshal(req.RawAssertions); err == nil { data.Assertions = string(j) } else { log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") } } // Status update logic switch { case !isSuccessful: log.Ctx(ctx).Debug().Msg("DNS check failed assertions") data.RequestStatus = "error" data.Error = 1 data.ErrorMessage = err.Error() if req.Status != "error" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", Region: h.Region, Message: err.Error(), CronTimestamp: req.CronTimestamp, Latency: latency, }) } case isSuccessful && req.DegradedAfter > 0 && latency > req.DegradedAfter && req.Status != "degraded": checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "degraded", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) data.RequestStatus = "degraded" case isSuccessful && ((req.DegradedAfter == 0 && req.Status != "active") || (latency < req.DegradedAfter && req.DegradedAfter != 0 && req.Status != "active")): checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) data.RequestStatus = "success" } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } event, f := c.Get("event") if f { t := event.(map[string]any) t["checker"] = map[string]string{ "uri": req.URI, "workspace_id": req.WorkspaceID, "monitor_id":req.MonitorID, "trigger": trigger, "type": "dns", } c.Set("event", t) } c.JSON(http.StatusOK, data) } func (h Handler) DNSHandlerRegion(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "check_dns_response__v0" const defaultRetry = 3 // Authorization check if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } // Fly region forwarding if h.CloudProvider == "fly" { region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } // Parse request var req request.DNSCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } retry := defaultRetry if req.Retry != 0 { retry = int(req.Retry) } id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to generate UUID") return } workspaceId, _ := strconv.Atoi(req.WorkspaceID) statusMap := map[string]string{ "active": "success", "error": "error", "degraded": "degraded", } requestStatus := statusMap[req.Status] data := DNSResponse{ ID: id.String(), Region: h.Region, URI: req.URI, WorkspaceID: int64(workspaceId), CronTimestamp: req.CronTimestamp, RequestStatus: requestStatus, Timestamp: time.Now().UTC().UnixMilli(), } var ( latency int64 isSuccessful = true called int ) op := func() (*checker.DnsResponse, error) { called++ log.Ctx(ctx).Debug().Msgf("performing dns check for %s (attempt %d/%d)", req.URI, called, retry) start := time.Now().UTC().UnixMilli() response, err := checker.Dns(ctx, req.URI) latency = time.Now().UTC().UnixMilli() - start if err != nil { log.Ctx(ctx).Error().Err(err).Msg("dns check failed") return nil, err } if len(req.RawAssertions) > 0 { log.Ctx(ctx).Debug().Msgf("evaluating %d dns assertions", len(req.RawAssertions)) isSuccessful, err = EvaluateDNSAssertions(req.RawAssertions, response) if err != nil { return nil, backoff.Permanent(err) } } if !isSuccessful && called < retry { return nil, backoff.RetryAfter(1) } if !isSuccessful { log.Ctx(ctx).Debug().Msg("dns assertions failed") return response, backoff.Permanent(fmt.Errorf("assertion failed")) } return response, nil } result, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) data.Latency = latency if len(req.RawAssertions) > 0 { if j, err := json.Marshal(req.RawAssertions); err == nil { data.Assertions = string(j) } else { log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") } } if err != nil { c.JSON(http.StatusOK, gin.H{"message": "uri not reachable"}) return } data.Records = FormatDNSResult(result) if req.RequestId != 0 { if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } } c.JSON(http.StatusOK, data) } func FormatDNSResult(result *checker.DnsResponse) map[string][]string { r := make(map[string][]string) a := make([]string, 0) aaaa := make([]string, 0) mx := make([]string, 0) ns := make([]string, 0) txt := make([]string, 0) for _, v := range result.A { a = append(a, v) } r["A"] = a for _, v := range result.AAAA { aaaa = append(aaaa, v) } r["AAAA"] = aaaa r["CNAME"] = []string{result.CNAME} for _, v := range result.MX { mx = append(mx, v) } r["MX"] = mx for _, v := range result.NS { ns = append(ns, v) } r["NS"] = ns for _, v := range result.TXT { txt = append(txt, v) } r["TXT"] = txt return r } func EvaluateDNSAssertions(rawAssertions []json.RawMessage, response *checker.DnsResponse) (bool, error) { for _, a := range rawAssertions { var assert assertions.RecordTarget if err := json.Unmarshal(a, &assert); err != nil { return false, fmt.Errorf("unable to parse assertion: %w", err) } var isSuccessfull bool switch assert.Key { case request.RecordA: isSuccessfull = assert.RecordEvaluate(response.A) case request.RecordAAAA: isSuccessfull = assert.RecordEvaluate(response.AAAA) case request.RecordCNAME: isSuccessfull = assert.RecordEvaluate([]string{response.CNAME}) case request.RecordMX: isSuccessfull = assert.RecordEvaluate(response.MX) case request.RecordNS: isSuccessfull = assert.RecordEvaluate(response.NS) case request.RecordTXT: isSuccessfull = assert.RecordEvaluate(response.TXT) default: return false, fmt.Errorf("unknown record type in assertion: %s", assert.Key) } if !isSuccessfull { return false, nil } } return true, nil } ================================================ FILE: apps/checker/handlers/dns_test.go ================================================ package handlers_test import ( "encoding/json" "reflect" "testing" // Adjust the import path if necessary to point to the correct package "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/handlers" ) // Mock DNSResult struct to match the expected input for FormatDNSResult. // If the real struct is in another package, import it accordingly. type DNSResult struct { A []string AAAA []string CNAME string MX []string NS []string TXT []string } func TestFormatDNSResult(t *testing.T) { tests := []struct { name string input DNSResult expected map[string][]string }{ { name: "All fields populated", input: DNSResult{ A: []string{"1.2.3.4", "5.6.7.8"}, AAAA: []string{"::1", "2001:db8::1"}, CNAME: "example.com", MX: []string{"mx1.example.com", "mx2.example.com"}, NS: []string{"ns1.example.com", "ns2.example.com"}, TXT: []string{"v=spf1", "google-site-verification=abc"}, }, expected: map[string][]string{ "A": {"1.2.3.4", "5.6.7.8"}, "AAAA": {"::1", "2001:db8::1"}, "CNAME": {"example.com"}, "MX": {"mx1.example.com", "mx2.example.com"}, "NS": {"ns1.example.com", "ns2.example.com"}, "TXT": {"v=spf1", "google-site-verification=abc"}, }, }, { name: "Empty fields", input: DNSResult{ A: []string{}, AAAA: []string{}, CNAME: "", MX: []string{}, NS: []string{}, TXT: []string{}, }, expected: map[string][]string{ "A": {}, "AAAA": {}, "CNAME": {""}, "MX": {}, "NS": {}, "TXT": {}, }, }, { name: "Single values", input: DNSResult{ A: []string{"8.8.8.8"}, AAAA: []string{"fe80::1"}, CNAME: "single.example.com", MX: []string{"mx.single.com"}, NS: []string{"ns.single.com"}, TXT: []string{"single-txt"}, }, expected: map[string][]string{ "A": {"8.8.8.8"}, "AAAA": {"fe80::1"}, "CNAME": {"single.example.com"}, "MX": {"mx.single.com"}, "NS": {"ns.single.com"}, "TXT": {"single-txt"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := handlers.FormatDNSResult(&checker.DnsResponse{ A: tt.input.A, AAAA: tt.input.AAAA, CNAME: tt.input.CNAME, MX: tt.input.MX, NS: tt.input.NS, TXT: tt.input.TXT, }) if !reflect.DeepEqual(got, tt.expected) { t.Errorf("FormatDNSResult() = %v, want %v", got, tt.expected) } }) } } func TestEvaluateDNSAssertions(t *testing.T) { type args struct { rawAssertions []json.RawMessage response *checker.DnsResponse } tests := []struct { name string args args wantSuccess bool wantErr bool }{ { name: "A record matches", args: args{ rawAssertions: []json.RawMessage{ json.RawMessage(`{"key":"A","compare":"eq","target":"1.2.3.4"}`), }, response: &checker.DnsResponse{ A: []string{"1.2.3.4", "5.6.7.8"}, }, }, wantSuccess: true, wantErr: false, }, { name: "CNAME does not match", args: args{ rawAssertions: []json.RawMessage{ json.RawMessage(`{"key":"CNAME","compare":"eq","target":"not-example.com"}`), }, response: &checker.DnsResponse{ CNAME: "example.com", }, }, wantSuccess: false, wantErr: false, }, { name: "CNAME Contains", args: args{ rawAssertions: []json.RawMessage{ json.RawMessage(`{"version":"v1","type":"dnsRecord","key":"CNAME","compare":"contains","target":"openstatus.dev"}`), }, response: &checker.DnsResponse{ CNAME: "openstatus.dev.", }, }, wantSuccess: true, wantErr: false, }, { name: "Unknown record type", args: args{ rawAssertions: []json.RawMessage{ json.RawMessage(`{"key":"FOO","compare":"eq","target":"bar"}`), }, response: &checker.DnsResponse{}, }, wantSuccess: false, wantErr: true, }, { name: "Invalid assertion JSON", args: args{ rawAssertions: []json.RawMessage{ json.RawMessage(`not a json`), }, response: &checker.DnsResponse{}, }, wantSuccess: false, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := handlers.EvaluateDNSAssertions(tt.args.rawAssertions, tt.args.response) if (err != nil) != tt.wantErr { t.Errorf("error = %v, wantErr %v", err, tt.wantErr) } if got != tt.wantSuccess { t.Errorf("EvaluateDNSAssertions() = %v, want %v", got, tt.wantSuccess) } }) } } ================================================ FILE: apps/checker/handlers/handler.go ================================================ package handlers import ( "net/http" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" ) type Handler struct { TbClient tinybird.Client Secret string CloudProvider string Region string } // Authorization could be handle by middleware func NewHTTPClient() *http.Client { return &http.Client{} } ================================================ FILE: apps/checker/handlers/ping.go ================================================ package handlers import ( "encoding/json" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v4" "github.com/gin-gonic/gin" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/rs/zerolog/log" ) type PingResponse struct { Body string `json:"body,omitempty"` Headers string `json:"headers,omitempty"` Region string `json:"region"` Timing string `json:"timing,omitempty"` RequestId int64 `json:"requestId,omitempty"` WorkspaceId int64 `json:"workspaceId,omitempty"` Latency int64 `json:"latency"` Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` } type Response struct { Headers map[string]string `json:"headers,omitempty"` Error string `json:"error,omitempty"` Body string `json:"body,omitempty"` Region string `json:"region"` Tags []string `json:"tags,omitempty"` RequestId int64 `json:"requestId,omitempty"` WorkspaceId int64 `json:"workspaceId,omitempty"` Latency int64 `json:"latency"` Timestamp int64 `json:"timestamp"` Timing checker.Timing `json:"timing"` Status int `json:"status,omitempty"` } func (h Handler) PingRegionHandler(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "check_response_http__v0" region := c.Param("region") if region == "" { c.String(http.StatusBadRequest, "region is required") return } fmt.Printf("Start of /ping/%s\n", region) if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.CloudProvider == "fly" { if region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } // We need a new client for each request to avoid connection reuse. requestClient := &http.Client{ Timeout: 45 * time.Second, } defer requestClient.CloseIdleConnections() var req request.PingRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } var res checker.Response op := func() error { headers := make([]struct { Key string `json:"key"` Value string `json:"value"` }, 0) for key, value := range req.Headers { headers = append(headers, struct { Key string `json:"key"` Value string `json:"value"` }{Key: key, Value: value}) } input := request.HttpCheckerRequest{ Headers: headers, URL: req.URL, Method: req.Method, Body: req.Body, } r, err := checker.Http(c.Request.Context(), requestClient, input) if err != nil { return fmt.Errorf("unable to ping: %w", err) } timingAsString, err := json.Marshal(r.Timing) if err != nil { return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(r.Headers) if err != nil { return nil } tbData := PingResponse{ RequestId: req.RequestId, WorkspaceId: req.WorkspaceId, StatusCode: r.Status, Latency: r.Latency, Body: r.Body, Headers: string(headersAsString), Timestamp: r.Timestamp, Timing: string(timingAsString), Region: h.Region, } res = r res.Region = h.Region if tbData.RequestId != 0 { if err := h.TbClient.SendEvent(ctx, tbData, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } } return nil } if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { c.JSON(http.StatusOK, gin.H{"message": "url not reachable"}) return } c.JSON(http.StatusOK, res) } ================================================ FILE: apps/checker/handlers/ping_test.go ================================================ package handlers_test import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/openstatushq/openstatus/apps/checker/handlers" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) type RoundTripFunc func(req *http.Request) *http.Response func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } func TestHandler_PingRegion(t *testing.T) { hclient := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusAccepted, Body: io.NopCloser(strings.NewReader(`Status Accepted`)), } })} client := tinybird.NewClient(hclient, "apiKey") t.Run("it should return 401 if there's no auth", func(t *testing.T) { region := "local" h := handlers.Handler{ TbClient: client, Secret: "", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.PingRegionHandler) w := httptest.NewRecorder() data := request.PingRequest{ URL: "https://www.openstatus.dev", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) router.ServeHTTP(w, req) assert.Equal(t, 401, w.Code) }) t.Run("it should return 400 if the payload is not ok", func(t *testing.T) { region := "local" h := handlers.Handler{ TbClient: client, Secret: "test", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.PingRegionHandler) w := httptest.NewRecorder() data := request.HttpCheckerRequest{ URL: "https://www.openstatus.dev", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) req.Header.Set("Authorization", "Basic test") router.ServeHTTP(w, req) assert.Equal(t, 400, w.Code) assert.Contains(t, w.Body.String(), "{\"error\":\"invalid request\"}") }) t.Run("it should return 200 if the payload is ok", func(t *testing.T) { region := "local" httptest.NewRequest(http.MethodGet, "http://www.openstatus.dev", nil) httptest.NewRecorder() h := handlers.Handler{ TbClient: client, Secret: "test", CloudProvider: "fly", Region: region, } router := gin.New() router.POST("/checker/:region", h.PingRegionHandler) w := httptest.NewRecorder() data := request.PingRequest{ URL: "https://www.openstatus.dev", Method: "GET", Headers: map[string]string{}, Body: "", } dataJson, _ := json.Marshal(data) req, _ := http.NewRequest(http.MethodPost, "/checker/"+region, strings.NewReader(string(dataJson))) req.Header.Set("Authorization", "Basic test") router.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) fmt.Println(w.Body.String()) }) } ================================================ FILE: apps/checker/handlers/tcp.go ================================================ package handlers import ( "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/cenkalti/backoff/v4" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/rs/zerolog/log" ) // Only used for Tinybird. type TCPData struct { ID string `json:"id"` Timing string `json:"timing"` ErrorMessage string `json:"errorMessage"` Region string `json:"region"` Trigger string `json:"trigger"` URI string `json:"uri"` RequestStatus string `json:"requestStatus,omitempty"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` MonitorID int64 `json:"monitorId"` Timestamp int64 `json:"timestamp"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` } func (h Handler) TCPHandler(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "tcp_response__v0" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.CloudProvider == "fly" { // if the request has been routed to a wrong region, we forward it to the correct one. region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } var req request.TCPCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } var trigger = "cron" if req.Trigger != "" { trigger = req.Trigger } e, f := c.Get("event") if f { t := e.(map[string]any) t["checker"] = map[string]string{ "uri": req.URI, "workspace_id": req.WorkspaceID, "monitor_id":req.MonitorID, "trigger": trigger, "type": "tcp", } c.Set("event", t) } var response checker.TCPResponse var retry int if req.Retry != 0 { retry = int(req.Retry) } else { retry = 3 } op := func() error { res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { return fmt.Errorf("unable to check tcp %s", err) } timingAsString, err := json.Marshal(res) if err != nil { return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart var requestStatus = "" switch req.Status { case "active": requestStatus = "success" case "error": requestStatus = "error" case "degraded": requestStatus = "degraded" } id, err := uuid.NewV7() if err != nil { return fmt.Errorf("error while generating uuid %w", err) } data := TCPData{ ID: id.String(), WorkspaceID: workspaceId, Timestamp: res.TCPStart, Error: 0, ErrorMessage: "", Region: h.Region, MonitorID: monitorId, Timing: string(timingAsString), Latency: latency, CronTimestamp: req.CronTimestamp, Trigger: trigger, URI: req.URI, RequestStatus: requestStatus, } response = checker.TCPResponse{ Timestamp: res.TCPStart, Timing: checker.TCPResponseTiming{ TCPStart: res.TCPStart, TCPDone: res.TCPDone, }, Latency: latency, Region: h.Region, JobType: "tcp", } if req.DegradedAfter == 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) data.RequestStatus = "success" } if (req.DegradedAfter > 0 && latency < req.DegradedAfter) && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) data.RequestStatus = "success" } if req.DegradedAfter > 0 && latency > req.DegradedAfter && req.Status != "degraded" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "degraded", Region: h.Region, CronTimestamp: req.CronTimestamp, Latency: latency, }) data.RequestStatus = "degraded" } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } return nil } if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") return } data := TCPData{ ID: id.String(), WorkspaceID: workspaceId, CronTimestamp: req.CronTimestamp, ErrorMessage: err.Error(), Region: h.Region, MonitorID: monitorId, Error: 1, Trigger: trigger, URI: req.URI, RequestStatus: "error", } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", Message: err.Error(), Region: h.Region, CronTimestamp: req.CronTimestamp, }) } returnData := c.Query("data") if returnData == "true" { c.JSON(http.StatusOK, response) return } c.JSON(http.StatusOK, nil) } func (h Handler) TCPHandlerRegion(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "check_tcp_response__v1" region := c.Param("region") if region == "" { c.String(http.StatusBadRequest, "region is required") return } if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.CloudProvider == "fly" { // if the request has been routed to a wrong region, we forward it to the correct one. region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } var req request.TCPCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } var called int var response checker.TCPResponse op := func() error { called++ timestamp := time.Now().UTC().UnixMilli() res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { return fmt.Errorf("unable to check tcp %s", err) } response = checker.TCPResponse{ Timestamp: timestamp, Timing: checker.TCPResponseTiming{ TCPStart: res.TCPStart, TCPDone: res.TCPDone, }, Latency: res.TCPDone - res.TCPStart, Region: h.Region, JobType: "tcp", } timingAsString, err := json.Marshal(res) if err != nil { return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart data := TCPData{ CronTimestamp: req.CronTimestamp, Timestamp: res.TCPStart, Error: 0, ErrorMessage: "", Region: h.Region, Timing: string(timingAsString), Latency: latency, RequestId: req.RequestId, Trigger: "api", URI: req.URI, } if req.RequestId != 0 { if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } } return nil } if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { c.JSON(http.StatusOK, gin.H{"message": "uri not reachable"}) return } if req.OtelConfig.Endpoint != "" { otelOS.RecordTCPMetrics(ctx, req, response, region) } c.JSON(http.StatusOK, response) } ================================================ FILE: apps/checker/justfile ================================================ build-probe: go build -o probe ./cmd/server/main.go build-private: go build -o provider ./cmd/private/main.go dev-probe: air -c .probe.air.toml dev-private: air -c .private.air.toml clean: echo "Cleaning..." rm -f main test: echo "Running tests..." go test ./... -v ================================================ FILE: apps/checker/pkg/assertions/assertions.go ================================================ package assertions import ( "encoding/json" "fmt" "slices" "strings" "github.com/openstatushq/openstatus/apps/checker/request" ) type BodyString struct { AssertionType request.AssertionType `json:"type"` Comparator request.StringComparator `json:"compare"` Target string `json:"target"` } type StatusTarget struct { AssertionType request.AssertionType `json:"type"` Comparator request.NumberComparator `json:"compare"` Target int64 `json:"target"` } type HeaderTarget struct { AssertionType request.AssertionType `json:"type"` Comparator request.StringComparator `json:"compare"` Target string `json:"target"` Key string `json:"key"` } type StringTargetType struct { Comparator request.StringComparator `json:"compare"` Target string `json:"target"` } type RecordTarget struct { Comparator request.RecordComparator `json:"compare"` Target string `json:"target"` Key request.Record `json:"key"` } func (target StringTargetType) StringEvaluate(s string) bool { switch target.Comparator { case request.StringContains: return strings.Contains(s, target.Target) case request.StringNotContains: return !strings.Contains(s, target.Target) case request.StringEmpty: return s == "" case request.StringNotEmpty: return s != "" case request.StringEquals: return s == target.Target case request.StringNotEquals: return s != target.Target case request.StringGreaterThan: return s > target.Target case request.StringGreaterThanEqual: return s >= target.Target case request.StringLowerThan: return s < target.Target case request.StringLowerThanEqual: return s <= target.Target } return false } func (target HeaderTarget) HeaderEvaluate(s string) bool { headers := make(map[string]any) if err := json.Unmarshal([]byte(s), &headers); err != nil { return false } v, found := headers[target.Key] if !found { return false } t := StringTargetType{Comparator: target.Comparator, Target: target.Target} // convert all headers to array str := fmt.Sprintf("%v", v) return t.StringEvaluate(str) } func (target StatusTarget) StatusEvaluate(value int64) bool { switch target.Comparator { case request.NumberEquals: if target.Target != value { return false } case request.NumberNotEquals: if target.Target == value { return false } case request.NumberGreaterThan: if target.Target >= value { return false } case request.NumberGreaterThanEqual: if target.Target > value { return false } case request.NumberLowerThan: if target.Target <= value { return false } case request.NumberLowerThanEqual: if target.Target < value { return false } default: fmt.Println("something strange ", target) } return true } func (target RecordTarget) RecordEvaluate(s []string) bool { switch target.Comparator { case request.RecordEquals: if !slices.Contains(s, target.Target) { return false } case request.RecordNotEquals: if slices.Contains(s, target.Target) { return false } case request.RecordContains: for _, record := range s { if strings.Contains(record, target.Target) { return true } } return false case request.RecordNotContains: for _, record := range s { if strings.Contains(record, target.Target) { return false } } return true } return true } ================================================ FILE: apps/checker/pkg/assertions/assertions_test.go ================================================ package assertions import ( "testing" "github.com/openstatushq/openstatus/apps/checker/request" ) func TestIntTarget_IntEvaluate(t *testing.T) { type fields struct { AssertionType request.AssertionType Comparator request.NumberComparator Target int64 } type args struct { value int64 } tests := []struct { name string fields fields args args want bool }{ { name: "Equals true", fields: fields{Comparator: request.NumberEquals, Target: 200}, args: args{value: 200}, want: true, }, { name: "Equals false", fields: fields{Comparator: request.NumberEquals, Target: 200}, args: args{value: 201}, want: false, }, { name: "Not Equals true", fields: fields{Comparator: request.NumberNotEquals, Target: 200}, args: args{value: 201}, want: true, }, { name: "Not Equals false", fields: fields{Comparator: request.NumberNotEquals, Target: 200}, args: args{value: 200}, want: false, }, { name: "greater than true", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 201}, want: true, }, { name: "greater than false 1", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 200}, want: false, }, { name: "greater than false", fields: fields{Comparator: request.NumberGreaterThan, Target: 200}, args: args{value: 199}, want: false, }, { name: "greater than equal true", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 201}, want: true, }, { name: "greater than equal true 1", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 200}, want: true, }, { name: "greater than equal false", fields: fields{Comparator: request.NumberGreaterThanEqual, Target: 200}, args: args{value: 199}, want: false, }, { name: "lower than true", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 199}, want: true, }, { name: "lower than false 1", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 200}, want: false, }, { name: "lower than false", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 201}, want: false, }, { name: "lower than Equal true", fields: fields{Comparator: request.NumberLowerThanEqual, Target: 200}, args: args{value: 199}, want: true, }, { name: "lower than equal true 1", fields: fields{Comparator: request.NumberLowerThanEqual, Target: 200}, args: args{value: 200}, want: true, }, { name: "lower than equal false", fields: fields{Comparator: request.NumberLowerThan, Target: 200}, args: args{value: 201}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target := StatusTarget{ AssertionType: tt.fields.AssertionType, Comparator: tt.fields.Comparator, Target: tt.fields.Target, } if got := target.StatusEvaluate(tt.args.value); got != tt.want { t.Errorf("IntTarget.IntEvaluate() = %v, want %v", got, tt.want) } }) } } func TestHeaderTarget_HeaderEvaluate(t *testing.T) { type fields struct { AssertionType request.AssertionType Comparator request.StringComparator Target string Key string } type args struct { s string } tests := []struct { name string fields fields args args want bool }{ {name: "Header 1", fields: fields{Comparator: request.StringEmpty, Target: "", Key: "headers1"}, args: args{s: `{"Content-Type":"text/plain;charset=UTF-8","Strict-Transport-Security":"max-age=3153600000","Vary":"Accept-Encoding"}`}, want: false}, {name: "Header 2", fields: fields{Comparator: request.StringNotEmpty, Target: "", Key: "headers1"}, args: args{s: `{"Content-Type":"text/plain;charset=UTF-8","Strict-Transport-Security":"max-age=3153600000","headers1":"Accept-Encoding"}`}, want: true}, {name: "it should return false if it can not decode the headers", fields: fields{Comparator: request.StringContains, Target: "Accept-Encoding", Key: "Vary"}, args: args{s: `}`}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target := HeaderTarget{ AssertionType: tt.fields.AssertionType, Comparator: tt.fields.Comparator, Target: tt.fields.Target, Key: tt.fields.Key, } if got := target.HeaderEvaluate(tt.args.s); got != tt.want { t.Errorf("HeaderTarget.HeaderEvaluate() = %v, want %v", got, tt.want) } }) } } func TestRecordTarget_RecordEvaluate(t *testing.T) { type fields struct { Comparator request.RecordComparator Target string } type args struct { s []string } tests := []struct { name string fields fields args args want bool }{ { name: "RecordEquals true", fields: fields{Comparator: request.RecordEquals, Target: "foo"}, args: args{s: []string{"foo", "bar"}}, want: true, }, { name: "RecordEquals false", fields: fields{Comparator: request.RecordEquals, Target: "baz"}, args: args{s: []string{"foo", "bar"}}, want: false, }, { name: "RecordNotEquals true", fields: fields{Comparator: request.RecordNotEquals, Target: "baz"}, args: args{s: []string{"foo", "bar"}}, want: true, }, { name: "RecordNotEquals false", fields: fields{Comparator: request.RecordNotEquals, Target: "foo"}, args: args{s: []string{"foo", "bar"}}, want: false, }, { name: "RecordContains true", fields: fields{Comparator: request.RecordContains, Target: "ba"}, args: args{s: []string{"foo", "bar"}}, want: true, }, { name: "RecordContains false", fields: fields{Comparator: request.RecordContains, Target: "baz"}, args: args{s: []string{"foo", "bar"}}, want: false, }, { name: "RecordNotContains true", fields: fields{Comparator: request.RecordNotContains, Target: "baz"}, args: args{s: []string{"foo", "bar"}}, want: true, }, { name: "RecordNotContains false", fields: fields{Comparator: request.RecordNotContains, Target: "ba"}, args: args{s: []string{"foo", "bar"}}, want: false, }, { name: "Empty slice", fields: fields{Comparator: request.RecordEquals, Target: "foo"}, args: args{s: []string{}}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target := RecordTarget{ Comparator: tt.fields.Comparator, Target: tt.fields.Target, } if got := target.RecordEvaluate(tt.args.s); got != tt.want { t.Errorf("RecordTarget.RecordEvaluate() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: apps/checker/pkg/job/dns_job.go ================================================ package job import ( "context" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) type DNSPrivateRegionData struct {} func (jobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) ( *DNSPrivateRegionData, error) { return nil,nil } ================================================ FILE: apps/checker/pkg/job/http_job.go ================================================ package job import ( "context" "encoding/json" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v5" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" "github.com/openstatushq/openstatus/apps/checker/request" ) func ProtoNumberAssertionToComparator(assertion v1.NumberComparator) (request.NumberComparator, error) { switch assertion { case v1.NumberComparator_NUMBER_COMPARATOR_EQUAL: return request.NumberEquals, nil case v1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL: return request.NumberNotEquals, nil case v1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN: return request.NumberGreaterThan, nil case v1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL: return request.NumberGreaterThanEqual, nil case v1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN: return request.NumberLowerThan, nil case v1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL: return request.NumberLowerThanEqual, nil default: } return "", fmt.Errorf("unknown comparator type: %v", assertion) } func ProtoStringAssertionToComparator(assertion v1.StringComparator) (request.StringComparator, error) { switch assertion { case v1.StringComparator_STRING_COMPARATOR_CONTAINS: return request.StringContains, nil case v1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS: return request.StringNotContains, nil case v1.StringComparator_STRING_COMPARATOR_EQUAL: return request.StringEquals, nil case v1.StringComparator_STRING_COMPARATOR_NOT_EQUAL: return request.StringNotEquals, nil case v1.StringComparator_STRING_COMPARATOR_EMPTY: return request.StringEmpty, nil case v1.StringComparator_STRING_COMPARATOR_NOT_EMPTY: return request.StringNotEmpty, nil } return "", fmt.Errorf("unknown comparator type: %v", assertion) } func (jr jobRunner) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor) (*HttpPrivateRegionData, error) { retry := monitor.Retry if retry == 0 { retry = 3 } requestClient := &http.Client{ Timeout: time.Duration(monitor.Timeout) * time.Millisecond, } defer requestClient.CloseIdleConnections() if !monitor.FollowRedirects { requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } } else { requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return http.ErrUseLastResponse } return nil } } var degradedAfter int64 if monitor.DegradedAt != nil { degradedAfter = *monitor.DegradedAt } headers := make([]struct { Key string `json:"key"` Value string `json:"value"` }, 0) if monitor.Headers != nil { for _, header := range monitor.Headers { headers = append(headers, struct { Key string `json:"key"` Value string `json:"value"` }{ Key: header.Key, Value: header.Value, }) } } req := request.HttpCheckerRequest{ URL: monitor.Url, MonitorID: monitor.Id, Method: monitor.Method, Body: monitor.Body, Retry: monitor.Retry, Timeout: monitor.Timeout, DegradedAfter: degradedAfter, FollowRedirects: monitor.FollowRedirects, Headers: headers, } var called int op := func() (*HttpPrivateRegionData, error) { called++ res, err := checker.Http(ctx, requestClient, req) if err != nil { return nil, fmt.Errorf("unable to ping: %w", err) } timingBytes, err := json.Marshal(res.Timing) if err != nil { return nil, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersBytes, err := json.Marshal(res.Headers) if err != nil { return nil, fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } id, err := uuid.NewV7() if err != nil { return nil, fmt.Errorf("error while generating uuid: %w", err) } status := statusCode(res.Status) isSuccessful := status.IsSuccessful() if len(monitor.HeaderAssertions) > 0 { headersAsString, err := json.Marshal(res.Headers) if err != nil { return nil, fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } for _, assertion := range monitor.HeaderAssertions { a, err := ProtoStringAssertionToComparator(assertion.Comparator) if err != nil { return nil, fmt.Errorf("error while parsing header assertion comparator: %w", err) } assert := assertions.HeaderTarget{ Comparator: a, Target: assertion.Target, Key: assertion.Key, } assert.HeaderEvaluate(string(headersAsString)) } } if len(monitor.StatusCodeAssertions) > 0 { for _, assertion := range monitor.StatusCodeAssertions { a, err := ProtoNumberAssertionToComparator(assertion.Comparator) if err != nil { return nil, fmt.Errorf("error while parsing header assertion comparator: %w", err) } assert := assertions.StatusTarget{ Comparator: a, Target: assertion.Target, } isSuccessful = isSuccessful && assert.StatusEvaluate(int64(res.Status)) } } if len(monitor.BodyAssertions) > 0 { for _, assertion := range monitor.BodyAssertions { a, err := ProtoStringAssertionToComparator(assertion.Comparator) if err != nil { return nil, fmt.Errorf("error while parsing header assertion comparator: %w", err) } assert := assertions.StringTargetType{ Comparator: a, Target: assertion.Target, } isSuccessful = isSuccessful && assert.StringEvaluate(res.Body) } } requestStatus := "success" if !isSuccessful { requestStatus = "error" } else if req.DegradedAfter > 0 && res.Latency > req.DegradedAfter { requestStatus = "degraded" } data := HttpPrivateRegionData{ ID: id.String(), Latency: res.Latency, StatusCode: res.Status, Timestamp: res.Timestamp, CronTimestamp: res.Timestamp, URL: req.URL, // Method: req.Method, Timing: string(timingBytes), Headers: string(headersBytes), Body: "", RequestStatus: requestStatus, // Assertions: assertionAsString, Error: 0, } if isSuccessful { if req.DegradedAfter != 0 && res.Latency > req.DegradedAfter { data.Body = res.Body } } else { data.Error = 1 if called < int(retry) { return nil, fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } } return &data, nil } resp, err := backoff.Retry(ctx, op, backoff.WithMaxTries(uint(retry)), backoff.WithBackOff(backoff.NewExponentialBackOff())) if err != nil { return nil, err } return resp, nil } ================================================ FILE: apps/checker/pkg/job/http_job_test.go ================================================ package job_test import ( "context" "testing" "github.com/openstatushq/openstatus/apps/checker/pkg/job" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/stretchr/testify/assert" ) // Save original checker.Http for restoration func TestHTTPJob_Success(t *testing.T) { // Mock checker.Http to simulate success monitor := &v1.HTTPMonitor{ Url: "https://openstat.us", Method: "GET", Timeout: 10000, Retry: 2, } data, err := job.NewJobRunner().HTTPJob(context.Background(), monitor) if err != nil { t.Fatalf("expected no error, got %v", err) } if data.RequestStatus != "success" { t.Errorf("expected RequestStatus 'success', got '%s'", data.RequestStatus) } if data.Error != 0 { t.Errorf("expected Error 0, got %d", data.Error) } } func TestHTTPJob_Failure(t *testing.T) { monitor := &v1.HTTPMonitor{ Url: "https://localhost:1234", Method: "GET", Timeout: 1000, Retry: 1, } data, err := job.NewJobRunner().HTTPJob(context.Background(), monitor) if err == nil { t.Fatalf("expected error, got nil") } if data != nil { t.Errorf("expected data to be nil on error, got %+v", data) } } func TestProtoStringAssertionToComparator(t *testing.T) { tests := []struct { name string input v1.StringComparator want request.StringComparator expectErr bool }{ { name: "Contains", input: v1.StringComparator_STRING_COMPARATOR_CONTAINS, want: request.StringContains, expectErr: false, }, { name: "NotContains", input: v1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS, want: request.StringNotContains, expectErr: false, }, { name: "Equals", input: v1.StringComparator_STRING_COMPARATOR_EQUAL, want: request.StringEquals, expectErr: false, }, { name: "NotEquals", input: v1.StringComparator_STRING_COMPARATOR_NOT_EQUAL, want: request.StringNotEquals, expectErr: false, }, { name: "Empty", input: v1.StringComparator_STRING_COMPARATOR_EMPTY, want: request.StringEmpty, expectErr: false, }, { name: "NotEmpty", input: v1.StringComparator_STRING_COMPARATOR_NOT_EMPTY, want: request.StringNotEmpty, expectErr: false, }, { name: "Unknown", input: v1.StringComparator(999), want: "", expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := job.ProtoStringAssertionToComparator(tt.input) if tt.expectErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.want, got) } }) } } func TestProtoNumberAssertionToComparator(t *testing.T) { tests := []struct { name string input v1.NumberComparator want request.NumberComparator expectErr bool }{ {"Equal", v1.NumberComparator_NUMBER_COMPARATOR_EQUAL, request.NumberEquals, false}, {"NotEqual", v1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL, request.NumberNotEquals, false}, {"GreaterThan", v1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN, request.NumberGreaterThan, false}, {"GreaterThanOrEqual", v1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL, request.NumberGreaterThanEqual, false}, {"LessThan", v1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN, request.NumberLowerThan, false}, {"LessThanOrEqual", v1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL, request.NumberLowerThanEqual, false}, {"Unknown", v1.NumberComparator(999), "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := job.ProtoNumberAssertionToComparator(tt.input) if tt.expectErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.want, got) } }) } } ================================================ FILE: apps/checker/pkg/job/job.go ================================================ package job import ( "context" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) type statusCode int func (s statusCode) IsSuccessful() bool { return s >= 200 && s < 300 } type HttpPrivateRegionData struct { ID string `json:"id"` URL string `json:"url"` Message string `json:"message,omitempty"` Timing string `json:"timing,omitempty"` Headers string `json:"headers,omitempty"` Body string `json:"body,omitempty"` RequestStatus string `json:"requestStatus,omitempty"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` Error uint8 `json:"error"` } type JobRunner interface { TCPJob(ctx context.Context, monitor *v1.TCPMonitor) (*TCPPrivateRegionData, error) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor) (*HttpPrivateRegionData, error) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*DNSPrivateRegionData, error) } type jobRunner struct{} func NewJobRunner() JobRunner { return &jobRunner{} } ================================================ FILE: apps/checker/pkg/job/monitors.go ================================================ package job type Monitor struct { ID int `json:"id"` Name string `json:"name"` URL string `json:"url"` Periodicity string `json:"periodicity"` Description string `json:"description"` Method string `json:"method"` Regions []string `json:"regions"` Active bool `json:"active"` Public bool `json:"public"` Timeout int `json:"timeout"` DegradedAfter int `json:"degraded_after,omitempty"` Body string `json:"body"` Headers []Header `json:"headers,omitempty"` Assertions []Assertion `json:"assertions,omitempty"` Retry int `json:"retry"` JobType string `json:"jobType"` } type Header struct { Key string `json:"key"` Value string `json:"value"` } type Assertion struct { Type string `json:"type"` Compare string `json:"compare"` Key string `json:"key"` Target any `json:"target"` } type Timing struct { DnsStart int64 `json:"dnsStart"` DnsDone int64 `json:"dnsDone"` ConnectStart int64 `json:"connectStart"` ConnectDone int64 `json:"connectDone"` TlsHandshakeStart int64 `json:"tlsHandshakeStart"` TlsHandshakeDone int64 `json:"tlsHandshakeDone"` FirstByteStart int64 `json:"firstByteStart"` FirstByteDone int64 `json:"firstByteDone"` TransferStart int64 `json:"transferStart"` TransferDone int64 `json:"transferDone"` } ================================================ FILE: apps/checker/pkg/job/tcp_job.go ================================================ package job import ( "context" "encoding/json" "fmt" "github.com/cenkalti/backoff/v5" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) // AssertionResult tracks the results of running assertions type AssertionResult struct { Type string Success bool Message string } // TCPPrivateRegionData represents the result of a TCP monitor check type TCPPrivateRegionData struct { ID string `json:"id"` URI string `json:"uri"` RequestStatus string `json:"request_status"` Message string `json:"message"` Latency int64 `json:"latency"` Timestamp int64 `json:"timestamp"` CronTimestamp int64 `json:"cron_timestamp"` Error int `json:"error"` Timing string `json:"timing"` } // runAssertions performs all configured assertions for TCP and returns their results func (jobRunner) TCPJob(ctx context.Context, monitor *v1.TCPMonitor) (*TCPPrivateRegionData, error) { retry := monitor.Retry if retry == 0 { retry = 3 } var degradedAfter int64 if monitor.DegradedAt != nil { degradedAfter = *monitor.DegradedAt } var called int op := func() (*TCPPrivateRegionData, error) { called++ res, err := checker.PingTCP(int(monitor.Timeout), monitor.Uri) if err != nil { if called < int(retry) { return nil, fmt.Errorf("TCP connection failed: %w", err) } // On final attempt, return the error in the result id, uuidErr := uuid.NewV7() if uuidErr != nil { return nil, fmt.Errorf("failed to generate UUID: %w", uuidErr) } return &TCPPrivateRegionData{ ID: id.String(), Latency: 0, Timestamp: res.TCPStart, CronTimestamp: res.TCPStart, URI: monitor.Uri, RequestStatus: "error", Error: 1, Message: err.Error(), }, nil } latency := res.TCPDone - res.TCPStart var requestStatus = "active" if degradedAfter > 0 && latency > degradedAfter { requestStatus = "degraded" } id, err := uuid.NewV7() if err != nil { return nil, fmt.Errorf("failed to generate UUID: %w", err) } timingAsString, err := json.Marshal(res) if err != nil { return nil, fmt.Errorf("error while parsing timing data %s: %w", monitor.Uri, err) } data := &TCPPrivateRegionData{ ID: id.String(), Latency: latency, Timestamp: res.TCPStart, CronTimestamp: res.TCPStart, URI: monitor.Uri, RequestStatus: requestStatus, Error: 0, Message: fmt.Sprintf("Successfully connected to %s", monitor.Uri), Timing: string(timingAsString), } return data, nil } resp, err := backoff.Retry(ctx, op, backoff.WithMaxTries(uint(retry)), backoff.WithBackOff(backoff.NewExponentialBackOff()), ) if err != nil { return nil, fmt.Errorf("TCP job failed after %d retries: %w", retry, err) } return resp, nil } ================================================ FILE: apps/checker/pkg/job/tcp_job_test.go ================================================ package job_test import ( "context" "testing" "github.com/openstatushq/openstatus/apps/checker/pkg/job" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) func TestTCPJob_Success(t *testing.T) { monitor := &v1.TCPMonitor{ Uri: "openstatus.dev:80", Timeout: 1, Retry: 1, } data, err := job.NewJobRunner().TCPJob(context.Background(), monitor) if err != nil { t.Fatalf("expected no error, got %v", err) } if data.RequestStatus != "active" { t.Errorf("expected RequestStatus 'active', got '%s'", data.RequestStatus) } if data.Error != 0 { t.Errorf("expected Error 0, got %d", data.Error) } } func TestTCPJob_Failure(t *testing.T) { monitor := &v1.TCPMonitor{ Uri: "localhost:1234", Timeout: 1, Retry: 1, } data, err := job.NewJobRunner().TCPJob(context.Background(), monitor) if err != nil { t.Fatalf("expected no error, got %v", err) } if data.RequestStatus != "error" { t.Errorf("expected RequestStatus 'error', got '%s'", data.RequestStatus) } if data.Error != 1 { t.Errorf("expected Error 1, got %d", data.Error) } } ================================================ FILE: apps/checker/pkg/logger/logger.go ================================================ package logger import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func Configure(logLevel string) { level, err := zerolog.ParseLevel(logLevel) if err != nil { level = zerolog.InfoLevel } zerolog.SetGlobalLevel(level) zerolog.DefaultContextLogger = func() *zerolog.Logger { logger := log.With().Caller().Logger() return &logger }() } ================================================ FILE: apps/checker/pkg/otel/otel.go ================================================ package otel import ( "context" "time" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/rs/zerolog/log" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" "go.opentelemetry.io/otel/metric" sdkMetrics "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" ) func setupOTelSDK(ctx context.Context, url string, headers map[string]string) (shutdown func(context.Context) error, err error) { res, err := newResource() if err != nil { return nil, err } meterProvider, err := newMeterProvider(ctx, res, url, headers) if err != nil { return nil, err } otel.SetMeterProvider(meterProvider) return meterProvider.Shutdown, nil } func newResource() (*resource.Resource, error) { return resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("openstatus-synthetic-check"), semconv.ServiceVersion("0.1.0"), )) } func newMeterProvider( ctx context.Context, res *resource.Resource, url string, headers map[string]string, ) (*sdkMetrics.MeterProvider, error) { exporter, err := otlpmetrichttp.New(ctx, otlpmetrichttp.WithEndpointURL(url), otlpmetrichttp.WithHeaders(headers), ) if err != nil { return nil, err } return sdkMetrics.NewMeterProvider( sdkMetrics.WithResource(res), sdkMetrics.WithReader(sdkMetrics.NewPeriodicReader(exporter, sdkMetrics.WithInterval(3*time.Second))), ), nil } // withMeter sets up the OTel SDK, passes a Meter to the callback, then shuts down. func withMeter(ctx context.Context, endpoint string, headers map[string]string, fn func(metric.Meter)) { shutdown, err := setupOTelSDK(ctx, endpoint, headers) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("Error setting up otel") return } defer func() { if err := shutdown(ctx); err != nil { log.Ctx(ctx).Error().Err(err).Msg("Error shutting down otel") } }() fn(otel.Meter("OpenStatus")) } // recordGauge creates a Float64Gauge and records a value. func recordGauge(ctx context.Context, meter metric.Meter, name, description string, value float64, att metric.MeasurementOption) error { gauge, err := meter.Float64Gauge(name, metric.WithDescription(description), metric.WithUnit("ms")) if err != nil { return err } gauge.Record(ctx, value, att) return nil } func recordErrorCounter(ctx context.Context, meter metric.Meter, att metric.MeasurementOption) { counter, err := meter.Int64Counter("openstatus.error", metric.WithDescription("Status of the check")) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("Error setting up counter") return } counter.Add(ctx, 1, att) } func RecordHTTPMetrics(ctx context.Context, req request.HttpCheckerRequest, result checker.Response, region string) { withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { att := metric.WithAttributes( attribute.String("openstatus.probes", region), attribute.String("openstatus.target", req.URL), semconv.HTTPResponseStatusCode(result.Status), ) if result.Error != "" { recordErrorCounter(ctx, meter, att) return } status, err := meter.Int64Counter("openstatus.status", metric.WithDescription("Status of the check")) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("Error setting up counter") } status.Add(ctx, 1, att) timings := []struct { name string description string value float64 }{ {"openstatus.http.request.duration", "Duration of the check", float64(result.Latency)}, {"openstatus.http.dns.duration", "Duration of the DNS lookup", float64(result.Timing.DnsDone - result.Timing.DnsStart)}, {"openstatus.http.connection.duration", "Duration of the connection", float64(result.Timing.ConnectDone - result.Timing.ConnectStart)}, {"openstatus.http.tls.duration", "Duration of the TLS handshake", float64(result.Timing.TlsHandshakeDone - result.Timing.TlsHandshakeStart)}, {"openstatus.http.ttfb.duration", "Duration of the TTFB", float64(result.Timing.FirstByteDone - result.Timing.FirstByteStart)}, {"openstatus.http.transfer.duration", "Duration of the transfer", float64(result.Timing.TransferDone - result.Timing.TransferStart)}, } for _, t := range timings { if err := recordGauge(ctx, meter, t.name, t.description, t.value, att); err != nil { log.Ctx(ctx).Error().Err(err).Str("metric", t.name).Msg("Error creating gauge") } } }) } func RecordTCPMetrics(ctx context.Context, req request.TCPCheckerRequest, result checker.TCPResponse, region string) { withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { att := metric.WithAttributes( attribute.String("openstatus.probes", region), attribute.String("openstatus.target", req.URI), ) if result.Error == 1 { recordErrorCounter(ctx, meter, att) return } timings := []struct { name string description string value float64 }{ {"openstatus.tcp.request.duration", "Duration of the check", float64(result.Latency)}, {"openstatus.tcp.tcp.duration", "Duration of the TCP connection", float64(result.Timing.TCPDone - result.Timing.TCPStart)}, } for _, t := range timings { if err := recordGauge(ctx, meter, t.name, t.description, t.value, att); err != nil { log.Ctx(ctx).Error().Err(err).Str("metric", t.name).Msg("Error creating gauge") } } }) } ================================================ FILE: apps/checker/pkg/otel/otel_test.go ================================================ package otel import ( "context" "net/http" "net/http/httptest" "testing" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" sdkMetrics "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" ) func newTestMeter(t *testing.T) (metric.Meter, *sdkMetrics.ManualReader) { t.Helper() reader := sdkMetrics.NewManualReader() provider := sdkMetrics.NewMeterProvider(sdkMetrics.WithReader(reader)) t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) return provider.Meter("test"), reader } func collectMetrics(t *testing.T, reader *sdkMetrics.ManualReader) metricdata.ResourceMetrics { t.Helper() var rm metricdata.ResourceMetrics require.NoError(t, reader.Collect(context.Background(), &rm)) return rm } func newOTLPTestServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) return server } // --- recordGauge tests --- func TestRecordGauge(t *testing.T) { meter, reader := newTestMeter(t) ctx := context.Background() att := metric.WithAttributes(attribute.String("test.key", "test-value")) err := recordGauge(ctx, meter, "test.gauge", "A test gauge", 42.5, att) require.NoError(t, err) rm := collectMetrics(t, reader) require.Len(t, rm.ScopeMetrics, 1) sm := rm.ScopeMetrics[0] require.Len(t, sm.Metrics, 1) m := sm.Metrics[0] assert.Equal(t, "test.gauge", m.Name) assert.Equal(t, "A test gauge", m.Description) assert.Equal(t, "ms", m.Unit) gauge, ok := m.Data.(metricdata.Gauge[float64]) require.True(t, ok, "expected Gauge[float64] data type") require.Len(t, gauge.DataPoints, 1) assert.Equal(t, 42.5, gauge.DataPoints[0].Value) attrs := gauge.DataPoints[0].Attributes val, found := attrs.Value(attribute.Key("test.key")) assert.True(t, found) assert.Equal(t, "test-value", val.AsString()) } func TestRecordGauge_MultipleMetrics(t *testing.T) { meter, reader := newTestMeter(t) ctx := context.Background() att := metric.WithAttributes(attribute.String("region", "us-east-1")) gauges := []struct { name string desc string value float64 }{ {"http.dns.duration", "DNS duration", 10.0}, {"http.tls.duration", "TLS duration", 25.0}, {"http.ttfb.duration", "TTFB duration", 50.0}, } for _, g := range gauges { require.NoError(t, recordGauge(ctx, meter, g.name, g.desc, g.value, att)) } rm := collectMetrics(t, reader) require.Len(t, rm.ScopeMetrics, 1) assert.Len(t, rm.ScopeMetrics[0].Metrics, 3) } // --- recordErrorCounter tests --- func TestRecordErrorCounter(t *testing.T) { meter, reader := newTestMeter(t) ctx := context.Background() att := metric.WithAttributes(attribute.String("region", "us-east-1")) recordErrorCounter(ctx, meter, att) rm := collectMetrics(t, reader) require.Len(t, rm.ScopeMetrics, 1) require.Len(t, rm.ScopeMetrics[0].Metrics, 1) m := rm.ScopeMetrics[0].Metrics[0] assert.Equal(t, "openstatus.error", m.Name) sum, ok := m.Data.(metricdata.Sum[int64]) require.True(t, ok, "expected Sum[int64] data type") require.Len(t, sum.DataPoints, 1) assert.Equal(t, int64(1), sum.DataPoints[0].Value) } // --- setupOTelSDK tests --- func TestSetupOTelSDK(t *testing.T) { server := newOTLPTestServer(t) ctx := context.Background() shutdown, err := setupOTelSDK(ctx, server.URL, nil) require.NoError(t, err) require.NotNil(t, shutdown) assert.NoError(t, shutdown(ctx)) } func TestSetupOTelSDK_InvalidURL(t *testing.T) { ctx := context.Background() shutdown, err := setupOTelSDK(ctx, "://invalid", nil) if err != nil { assert.Nil(t, shutdown, "shutdown should be nil when setup fails") } else { require.NotNil(t, shutdown) _ = shutdown(ctx) } } // --- withMeter tests --- func TestWithMeter(t *testing.T) { server := newOTLPTestServer(t) called := false withMeter(context.Background(), server.URL, nil, func(meter metric.Meter) { called = true assert.NotNil(t, meter) }) assert.True(t, called, "callback should be called") } func TestWithMeter_InvalidEndpoint(t *testing.T) { called := false // Must not panic. The OTLP exporter no longer fails at creation for // invalid URLs (it defers the error to export time), so the callback // will still be invoked. withMeter(context.Background(), "://invalid", nil, func(meter metric.Meter) { called = true }) assert.True(t, called, "callback should be called since exporter defers URL validation") } // --- RecordHTTPMetrics tests --- func TestRecordHTTPMetrics_Success(t *testing.T) { server := newOTLPTestServer(t) req := request.HttpCheckerRequest{ URL: "https://example.com", MonitorID: "mon-1", } req.OtelConfig.Endpoint = server.URL result := checker.Response{ Status: 200, Latency: 150, Timing: checker.Timing{ DnsStart: 0, DnsDone: 10, ConnectStart: 10, ConnectDone: 30, TlsHandshakeStart: 30, TlsHandshakeDone: 55, FirstByteStart: 55, FirstByteDone: 100, TransferStart: 100, TransferDone: 150, }, } // Should not panic. RecordHTTPMetrics(context.Background(), req, result, "us-east-1") } func TestRecordHTTPMetrics_Error(t *testing.T) { server := newOTLPTestServer(t) req := request.HttpCheckerRequest{ URL: "https://example.com", MonitorID: "mon-1", } req.OtelConfig.Endpoint = server.URL result := checker.Response{ Status: 500, Error: "connection refused", } // Should record error counter and not panic. RecordHTTPMetrics(context.Background(), req, result, "us-east-1") } func TestRecordHTTPMetrics_SetupFailure(t *testing.T) { req := request.HttpCheckerRequest{ URL: "https://example.com", MonitorID: "mon-1", } req.OtelConfig.Endpoint = "://invalid" result := checker.Response{ Status: 200, Latency: 100, } // Must not panic — this was the original nil pointer bug. RecordHTTPMetrics(context.Background(), req, result, "us-east-1") } // --- RecordTCPMetrics tests --- func TestRecordTCPMetrics_Success(t *testing.T) { server := newOTLPTestServer(t) req := request.TCPCheckerRequest{ URI: "example.com:443", MonitorID: "mon-2", } req.OtelConfig.Endpoint = server.URL result := checker.TCPResponse{ Latency: 45, Timing: checker.TCPResponseTiming{ TCPStart: 0, TCPDone: 45, }, } // Should not panic. RecordTCPMetrics(context.Background(), req, result, "us-east-1") } func TestRecordTCPMetrics_Error(t *testing.T) { server := newOTLPTestServer(t) req := request.TCPCheckerRequest{ URI: "example.com:443", MonitorID: "mon-2", } req.OtelConfig.Endpoint = server.URL result := checker.TCPResponse{ Error: 1, } // Should record error counter and not panic. RecordTCPMetrics(context.Background(), req, result, "us-east-1") } func TestRecordTCPMetrics_SetupFailure(t *testing.T) { req := request.TCPCheckerRequest{ URI: "example.com:443", MonitorID: "mon-2", } req.OtelConfig.Endpoint = "://invalid" result := checker.TCPResponse{ Latency: 45, } // Must not panic — same nil pointer guard as HTTP. RecordTCPMetrics(context.Background(), req, result, "us-east-1") } ================================================ FILE: apps/checker/pkg/scheduler/scheduler.go ================================================ package scheduler import ( "context" "log" "sync" "time" "connectrpc.com/connect" "github.com/madflojo/tasks" "github.com/openstatushq/openstatus/apps/checker/pkg/job" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) const ( Interval10s = "10s" Interval30s = "30s" Interval1m = "1m" Interval5m = "5m" Interval10m = "10m" Interval30m = "30m" Interval1h = "1h" ) type MonitorManager struct { Client v1.PrivateLocationServiceClient JobRunner job.JobRunner Scheduler *tasks.Scheduler mu sync.Mutex } // UpdateMonitors fetches the latest monitors and starts/stops jobs as needed func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { res, err := mm.Client.Monitors(ctx, &connect.Request[v1.MonitorsRequest]{}) if err != nil { log.Printf("Failed to fetch monitors: %v", err) return } currentIDs := make(map[string]struct{}) // HTTP monitors: start jobs for new monitors for _, m := range res.Msg.HttpMonitors { currentIDs[m.Id] = struct{}{} _, err := mm.Scheduler.Lookup(m.Id) if err != nil { interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second task := tasks.Task{ Interval: interval, RunOnce: false, RunSingleInstance: true, // StartAfter: time.Duration(1) * time.Second, ErrFunc: func(e error) { log.Printf("An error occurred when executing task %s", e) }, FuncWithTaskContext: func(ctx tasks.TaskContext) error { monitor := m c := context.Background() log.Printf("Starting job for monitor %s (%s)", monitor.Id, monitor.Url) data, err := mm.JobRunner.HTTPJob(c, monitor) if err != nil { log.Printf("Monitor check failed for %s (%s): %v", monitor.Id, monitor.Url, err) return err } resp, ingestErr := mm.Client.IngestHTTP(c, &connect.Request[v1.IngestHTTPRequest]{ Msg: &v1.IngestHTTPRequest{ MonitorId: monitor.Id, Id: data.ID, Url: monitor.Url, Message: data.Message, Latency: data.Latency, Timing: data.Timing, Headers: data.Headers, Body: data.Body, RequestStatus: data.RequestStatus, StatusCode: int64(data.StatusCode), Error: int64(data.Error), CronTimestamp: data.CronTimestamp, Timestamp: data.Timestamp, }, }) if ingestErr != nil { log.Printf("Failed to ingest HTTP result for %s (%s): %v", monitor.Id, monitor.Url, ingestErr) return ingestErr } log.Printf("Monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Url, resp) return nil }, } err := mm.Scheduler.AddWithID(m.Id, &task) if err != nil { log.Printf("Failed to add HTTP monitor job for %s (%s): %v", m.Id, m.Url, err) continue } log.Printf("Started monitoring job for %s (%s)", m.Id, m.Url) continue } } // TCP monitors: start jobs for new monitors for _, m := range res.Msg.TcpMonitors { currentIDs[m.Id] = struct{}{} _, err := mm.Scheduler.Lookup(m.Id) if err != nil { interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second task := tasks.Task{ Interval: interval, RunOnce: false, // StartAfter: time.Now().Add(5 * time.Millisecond), RunSingleInstance: true, FuncWithTaskContext: func(ctx tasks.TaskContext) error { monitor := m log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) data, err := mm.JobRunner.TCPJob(ctx.Context, monitor) if err != nil { log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) } resp, ingestErr := mm.Client.IngestTCP(ctx.Context, &connect.Request[v1.IngestTCPRequest]{ Msg: &v1.IngestTCPRequest{ MonitorId: monitor.Id, Id: data.ID, Uri: monitor.Uri, Message: data.Message, Latency: data.Latency, RequestStatus: data.RequestStatus, Error: int64(data.Error), CronTimestamp: data.CronTimestamp, Timestamp: data.Timestamp, }, }) if ingestErr != nil { log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) return ingestErr } log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) return nil }, } err := mm.Scheduler.AddWithID(m.Id, &task) if err != nil { log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) continue } log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) } } for _, m := range res.Msg.DnsMonitors { currentIDs[m.Id] = struct{}{} _, err := mm.Scheduler.Lookup(m.Id) if err != nil { interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second task := tasks.Task{ Interval: interval, RunOnce: false, // StartAfter: time.Now().Add(5 * time.Millisecond), RunSingleInstance: true, FuncWithTaskContext: func(ctx tasks.TaskContext) error { monitor := m log.Printf("Starting TCP job for monitor %s (%s)", monitor.Id, monitor.Uri) _, err := mm.JobRunner.DNSJob(ctx.Context, monitor) if err != nil { log.Printf("TCP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) } resp, ingestErr := mm.Client.IngestDNS(ctx.Context, &connect.Request[v1.IngestDNSRequest]{ Msg: &v1.IngestDNSRequest{ MonitorId: monitor.Id, // Id: data.ID, // Uri: monitor.Uri, // Message: data.Message, // Latency: data.Latency, // RequestStatus: data.RequestStatus, // Error: int64(data.Error), // CronTimestamp: data.CronTimestamp, // Timestamp: data.Timestamp, }, }) if ingestErr != nil { log.Printf("Failed to ingest TCP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) return ingestErr } log.Printf("TCP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) return nil }, } err := mm.Scheduler.AddWithID(m.Id, &task) if err != nil { log.Printf("Failed to add TCP monitor job for %s (%s): %v", m.Id, m.Uri, err) continue } log.Printf("Started TCP monitoring job for %s (%s)", m.Id, m.Uri) } } for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { mm.Scheduler.Del(id) } } } func intervalToSecond(interval string) int { switch interval { case Interval30s: return 30 case Interval1m: return 60 case Interval5m: return 300 case Interval10m: return 600 case Interval30m: return 1800 case Interval1h: return 3600 case Interval10s: return 10 default: return 0 } } ================================================ FILE: apps/checker/pkg/scheduler/scheduler_test.go ================================================ package scheduler_test import ( "context" "sync/atomic" "sync" "testing" "time" "connectrpc.com/connect" "github.com/madflojo/tasks" "github.com/openstatushq/openstatus/apps/checker/pkg/job" "github.com/openstatushq/openstatus/apps/checker/pkg/scheduler" v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" ) // mockJobRunner implements job.JobRunner for testing type mockJobRunner struct { HTTPJobCalled atomic.Bool TCPJobCalled atomic.Bool DNSJobCalled atomic.Bool mu sync.Mutex } func (m *mockJobRunner) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor) (*job.HttpPrivateRegionData, error) { m.HTTPJobCalled.Store(true) return &job.HttpPrivateRegionData{}, nil } func (m *mockJobRunner) TCPJob(ctx context.Context, monitor *v1.TCPMonitor) (*job.TCPPrivateRegionData, error) { m.TCPJobCalled.Store(true) return &job.TCPPrivateRegionData{}, nil } func (m *mockJobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*job.DNSPrivateRegionData, error) { m.TCPJobCalled.Store(true) return &job.DNSPrivateRegionData{}, nil } // mockClient implements v1.PrivateLocationServiceClient for testing type mockClient struct { MonitorsFunc func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) IngestHTTPFunc func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) IngestTCPFunc func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) IngestDNSFunc func(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { return m.MonitorsFunc(ctx, req) } func (m *mockClient) IngestHTTP(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) { return m.IngestHTTPFunc(ctx, req) } func (m *mockClient) IngestTCP(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) { return m.IngestTCPFunc(ctx, req) } func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) { return m.IngestDNSFunc(ctx, req) } func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() httpMonitor := &v1.HTTPMonitor{Id: "http1", Url: "http://openstat.us", Periodicity: "10s"} tcpMonitor := &v1.TCPMonitor{Id: "tcp1", Uri: "openstatus:80", Periodicity: "10s"} client := &mockClient{ MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { return connect.NewResponse(&v1.MonitorsResponse{ HttpMonitors: []*v1.HTTPMonitor{httpMonitor}, TcpMonitors: []*v1.TCPMonitor{tcpMonitor}, }), nil }, IngestHTTPFunc: func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) { return connect.NewResponse(&v1.IngestHTTPResponse{}), nil }, IngestTCPFunc: func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) { return connect.NewResponse(&v1.IngestTCPResponse{}), nil }, } jobRunner := &mockJobRunner{} s := tasks.New() defer s.Stop() mm := &scheduler.MonitorManager{ Client: client, JobRunner: jobRunner, Scheduler: s, } mm.UpdateMonitors(ctx) time.Sleep(12 * time.Second) // allow jobs to run if !jobRunner.HTTPJobCalled.Load() == true { t.Errorf("expected HTTPJob to be called") } if !jobRunner.TCPJobCalled.Load() == true { t.Errorf("expected TCPJob to be called") } // Remove monitors and ensure jobs are stopped client.MonitorsFunc = func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { return connect.NewResponse(&v1.MonitorsResponse{ HttpMonitors: []*v1.HTTPMonitor{}, TcpMonitors: []*v1.TCPMonitor{}, }), nil } mm.UpdateMonitors(ctx) time.Sleep(1 * time.Second) if _, err := mm.Scheduler.Lookup("http1"); err == nil { t.Errorf("expected HTTP job to be removed") } if _, err := mm.Scheduler.Lookup("tcp1"); err == nil { t.Errorf("expected TCP job to be removed") } } ================================================ FILE: apps/checker/pkg/tinybird/client.go ================================================ package tinybird import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "os" "github.com/rs/zerolog/log" ) func getBaseURL() string { // Use local Tinybird container if available (Docker/self-hosted) // https://www.tinybird.co/docs/api-reference if tinybirdURL := os.Getenv("TINYBIRD_URL"); tinybirdURL != "" { return tinybirdURL + "/v0/events" } return "https://api.tinybird.co/v0/events" } type Client interface { SendEvent(ctx context.Context, event any, dataSourceName string) error } type client struct { httpClient *http.Client apiKey string baseURL string } func NewClient(httpClient *http.Client, apiKey string) Client { return client{ httpClient: httpClient, apiKey: apiKey, baseURL: getBaseURL(), } } func (c client) SendEvent(ctx context.Context, event any, dataSourceName string) error { requestURL, err := url.Parse(c.baseURL) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("unable to parse url") return fmt.Errorf("unable to parse url: %w", err) } q := requestURL.Query() q.Add("name", dataSourceName) requestURL.RawQuery = q.Encode() var payload bytes.Buffer if err := json.NewEncoder(&payload).Encode(event); err != nil { log.Ctx(ctx).Error().Err(err).Msg("unable to encode payload") return fmt.Errorf("unable to encode payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload.Bytes())) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("unable to create request") return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) resp, err := c.httpClient.Do(req) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("unable to send request") return fmt.Errorf("unable to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { log.Ctx(ctx).Error().Str("status", resp.Status).Msg("unexpected status code") return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } return nil } ================================================ FILE: apps/checker/pkg/tinybird/client_test.go ================================================ package tinybird_test import ( "fmt" "net/http" "testing" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/stretchr/testify/require" ) type interceptorHTTPClient struct { f func(req *http.Request) (*http.Response, error) } func (i *interceptorHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { return i.f(req) } func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { return &http.Client{ Transport: i, } } func TestSendEvent(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("it should return an error if it can not send the event", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unable to send request") }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.Error(t, err) }) t.Run("it should return an error if the response status code is not 200", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusInternalServerError, }, nil }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.Error(t, err) }) t.Run("it should succeed and return nothing", func(t *testing.T) { var url string interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { url = req.URL.String() return &http.Response{ StatusCode: http.StatusAccepted, }, nil }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.NoError(t, err) require.Equal(t, "https://api.tinybird.co/v0/events?name=test", url) }) } ================================================ FILE: apps/checker/private-location.Dockerfile ================================================ FROM --platform=$BUILDPLATFORM golang:1.25-alpine as builder WORKDIR /go/src/app RUN apk add --no-cache tzdata ENV TZ=UTC ENV CGO_ENABLED=0 COPY go.* . RUN go mod download COPY . . RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w" -o private ./cmd/private FROM scratch WORKDIR /opt/bin COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /go/src/app/private /opt/bin/private ENV TZ=UTC ENV USER=1000 ENV GIN_MODE=release CMD [ "/opt/bin/private" ] ================================================ FILE: apps/checker/proto/private_location/v1/assertions.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/assertions.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type NumberComparator int32 const ( NumberComparator_NUMBER_COMPARATOR_UNSPECIFIED NumberComparator = 0 NumberComparator_NUMBER_COMPARATOR_EQUAL NumberComparator = 1 NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL NumberComparator = 2 NumberComparator_NUMBER_COMPARATOR_GREATER_THAN NumberComparator = 3 NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL NumberComparator = 4 NumberComparator_NUMBER_COMPARATOR_LESS_THAN NumberComparator = 5 NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL NumberComparator = 6 ) // Enum value maps for NumberComparator. var ( NumberComparator_name = map[int32]string{ 0: "NUMBER_COMPARATOR_UNSPECIFIED", 1: "NUMBER_COMPARATOR_EQUAL", 2: "NUMBER_COMPARATOR_NOT_EQUAL", 3: "NUMBER_COMPARATOR_GREATER_THAN", 4: "NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL", 5: "NUMBER_COMPARATOR_LESS_THAN", 6: "NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL", } NumberComparator_value = map[string]int32{ "NUMBER_COMPARATOR_UNSPECIFIED": 0, "NUMBER_COMPARATOR_EQUAL": 1, "NUMBER_COMPARATOR_NOT_EQUAL": 2, "NUMBER_COMPARATOR_GREATER_THAN": 3, "NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL": 4, "NUMBER_COMPARATOR_LESS_THAN": 5, "NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL": 6, } ) func (x NumberComparator) Enum() *NumberComparator { p := new(NumberComparator) *p = x return p } func (x NumberComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (NumberComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[0].Descriptor() } func (NumberComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[0] } func (x NumberComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use NumberComparator.Descriptor instead. func (NumberComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{0} } type StringComparator int32 const ( StringComparator_STRING_COMPARATOR_UNSPECIFIED StringComparator = 0 StringComparator_STRING_COMPARATOR_CONTAINS StringComparator = 1 StringComparator_STRING_COMPARATOR_NOT_CONTAINS StringComparator = 2 StringComparator_STRING_COMPARATOR_EQUAL StringComparator = 3 StringComparator_STRING_COMPARATOR_NOT_EQUAL StringComparator = 4 StringComparator_STRING_COMPARATOR_EMPTY StringComparator = 5 StringComparator_STRING_COMPARATOR_NOT_EMPTY StringComparator = 6 StringComparator_STRING_COMPARATOR_GREATER_THAN StringComparator = 7 StringComparator_STRING_COMPARATOR_GREATER_THAN_OR_EQUAL StringComparator = 8 StringComparator_STRING_COMPARATOR_LESS_THAN StringComparator = 9 StringComparator_STRING_COMPARATOR_LESS_THAN_OR_EQUAL StringComparator = 10 ) // Enum value maps for StringComparator. var ( StringComparator_name = map[int32]string{ 0: "STRING_COMPARATOR_UNSPECIFIED", 1: "STRING_COMPARATOR_CONTAINS", 2: "STRING_COMPARATOR_NOT_CONTAINS", 3: "STRING_COMPARATOR_EQUAL", 4: "STRING_COMPARATOR_NOT_EQUAL", 5: "STRING_COMPARATOR_EMPTY", 6: "STRING_COMPARATOR_NOT_EMPTY", 7: "STRING_COMPARATOR_GREATER_THAN", 8: "STRING_COMPARATOR_GREATER_THAN_OR_EQUAL", 9: "STRING_COMPARATOR_LESS_THAN", 10: "STRING_COMPARATOR_LESS_THAN_OR_EQUAL", } StringComparator_value = map[string]int32{ "STRING_COMPARATOR_UNSPECIFIED": 0, "STRING_COMPARATOR_CONTAINS": 1, "STRING_COMPARATOR_NOT_CONTAINS": 2, "STRING_COMPARATOR_EQUAL": 3, "STRING_COMPARATOR_NOT_EQUAL": 4, "STRING_COMPARATOR_EMPTY": 5, "STRING_COMPARATOR_NOT_EMPTY": 6, "STRING_COMPARATOR_GREATER_THAN": 7, "STRING_COMPARATOR_GREATER_THAN_OR_EQUAL": 8, "STRING_COMPARATOR_LESS_THAN": 9, "STRING_COMPARATOR_LESS_THAN_OR_EQUAL": 10, } ) func (x StringComparator) Enum() *StringComparator { p := new(StringComparator) *p = x return p } func (x StringComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (StringComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[1].Descriptor() } func (StringComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[1] } func (x StringComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use StringComparator.Descriptor instead. func (StringComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } type RecordComparator int32 const ( RecordComparator_RECORD_COMPARATOR_UNSPECIFIED RecordComparator = 0 RecordComparator_RECORD_COMPARATOR_EQUAL RecordComparator = 1 RecordComparator_RECORD_COMPARATOR_NOT_EQUAL RecordComparator = 2 RecordComparator_RECORD_COMPARATOR_CONTAINS RecordComparator = 3 RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS RecordComparator = 4 ) // Enum value maps for RecordComparator. var ( RecordComparator_name = map[int32]string{ 0: "RECORD_COMPARATOR_UNSPECIFIED", 1: "RECORD_COMPARATOR_EQUAL", 2: "RECORD_COMPARATOR_NOT_EQUAL", 3: "RECORD_COMPARATOR_CONTAINS", 4: "RECORD_COMPARATOR_NOT_CONTAINS", } RecordComparator_value = map[string]int32{ "RECORD_COMPARATOR_UNSPECIFIED": 0, "RECORD_COMPARATOR_EQUAL": 1, "RECORD_COMPARATOR_NOT_EQUAL": 2, "RECORD_COMPARATOR_CONTAINS": 3, "RECORD_COMPARATOR_NOT_CONTAINS": 4, } ) func (x RecordComparator) Enum() *RecordComparator { p := new(RecordComparator) *p = x return p } func (x RecordComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (RecordComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[2].Descriptor() } func (RecordComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[2] } func (x RecordComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use RecordComparator.Descriptor instead. func (RecordComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} } type StatusCodeAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target int64 `protobuf:"varint,1,opt,name=target,proto3" json:"target,omitempty"` Comparator NumberComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.NumberComparator" json:"comparator,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StatusCodeAssertion) Reset() { *x = StatusCodeAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StatusCodeAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*StatusCodeAssertion) ProtoMessage() {} func (x *StatusCodeAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StatusCodeAssertion.ProtoReflect.Descriptor instead. func (*StatusCodeAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{0} } func (x *StatusCodeAssertion) GetTarget() int64 { if x != nil { return x.Target } return 0 } func (x *StatusCodeAssertion) GetComparator() NumberComparator { if x != nil { return x.Comparator } return NumberComparator_NUMBER_COMPARATOR_UNSPECIFIED } type BodyAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` Comparator StringComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.StringComparator" json:"comparator,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BodyAssertion) Reset() { *x = BodyAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BodyAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*BodyAssertion) ProtoMessage() {} func (x *BodyAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BodyAssertion.ProtoReflect.Descriptor instead. func (*BodyAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } func (x *BodyAssertion) GetTarget() string { if x != nil { return x.Target } return "" } func (x *BodyAssertion) GetComparator() StringComparator { if x != nil { return x.Comparator } return StringComparator_STRING_COMPARATOR_UNSPECIFIED } type HeaderAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` Comparator StringComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.StringComparator" json:"comparator,omitempty"` Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HeaderAssertion) Reset() { *x = HeaderAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HeaderAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*HeaderAssertion) ProtoMessage() {} func (x *HeaderAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HeaderAssertion.ProtoReflect.Descriptor instead. func (*HeaderAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} } func (x *HeaderAssertion) GetTarget() string { if x != nil { return x.Target } return "" } func (x *HeaderAssertion) GetComparator() StringComparator { if x != nil { return x.Comparator } return StringComparator_STRING_COMPARATOR_UNSPECIFIED } func (x *HeaderAssertion) GetKey() string { if x != nil { return x.Key } return "" } type RecordAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Record string `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` Comparator RecordComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.RecordComparator" json:"comparator,omitempty"` Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RecordAssertion) Reset() { *x = RecordAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RecordAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*RecordAssertion) ProtoMessage() {} func (x *RecordAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RecordAssertion.ProtoReflect.Descriptor instead. func (*RecordAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{3} } func (x *RecordAssertion) GetRecord() string { if x != nil { return x.Record } return "" } func (x *RecordAssertion) GetComparator() RecordComparator { if x != nil { return x.Comparator } return RecordComparator_RECORD_COMPARATOR_UNSPECIFIED } func (x *RecordAssertion) GetTarget() string { if x != nil { return x.Target } return "" } var File_private_location_v1_assertions_proto protoreflect.FileDescriptor const file_private_location_v1_assertions_proto_rawDesc = "" + "\n" + "$private_location/v1/assertions.proto\x12\x13private_location.v1\"t\n" + "\x13StatusCodeAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\x03R\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.NumberComparatorR\n" + "comparator\"n\n" + "\rBodyAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\tR\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\"\x82\x01\n" + "\x0fHeaderAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\tR\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\x12\x10\n" + "\x03key\x18\x03 \x01(\tR\x03key\"\x88\x01\n" + "\x0fRecordAssertion\x12\x16\n" + "\x06record\x18\x01 \x01(\tR\x06record\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.RecordComparatorR\n" + "comparator\x12\x16\n" + "\x06target\x18\x03 \x01(\tR\x06target*\x8f\x02\n" + "\x10NumberComparator\x12!\n" + "\x1dNUMBER_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17NUMBER_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + "\x1bNUMBER_COMPARATOR_NOT_EQUAL\x10\x02\x12\"\n" + "\x1eNUMBER_COMPARATOR_GREATER_THAN\x10\x03\x12+\n" + "'NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\x04\x12\x1f\n" + "\x1bNUMBER_COMPARATOR_LESS_THAN\x10\x05\x12(\n" + "$NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL\x10\x06*\x91\x03\n" + "\x10StringComparator\x12!\n" + "\x1dSTRING_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSTRING_COMPARATOR_CONTAINS\x10\x01\x12\"\n" + "\x1eSTRING_COMPARATOR_NOT_CONTAINS\x10\x02\x12\x1b\n" + "\x17STRING_COMPARATOR_EQUAL\x10\x03\x12\x1f\n" + "\x1bSTRING_COMPARATOR_NOT_EQUAL\x10\x04\x12\x1b\n" + "\x17STRING_COMPARATOR_EMPTY\x10\x05\x12\x1f\n" + "\x1bSTRING_COMPARATOR_NOT_EMPTY\x10\x06\x12\"\n" + "\x1eSTRING_COMPARATOR_GREATER_THAN\x10\a\x12+\n" + "'STRING_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\b\x12\x1f\n" + "\x1bSTRING_COMPARATOR_LESS_THAN\x10\t\x12(\n" + "$STRING_COMPARATOR_LESS_THAN_OR_EQUAL\x10\n" + "*\xb7\x01\n" + "\x10RecordComparator\x12!\n" + "\x1dRECORD_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17RECORD_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + "\x1bRECORD_COMPARATOR_NOT_EQUAL\x10\x02\x12\x1e\n" + "\x1aRECORD_COMPARATOR_CONTAINS\x10\x03\x12\"\n" + "\x1eRECORD_COMPARATOR_NOT_CONTAINS\x10\x04BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_assertions_proto_rawDescOnce sync.Once file_private_location_v1_assertions_proto_rawDescData []byte ) func file_private_location_v1_assertions_proto_rawDescGZIP() []byte { file_private_location_v1_assertions_proto_rawDescOnce.Do(func() { file_private_location_v1_assertions_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc))) }) return file_private_location_v1_assertions_proto_rawDescData } var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_private_location_v1_assertions_proto_goTypes = []any{ (NumberComparator)(0), // 0: private_location.v1.NumberComparator (StringComparator)(0), // 1: private_location.v1.StringComparator (RecordComparator)(0), // 2: private_location.v1.RecordComparator (*StatusCodeAssertion)(nil), // 3: private_location.v1.StatusCodeAssertion (*BodyAssertion)(nil), // 4: private_location.v1.BodyAssertion (*HeaderAssertion)(nil), // 5: private_location.v1.HeaderAssertion (*RecordAssertion)(nil), // 6: private_location.v1.RecordAssertion } var file_private_location_v1_assertions_proto_depIdxs = []int32{ 0, // 0: private_location.v1.StatusCodeAssertion.comparator:type_name -> private_location.v1.NumberComparator 1, // 1: private_location.v1.BodyAssertion.comparator:type_name -> private_location.v1.StringComparator 1, // 2: private_location.v1.HeaderAssertion.comparator:type_name -> private_location.v1.StringComparator 2, // 3: private_location.v1.RecordAssertion.comparator:type_name -> private_location.v1.RecordComparator 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_assertions_proto_init() } func file_private_location_v1_assertions_proto_init() { if File_private_location_v1_assertions_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc)), NumEnums: 3, NumMessages: 4, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_assertions_proto_goTypes, DependencyIndexes: file_private_location_v1_assertions_proto_depIdxs, EnumInfos: file_private_location_v1_assertions_proto_enumTypes, MessageInfos: file_private_location_v1_assertions_proto_msgTypes, }.Build() File_private_location_v1_assertions_proto = out.File file_private_location_v1_assertions_proto_goTypes = nil file_private_location_v1_assertions_proto_depIdxs = nil } ================================================ FILE: apps/checker/proto/private_location/v1/dns_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/dns_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type DNSMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` RecordAssertions []*RecordAssertion `protobuf:"bytes,13,rep,name=record_assertions,json=recordAssertions,proto3" json:"record_assertions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DNSMonitor) Reset() { *x = DNSMonitor{} mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DNSMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*DNSMonitor) ProtoMessage() {} func (x *DNSMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DNSMonitor.ProtoReflect.Descriptor instead. func (*DNSMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_dns_monitor_proto_rawDescGZIP(), []int{0} } func (x *DNSMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *DNSMonitor) GetUri() string { if x != nil { return x.Uri } return "" } func (x *DNSMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *DNSMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *DNSMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *DNSMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } func (x *DNSMonitor) GetRecordAssertions() []*RecordAssertion { if x != nil { return x.RecordAssertions } return nil } var File_private_location_v1_dns_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_dns_monitor_proto_rawDesc = "" + "\n" + "%private_location/v1/dns_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"\x89\x02\n" + "\n" + "DNSMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12 \n" + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12Q\n" + "\x11record_assertions\x18\r \x03(\v2$.private_location.v1.RecordAssertionR\x10recordAssertionsB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_dns_monitor_proto_rawDescOnce sync.Once file_private_location_v1_dns_monitor_proto_rawDescData []byte ) func file_private_location_v1_dns_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_dns_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_dns_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc))) }) return file_private_location_v1_dns_monitor_proto_rawDescData } var file_private_location_v1_dns_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_private_location_v1_dns_monitor_proto_goTypes = []any{ (*DNSMonitor)(nil), // 0: private_location.v1.DNSMonitor (*RecordAssertion)(nil), // 1: private_location.v1.RecordAssertion } var file_private_location_v1_dns_monitor_proto_depIdxs = []int32{ 1, // 0: private_location.v1.DNSMonitor.record_assertions:type_name -> private_location.v1.RecordAssertion 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name } func init() { file_private_location_v1_dns_monitor_proto_init() } func file_private_location_v1_dns_monitor_proto_init() { if File_private_location_v1_dns_monitor_proto != nil { return } file_private_location_v1_assertions_proto_init() file_private_location_v1_dns_monitor_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_dns_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_dns_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_dns_monitor_proto_msgTypes, }.Build() File_private_location_v1_dns_monitor_proto = out.File file_private_location_v1_dns_monitor_proto_goTypes = nil file_private_location_v1_dns_monitor_proto_depIdxs = nil } ================================================ FILE: apps/checker/proto/private_location/v1/http_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/http_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Headers struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Headers) Reset() { *x = Headers{} mi := &file_private_location_v1_http_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Headers) String() string { return protoimpl.X.MessageStringOf(x) } func (*Headers) ProtoMessage() {} func (x *Headers) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_http_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Headers.ProtoReflect.Descriptor instead. func (*Headers) Descriptor() ([]byte, []int) { return file_private_location_v1_http_monitor_proto_rawDescGZIP(), []int{0} } func (x *Headers) GetKey() string { if x != nil { return x.Key } return "" } func (x *Headers) GetValue() string { if x != nil { return x.Value } return "" } type HTTPMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` Periodicity string `protobuf:"bytes,3,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"` Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"` Timeout int64 `protobuf:"varint,6,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,7,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Retry int64 `protobuf:"varint,8,opt,name=retry,proto3" json:"retry,omitempty"` FollowRedirects bool `protobuf:"varint,9,opt,name=follow_redirects,json=followRedirects,proto3" json:"follow_redirects,omitempty"` Headers []*Headers `protobuf:"bytes,10,rep,name=headers,proto3" json:"headers,omitempty"` StatusCodeAssertions []*StatusCodeAssertion `protobuf:"bytes,11,rep,name=status_code_assertions,json=statusCodeAssertions,proto3" json:"status_code_assertions,omitempty"` BodyAssertions []*BodyAssertion `protobuf:"bytes,12,rep,name=body_assertions,json=bodyAssertions,proto3" json:"body_assertions,omitempty"` HeaderAssertions []*HeaderAssertion `protobuf:"bytes,13,rep,name=header_assertions,json=headerAssertions,proto3" json:"header_assertions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HTTPMonitor) Reset() { *x = HTTPMonitor{} mi := &file_private_location_v1_http_monitor_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HTTPMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*HTTPMonitor) ProtoMessage() {} func (x *HTTPMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_http_monitor_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HTTPMonitor.ProtoReflect.Descriptor instead. func (*HTTPMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_http_monitor_proto_rawDescGZIP(), []int{1} } func (x *HTTPMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *HTTPMonitor) GetUrl() string { if x != nil { return x.Url } return "" } func (x *HTTPMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *HTTPMonitor) GetMethod() string { if x != nil { return x.Method } return "" } func (x *HTTPMonitor) GetBody() string { if x != nil { return x.Body } return "" } func (x *HTTPMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *HTTPMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *HTTPMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } func (x *HTTPMonitor) GetFollowRedirects() bool { if x != nil { return x.FollowRedirects } return false } func (x *HTTPMonitor) GetHeaders() []*Headers { if x != nil { return x.Headers } return nil } func (x *HTTPMonitor) GetStatusCodeAssertions() []*StatusCodeAssertion { if x != nil { return x.StatusCodeAssertions } return nil } func (x *HTTPMonitor) GetBodyAssertions() []*BodyAssertion { if x != nil { return x.BodyAssertions } return nil } func (x *HTTPMonitor) GetHeaderAssertions() []*HeaderAssertion { if x != nil { return x.HeaderAssertions } return nil } var File_private_location_v1_http_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_http_monitor_proto_rawDesc = "" + "\n" + "&private_location/v1/http_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"1\n" + "\aHeaders\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"\xc6\x04\n" + "\vHTTPMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03url\x18\x02 \x01(\tR\x03url\x12 \n" + "\vperiodicity\x18\x03 \x01(\tR\vperiodicity\x12\x16\n" + "\x06method\x18\x04 \x01(\tR\x06method\x12\x12\n" + "\x04body\x18\x05 \x01(\tR\x04body\x12\x18\n" + "\atimeout\x18\x06 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\a \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12\x14\n" + "\x05retry\x18\b \x01(\x03R\x05retry\x12)\n" + "\x10follow_redirects\x18\t \x01(\bR\x0ffollowRedirects\x126\n" + "\aheaders\x18\n" + " \x03(\v2\x1c.private_location.v1.HeadersR\aheaders\x12^\n" + "\x16status_code_assertions\x18\v \x03(\v2(.private_location.v1.StatusCodeAssertionR\x14statusCodeAssertions\x12K\n" + "\x0fbody_assertions\x18\f \x03(\v2\".private_location.v1.BodyAssertionR\x0ebodyAssertions\x12Q\n" + "\x11header_assertions\x18\r \x03(\v2$.private_location.v1.HeaderAssertionR\x10headerAssertionsB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_http_monitor_proto_rawDescOnce sync.Once file_private_location_v1_http_monitor_proto_rawDescData []byte ) func file_private_location_v1_http_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_http_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_http_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_http_monitor_proto_rawDesc), len(file_private_location_v1_http_monitor_proto_rawDesc))) }) return file_private_location_v1_http_monitor_proto_rawDescData } var file_private_location_v1_http_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_private_location_v1_http_monitor_proto_goTypes = []any{ (*Headers)(nil), // 0: private_location.v1.Headers (*HTTPMonitor)(nil), // 1: private_location.v1.HTTPMonitor (*StatusCodeAssertion)(nil), // 2: private_location.v1.StatusCodeAssertion (*BodyAssertion)(nil), // 3: private_location.v1.BodyAssertion (*HeaderAssertion)(nil), // 4: private_location.v1.HeaderAssertion } var file_private_location_v1_http_monitor_proto_depIdxs = []int32{ 0, // 0: private_location.v1.HTTPMonitor.headers:type_name -> private_location.v1.Headers 2, // 1: private_location.v1.HTTPMonitor.status_code_assertions:type_name -> private_location.v1.StatusCodeAssertion 3, // 2: private_location.v1.HTTPMonitor.body_assertions:type_name -> private_location.v1.BodyAssertion 4, // 3: private_location.v1.HTTPMonitor.header_assertions:type_name -> private_location.v1.HeaderAssertion 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_http_monitor_proto_init() } func file_private_location_v1_http_monitor_proto_init() { if File_private_location_v1_http_monitor_proto != nil { return } file_private_location_v1_assertions_proto_init() file_private_location_v1_http_monitor_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_http_monitor_proto_rawDesc), len(file_private_location_v1_http_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_http_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_http_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_http_monitor_proto_msgTypes, }.Build() File_private_location_v1_http_monitor_proto = out.File file_private_location_v1_http_monitor_proto_goTypes = nil file_private_location_v1_http_monitor_proto_depIdxs = nil } ================================================ FILE: apps/checker/proto/private_location/v1/private_location.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: private_location/v1/private_location.proto package v1 import ( connect "connectrpc.com/connect" context "context" errors "errors" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // PrivateLocationServiceName is the fully-qualified name of the PrivateLocationService service. PrivateLocationServiceName = "private_location.v1.PrivateLocationService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // PrivateLocationServiceMonitorsProcedure is the fully-qualified name of the // PrivateLocationService's Monitors RPC. PrivateLocationServiceMonitorsProcedure = "/private_location.v1.PrivateLocationService/Monitors" // PrivateLocationServiceIngestTCPProcedure is the fully-qualified name of the // PrivateLocationService's IngestTCP RPC. PrivateLocationServiceIngestTCPProcedure = "/private_location.v1.PrivateLocationService/IngestTCP" // PrivateLocationServiceIngestHTTPProcedure is the fully-qualified name of the // PrivateLocationService's IngestHTTP RPC. PrivateLocationServiceIngestHTTPProcedure = "/private_location.v1.PrivateLocationService/IngestHTTP" // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService // service. type PrivateLocationServiceClient interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceClient constructs a client for the // private_location.v1.PrivateLocationService service. By default, it uses the Connect protocol with // the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To use // the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PrivateLocationServiceClient { baseURL = strings.TrimRight(baseURL, "/") privateLocationServiceMethods := File_private_location_v1_private_location_proto.Services().ByName("PrivateLocationService").Methods() return &privateLocationServiceClient{ monitors: connect.NewClient[MonitorsRequest, MonitorsResponse]( httpClient, baseURL+PrivateLocationServiceMonitorsProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("Monitors")), connect.WithClientOptions(opts...), ), ingestTCP: connect.NewClient[IngestTCPRequest, IngestTCPResponse]( httpClient, baseURL+PrivateLocationServiceIngestTCPProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestTCP")), connect.WithClientOptions(opts...), ), ingestHTTP: connect.NewClient[IngestHTTPRequest, IngestHTTPResponse]( httpClient, baseURL+PrivateLocationServiceIngestHTTPProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithClientOptions(opts...), ), ingestDNS: connect.NewClient[IngestDNSRequest, IngestDNSResponse]( httpClient, baseURL+PrivateLocationServiceIngestDNSProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), } } // privateLocationServiceClient implements PrivateLocationServiceClient. type privateLocationServiceClient struct { monitors *connect.Client[MonitorsRequest, MonitorsResponse] ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. func (c *privateLocationServiceClient) Monitors(ctx context.Context, req *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) { return c.monitors.CallUnary(ctx, req) } // IngestTCP calls private_location.v1.PrivateLocationService.IngestTCP. func (c *privateLocationServiceClient) IngestTCP(ctx context.Context, req *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) { return c.ingestTCP.CallUnary(ctx, req) } // IngestHTTP calls private_location.v1.PrivateLocationService.IngestHTTP. func (c *privateLocationServiceClient) IngestHTTP(ctx context.Context, req *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return c.ingestHTTP.CallUnary(ctx, req) } // IngestDNS calls private_location.v1.PrivateLocationService.IngestDNS. func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return c.ingestDNS.CallUnary(ctx, req) } // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It // returns the path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { privateLocationServiceMethods := File_private_location_v1_private_location_proto.Services().ByName("PrivateLocationService").Methods() privateLocationServiceMonitorsHandler := connect.NewUnaryHandler( PrivateLocationServiceMonitorsProcedure, svc.Monitors, connect.WithSchema(privateLocationServiceMethods.ByName("Monitors")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestTCPHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestTCPProcedure, svc.IngestTCP, connect.WithSchema(privateLocationServiceMethods.ByName("IngestTCP")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestHTTPHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestHTTPProcedure, svc.IngestHTTP, connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestDNSHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestDNSProcedure, svc.IngestDNS, connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: privateLocationServiceMonitorsHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestTCPProcedure: privateLocationServiceIngestTCPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestHTTPProcedure: privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedPrivateLocationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedPrivateLocationServiceHandler struct{} func (UnimplementedPrivateLocationServiceHandler) Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.Monitors is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestTCP is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestHTTP is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } ================================================ FILE: apps/checker/proto/private_location/v1/private_location.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/private_location.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type MonitorsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MonitorsRequest) Reset() { *x = MonitorsRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MonitorsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*MonitorsRequest) ProtoMessage() {} func (x *MonitorsRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MonitorsRequest.ProtoReflect.Descriptor instead. func (*MonitorsRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{0} } type MonitorsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MonitorsResponse) Reset() { *x = MonitorsResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MonitorsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*MonitorsResponse) ProtoMessage() {} func (x *MonitorsResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MonitorsResponse.ProtoReflect.Descriptor instead. func (*MonitorsResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{1} } func (x *MonitorsResponse) GetHttpMonitors() []*HTTPMonitor { if x != nil { return x.HttpMonitors } return nil } func (x *MonitorsResponse) GetTcpMonitors() []*TCPMonitor { if x != nil { return x.TcpMonitors } return nil } func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { if x != nil { return x.DnsMonitors } return nil } type IngestTCPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` Message string `protobuf:"bytes,7,opt,name=message,proto3" json:"message,omitempty"` RequestStatus string `protobuf:"bytes,8,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Error int64 `protobuf:"varint,9,opt,name=error,proto3" json:"error,omitempty"` Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestTCPRequest) Reset() { *x = IngestTCPRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestTCPRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestTCPRequest) ProtoMessage() {} func (x *IngestTCPRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestTCPRequest.ProtoReflect.Descriptor instead. func (*IngestTCPRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{2} } func (x *IngestTCPRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestTCPRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestTCPRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestTCPRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestTCPRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestTCPRequest) GetUri() string { if x != nil { return x.Uri } return "" } func (x *IngestTCPRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestTCPRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestTCPRequest) GetError() int64 { if x != nil { return x.Error } return 0 } func (x *IngestTCPRequest) GetTiming() string { if x != nil { return x.Timing } return "" } type IngestTCPResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestTCPResponse) Reset() { *x = IngestTCPResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestTCPResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestTCPResponse) ProtoMessage() {} func (x *IngestTCPResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestTCPResponse.ProtoReflect.Descriptor instead. func (*IngestTCPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{3} } type IngestHTTPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Url string `protobuf:"bytes,6,opt,name=url,proto3" json:"url,omitempty"` RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` Body string `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` Headers string `protobuf:"bytes,10,opt,name=headers,proto3" json:"headers,omitempty"` Timing string `protobuf:"bytes,11,opt,name=timing,proto3" json:"timing,omitempty"` StatusCode int64 `protobuf:"varint,12,opt,name=statusCode,proto3" json:"statusCode,omitempty"` Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestHTTPRequest) Reset() { *x = IngestHTTPRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestHTTPRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestHTTPRequest) ProtoMessage() {} func (x *IngestHTTPRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestHTTPRequest.ProtoReflect.Descriptor instead. func (*IngestHTTPRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{4} } func (x *IngestHTTPRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestHTTPRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestHTTPRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestHTTPRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestHTTPRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestHTTPRequest) GetUrl() string { if x != nil { return x.Url } return "" } func (x *IngestHTTPRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestHTTPRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestHTTPRequest) GetBody() string { if x != nil { return x.Body } return "" } func (x *IngestHTTPRequest) GetHeaders() string { if x != nil { return x.Headers } return "" } func (x *IngestHTTPRequest) GetTiming() string { if x != nil { return x.Timing } return "" } func (x *IngestHTTPRequest) GetStatusCode() int64 { if x != nil { return x.StatusCode } return 0 } func (x *IngestHTTPRequest) GetError() int64 { if x != nil { return x.Error } return 0 } type IngestHTTPResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestHTTPResponse) Reset() { *x = IngestHTTPResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestHTTPResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestHTTPResponse) ProtoMessage() {} func (x *IngestHTTPResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestHTTPResponse.ProtoReflect.Descriptor instead. func (*IngestHTTPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{5} } type Records struct { state protoimpl.MessageState `protogen:"open.v1"` Record []string `protobuf:"bytes,1,rep,name=record,proto3" json:"record,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Records) Reset() { *x = Records{} mi := &file_private_location_v1_private_location_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Records) String() string { return protoimpl.X.MessageStringOf(x) } func (*Records) ProtoMessage() {} func (x *Records) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Records.ProtoReflect.Descriptor instead. func (*Records) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{6} } func (x *Records) GetRecord() []string { if x != nil { return x.Record } return nil } type IngestDNSRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` Records map[string]*Records `protobuf:"bytes,9,rep,name=records,proto3" json:"records,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` Error int64 `protobuf:"varint,11,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestDNSRequest) Reset() { *x = IngestDNSRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestDNSRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestDNSRequest) ProtoMessage() {} func (x *IngestDNSRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestDNSRequest.ProtoReflect.Descriptor instead. func (*IngestDNSRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{7} } func (x *IngestDNSRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestDNSRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestDNSRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestDNSRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestDNSRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestDNSRequest) GetUri() string { if x != nil { return x.Uri } return "" } func (x *IngestDNSRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestDNSRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestDNSRequest) GetRecords() map[string]*Records { if x != nil { return x.Records } return nil } func (x *IngestDNSRequest) GetTiming() string { if x != nil { return x.Timing } return "" } func (x *IngestDNSRequest) GetError() int64 { if x != nil { return x.Error } return 0 } type IngestDNSResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestDNSResponse) Reset() { *x = IngestDNSResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestDNSResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestDNSResponse) ProtoMessage() {} func (x *IngestDNSResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestDNSResponse.ProtoReflect.Descriptor instead. func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + "\x0fMonitorsRequest\"\xe1\x01\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + "\amessage\x18\a \x01(\tR\amessage\x12$\n" + "\rrequestStatus\x18\b \x01(\tR\rrequestStatus\x12\x14\n" + "\x05error\x18\t \x01(\x03R\x05error\x12\x16\n" + "\x06timing\x18\n" + " \x01(\tR\x06timing\"\x13\n" + "\x11IngestTCPResponse\"\xed\x02\n" + "\x11IngestHTTPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03url\x18\x06 \x01(\tR\x03url\x12$\n" + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + "\amessage\x18\b \x01(\tR\amessage\x12\x12\n" + "\x04body\x18\t \x01(\tR\x04body\x12\x18\n" + "\aheaders\x18\n" + " \x01(\tR\aheaders\x12\x16\n" + "\x06timing\x18\v \x01(\tR\x06timing\x12\x1e\n" + "\n" + "statusCode\x18\f \x01(\x03R\n" + "statusCode\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\"\x14\n" + "\x12IngestHTTPResponse\"!\n" + "\aRecords\x12\x16\n" + "\x06record\x18\x01 \x03(\tR\x06record\"\xc6\x03\n" + "\x10IngestDNSRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03uri\x18\x06 \x01(\tR\x03uri\x12$\n" + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + "\amessage\x18\b \x01(\tR\amessage\x12L\n" + "\arecords\x18\t \x03(\v22.private_location.v1.IngestDNSRequest.RecordsEntryR\arecords\x12\x16\n" + "\x06timing\x18\n" + " \x01(\tR\x06timing\x12\x14\n" + "\x05error\x18\v \x01(\x03R\x05error\x1aX\n" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + "\x11IngestDNSResponse2\x90\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once file_private_location_v1_private_location_proto_rawDescData []byte ) func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { file_private_location_v1_private_location_proto_rawDescOnce.Do(func() { file_private_location_v1_private_location_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc))) }) return file_private_location_v1_private_location_proto_rawDescData } var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse (*IngestTCPRequest)(nil), // 2: private_location.v1.IngestTCPRequest (*IngestTCPResponse)(nil), // 3: private_location.v1.IngestTCPResponse (*IngestHTTPRequest)(nil), // 4: private_location.v1.IngestHTTPRequest (*IngestHTTPResponse)(nil), // 5: private_location.v1.IngestHTTPResponse (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse 9, // [9:13] is the sub-list for method output_type 5, // [5:9] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } func file_private_location_v1_private_location_proto_init() { if File_private_location_v1_private_location_proto != nil { return } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, NumMessages: 10, NumExtensions: 0, NumServices: 1, }, GoTypes: file_private_location_v1_private_location_proto_goTypes, DependencyIndexes: file_private_location_v1_private_location_proto_depIdxs, MessageInfos: file_private_location_v1_private_location_proto_msgTypes, }.Build() File_private_location_v1_private_location_proto = out.File file_private_location_v1_private_location_proto_goTypes = nil file_private_location_v1_private_location_proto_depIdxs = nil } ================================================ FILE: apps/checker/proto/private_location/v1/tcp_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/tcp_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type TCPMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TCPMonitor) Reset() { *x = TCPMonitor{} mi := &file_private_location_v1_tcp_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TCPMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*TCPMonitor) ProtoMessage() {} func (x *TCPMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_tcp_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TCPMonitor.ProtoReflect.Descriptor instead. func (*TCPMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_tcp_monitor_proto_rawDescGZIP(), []int{0} } func (x *TCPMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *TCPMonitor) GetUri() string { if x != nil { return x.Uri } return "" } func (x *TCPMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *TCPMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *TCPMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *TCPMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } var File_private_location_v1_tcp_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_tcp_monitor_proto_rawDesc = "" + "\n" + "%private_location/v1/tcp_monitor.proto\x12\x13private_location.v1\"\xb6\x01\n" + "\n" + "TCPMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12 \n" + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + "\x05retry\x18\x06 \x01(\x03R\x05retryB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_tcp_monitor_proto_rawDescOnce sync.Once file_private_location_v1_tcp_monitor_proto_rawDescData []byte ) func file_private_location_v1_tcp_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_tcp_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_tcp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_tcp_monitor_proto_rawDesc), len(file_private_location_v1_tcp_monitor_proto_rawDesc))) }) return file_private_location_v1_tcp_monitor_proto_rawDescData } var file_private_location_v1_tcp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_private_location_v1_tcp_monitor_proto_goTypes = []any{ (*TCPMonitor)(nil), // 0: private_location.v1.TCPMonitor } var file_private_location_v1_tcp_monitor_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type 0, // [0:0] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_private_location_v1_tcp_monitor_proto_init() } func file_private_location_v1_tcp_monitor_proto_init() { if File_private_location_v1_tcp_monitor_proto != nil { return } file_private_location_v1_tcp_monitor_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_tcp_monitor_proto_rawDesc), len(file_private_location_v1_tcp_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_tcp_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_tcp_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_tcp_monitor_proto_msgTypes, }.Build() File_private_location_v1_tcp_monitor_proto = out.File file_private_location_v1_tcp_monitor_proto_goTypes = nil file_private_location_v1_tcp_monitor_proto_depIdxs = nil } ================================================ FILE: apps/checker/request/request.go ================================================ package request import ( "encoding/json" ) type AssertionType string const ( AssertionHeader AssertionType = "header" AssertionTextBody AssertionType = "textBody" AssertionStatus AssertionType = "status" AssertionJsonBody AssertionType = "jsonBody" AssertionDnsRecord AssertionType = "dnsRecord" ) type StringComparator string const ( StringContains StringComparator = "contains" StringNotContains StringComparator = "not_contains" StringEquals StringComparator = "eq" StringNotEquals StringComparator = "not_eq" StringEmpty StringComparator = "empty" StringNotEmpty StringComparator = "not_empty" StringGreaterThan StringComparator = "gt" StringGreaterThanEqual StringComparator = "gte" StringLowerThan StringComparator = "lt" StringLowerThanEqual StringComparator = "lte" ) type NumberComparator string const ( NumberEquals NumberComparator = "eq" NumberNotEquals NumberComparator = "not_eq" NumberGreaterThan NumberComparator = "gt" NumberGreaterThanEqual NumberComparator = "gte" NumberLowerThan NumberComparator = "lt" NumberLowerThanEqual NumberComparator = "lte" ) type RecordComparator string const ( RecordEquals RecordComparator = "eq" RecordNotEquals RecordComparator = "not_eq" RecordContains RecordComparator = "contains" RecordNotContains RecordComparator = "not_contains" ) type Record string const ( RecordA Record = "A" RecordAAAA Record = "AAAA" RecordCNAME Record = "CNAME" RecordMX Record = "MX" RecordNS Record = "NS" RecordTXT Record = "TXT" ) type Assertion struct { AssertionType AssertionType `json:"type"` Comparator json.RawMessage `json:"compare"` RawTarget json.RawMessage `json:"target"` } type HttpCheckerRequest struct { Headers []struct { Key string `json:"key"` Value string `json:"value"` } `json:"headers,omitempty"` WorkspaceID string `json:"workspaceId"` URL string `json:"url"` MonitorID string `json:"monitorId"` Method string `json:"method"` Status string `json:"status"` Body string `json:"body"` Trigger string `json:"trigger,omitempty"` RawAssertions []json.RawMessage `json:"assertions,omitempty"` CronTimestamp int64 `json:"cronTimestamp"` Timeout int64 `json:"timeout"` DegradedAfter int64 `json:"degradedAfter,omitempty"` Retry int64 `json:"retry,omitempty"` FollowRedirects bool `json:"followRedirects,omitempty"` OtelConfig struct { Endpoint string `json:"endpoint"` Headers map[string]string `json:"headers,omitempty"` } `json:"otelConfig"` } type TCPCheckerRequest struct { Status string `json:"status"` WorkspaceID string `json:"workspaceId"` URI string `json:"uri"` MonitorID string `json:"monitorId"` Trigger string `json:"trigger,omitempty"` RawAssertions []json.RawMessage `json:"assertions,omitempty"` RequestId int64 `json:"requestId,omitempty"` CronTimestamp int64 `json:"cronTimestamp"` Timeout int64 `json:"timeout"` DegradedAfter int64 `json:"degradedAfter,omitempty"` Retry int64 `json:"retry,omitempty"` OtelConfig struct { Endpoint string `json:"endpoint"` Headers map[string]string `json:"headers,omitempty"` } `json:"otelConfig"` } type TCPRequest struct { WorkspaceID string `json:"workspaceId"` URL string `json:"url"` MonitorID string `json:"monitorId"` CronTimestamp int64 `json:"cronTimestamp"` Timeout int64 `json:"timeout"` } type PingRequest struct { Headers map[string]string `json:"headers"` URL string `json:"url"` Method string `json:"method"` Body string `json:"body"` RequestId int64 `json:"requestId"` WorkspaceId int64 `json:"workspaceId"` } type DNSCheckerRequest struct { Status string `json:"status"` WorkspaceID string `json:"workspaceId"` URI string `json:"uri"` MonitorID string `json:"monitorId"` Trigger string `json:"trigger,omitempty"` RawAssertions []json.RawMessage `json:"assertions,omitempty"` RequestId int64 `json:"requestId,omitempty"` CronTimestamp int64 `json:"cronTimestamp"` Timeout int64 `json:"timeout"` DegradedAfter int64 `json:"degradedAfter,omitempty"` Retry int64 `json:"retry,omitempty"` OtelConfig struct { Endpoint string `json:"endpoint"` Headers map[string]string `json:"headers,omitempty"` } `json:"otelConfig"` } ================================================ FILE: apps/dashboard/.dockerignore ================================================ # This file is generated by Dofigen v2.5.1 # See https://github.com/lenra-io/dofigen ================================================ FILE: apps/dashboard/.gitignore ================================================ .vercel ================================================ FILE: apps/dashboard/Dockerfile ================================================ # syntax=docker/dockerfile:1.11 # This file is generated by Dofigen v2.5.1 # See https://github.com/lenra-io/dofigen # builder FROM node@sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2 AS builder LABEL \ org.opencontainers.image.base.digest="sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2" \ org.opencontainers.image.base.name="docker.io/node:24-slim" ENV \ PROJECT_ID_VERCEL="test" \ CRON_SECRET="test" \ DATABASE_URL="http://libsql:8080" \ DATABASE_AUTH_TOKEN="test" \ UPSTASH_REDIS_REST_TOKEN="test" \ UPSTASH_REDIS_REST_URL="test" \ AUTH_SECRET="build-time-placeholder-min-32-chars-long" \ OPENPANEL_CLIENT_SECRET="test" \ VERCEL_AUTH_BEARER_TOKEN="test" \ TEAM_ID_VERCEL="test" \ TINY_BIRD_API_KEY="test" \ UNKEY_TOKEN="test" \ NEXT_PUBLIC_URL="http://localhost:3002" \ STRIPE_SECRET_KEY="test" \ UNKEY_API_ID="test" \ NEXT_PUBLIC_OPENPANEL_CLIENT_ID="test" \ PATH="$PNPM_HOME:$PATH" \ NODE_ENV="production" \ SELF_HOST="true" \ PNPM_HOME="/pnpm" \ RESEND_API_KEY="test" WORKDIR /app COPY \ --link \ "." "/app/" RUN <<EOF corepack enable pnpm install --frozen-lockfile pnpm turbo run build --filter=@openstatus/dashboard EOF # runtime FROM node@sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2 AS runtime LABEL \ io.dofigen.version="2.5.1" \ org.opencontainers.image.base.digest="sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2" \ org.opencontainers.image.base.name="docker.io/node:24-slim" WORKDIR /app/apps/dashboard COPY \ --from=builder \ --chown=1000:1000 \ --chmod=555 \ --link \ "/app/apps/dashboard/.next/standalone/apps/dashboard/" "./" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/node_modules/" "/app/node_modules/" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/apps/dashboard/.next/static/" "./.next/static/" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/apps/dashboard/public/" "./public/" USER 0:0 RUN <<EOF apt-get update apt-get install -y --no-install-recommends curl rm -rf /var/lib/apt/lists/* EOF USER 1000:1000 EXPOSE 3000 HEALTHCHECK \ --interval=30s \ --timeout=10s \ --start-period=45s \ --retries=3 \ CMD curl -f http://localhost:3000/ || exit 1 CMD ["node", "server.js"] ================================================ FILE: apps/dashboard/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/dashboard/docker-compose.yaml ================================================ name: server services: server: build: context: ../.. dockerfile: apps/dashboard/Dockerfile ports: - 3000:3000 image: dashboard env_file: - ../../.env.docker command: . ================================================ FILE: apps/dashboard/dofigen.yml ================================================ builders: # Stage 1: Next.js build with Node.js builder: fromImage: node:24-slim workdir: /app copy: - . /app/ env: NODE_ENV: production PNPM_HOME: /pnpm PATH: $PNPM_HOME:$PATH # Build-time environment variables (placeholder values, overwritten by .env.docker at runtime) DATABASE_URL: http://libsql:8080 DATABASE_AUTH_TOKEN: test NEXT_PUBLIC_OPENPANEL_CLIENT_ID: test NEXT_PUBLIC_URL: http://localhost:3002 TEAM_ID_VERCEL: test PROJECT_ID_VERCEL: test VERCEL_AUTH_BEARER_TOKEN: test OPENPANEL_CLIENT_SECRET: test RESEND_API_KEY: test UPSTASH_REDIS_REST_URL: test UPSTASH_REDIS_REST_TOKEN: test UNKEY_TOKEN: test UNKEY_API_ID: test TINY_BIRD_API_KEY: test CRON_SECRET: test STRIPE_SECRET_KEY: test AUTH_SECRET: build-time-placeholder-min-32-chars-long SELF_HOST: "true" run: - corepack enable - pnpm install --frozen-lockfile - pnpm turbo run build --filter=@openstatus/dashboard # Runtime stage fromImage: node:24-slim workdir: /app/apps/dashboard # Copy artifacts from builder copy: # Copy Next.js standalone output - fromBuilder: builder source: /app/apps/dashboard/.next/standalone/apps/dashboard/ target: ./ chmod: "555" # Copy root node_modules (required for pnpm symlinks) - fromBuilder: builder source: /app/node_modules/ target: /app/node_modules/ # Copy static assets - fromBuilder: builder source: /app/apps/dashboard/.next/static/ target: ./.next/static/ # Copy public directory - fromBuilder: builder source: /app/apps/dashboard/public/ target: ./public/ # Install curl for health checks root: run: - apt-get update - apt-get install -y --no-install-recommends curl - rm -rf /var/lib/apt/lists/* # Security: run as non-root user user: "1000:1000" # Expose port expose: "3000" # Health check healthcheck: interval: 30s timeout: 10s start: 45s retries: 3 cmd: curl -f http://localhost:3000/ || exit 1 # Start application cmd: - node - server.js ================================================ FILE: apps/dashboard/env.ts ================================================ const file = Bun.file("./.env.example"); await Bun.write("./.env", file); ================================================ FILE: apps/dashboard/instrumentation-client.ts ================================================ // This file configures the initialization of Sentry on the client. // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_FRONTEND, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.5, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, replaysOnErrorSampleRate: 1.0, // This sets the sample rate to be 10%. You may want this to be 100% while // in development and sample at a lower rate in production replaysSessionSampleRate: 0.1, // You can remove this option if you're not planning to use the Sentry Session Replay feature: integrations: [ Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), Sentry.captureConsoleIntegration({ levels: ["error"] }), ], }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export const onRequestError = Sentry.captureRequestError; ================================================ FILE: apps/dashboard/next-env.d.ts ================================================ /// <reference types="next" /> /// <reference types="next/image-types/global" /> import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. ================================================ FILE: apps/dashboard/next.config.ts ================================================ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: process.env.SELF_HOST === "true" ? "standalone" : undefined, images: { remotePatterns: [ new URL("https://openstatus.dev/**"), new URL("https://**.public.blob.vercel-storage.com/**"), new URL("https://www.openstatus.dev/**"), ], }, logging: { fetches: { fullUrl: true, }, }, }; // For detailed options, refer to the official documentation: // - Webpack plugin options: https://github.com/getsentry/sentry-webpack-plugin#options // - Next.js Sentry setup guide: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ const sentryConfig = { // Prevent log output unless running in a CI environment (helps reduce noise in logs) silent: !process.env.CI, org: "openstatus", project: "openstatus", authToken: process.env.SENTRY_AUTH_TOKEN, // Upload a larger set of source maps for improved stack trace accuracy (increases build time) widenClientFileUpload: true, // If set to true, transpiles Sentry SDK to be compatible with IE11 (increases bundle size) transpileClientSDK: false, // Tree-shake Sentry logger statements to reduce bundle size webpack: { treeshake: { removeDebugLogging: true, }, }, }; export default withSentryConfig(nextConfig, sentryConfig); ================================================ FILE: apps/dashboard/package.json ================================================ { "name": "@openstatus/dashboard", "version": "1.0.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "tsc": "tsc --noEmit" }, "dependencies": { "@auth/core": "0.40.0", "@auth/drizzle-adapter": "1.10.0", "@date-fns/tz": "1.2.0", "@date-fns/utc": "2.1.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@hookform/devtools": "4.4.0", "@hookform/resolvers": "5.1.0", "@libsql/client": "0.15.15", "@openpanel/nextjs": "1.2.0", "@openstatus/analytics": "workspace:*", "@openstatus/api": "workspace:*", "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", "@openstatus/header-analysis": "workspace:*", "@openstatus/icons": "workspace:*", "@openstatus/importers": "workspace:*", "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-emails": "workspace:*", "@openstatus/notification-google-chat": "workspace:*", "@openstatus/notification-grafana-oncall": "workspace:*", "@openstatus/notification-ntfy": "workspace:*", "@openstatus/notification-opsgenie": "workspace:*", "@openstatus/notification-pagerduty": "workspace:*", "@openstatus/notification-slack": "workspace:*", "@openstatus/notification-telegram": "workspace:*", "@openstatus/notification-twillio-whatsapp": "workspace:*", "@openstatus/notification-webhook": "workspace:*", "@openstatus/react": "workspace:*", "@openstatus/regions": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", "@openstatus/ui": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-tooltip": "1.2.7", "@sentry/nextjs": "10.31.0", "@stripe/stripe-js": "2.1.6", "@tanstack/react-query": "5.81.5", "@tanstack/react-table": "8.21.3", "@trpc/client": "11.4.4", "@trpc/next": "11.4.4", "@trpc/react-query": "11.4.4", "@trpc/server": "11.4.4", "@trpc/tanstack-react-query": "11.4.4", "@unkey/api": "2.2.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "date-fns": "3.6.0", "lucide-react": "0.525.0", "next": "16.1.6", "next-auth": "5.0.0-beta.29", "next-themes": "0.4.6", "nuqs": "2.8.5", "random-word-slugs": "0.1.7", "react": "19.2.3", "react-day-picker": "8.10.1", "react-dom": "19.2.3", "react-hook-form": "7.68.0", "recharts": "2.15.0", "rehype-react": "8.0.0", "remark-gfm": "4.0.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "sonner": "2.0.5", "stripe": "13.8.0", "superjson": "2.2.2", "tailwind-merge": "3.3.1", "unified": "11.0.5", "zod": "4.1.13" }, "devDependencies": { "@tailwindcss/postcss": "4.1.11", "@tailwindcss/typography": "0.5.10", "@types/dom-speech-recognition": "0.0.6", "@types/node": "24.0.8", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "shadcn": "3.8.4", "tailwindcss": "4.1.11", "tw-animate-css": "1.3.4", "typescript": "5.9.3" } } ================================================ FILE: apps/dashboard/postcss.config.mjs ================================================ const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ================================================ FILE: apps/dashboard/sentry.edge.config.ts ================================================ // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). // The config you add here will be used whenever one of the edge features is loaded. // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/dashboard/sentry.server.config.ts ================================================ // This file configures the initialization of Sentry on the server. // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.2, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/dashboard/src/app/(dashboard)/agents/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Bot } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Agents", icon: Bot }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/agents/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/agents/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { Book } from "lucide-react"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="sm" className="group h-7 w-7" asChild> <a href={"https://docs.openstatus.dev/reference/cli-reference"} target="_blank" rel="noreferrer" > <Book className="h-4 w-4 text-muted-foreground group-hover:text-foreground" /> </a> </Button> </TooltipTrigger> <TooltipContent>View Documentation</TooltipContent> </Tooltip> </TooltipProvider> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/agents/page.tsx ================================================ "use client"; import { Code } from "@/components/common/code"; import { Link } from "@/components/common/link"; import { Note } from "@/components/common/note"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { Section } from "@/components/content/section"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import { Info } from "lucide-react"; const messages = [ { message: "@openstatus create an incident for the payment API – high latency detected.", description: "Open a new incident and notify your subscribers.", }, { message: "@openstatus keep the status page updated that we are still monitoring the issue.", description: "Update the status report to 'Monitoring'.", }, { message: "@openstatus resolve the ongoing incident on my API status page.", description: "Close an active incident and update your subscribers.", }, { message: "@openstatus schedule a maintenance window for my database next Friday from 2–3 PM.", description: "Plan downtime so subscribers are informed in advance.", }, ]; export default function Page() { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Agents</SectionTitle> <SectionDescription> Use our Slack agent to manage your status pages and incidents.{" "} <Link href="https://www.openstatus.dev/blog/openstatus-slack-agent" target="_blank" rel="noopener noreferrer" > Read more </Link> . </SectionDescription> </SectionHeader> {!workspace?.limits["slack-agent"] ? ( <Note color="info" size="sm"> <Info /> This is a paid feature. Upgrade your plan to use the Slack agent. </Note> ) : null} <Button size="sm" asChild> <Link href="/settings/integrations">Install the Slack agent</Link> </Button> </Section> <Section> <SectionHeader> <SectionTitle>Messages</SectionTitle> <SectionDescription> Here are some examples of messages that the Slack agent can handle. </SectionDescription> </SectionHeader> <Note size="sm"> <Info /> Mention the @openstatus bot to trigger a response. This keeps threads clean without the bot spamming your team. </Note> <ul className="flex flex-col gap-2"> {messages.map((message, i) => ( <li key={i} className="flex flex-col gap-0.5"> <p className="text-muted-foreground text-xs"> {message.description} </p> <Code>{message.message}</Code> </li> ))} </ul> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/cli/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Terminal } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "CLI", icon: Terminal }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/cli/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { Book } from "lucide-react"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="sm" className="group h-7 w-7" asChild> <a href={"https://docs.openstatus.dev/reference/cli-reference"} target="_blank" rel="noreferrer" > <Book className="h-4 w-4 text-muted-foreground group-hover:text-foreground" /> </a> </Button> </TooltipTrigger> <TooltipContent>View Documentation</TooltipContent> </Tooltip> </TooltipProvider> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/cli/page.tsx ================================================ import { Code } from "@/components/common/code"; import { Link } from "@/components/common/link"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { Section } from "@/components/content/section"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { FileDown, FileJson, Key, Terminal } from "lucide-react"; import React from "react"; const OS = ["macOs", "Windows", "Linux"] as const; const installs = [ { title: "Install CLI", icon: Terminal, description: "Install the OpenStatus CLI to set up your monitors straight in your code.", command: { macOs: [ "brew install openstatusHQ/cli/openstatus --cask", "curl -fsSL https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.sh | bash", ], Linux: [ "curl -fsSL https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.sh | bash", ], Windows: [ "iwr https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.ps1| iex", ], }, }, { title: "Add API Key", icon: Key, description: ( <> Create an API key in your workspace{" "} <Link href="/settings/general">settings.</Link> </> ), command: { macOs: ["export OPENSTATUS_API_TOKEN=<your-api-token>"], Windows: ["set OPENSTATUS_API_TOKEN=<your-api-token>"], Linux: ["export OPENSTATUS_API_TOKEN=<your-api-token>"], }, }, { title: "Import Monitors", icon: FileDown, description: "Import monitors from your workspace to a YAML file.", command: "openstatus monitors import", }, { title: "Manage Monitors", icon: FileJson, description: "Add, remove, or update monitors from a YAML file and apply your changes.", command: "openstatus monitors apply", }, ] satisfies { title: string; icon: React.ElementType; description: React.ReactNode; command: string | Record<(typeof OS)[number], string[]>; }[]; const commands = [ { command: "openstatus monitors list [options]", description: "List all monitors in your workspace.", }, { command: "openstatus monitors info [monitor-id] [options]", description: "Get information about a specific monitor.", }, { command: "openstatus monitors trigger [monitor-id] [options]", description: "Trigger a monitor.", }, { command: "openstatus run [options]", description: "Run a list of monitors.", }, ]; const templates = [ { description: "MCP server", template: `# yaml-language-server: $schema=https://www.openstatus.dev/schema.json mcp-server: name: "HF MCP Server" description: "Hugging Face MCP server monitoring" frequency: "1m" active: true regions: ["iad", "ams", "lax"] retry: 3 kind: http request: url: https://hf.co/mcp method: POST body: > { "jsonrpc": "2.0", "id": "openstatus", "method": "ping" } headers: User-Agent: OpenStatus Accept: application/json, text/event-stream Content-Type: application/json assertions: - kind: statusCode compare: eq target: 200 - kind: textBody compare: eq target: '{"result":{},"jsonrpc":"2.0","id":"openstatus"}' `, }, ]; export default function Page() { return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>CLI</SectionTitle> <SectionDescription> Get started with the CLI to export and manage your monitors in your code.{" "} <Link href="https://docs.openstatus.dev/reference/cli-reference/" target="_blank" rel="noopener noreferrer" > Read more </Link> . </SectionDescription> </SectionHeader> <Tabs defaultValue={OS[0]} className="flex flex-col gap-3"> <TabsList> {OS.map((os) => ( <TabsTrigger key={os} value={os}> {os} </TabsTrigger> ))} </TabsList> {OS.map((os) => ( <TabsContent key={os} value={os} className="flex flex-col gap-6"> {installs.map((step, i) => { const commands = typeof step.command === "string" ? step.command : step.command[os]; return ( <div key={i} className="flex flex-col gap-3"> <div className="flex flex-col gap-1"> <p className="flex items-center gap-2 font-medium text-sm"> <step.icon className="size-4" /> {step.title} </p> <p className="text-muted-foreground text-sm"> {step.description} </p> </div> {typeof commands === "string" ? ( <Code>{commands}</Code> ) : ( <> {commands.map((command, i) => ( <React.Fragment key={command}> <Code>{command}</Code> {i < commands.length - 1 && ( <span className="text-muted-foreground">or</span> )} </React.Fragment> ))} </> )} </div> ); })} </TabsContent> ))} </Tabs> </Section> <Section> <SectionHeader> <SectionTitle>Commands</SectionTitle> <SectionDescription> We have a few more commands to run. Check the{" "} <Link href="https://docs.openstatus.dev/reference/cli-reference/" target="_blank" rel="noopener noreferrer" > documentation </Link>{" "} to read more. </SectionDescription> </SectionHeader> <ul className="flex flex-col gap-2"> {commands.map((command, i) => ( <li key={i} className="flex flex-col gap-0.5"> <p className="text-muted-foreground text-xs"> {command.description} </p> <Code>{command.command}</Code> </li> ))} </ul> </Section> <Section> <SectionHeader> <SectionTitle>GitHub Action</SectionTitle> <SectionDescription> We provide you with a github action in case you'd like to use the CLI within your CI/CD workflows. Check the{" "} <Link href="https://github.com/openstatusHQ/openstatus-github-action" target="_blank" rel="noopener noreferrer" > GitHub integration </Link>{" "} page or our{" "} <Link href="https://docs.openstatus.dev/guides/how-to-run-synthetic-test-github-action/" target="_blank" rel="noopener noreferrer" > guide </Link>{" "} to to run synthetic tests in a GitHub action. </SectionDescription> </SectionHeader> {/* TODO: add code example */} </Section> <Section> <SectionHeader> <SectionTitle>Templates</SectionTitle> <SectionDescription> We have a few templates to help you get started. Check the{" "} <Link href="https://github.com/openstatusHQ/cli-template" target="_blank" rel="noopener noreferrer" > <code>@openstatusHQ/cli-template</code> </Link>{" "} repository for more. </SectionDescription> </SectionHeader> <div className="flex flex-col gap-6"> {templates.map((template, i) => ( <div key={i} className="flex flex-col gap-0.5"> <p className="text-muted-foreground text-xs"> {template.description} </p> <Code>{template.template}</Code> </div> ))} </div> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/invite/client.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useQueryStates } from "nuqs"; import { useTransition } from "react"; import { toast } from "sonner"; import { searchParamsParsers } from "./search-params"; export function Client() { const trpc = useTRPC(); const [isPending, startTransition] = useTransition(); const [{ token }] = useQueryStates(searchParamsParsers); const { data: invitation, error } = useQuery({ ...trpc.invitation.get.queryOptions({ token }), retry: false, }); const acceptInvitationMutation = useMutation( trpc.invitation.accept.mutationOptions({ onSuccess: (workspace) => { if (!workspace) return; document.cookie = `workspace-slug=${workspace.slug}; path=/;`; window.location.href = "/overview"; }, }), ); // TODO: check if we can have a high level wrapper for isTRPCClientError errors if (isTRPCClientError(error)) { return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle className="text-destructive">Error</SectionTitle> <SectionDescription className="font-mono"> {error.message} </SectionDescription> </SectionHeader> </Section> </SectionGroup> ); } if (!invitation) return null; if (invitation.acceptedAt) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Invitation</SectionTitle> <SectionDescription> You've been invited to join the workspace{" "} {invitation.workspace.name ? ( <span className="font-semibold">{invitation.workspace.name}</span> ) : ( <span className="font-mono">{invitation.workspace.slug}</span> )} . </SectionDescription> </SectionHeader> <Button size="sm" onClick={() => { startTransition(async () => { try { const promise = acceptInvitationMutation.mutateAsync({ id: invitation.id, }); toast.promise(promise, { loading: "Accepting invitation...", success: "Invitation accepted", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to accept invitation"; }, }); await promise; } catch (error) { console.error(error); } }); }} > {isPending ? "Accepting..." : "Accept Invitation"} </Button> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/invite/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <NavBreadcrumb items={[{ type: "page", label: "Invite" }]} /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/invite/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/invite/page.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { redirect } from "next/navigation"; import type { SearchParams } from "nuqs"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function InvitePage(props: { searchParams: Promise<SearchParams>; }) { const { token } = await searchParamsCache.parse(props.searchParams); if (!token) { return redirect("/overview"); } const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.invitation.get.queryOptions({ token })); return ( <HydrateClient> <Client /> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/invite/search-params.tsx ================================================ import { createSearchParamsCache, parseAsString } from "nuqs/server"; export const searchParamsParsers = { token: parseAsString, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/layout.tsx ================================================ import { AppSidebar } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { SidebarInset, SidebarProvider, } from "@openstatus/ui/components/ui/sidebar"; import { cookies } from "next/headers"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const cookieStore = await cookies(); const hasState = cookieStore.has("sidebar_state"); const defaultOpen = hasState ? cookieStore.get("sidebar_state")?.value === "true" : true; return ( <HydrateSidebar> <SidebarProvider defaultOpen={defaultOpen}> <AppSidebar /> <SidebarInset>{children}</SidebarInset> </SidebarProvider> </HydrateSidebar> ); } async function HydrateSidebar({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.page.list.queryOptions()); await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); await queryClient.prefetchQuery(trpc.workspace.get.queryOptions()); await queryClient.prefetchQuery(trpc.user.get.queryOptions()); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Activity } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Monitors", icon: Activity }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/monitors/columns"; import { MonitorDataTableActionBar } from "@/components/data-table/monitors/data-table-action-bar"; import { MonitorDataTableToolbar } from "@/components/data-table/monitors/data-table-toolbar"; import { MetricCardButton, MetricCardGroup, MetricCardHeader, MetricCardSkeleton, MetricCardTitle, MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { getMonitorListMetrics } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import type { ColumnFiltersState, SortingState } from "@tanstack/react-table"; import { ArrowDown, CheckCircle, ListFilter } from "lucide-react"; import { useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; import { searchParamsParsers } from "./search-params"; const icons = { default: { active: CheckCircle, inactive: ListFilter, }, p95: { active: ArrowDown, inactive: ListFilter, }, } as const; export function Client() { const trpc = useTRPC(); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: tags } = useQuery(trpc.monitorTag.list.queryOptions()); const [searchParams, setSearchParams] = useQueryStates(searchParamsParsers); const [sorting, setSorting] = useState<SortingState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const monitorsByType = { http: monitors ?.filter((m) => m.jobType === "http") .map((m) => m.id.toString()) ?? [], tcp: monitors ?.filter((m) => m.jobType === "tcp") .map((m) => m.id.toString()) ?? [], }; const { http: httpMonitors, tcp: tcpMonitors } = monitorsByType; // HMM: why do we need two queries? const { data: globalHttpMetrics, isLoading: isLoadingHttp } = useQuery({ ...trpc.tinybird.globalMetrics.queryOptions({ monitorIds: httpMonitors, type: "http", }), enabled: httpMonitors.length > 0, }); const { data: globalTcpMetrics, isLoading: isLoadingTcp } = useQuery({ ...trpc.tinybird.globalMetrics.queryOptions({ monitorIds: tcpMonitors, type: "tcp", }), enabled: tcpMonitors.length > 0, }); // TODO: ideally we read from the searchParamsCache and there is no layout shift // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> useEffect(() => { if (searchParams.status) { setColumnFilters([{ id: "status", value: [searchParams.status] }]); } if (searchParams.sort) { setSorting([searchParams.sort]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!monitors) return null; const metrics = getMonitorListMetrics(monitors, [ ...(globalHttpMetrics?.data ?? []), ...(globalTcpMetrics?.data ?? []), ]); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Monitors</SectionTitle> <SectionDescription> Create and manage your monitors. </SectionDescription> </SectionHeader> <MetricCardGroup> {metrics.map((metric) => { const statusArray = columnFilters.find((f) => f.id === "status") ?.value as string[] | undefined; let isActive = false; if (metric.key === "p95") { isActive = !!sorting.find((s) => s.id === "p95" && s.desc); } else { isActive = Array.isArray(statusArray) && statusArray.includes(metric.key); } const iconGroup = metric.key === "p95" ? icons.p95 : icons.default; const Icon = iconGroup[isActive ? "active" : "inactive"]; return ( <MetricCardButton key={metric.title} variant={metric.variant} onClick={() => { if (metric.key === "p95") { if (sorting.length === 0 || !isActive) { setSearchParams({ sort: { id: "p95", desc: true } }); setSorting([{ id: "p95", desc: true }]); } else { setSearchParams({ sort: null }); setSorting([]); } } else { if (columnFilters.length === 0 || !isActive) { setSearchParams({ status: metric.key }); setColumnFilters([{ id: "status", value: [metric.key] }]); } else { setSearchParams({ status: null }); setColumnFilters([]); } } }} > <MetricCardHeader className="flex w-full items-center justify-between gap-2"> <MetricCardTitle className="truncate"> {metric.title} </MetricCardTitle> <Icon className="size-4" /> </MetricCardHeader> {metric.key === "p95" && (isLoadingHttp || isLoadingTcp) ? ( <MetricCardSkeleton className="h-6 w-12" /> ) : ( <MetricCardValue>{metric.value}</MetricCardValue> )} </MetricCardButton> ); })} </MetricCardGroup> </Section> <Section> <DataTable columns={columns} data={monitors.map((monitor) => ({ ...monitor, globalMetrics: isLoadingHttp || isLoadingTcp ? undefined : monitor.jobType === "http" ? globalHttpMetrics?.data?.find( (m) => m.monitorId === monitor.id.toString(), ) ?? false : globalTcpMetrics?.data?.find( (m) => m.monitorId === monitor.id.toString(), ) ?? false, }))} actionBar={MonitorDataTableActionBar} toolbarComponent={(props) => ( <MonitorDataTableToolbar {...props} tags={tags ?? []} /> )} paginationComponent={DataTablePaginationSimple} columnFilters={columnFilters} setColumnFilters={setColumnFilters} sorting={sorting} setSorting={setSorting} defaultColumnVisibility={{ active: false, url: false, jobType: false, }} /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/nav-actions.tsx ================================================ "use client"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; export function NavActions() { const trpc = useTRPC(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); if (!workspace || !monitors) return null; const limitReached = monitors.length >= workspace.limits.monitors; return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> {limitReached ? ( <Button size="sm" data-limited={limitReached} className="data-[limited=true]:opacity-80" onClick={() => setOpenDialog(true)} > Create Monitor </Button> ) : ( <Button size="sm" asChild> <Link href="/monitors/create">Create Monitor</Link> </Button> )} <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import type { SearchParams } from "nuqs"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { const queryClient = getQueryClient(); await searchParamsCache.parse(searchParams); await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); await queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()); return ( <HydrateClient> <Client /> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts ================================================ import { createParser, createSearchParamsCache, parseAsStringEnum, } from "nuqs/server"; export const parseAsSort = createParser({ parse(queryValue) { const [id, desc] = queryValue.split("."); if (!id && !desc) return null; return { id, desc: desc === "desc" }; }, serialize(value) { return `${value.id}.${value.desc ? "desc" : "asc"}`; }, }); export const searchParamsParsers = { status: parseAsStringEnum(["active", "degraded", "error", "inactive"]), sort: parseAsSort, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { Activity } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { MONITOR_TABS } from "./constants"; export function Breadcrumb() { const { id } = useParams<{ id: string }>(); const pathname = usePathname(); const trpc = useTRPC(); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); if (!monitor) return null; const segment = pathname.split("/").pop() ?? ""; const currentTab = MONITOR_TABS.find((tab) => tab.value === segment); return ( <NavBreadcrumb items={[ { type: "link", label: "Monitors", href: "/monitors", icon: Activity }, { type: "link", label: monitor.name, href: `/monitors/${id}/overview`, }, ...(currentTab ? [ { type: "page" as const, label: currentTab.label, icon: currentTab.icon, }, ] : []), ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/constants.ts ================================================ import type { LucideIcon } from "lucide-react"; import { Cog, LayoutGrid, Logs, Siren } from "lucide-react"; export const MONITOR_TABS: { value: string; label: string; icon: LucideIcon; }[] = [ { value: "overview", label: "Overview", icon: LayoutGrid }, { value: "logs", label: "Logs", icon: Logs }, { value: "incidents", label: "Incidents", icon: Siren }, { value: "edit", label: "Settings", icon: Cog }, ]; ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()); await queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/page.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormMonitorUpdate } from "@/components/forms/monitor/update"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); if (!monitor) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{monitor.name}</SectionTitle> <SectionDescription>Customize your monitor.</SectionDescription> </SectionHeader> <FormMonitorUpdate /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx ================================================ import { getQueryClient, trpc } from "@/lib/trpc/server"; import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; import { Sidebar } from "../sidebar"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const queryClient = getQueryClient(); const { id } = await params; await queryClient.prefetchQuery( trpc.incident.list.queryOptions({ monitorId: Number.parseInt(id) }), ); return ( <SidebarProvider defaultOpen={false}> <div className="w-full flex-1">{children}</div> <div className="hidden lg:block"> <Sidebar /> </div> </SidebarProvider> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/incidents/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: incidents } = useQuery( trpc.incident.list.queryOptions({ monitorId: Number.parseInt(id), }), ); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); if (!incidents || !monitor) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{monitor.name}</SectionTitle> <SectionDescription> {monitor.jobType === "http" ? ( <a href={monitor.url} target="_blank" rel="noopener noreferrer"> {monitor.url} </a> ) : ( monitor.url )} </SectionDescription> </SectionHeader> {incidents.length === 0 ? ( <EmptyStateContainer> <EmptyStateTitle>No incidents</EmptyStateTitle> <EmptyStateDescription> No incidents found for this monitor. </EmptyStateDescription> </EmptyStateContainer> ) : ( <DataTable columns={columns} data={incidents} paginationComponent={DataTablePaginationSimple} /> )} </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; import { Tabs } from "./tabs"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const { id } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); await queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { BillingOverlay, BillingOverlayButton, BillingOverlayContainer, BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { Section } from "@/components/content/section"; import { ButtonReset } from "@/components/controls-search/button-reset"; import { CommandRegion } from "@/components/controls-search/command-region"; import { DropdownStatus } from "@/components/controls-search/dropdown-status"; import { DropdownTrigger } from "@/components/controls-search/dropdown-trigger"; import { PopoverDate } from "@/components/controls-search/popover-date"; import { getColumns } from "@/components/data-table/response-logs/columns"; import { Sheet } from "@/components/data-table/response-logs/data-table-sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; import { DataTableSkeleton } from "@/components/ui/data-table/data-table-skeleton"; import { exampleLogs } from "@/data/response-logs"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { Lock } from "lucide-react"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useCallback, useMemo } from "react"; import { searchParamsParsers } from "./search-params"; export function Client() { const trpc = useTRPC(); const { id } = useParams<{ id: string }>(); const [ { regions, status, selected, trigger, from, to, pageIndex, pageSize }, setSearchParams, ] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); const enabled = workspace && workspace?.plan !== "free"; const { data: _logs, isLoading } = useQuery({ ...trpc.tinybird.list.queryOptions({ monitorId: id, from, to }), enabled, }); const { data: _log } = useQuery({ ...trpc.tinybird.get.queryOptions({ id: selected, monitorId: id }), enabled: !!selected && enabled, }); const pagination = useMemo( () => ({ pageIndex, pageSize }), [pageIndex, pageSize], ); const setPagination = useCallback( (p: PaginationState | ((old: PaginationState) => PaginationState)) => { const next = typeof p === "function" ? p({ pageIndex, pageSize }) : p; if (next.pageIndex !== pageIndex || next.pageSize !== pageSize) { setSearchParams({ pageIndex: next.pageIndex, pageSize: next.pageSize, }); } }, [pageIndex, pageSize, setSearchParams], ); const columns = useMemo( () => getColumns(monitor?.privateLocations ?? []), [monitor?.privateLocations], ); if (!workspace || !monitor) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{monitor.name}</SectionTitle> <SectionDescription> {monitor.jobType === "http" ? ( <a href={monitor.url} target="_blank" rel="noopener noreferrer"> {monitor.url} </a> ) : ( monitor.url )} </SectionDescription> </SectionHeader> <div className="flex flex-wrap items-center gap-2"> <PopoverDate /> {monitor.jobType === "http" ? <DropdownStatus /> : null} <DropdownTrigger /> <CommandRegion regions={monitor.regions} privateLocations={monitor?.privateLocations} /> <ButtonReset /> </div> </Section> <Section> {isLoading ? ( <DataTableSkeleton rows={10} /> ) : !enabled ? ( <BillingPlaceholder /> ) : ( <DataTable data={_logs?.data ?? []} columns={columns} onRowClick={(row) => { if (!row.original.id) return; setSearchParams({ selected: row.original.id }); }} columnFilters={[ { id: "trigger", value: trigger }, { id: "requestStatus", value: status }, { id: "region", value: regions }, ].filter((i) => Boolean(i.value))} pagination={pagination} setPagination={setPagination} paginationComponent={DataTablePagination} defaultColumnVisibility={ monitor.jobType === "tcp" || monitor.jobType === "dns" ? { timing: false, statusCode: false } : {} } // NOTE: required to control the pagination autoResetPageIndex={false} /> )} <Sheet data={_log?.data?.length ? _log.data[0] : null} privateLocations={monitor?.privateLocations ?? []} onClose={() => setTimeout(() => setSearchParams({ selected: null }), 300) } /> </Section> </SectionGroup> ); } function BillingPlaceholder() { const columns = useMemo(() => getColumns([]), []); return ( <BillingOverlayContainer> <DataTable data={exampleLogs} columns={columns} /> <BillingOverlay> <BillingOverlayButton asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </BillingOverlayButton> <BillingOverlayDescription> Access response headers, timing phases and more for each request.{" "} <Link href="https://docs.openstatus.dev/monitoring/monitor-data-collected/" rel="noreferrer" target="_blank" > Learn more </Link> . </BillingOverlayDescription> </BillingOverlay> </BillingOverlayContainer> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx ================================================ import type { SearchParams } from "nuqs/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { // NOTE: store in cache to avoid flicker on clients first render await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts ================================================ import { PERIODS, STATUS, TRIGGER } from "@/data/metrics.client"; import { endOfDay } from "date-fns"; import { startOfDay } from "date-fns"; import { createSearchParamsCache, parseAsArrayOf, parseAsInteger, parseAsIsoDateTime, parseAsString, parseAsStringLiteral, } from "nuqs/server"; export const searchParamsParsers = { period: parseAsStringLiteral(PERIODS).withDefault("1d"), regions: parseAsArrayOf(parseAsString), status: parseAsStringLiteral(STATUS), trigger: parseAsStringLiteral(TRIGGER), selected: parseAsString, from: parseAsIsoDateTime.withDefault(startOfDay(new Date())), to: parseAsIsoDateTime.withDefault(endOfDay(new Date())), pageIndex: parseAsInteger.withDefault(0), pageSize: parseAsInteger.withDefault(20), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx ================================================ "use client"; import { DataTableSheetTest } from "@/components/data-table/response-logs/data-table-sheet-test"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { getActions } from "@/data/monitors.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { deserialize } from "@openstatus/assertions"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { Zap } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; export function NavActions() { const { id } = useParams<{ id: string }>(); const [test, setTest] = useState<TestTCP | TestHTTP | TestDNS | null>(null); const queryClient = useQueryClient(); const trpc = useTRPC(); const router = useRouter(); const pathname = usePathname(); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); const deleteMonitorMutation = useMutation( trpc.monitor.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); if (pathname.includes(`/monitors/${id}`)) { router.push("/monitors"); } }, }), ); const cloneMonitorMutation = useMutation( trpc.monitor.clone.mutationOptions({ onSuccess: (newMonitor) => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); router.push(`/monitors/${newMonitor.id}`); }, }), ); const testHttpMutation = useMutation(trpc.checker.testHttp.mutationOptions()); const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); const actions = getActions({ edit: () => router.push(`/monitors/${id}/edit`), "copy-id": async () => { await navigator.clipboard.writeText(id); toast.success("Monitor ID copied to clipboard"); }, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: Number.parseInt(id), }); toast.promise(promise, { loading: "Cloning monitor...", success: "Monitor cloned", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to clone monitor"; }, }); }, }); async function testAction() { if (monitor?.jobType === "http") { const assertions = deserialize(monitor.assertions ?? "[]"); const promise = testHttpMutation.mutateAsync({ url: monitor.url, body: monitor.body, method: monitor.method, headers: monitor.headers, assertions: assertions.map((a) => a.schema), }); toast.promise(promise, { loading: "Testing HTTP request...", success: (data) => { setTest(data); return "HTTP test completed successfully"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "HTTP test failed"; }, }); } else if (monitor?.jobType === "tcp") { const promise = testTcpMutation.mutateAsync({ url: monitor.url }); toast.promise(promise, { loading: "Testing TCP connection...", success: (data) => { setTest(data); return "TCP test completed successfully"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "TCP test failed"; }, }); } else if (monitor?.jobType === "dns") { const assertions = deserialize(monitor.assertions ?? "[]"); const promise = testDnsMutation.mutateAsync({ url: monitor.url, assertions: assertions.map((a) => a.schema), }); toast.promise(promise, { loading: "Testing DNS request...", success: (data) => { setTest(data); return "DNS test completed successfully"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "DNS test failed"; }, }); } } if (!monitor) return null; return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> <div className="hidden font-medium text-muted-foreground lg:inline-block"> {!monitor.active ? ( <span className="relative ml-1.5 inline-flex"> <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/70" /> </span> ) : monitor.status === "active" ? ( <span className="relative ml-1.5 inline-flex"> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success/80 opacity-75" /> <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-success" /> </span> ) : monitor.status === "error" ? ( <span className="relative ml-1.5 inline-flex"> <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-destructive" /> </span> ) : ( <span className="relative ml-1.5 inline-flex"> <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-warning" /> </span> )} </div> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" className="group h-7 w-7" type="button" onClick={testAction} > <Zap className="text-muted-foreground group-hover:text-foreground" /> </Button> </TooltipTrigger> <TooltipContent>Test Monitor</TooltipContent> </Tooltip> </TooltipProvider> <QuickActions actions={actions} deleteAction={{ confirmationValue: monitor.name ?? "monitor", submitAction: async () => { await deleteMonitorMutation.mutateAsync({ id: Number.parseInt(id), }); }, }} /> <DataTableSheetTest data={test} monitor={monitor} onClose={async () => { await new Promise((resolve) => setTimeout(() => resolve(true), 300)); setTest(null); }} /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx ================================================ "use client"; import { ChartAreaLatency } from "@/components/chart/chart-area-latency"; import { ChartAreaTimingPhases } from "@/components/chart/chart-area-timing-phases"; import { ChartBarUptime } from "@/components/chart/chart-bar-uptime"; import { ChartLineRegions } from "@/components/chart/chart-line-regions"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { ButtonReset } from "@/components/controls-search/button-reset"; import { CommandRegion } from "@/components/controls-search/command-region"; import { DropdownInterval } from "@/components/controls-search/dropdown-interval"; import { DropdownPercentile } from "@/components/controls-search/dropdown-percentile"; import { DropdownPeriod } from "@/components/controls-search/dropdown-period"; import { AuditLogsWrapper } from "@/components/data-table/audit-logs/wrapper"; import { getColumns as getRegionColumns } from "@/components/data-table/response-logs/regions/columns"; import { GlobalUptimeSection } from "@/components/metric/global-uptime/section"; import { PopoverQuantile } from "@/components/popovers/popover-quantile"; import { PopoverResolution } from "@/components/popovers/popover-resolution"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; import { mapRegionMetrics } from "@/data/metrics.client"; import { periodToFromDate } from "@/data/metrics.client"; import type { RegionMetric } from "@/data/region-metrics"; import { useTRPC } from "@/lib/trpc/client"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { endOfDay } from "date-fns"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import React, { useMemo } from "react"; import { searchParamsParsers } from "./search-params"; const TIMELINE_INTERVAL = 30; // in days export function Client() { const trpc = useTRPC(); const { id } = useParams<{ id: string }>(); const [{ period, regions, percentile, interval }] = useQueryStates(searchParamsParsers); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); const selectedRegions = regions ?? undefined; const fromDate = periodToFromDate[period]; const toDate = endOfDay(new Date()); const regionTimelineQuery = { ...trpc.tinybird.metricsRegions.queryOptions({ monitorId: id, period: period, type: (monitor?.jobType ?? "http") as "http" | "tcp", regions: selectedRegions, // Request 30-minute buckets by default interval: 30, fromDate: fromDate.toISOString(), toDate: toDate.toISOString(), }), enabled: !!monitor, } as const; const { data: regionTimeline, isLoading } = useQuery(regionTimelineQuery); const regionMetrics: RegionMetric[] = React.useMemo(() => { return mapRegionMetrics( regionTimeline, // NOTE: while loading, we show the selected regions with empty data, // once the data is loaded, we show all the regions that we get from TB isLoading ? monitor?.regions ?? [] : [ ...monitorRegions, ...(monitor?.privateLocations?.map((location) => location.id.toString(), ) ?? []), ], percentile, ); }, [regionTimeline, monitor, percentile, isLoading]); const regionColumns = useMemo( () => getRegionColumns(monitor?.privateLocations ?? []), [monitor?.privateLocations], ); if (!monitor) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{monitor.name}</SectionTitle> <SectionDescription> {monitor.jobType === "http" ? ( <a href={monitor.url} target="_blank" rel="noopener noreferrer"> {monitor.url} </a> ) : ( monitor.url )} </SectionDescription> </SectionHeader> <div className="flex flex-wrap gap-2"> <div> <DropdownPeriod /> including{" "} <CommandRegion regions={monitor.regions} privateLocations={monitor.privateLocations} /> </div> <div> <ButtonReset only={["period", "regions"]} /> </div> </div> <GlobalUptimeSection monitorId={id} jobType={monitor.jobType as "http" | "tcp"} period={period} regions={selectedRegions} /> </Section> <Section> <SectionHeader> <SectionTitle>Uptime</SectionTitle> <SectionDescription> Uptime across all the selected regions </SectionDescription> </SectionHeader> <ChartBarUptime monitorId={id} type={monitor.jobType as "http" | "tcp"} period={period} regions={selectedRegions} /> </Section> <Section> {/* TODO: based on http, we have Timing Phases instead of Latency */} <SectionHeader> <SectionTitle>Latency</SectionTitle> <SectionDescription> Response time across all the regions </SectionDescription> </SectionHeader> <div className="flex flex-wrap gap-2"> <div> The <DropdownPercentile />{" "} <PopoverQuantile>quantile</PopoverQuantile> within a{" "} <DropdownInterval />{" "} <PopoverResolution>resolution</PopoverResolution> </div> <div> <ButtonReset only={["percentile", "interval"]} /> </div> </div> {monitor.jobType === "http" ? ( <ChartAreaTimingPhases monitorId={id} degradedAfter={monitor.degradedAfter} type={monitor.jobType as "http"} period={period} percentile={percentile} interval={interval} regions={selectedRegions} /> ) : ( <ChartAreaLatency monitorId={id} percentile={percentile} degradedAfter={monitor.degradedAfter} type={monitor.jobType as "http" | "tcp"} period={period} regions={selectedRegions} /> )} </Section> <Section> <SectionHeader> <SectionTitle>Regions</SectionTitle> <SectionDescription> Every selected region's latency trend </SectionDescription> </SectionHeader> <div className="flex flex-wrap gap-2"> <div> The <DropdownPercentile />{" "} <PopoverQuantile>quantile</PopoverQuantile> trend over the{" "} <DropdownPeriod /> </div> <div> <ButtonReset only={["percentile", "period"]} /> </div> </div> <Tabs defaultValue="table"> <TabsList> <TabsTrigger value="table">Table</TabsTrigger> <TabsTrigger value="chart">Chart</TabsTrigger> </TabsList> <TabsContent value="table"> <DataTable data={regionMetrics} columns={regionColumns} paginationComponent={({ table }) => ( <DataTablePagination table={table} /> )} /> </TabsContent> <TabsContent value="chart"> <ChartLineRegions className="mt-3" regions={regionMetrics.map((region) => region.region)} privateLocations={monitor?.privateLocations ?? []} data={regionMetrics.reduce( (acc, region) => { region.trend.forEach((t) => { const existing = acc.find( (d) => d.timestamp === t.timestamp, ); if (existing) { existing[region.region] = t[region.region]; } else { acc.push({ timestamp: t.timestamp, [region.region]: t[region.region], }); } }); return acc; }, [] as { timestamp: number; [key: string]: number }[], )} /> </TabsContent> </Tabs> </Section> <Section> <SectionHeader> <SectionTitle>Timeline</SectionTitle> <SectionDescription> What happened to your monitor over the last {TIMELINE_INTERVAL} days </SectionDescription> </SectionHeader> <AuditLogsWrapper monitorId={id} interval={TIMELINE_INTERVAL} /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/layout.tsx ================================================ import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; import { Sidebar } from "../sidebar"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <SidebarProvider defaultOpen={false}> {/* blur-2xl */} <div className="w-full flex-1">{children}</div> <div className="hidden lg:block"> <Sidebar /> </div> </SidebarProvider> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/page.tsx ================================================ import type { SearchParams } from "nuqs/server"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { // NOTE: store in cache to avoid flicker on clients first render await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/search-params.ts ================================================ import { INTERVALS } from "@/data/metrics.client"; import { createSearchParamsCache, parseAsArrayOf, parseAsNumberLiteral, parseAsString, parseAsStringLiteral, } from "nuqs/server"; const PERIOD = ["1d", "7d", "14d"] as const; const PERCENTILE = ["p50", "p75", "p90", "p95", "p99"] as const; export const searchParamsParsers = { period: parseAsStringLiteral(PERIOD).withDefault("1d"), regions: parseAsArrayOf(parseAsString), percentile: parseAsStringLiteral(PERCENTILE).withDefault("p50"), interval: parseAsNumberLiteral(INTERVALS).withDefault(30), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function Page({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; redirect(`/monitors/${id}/overview`); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx ================================================ "use client"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { SidebarRight } from "@/components/nav/sidebar-right"; import { monitorTypes } from "@/data/monitors.client"; import { formatMilliseconds } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { deserialize } from "@openstatus/assertions"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { useQuery } from "@tanstack/react-query"; import { Logs } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; export function Sidebar() { const router = useRouter(); const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); if (!monitor) return null; const assertions = monitor.assertions ? deserialize(monitor.assertions) : []; const type = monitorTypes.find((type) => type.id === monitor.jobType); return ( <SidebarRight header="Monitor" metadata={[ { label: "Overview", items: [ { label: "External Name", value: monitor.externalName || monitor.name, }, { label: "Status", // FIXME: dynamic value: <span className="text-success">Normal</span>, }, { label: "Type", value: type ? ( <span className="flex items-center gap-1"> <span className="uppercase">{type.label}</span> <type.icon className="h-2.5 w-2.5 text-muted-foreground" /> </span> ) : ( <span className="uppercase">{monitor.jobType}</span> ), }, { label: "Endpoint", value: monitor.url.replace(/^https?:\/\//, ""), }, { label: "Regions", value: monitor.regions.length > 6 ? `${monitor.regions.length} regions` : monitor.regions.join(", "), }, { label: "Tags", value: ( <div className="group/badges -space-x-2 flex flex-wrap"> {monitor.tags.map((tag) => ( <Badge key={tag.id} variant="outline" className="relative flex translate-x-0 items-center gap-1.5 rounded-full bg-background transition-transform hover:z-10 hover:translate-x-1" > <div className="size-2.5 rounded-full" style={{ backgroundColor: tag.color }} /> {tag.name} </Badge> ))} </div> ), }, ], }, { label: "Configuration", items: [ { label: "Periodicity", value: monitor.periodicity }, { label: "Timeout", value: formatMilliseconds(monitor.timeout), }, { label: "Public", value: String(monitor.public) }, { label: "Active", value: String(monitor.active) }, { label: "Follow redirects", value: String(monitor.followRedirects), }, ], }, { label: "Notifications", items: monitor.notifications.flatMap((notification) => { const arr = []; arr.push({ label: "Name", value: ( <TableCellLink // TODO: add the ?id= to the href and open the sheet href={"/notifications"} value={notification.name} /> ), }); arr.push({ label: "Type", value: notification.provider, isNested: true, }); arr.push({ label: "Value", value: notification.data, // TODO: improve this based on the provider - we might wanna parse it! isNested: true, }); return arr; }), }, { label: "Assertions", items: assertions.length > 0 ? assertions.flatMap((assertion) => { const arr = []; arr.push({ label: "Type", value: assertion.schema.type, }); arr.push({ label: "Compare", value: assertion.schema.compare, isNested: true, }); if ( (assertion.schema.type === "header" || assertion.schema.type === "dnsRecord") && assertion.schema.key ) { arr.push({ label: "Key", value: assertion.schema.key, isNested: true, }); } arr.push({ label: "Value", value: assertion.schema.target, isNested: true, }); return arr; }) : [], }, // { // label: "Last Logs", // items: [ // ...Array.from({ length: 20 }).map((_, index) => { // const date = new Date(new Date().getTime() - index * 500000); // return { // label: [ // "Amsterdam", // "Frankfurt", // "New York", // "Singapore", // "Johannesburg", // ][index % 5], // value: ( // <div className="flex items-center justify-between gap-2"> // <CircleCheck className="h-4 w-4 text-success" /> // <TooltipProvider> // <Tooltip> // <TooltipTrigger> // <span className="underline decoration-muted-foreground/50 decoration-dashed underline-offset-2"> // {date.toLocaleTimeString("en-US", { // hour: "2-digit", // minute: "2-digit", // })} // </span> // </TooltipTrigger> // <TooltipContent align="center" side="left"> // {date.toLocaleString("en-US")} // </TooltipContent> // </Tooltip> // </TooltipProvider> // </div> // ), // }; // }), // ], // }, ]} footerButton={{ onClick: () => router.push(`/monitors/${id}/logs`), children: ( <> <Logs /> <span>View all logs</span> </> ), }} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/[id]/tabs.tsx ================================================ "use client"; import { NavTabs } from "@/components/nav/nav-tabs"; import { useParams } from "next/navigation"; import { MONITOR_TABS } from "./constants"; export function Tabs() { const { id } = useParams<{ id: string }>(); return ( <NavTabs items={MONITOR_TABS.map((tab) => ({ ...tab, href: `/monitors/${id}/${tab.value}`, }))} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Activity } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Monitors", href: "/monitors", icon: Activity, }, { type: "page", label: "Create Monitor" }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/create/layout.tsx ================================================ import { AppHeader, AppHeaderContent } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { EmptyStateDescription } from "@/components/content/empty-state"; import { Section, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormGeneral } from "@/components/forms/monitor/form-general"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; export default function Page() { const trpc = useTRPC(); const queryClient = useQueryClient(); const router = useRouter(); const triggerCheckMutation = useMutation( trpc.checker.triggerChecker.mutationOptions({}), ); const createMonitorMutation = useMutation( trpc.monitor.new.mutationOptions({ onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); if (data.active) { triggerCheckMutation.mutate({ id: data.id }); } router.push(`/monitors/${data.id}/edit`); }, }), ); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Create Monitor</SectionTitle> </SectionHeader> <FormGeneral onSubmit={async (data) => { await createMonitorMutation.mutateAsync({ name: data.name, jobType: data.type, url: data.url, method: data.method, headers: data.headers, body: data.body, active: data.active, assertions: data.assertions, saveCheck: data.saveCheck, skipCheck: data.skipCheck, }); }} /> </Section> <Section> <EmptyStateContainer> <EmptyStateTitle>Create and start customizing</EmptyStateTitle> <EmptyStateDescription> Change the <span className="text-foreground">periodicity</span>, set up the <span className="text-foreground">regions</span>,{" "} <span className="text-foreground">timeout</span> or{" "} <span className="text-foreground">degraded</span> duration and more... </EmptyStateDescription> </EmptyStateContainer> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Bell } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Notifications", icon: Bell }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/client.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { ActionCard, ActionCardDescription, ActionCardHeader, ActionCardTitle, } from "@/components/content/action-card"; import { ActionCardGroup } from "@/components/content/action-card"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/notifications/columns"; import { FormSheetNotifier } from "@/components/forms/notifications/sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; import { config } from "@/data/notifications.client"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useQueryStates } from "nuqs"; import { searchParamsParsers } from "./search-params"; // FIXME: WARNING we are using the `web` api url here const BASE_URL = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://www.openstatus.dev"; export function Client() { const trpc = useTRPC(); const { data: notifications, refetch } = useQuery( trpc.notification.list.queryOptions(), ); const [searchParams] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const createNotifierMutation = useMutation( trpc.notification.new.mutationOptions({ onSuccess: () => refetch(), }), ); if (!notifications || !monitors || !workspace) return null; const limitReached = notifications.length >= workspace.limits["notification-channels"]; return ( <SectionGroup> <SectionHeader> <SectionTitle>Notifications</SectionTitle> <SectionDescription> Define your notifications to receive alerts when downtime occurs. </SectionDescription> </SectionHeader> <Section> {notifications.length === 0 ? ( <EmptyStateContainer> <EmptyStateTitle>No notifier found</EmptyStateTitle> </EmptyStateContainer> ) : ( <DataTable columns={columns} data={notifications} /> )} </Section> <Section> <SectionHeader> <SectionTitle>Create a new notifier</SectionTitle> <SectionDescription> Define your notifications to receive alerts when downtime occurs.{" "} <Link href="https://docs.openstatus.dev/reference/notification/" rel="noreferrer" target="_blank" > Learn more </Link> . </SectionDescription> </SectionHeader> <ActionCardGroup className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> {Object.keys(config).map((notifier) => { const key = notifier as keyof typeof config; const Icon = config[key].icon; let enabled = true; if (key in workspace.limits) { enabled = workspace.limits[ key as "opsgenie" | "sms" | "opsgenie" | "whatsapp" ]; } if (limitReached) { enabled = false; } if (!searchParams.channel && key === "pagerduty") { const PAGERDUTY_URL = `https://app.pagerduty.com/install/integration?app_id=${process.env.NEXT_PUBLIC_PAGERDUTY_APP_ID}&redirect_url=${BASE_URL}/api/callback/pagerduty?workspace=${workspace.slug}&version=2`; return ( <a key={key} href={PAGERDUTY_URL} data-disabled={!enabled} className="data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50" > <ActionCard className="h-full w-full"> <ActionCardHeader> <div className="flex items-center gap-2"> <div className="flex size-6 items-center justify-center rounded-md border border-border bg-muted"> <Icon className="size-3" /> </div> <ActionCardTitle>{config[key].label}</ActionCardTitle> </div> <ActionCardDescription> Send notifications to {config[key].label} </ActionCardDescription> </ActionCardHeader> </ActionCard> </a> ); } return ( <FormSheetNotifier key={notifier} provider={key} monitors={monitors} defaultOpen={searchParams.channel === key} onSubmit={async (values) => { await createNotifierMutation.mutateAsync({ provider: key, name: values.name, data: { [key]: values.data }, monitors: values.monitors, }); }} disabled={!enabled} > <ActionCard className="h-full w-full"> <ActionCardHeader> <div className="flex items-center gap-2"> <div className="flex size-6 items-center justify-center rounded-md border border-border bg-muted"> <Icon className="size-3" /> </div> <ActionCardTitle>{config[key].label}</ActionCardTitle> </div> <ActionCardDescription> Send notifications to {config[key].label} </ActionCardDescription> </ActionCardHeader> </ActionCard> </FormSheetNotifier> ); })} <ActionCard className="border-dashed"> <ActionCardHeader> <div className="flex items-center gap-2"> <div className="flex size-6 items-center justify-center rounded-md border border-border bg-muted" /> <ActionCardTitle className="text-muted-foreground"> Your Notifier </ActionCardTitle> </div> <ActionCardDescription> Missing a channel?{" "} <Link href="mailto:ping@openstatus.dev">Contact us</Link> </ActionCardDescription> </ActionCardHeader> </ActionCard> </ActionCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); return ( <HydrateClient> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/page.tsx ================================================ import type { SearchParams } from "nuqs"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/notifications/search-params.ts ================================================ import { createSearchParamsCache, parseAsString, parseAsStringEnum, } from "nuqs/server"; export const searchParamsParsers = { config: parseAsString, channel: parseAsStringEnum(["pagerduty"]), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/onboarding/client.tsx ================================================ "use client"; import { ActionCard, ActionCardDescription, ActionCardGroup, ActionCardHeader, ActionCardTitle, } from "@/components/content/action-card"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { CreateMonitorForm } from "@/components/forms/onboarding/create-monitor"; import { CreatePageForm } from "@/components/forms/onboarding/create-page"; import { LearnFromForm } from "@/components/forms/onboarding/learn-from"; import { extractDomain } from "@/lib/domains"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpRight } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useEffect } from "react"; import { searchParamsParsers } from "./search-params"; const moreActions = [ { id: "notifier", title: "Create a notifier", description: "Get notified when your website or API is down.", href: "/notifications", }, { id: "workspace", title: "Setup workspace", description: "Add a name to your workspace and share it with your team.", href: "/settings/general", }, { id: "monitor", title: "Update monitor", description: "Change region, schedule, timeout and more.", href: "/monitors", }, { id: "cal", title: "Schedule a call", description: "Book a meeting with us to get you started with OpenStatus.", href: "https://openstatus.dev/cal", }, { id: "docs", title: "Documentation", description: "Read our documentation to get started with OpenStatus.", href: "https://docs.openstatus.dev", }, { id: "changelog", title: "Changelog", description: "See what's new in OpenStatus.", href: "https://openstatus.dev/changelog", }, { id: "discord", title: "Discord", description: "Join our Discord server if you get stuck.", href: "https://discord.gg/openstatus", }, { id: "github", title: "GitHub", description: "Leave a star on GitHub, request features or report issues.", href: "https://github.com/openstatus-dev/openstatus", }, ]; export function Client() { const [{ step, callbackUrl }, setSearchParams] = useQueryStates(searchParamsParsers); const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: workspace, refetch } = useQuery( trpc.workspace.get.queryOptions(), ); const triggerCheckMutation = useMutation( trpc.checker.triggerChecker.mutationOptions({}), ); const createMonitorMutation = useMutation( trpc.monitor.new.mutationOptions({ onSuccess: async (data) => { await setSearchParams({ step: "2" }); if (data.active) { triggerCheckMutation.mutate({ id: data.id }); } refetch(); queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); }, }), ); const createPageMutation = useMutation( trpc.page.create.mutationOptions({ onSuccess: async () => { await setSearchParams({ step: "next" }); refetch(); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); const createFeedbackMutation = useMutation( trpc.feedback.submit.mutationOptions({}), ); useEffect(() => { if (!callbackUrl) return; // Validate and normalize the callbackUrl to prevent XSS via javascript: or other dangerous schemes try { const url = new URL(callbackUrl, window.location.origin); if (url.pathname === "/" || url.pathname === "") return; if (url.origin !== window.location.origin) return; if (url.protocol !== "http:" && url.protocol !== "https:") return; // Navigate using the parsed URL to avoid raw-input parsing discrepancies router.push(`${url.pathname}${url.search}${url.hash}`); } catch { // Malformed URLs are not safe to navigate to } }, [callbackUrl, router]); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Getting Started</SectionTitle> <SectionDescription> Welcome to OpenStatus. Let's get you set up. </SectionDescription> </SectionHeader> </Section> {step === "1" && ( <Section> <SectionHeader className="h-8 flex-row items-center justify-between"> <SectionDescription className="tabular-nums"> Step <span className="font-medium text-foreground">1</span> of{" "} <span className="font-medium text-foreground">2</span> </SectionDescription> <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setSearchParams({ step: "2" })} > Skip </Button> </SectionHeader> <FormCard> <FormCardHeader> <FormCardTitle>Create a monitor</FormCardTitle> <FormCardDescription> Get uptime, response time and more for your website or API. </FormCardDescription> </FormCardHeader> <FormCardContent> <CreateMonitorForm id="create-monitor-form" onSubmit={async (values) => { await createMonitorMutation.mutateAsync({ url: values.url, name: new URL(values.url).hostname, method: "GET", headers: [], assertions: [], jobType: "http", active: true, }); }} /> </FormCardContent> <FormCardFooter> <Button form="create-monitor-form">Submit</Button> </FormCardFooter> </FormCard> </Section> )} {step === "2" && ( <Section> <SectionHeader className="h-8 flex-row items-center justify-between"> <SectionDescription className="tabular-nums"> Step <span className="font-medium text-foreground">2</span> of{" "} <span className="font-medium text-foreground">2</span> </SectionDescription> <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setSearchParams({ step: "next" })} > Skip </Button> </SectionHeader> <FormCard> <FormCardHeader> <FormCardTitle>Create a page</FormCardTitle> <FormCardDescription> Inform your users about the status of your website or API. </FormCardDescription> </FormCardHeader> <FormCardContent> <CreatePageForm id="create-page-form" defaultValues={{ slug: extractDomain(createMonitorMutation.data?.url ?? ""), }} onSubmit={async (values) => { if (!workspace?.id) return; await createPageMutation.mutateAsync({ slug: values.slug, title: values.slug.replace(/-/g, " "), description: "", monitors: createMonitorMutation.data?.id ? [{ monitorId: createMonitorMutation.data.id, order: 0 }] : [], workspaceId: workspace.id, legacyPage: false, }); }} /> </FormCardContent> <FormCardFooter> <Button form="create-page-form">Submit</Button> </FormCardFooter> </FormCard> </Section> )} {step === "next" && ( <> <Section> <SectionHeader className="h-8 flex-row items-center justify-between"> <SectionDescription> We'd love to know what you are looking for with openstatus. This will help us improve our product and services. </SectionDescription> </SectionHeader> <LearnFromForm onSubmit={async (values) => { await createFeedbackMutation.mutateAsync({ message: `I want to use OpenStatus for *${values.from}${ values.other ? `: ${values.other || "others"}` : "" }*`, }); }} /> </Section> <Section> <SectionHeader> <SectionDescription className="tabular-nums"> What's next? </SectionDescription> </SectionHeader> <ActionCardGroup className="sm:grid-cols-2"> {moreActions.map((action) => { const isExternal = action.href.startsWith("http"); const isMonitor = action.id === "monitor"; const href = isMonitor && createMonitorMutation.data?.id ? `${action.href}/${createMonitorMutation.data.id}` : action.href; return ( <Link key={action.id} href={href} target={isExternal ? "_blank" : undefined} rel={isExternal ? "noopener noreferrer" : undefined} > <ActionCard className="h-full w-full"> <ActionCardHeader> <ActionCardTitle className="flex items-center justify-between gap-2"> {action.title} {isExternal && ( <ArrowUpRight className="size-4 shrink-0 text-muted-foreground group-hover/action-card:text-foreground" /> )} </ActionCardTitle> <ActionCardDescription> {action.description} </ActionCardDescription> </ActionCardHeader> </ActionCard> </Link> ); })} </ActionCardGroup> </Section> </> )} </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/onboarding/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <NavBreadcrumb items={[{ type: "page", label: "Onboarding" }]} /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/onboarding/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/onboarding/page.tsx ================================================ import { Client } from "./client"; import { searchParamsCache } from "./search-params"; import type { SearchParams } from "nuqs"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/onboarding/search-params.ts ================================================ import { createSearchParamsCache, parseAsString, parseAsStringLiteral, } from "nuqs/server"; const STEPS = ["1", "2", "next"] as const; export const searchParamsParsers = { step: parseAsStringLiteral(STEPS).withDefault("1"), callbackUrl: parseAsString, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/overview/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { LayoutGrid } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Overview", icon: LayoutGrid }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/overview/data-table-status-reports.tsx ================================================ "use client"; import { DataTable as UpdatesDataTable } from "@/components/data-table/status-report-updates/data-table"; import { columns as statusReportsColumns } from "@/components/data-table/status-reports/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; import type { RouterOutputs } from "@openstatus/api"; type StatusReport = RouterOutputs["statusReport"]["list"][number]; export function DataTableStatusReports({ statusReports, }: { statusReports: StatusReport[]; }) { return ( <DataTable columns={statusReportsColumns} data={statusReports} onRowClick={(row) => row.getCanExpand() ? row.toggleExpanded() : undefined } rowComponent={({ row }) => ( <UpdatesDataTable updates={row.original.updates} reportId={row.original.id} /> )} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/overview/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); await queryClient.prefetchQuery(trpc.page.list.queryOptions()); await queryClient.prefetchQuery( trpc.incident.list.queryOptions({ period: "7d", }), ); await queryClient.prefetchQuery( trpc.statusReport.list.queryOptions({ period: "7d", }), ); await queryClient.prefetchQuery( trpc.maintenance.list.queryOptions({ period: "7d", }), ); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/overview/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/overview/page.tsx ================================================ "use client"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { Note, NoteButton } from "@/components/common/note"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { Section } from "@/components/content/section"; import { columns as incidentsColumns } from "@/components/data-table/incidents/columns"; import { columns as maintenancesColumns } from "@/components/data-table/maintenances/columns"; import { MetricCard, MetricCardGroup, MetricCardHeader, MetricCardTitle, MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceToNowStrict } from "date-fns"; import { Bot, List, Search } from "lucide-react"; import Link from "next/link"; import { DataTableStatusReports } from "./data-table-status-reports"; // FIXME: the page is server side // whenever I change the maintenances, the page is not updated // we need to move the queryClient to the layout and prefetch the data there export default function Page() { const trpc = useTRPC(); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: pages } = useQuery(trpc.page.list.queryOptions()); const { data: incidents } = useQuery( trpc.incident.list.queryOptions({ period: "7d", }), ); const { data: statusReports } = useQuery( trpc.statusReport.list.queryOptions({ period: "7d", }), ); const { data: maintenances } = useQuery( trpc.maintenance.list.queryOptions({ period: "7d", }), ); if (!monitors || !pages || !incidents || !statusReports || !maintenances) return null; const lastIncident = incidents.length > 0 ? incidents[0] : null; const lastStatusReport = statusReports.length > 0 ? statusReports[0] : null; const lastMaintenance = maintenances.length > 0 ? maintenances[0] : null; const incidentDistance = lastIncident ? formatDistanceToNowStrict(lastIncident.startedAt, { addSuffix: true, }) : "None"; const statusReportDistance = lastStatusReport?.createdAt ? formatDistanceToNowStrict(lastStatusReport.createdAt, { addSuffix: true, }) : "None"; const maintenanceDistance = lastMaintenance?.createdAt ? formatDistanceToNowStrict(lastMaintenance.createdAt, { addSuffix: true, }) : "None"; const metrics = [ { title: "Monitors", value: monitors.length, href: "/monitors", variant: "default" as const, icon: List, }, { title: "Status Pages", value: pages.length, href: "/status-pages", variant: "default" as const, icon: List, }, { title: lastIncident?.resolvedAt === undefined && lastIncident ? "Active Incident" : "Recent Incident", value: incidentDistance, disabled: !lastIncident?.monitorId, href: `/monitors/${lastIncident?.monitorId}/incidents`, variant: lastIncident?.resolvedAt === undefined && lastIncident ? ("warning" as const) : ("default" as const), icon: Search, }, { title: "Last Report", value: statusReportDistance, disabled: !lastStatusReport?.pageId, href: `/status-pages/${lastStatusReport?.pageId}/status-reports`, variant: "default" as const, icon: Search, }, { title: "Last Maintenance", value: maintenanceDistance, disabled: !lastMaintenance?.pageId, href: `/status-pages/${lastMaintenance?.pageId}/maintenances`, variant: "default" as const, icon: Search, }, ]; return ( <SectionGroup> <Note> <Bot /> Use our Slack agent to manage your status pages and incidents. <NoteButton variant="default" asChild> <Link href="/agents">Learn more</Link> </NoteButton> </Note> <Section> <SectionHeader> <SectionTitle>Overview</SectionTitle> <SectionDescription> Welcome to your OpenStatus dashboard. </SectionDescription> </SectionHeader> <MetricCardGroup> {metrics.map((metric) => ( <Link href={metric.href} key={metric.title} className={cn(metric.disabled && "pointer-events-none")} aria-disabled={metric.disabled} > <MetricCard variant={metric.variant}> <MetricCardHeader className="flex items-center justify-between gap-2"> <MetricCardTitle className="truncate"> {metric.title} </MetricCardTitle> <metric.icon className="size-4" /> </MetricCardHeader> <MetricCardValue>{metric.value}</MetricCardValue> </MetricCard> </Link> ))} </MetricCardGroup> </Section> <Section> <SectionHeader> <SectionTitle>Incidents</SectionTitle> <SectionDescription> Incidents over the last 7 days. </SectionDescription> </SectionHeader> {incidents.length > 0 ? ( <DataTable columns={incidentsColumns} data={incidents} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No incidents found</EmptyStateTitle> </EmptyStateContainer> )} </Section> <Section> <SectionHeader> <SectionTitle>Reports</SectionTitle> <SectionDescription>Reports over the last 7 days.</SectionDescription> </SectionHeader> {statusReports.length > 0 ? ( <DataTableStatusReports statusReports={statusReports} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No reports found</EmptyStateTitle> </EmptyStateContainer> )} </Section> <Section> <SectionHeader> <SectionTitle>Maintenance</SectionTitle> <SectionDescription> Maintenance over the last 7 days. </SectionDescription> </SectionHeader> {maintenances.length > 0 ? ( <DataTable columns={maintenancesColumns} data={maintenances} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No maintenances found</EmptyStateTitle> </EmptyStateContainer> )} </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/page.tsx ================================================ import { redirect } from "next/navigation"; export default function Page() { redirect("/overview"); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/private-locations/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Globe } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Private Locations", icon: Globe }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/private-locations/client.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { BillingOverlay, BillingOverlayButton, BillingOverlayContainer, BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/private-locations/columns"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useQuery } from "@tanstack/react-query"; import { Lock } from "lucide-react"; import { useState } from "react"; const EXAMPLES = [ { id: 1, name: "Private Location 1", token: "my-secret-token", createdAt: new Date("2025-01-01"), updatedAt: new Date("2025-01-01"), workspaceId: 1, lastSeenAt: new Date("2025-10-08"), privateLocationToMonitors: [], monitors: [], }, { id: 2, name: "Private Location 2", token: "my-secret-token", createdAt: new Date("2025-01-01"), updatedAt: new Date("2025-01-01"), workspaceId: 1, lastSeenAt: new Date("2025-06-08"), privateLocationToMonitors: [], monitors: [], }, ] satisfies RouterOutputs["privateLocation"]["list"]; export function Client() { const trpc = useTRPC(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: privateLocations } = useQuery( trpc.privateLocation.list.queryOptions(), ); if (!privateLocations || !workspace) return null; return ( <SectionGroup> <SectionHeader> <SectionTitle>Private Locations</SectionTitle> <SectionDescription> Create and manage your private locations. </SectionDescription> </SectionHeader> <Section> {workspace.limits["private-locations"] === false ? ( <BillingOverlayContainer> <DataTable columns={columns} data={[...EXAMPLES, ...EXAMPLES, ...EXAMPLES]} /> <BillingOverlay> <BillingOverlayButton onClick={() => setOpenDialog(true)}> <Lock /> Upgrade </BillingOverlayButton> <BillingOverlayDescription> Create private locations to monitor your internal services.{" "} <Link href="https://docs.openstatus.dev/tutorial/how-to-create-private-location/" rel="noreferrer" target="_blank" > Learn more </Link> . </BillingOverlayDescription> </BillingOverlay> <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} limit="private-locations" /> </BillingOverlayContainer> ) : ( <DataTable columns={columns} data={privateLocations} /> )} </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/private-locations/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); await queryClient.prefetchQuery(trpc.privateLocation.list.queryOptions()); return ( <HydrateClient> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/private-locations/nav-actions.tsx ================================================ "use client"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { FormSheetPrivateLocation } from "@/components/forms/private-location/sheet"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; export function NavActions() { const trpc = useTRPC(); const queryClient = useQueryClient(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const createPrivateLocationMutation = useMutation( trpc.privateLocation.new.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.privateLocation.list.queryKey(), }); }, }), ); if (!workspace || !monitors) return null; const limitReached = !workspace.limits["private-locations"]; return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> {limitReached ? ( <Button size="sm" data-disabled={limitReached} className="data-[disabled=true]:opacity-50" onClick={() => setOpenDialog(true)} > Create Private Location </Button> ) : ( <FormSheetPrivateLocation monitors={monitors} onSubmit={async (values) => { await createPrivateLocationMutation.mutateAsync({ name: values.name, monitors: values.monitors, token: values.token, }); }} > <Button size="sm">Create Private Location</Button> </FormSheetPrivateLocation> )} <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} limit="private-locations" /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/private-locations/page.tsx ================================================ import { Client } from "./client"; export default async function Page() { return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/(list)/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <NavBreadcrumb items={[{ type: "page", label: "Settings" }]} /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/(list)/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/(list)/page.tsx ================================================ import { Link } from "@/components/common/link"; import { ActionCard, ActionCardDescription, ActionCardGroup, ActionCardHeader, ActionCardTitle, } from "@/components/content/action-card"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; const settings = [ { title: "General", description: "Manage your workspace settings.", href: "/settings/general", }, { title: "Billing", description: "Manage your billing information and payment methods.", href: "/settings/billing", }, { title: "Account", description: "Manage your account information.", href: "/settings/account", }, { title: "Integrations", description: "Connect third-party services to your workspace.", href: "/settings/integrations", }, ]; export default function Page() { return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Settings</SectionTitle> <SectionDescription> All your settings in one place. </SectionDescription> </SectionHeader> <ActionCardGroup> {settings.map((setting) => ( <Link href={setting.href} key={setting.href}> <ActionCard className="h-full w-full"> <ActionCardHeader> <ActionCardTitle>{setting.title}</ActionCardTitle> <ActionCardDescription> {setting.description} </ActionCardDescription> </ActionCardHeader> </ActionCard> </Link> ))} </ActionCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Cog, User } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Settings", icon: Cog, href: "/settings/general", }, { type: "page", label: "Account", icon: User }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.member.list.queryOptions()); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="w-full flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/account/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/account/page.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { Section, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; import { FormCardDescription, FormCardFooterInfo, FormCardHeader, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { FormCard, FormCardContent, FormCardFooter, } from "@/components/forms/form-card"; import { ThemeToggle } from "@/components/theme-toggle"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { useMutation, useQuery } from "@tanstack/react-query"; import { signOut } from "next-auth/react"; export default function Page() { const trpc = useTRPC(); const { data: user } = useQuery(trpc.user.get.queryOptions()); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: members } = useQuery(trpc.member.list.queryOptions()); const deleteAccountMutation = useMutation( trpc.user.deleteAccount.mutationOptions(), ); if (!user || !workspace || !members) return null; const isOwner = members.find((m) => m.user.id === user.id)?.role === "owner"; const hasPaidPlan = !!workspace.plan && workspace.plan !== "free"; const isDeleteDisabled = isOwner && hasPaidPlan; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Account</SectionTitle> </SectionHeader> <FormCard> <FormCardUpgrade /> <FormCardHeader> <FormCardTitle>Personal Information</FormCardTitle> <FormCardDescription> Manage your personal information. </FormCardDescription> </FormCardHeader> <FormCardContent> <form className="grid gap-4"> <div className="grid gap-1.5"> <Label>Name</Label> <Input defaultValue={user?.name ?? undefined} /> </div> <div className="grid gap-1.5"> <Label>Email</Label> <Input defaultValue={user?.email ?? undefined} /> </div> </form> </FormCardContent> <FormCardFooter className="[&>:last-child]:ml-0"> <FormCardFooterInfo> Please contact us if you want to change your email or name. </FormCardFooterInfo> </FormCardFooter> </FormCard> <FormCard> <FormCardHeader> <FormCardTitle>Appearance</FormCardTitle> <FormCardDescription> Choose your preferred theme. </FormCardDescription> </FormCardHeader> <FormCardContent className="pb-4"> <ThemeToggle /> </FormCardContent> </FormCard> <FormCard variant="destructive"> <FormCardHeader> <FormCardTitle>Delete Account</FormCardTitle> <FormCardDescription> This will permanently delete your account and remove you from all workspaces. This action cannot be undone. </FormCardDescription> </FormCardHeader> {isDeleteDisabled ? ( <FormCardContent> <p className="text-destructive text-sm"> You must cancel your subscription before deleting your account. Go to{" "} <a href="/settings/billing" className="font-medium underline underline-offset-4" > Billing </a>{" "} to manage your subscription. </p> </FormCardContent> ) : null} <FormCardFooter variant="destructive"> <FormCardFooterInfo> Need help? Contact us at{" "} <Link href="mailto:ping@openstatus.dev">ping@openstatus.dev</Link> . </FormCardFooterInfo> <FormAlertDialog confirmationValue={user.email || user.name || "delete-account"} submitAction={async () => { await deleteAccountMutation.mutateAsync(); await signOut({ redirectTo: "/" }); }} > <Button variant="destructive" size="sm" disabled={isDeleteDisabled} > Delete </Button> </FormAlertDialog> </FormCardFooter> </FormCard> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Cog, CreditCard } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Settings", icon: Cog, href: "/settings/general", }, { type: "page", label: "Billing", icon: CreditCard }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx ================================================ "use client"; import { BillingAddons } from "@/components/content/billing-addons"; import { BillingProgress } from "@/components/content/billing-progress"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { DataTable } from "@/components/data-table/billing/data-table"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardGroup, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useEffect, useMemo, useTransition } from "react"; import { toast } from "sonner"; import { searchParamsParsers } from "./search-params"; const BASE_URL = process.env.NODE_ENV === "production" ? "https://app.openstatus.dev" : "http://localhost:3000"; function calculateTotalRequests(limits: Limits) { const monitors = limits.monitors; const maxRegions = limits["max-regions"]; const periodicity = limits.periodicity; if (periodicity.includes("30s")) { return monitors * maxRegions * 2 * 60 * 24 * 30; } if (periodicity.includes("1m")) { return monitors * maxRegions * 60 * 24 * 30; } if (periodicity.includes("5m")) { return monitors * maxRegions * 12 * 24 * 30; } if (periodicity.includes("10m")) { return monitors * maxRegions * 6 * 24 * 30; } if (periodicity.includes("30m")) { return monitors * maxRegions * 2 * 24 * 30; } if (periodicity.includes("1h")) { return monitors * maxRegions * 24 * 30; } return 0; } export function Client() { const trpc = useTRPC(); const router = useRouter(); const [isPending, startTransition] = useTransition(); const [{ success }, setSearchParams] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const customerPortalMutation = useMutation( trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ onSuccess: (url) => { if (!url) return; router.push(url); }, }), ); const { data: httpWorkspace30d } = useQuery({ ...trpc.tinybird.workspace30d.queryOptions({ type: "http", }), enabled: !!workspace, }); const { data: tcpWorkspace30d } = useQuery({ ...trpc.tinybird.workspace30d.queryOptions({ type: "tcp", }), enabled: !!workspace, }); useEffect(() => { if (success) { setTimeout(() => { toast.success("Billing information updated", { duration: 5_000, onAutoClose: () => setSearchParams({ success: null }), onDismiss: () => setSearchParams({ success: null }), }); }, 500); } }, [success, setSearchParams]); const totalRequests = useMemo(() => { const httpRequests = httpWorkspace30d?.data?.reduce( (acc, curr) => acc + curr.count, 0, ); const tcpRequests = tcpWorkspace30d?.data?.reduce( (acc, curr) => acc + curr.count, 0, ); return (httpRequests ?? 0) + (tcpRequests ?? 0); }, [httpWorkspace30d, tcpWorkspace30d]); if (!workspace) return null; const planAddons = allPlans[workspace.plan].addons; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Billing</SectionTitle> <SectionDescription> Manage your billing information and payment methods. </SectionDescription> </SectionHeader> <FormCardGroup> <FormCard> <FormCardHeader> <FormCardTitle>Usage</FormCardTitle> <FormCardDescription> Overview of your current usage, limits and addons. </FormCardDescription> </FormCardHeader> <FormCardContent> <div className="flex flex-col gap-2"> <BillingProgress label="Monitors" value={workspace.usage?.monitors ?? 0} max={workspace.limits.monitors} /> <BillingProgress label="Status Pages" value={workspace.usage?.pages ?? 0} max={workspace.limits["status-pages"]} /> <BillingProgress label="Page Components" value={workspace.usage?.pageComponents ?? 0} max={workspace.limits["page-components"]} /> <BillingProgress label="Notifications" value={workspace.usage?.notifications ?? 0} max={workspace.limits["notification-channels"]} /> <BillingProgress label="Total requests in the last 30 days" value={totalRequests} max={calculateTotalRequests(workspace.limits)} /> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormCardHeader className="col-span-full px-0 pt-0 pb-0"> <FormCardTitle>Add-ons</FormCardTitle> <FormCardDescription> Extend your limits with additional features. </FormCardDescription> </FormCardHeader> <div className="flex flex-col gap-2 pt-4"> {planAddons["email-domain-protection"] ? ( <BillingAddons label={planAddons["email-domain-protection"].title} description={ planAddons["email-domain-protection"].description } addon="email-domain-protection" workspace={workspace} /> ) : null} {planAddons["white-label"] ? ( <BillingAddons label={planAddons["white-label"].title} description={planAddons["white-label"].description} addon="white-label" workspace={workspace} /> ) : null} {planAddons["status-pages"] ? ( <BillingAddons label={planAddons["status-pages"].title} description={planAddons["status-pages"].description} addon="status-pages" workspace={workspace} /> ) : null} {Object.keys(planAddons).length === 0 ? ( <EmptyStateContainer> <EmptyStateTitle>No add-ons available</EmptyStateTitle> </EmptyStateContainer> ) : null} </div> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Access your{" "} <span className="font-medium">billing information</span>,{" "} <span className="font-medium">invoices</span> and{" "} <span className="font-medium">payment methods</span> via Stripe. </FormCardFooterInfo> <Button size="sm" onClick={() => { startTransition(async () => { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, returnUrl: `${BASE_URL}/settings/billing`, }); }); }} disabled={isPending} > {isPending ? "Loading..." : "Customer Portal"} </Button> </FormCardFooter> </FormCard> <FormCard> <FormCardHeader> <FormCardTitle>Plans</FormCardTitle> <FormCardDescription> Choose a plan that fits your needs. </FormCardDescription> </FormCardHeader> <FormCardSeparator /> <FormCardContent className="pb-4"> <DataTable /> </FormCardContent> </FormCard> </FormCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/page.tsx ================================================ import type { SearchParams } from "nuqs"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts ================================================ import { createSearchParamsCache, parseAsBoolean } from "nuqs/server"; export const searchParamsParsers = { success: parseAsBoolean, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Cog, SlidersHorizontal } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Settings", icon: Cog, href: "/settings/general", }, { type: "page", label: "General", icon: SlidersHorizontal }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.member.list.queryOptions()); await queryClient.prefetchQuery(trpc.invitation.list.queryOptions()); await queryClient.prefetchQuery(trpc.apiKeyRouter.getAll.queryOptions()); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="w-full flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/general/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/general/page.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormCardGroup } from "@/components/forms/form-card"; import { FormApiKey } from "@/components/forms/settings/form-api-key"; import { FormMembers } from "@/components/forms/settings/form-members"; import { FormSlug } from "@/components/forms/settings/form-slug"; import { FormWorkspace } from "@/components/forms/settings/form-workspace"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const BASE_URL = "https://app.openstatus.dev/invite"; export default function Page() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const updateWorkspaceNameMutation = useMutation( trpc.workspace.updateName.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.workspace.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); const sendInvitationMutation = useMutation( trpc.emailRouter.sendTeamInvitation.mutationOptions(), ); const createInvitationMutation = useMutation( trpc.invitation.create.mutationOptions({ onSuccess: (data) => { sendInvitationMutation.mutate({ id: data.id, baseUrl: BASE_URL }); queryClient.invalidateQueries({ queryKey: trpc.invitation.list.queryKey(), }); }, }), ); if (!workspace) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>General</SectionTitle> <SectionDescription> Manage your workspace settings. </SectionDescription> </SectionHeader> <FormCardGroup> <FormWorkspace defaultValues={{ name: workspace.name || "" }} onSubmit={async (values) => { await updateWorkspaceNameMutation.mutateAsync({ name: values.name, }); }} /> <FormSlug defaultValues={{ slug: workspace.slug }} /> <FormMembers onCreate={async (values) => { await createInvitationMutation.mutateAsync({ email: values.email, }); }} locked={ (typeof workspace.limits.members === "number" && workspace.limits.members === 1) || workspace.limits.members !== "Unlimited" } /> <FormApiKey /> </FormCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/integrations/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { Blocks, Cog } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Settings", icon: Cog, href: "/settings/general", }, { type: "page", label: "Integrations", icon: Blocks }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/integrations/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Tabs } from "../tabs"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.integrationRouter.list.queryOptions()); await queryClient.prefetchQuery(trpc.workspace.get.queryOptions()); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="w-full flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/integrations/nav-actions.tsx ================================================ import { NavFeedback } from "@/components/nav/nav-feedback"; export function NavActions() { return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/integrations/page.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormCardGroup } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { SlackIntegrationCard } from "./slack-card"; export default function Page() { const trpc = useTRPC(); // FIXME: we should use workspace limit here const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: integrations } = useQuery( trpc.integrationRouter.list.queryOptions(), ); if (!integrations) return null; const slackIntegration = integrations.find((i) => i.name === "slack-agent"); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Integrations</SectionTitle> <SectionDescription> Connect third-party services to your workspace. </SectionDescription> </SectionHeader> <FormCardGroup> <SlackIntegrationCard locked={!workspace?.limits["slack-agent"]} integration={ slackIntegration ? { id: slackIntegration.id, externalId: slackIntegration.externalId, data: slackIntegration.data as { teamName?: string; }, } : null } /> </FormCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/integrations/slack-card.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Lock } from "lucide-react"; import { useRouter } from "next/navigation"; const SERVER_URL = process.env.NODE_ENV === "production" ? "https://api.openstatus.dev" : "http://localhost:3000"; interface SlackIntegrationCardProps { locked?: boolean; integration: { id: number; externalId: string; data: { teamName?: string }; } | null; } export function SlackIntegrationCard({ locked, integration, }: SlackIntegrationCardProps) { const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const isConnected = !!integration; const deleteIntegration = useMutation( trpc.integrationRouter.deleteIntegration.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.integrationRouter.list.queryKey(), }); router.refresh(); }, }), ); const generateToken = useMutation( trpc.integrationRouter.generateInstallToken.mutationOptions({ onSuccess: (data) => { window.location.href = `${SERVER_URL}/slack/install?token=${data.token}`; }, }), ); const handleInstall = () => { generateToken.mutate(); }; const handleDisconnect = () => { if (!integration) return; deleteIntegration.mutate({ integrationId: integration.id }); }; return ( <FormCard> {locked ? <FormCardUpgrade /> : null} <FormCardHeader> <div className="flex items-center gap-2"> <FormCardTitle>Slack</FormCardTitle> {isConnected && <Badge variant="secondary">Connected</Badge>} </div> <FormCardDescription> Manage status reports directly from Slack. Mention the bot in a channel to create and update incidents. </FormCardDescription> </FormCardHeader> <FormCardContent> {isConnected ? ( <p className="text-muted-foreground text-sm"> Connected to{" "} <strong>{integration.data?.teamName ?? "Slack workspace"}</strong> </p> ) : ( <p className="text-muted-foreground text-sm"> Connect your Slack workspace to get started. </p> )} </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://www.openstatus.dev/blog/openstatus-slack-agent" rel="noreferrer" target="_blank" > Slack Agent </Link> . </FormCardFooterInfo> {locked ? ( <Button type="button" asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </Button> ) : isConnected ? ( <Button variant="destructive" size="sm" onClick={handleDisconnect} disabled={deleteIntegration.isPending} > {deleteIntegration.isPending ? "Disconnecting..." : "Disconnect"} </Button> ) : ( <Button size="sm" onClick={handleInstall} disabled={generateToken.isPending} > {generateToken.isPending ? "Connecting..." : "Add to Slack"} </Button> )} </FormCardFooter> </FormCard> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/settings/tabs.tsx ================================================ "use client"; import { NavTabs } from "@/components/nav/nav-tabs"; import { Blocks, Cog, CreditCard, User } from "lucide-react"; export function Tabs() { return ( <NavTabs items={[ { value: "general", label: "General", icon: Cog, href: "/settings/general", }, { value: "account", label: "Account", icon: User, href: "/settings/account", }, { value: "billing", label: "Billing", icon: CreditCard, href: "/settings/billing", }, { value: "integrations", label: "Integrations", icon: Blocks, href: "/settings/integrations", }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { PanelTop } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[{ type: "page", label: "Status Pages", icon: PanelTop }]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx ================================================ "use client"; import { Note, NoteButton } from "@/components/common/note"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/status-pages/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { Palette } from "lucide-react"; import Link from "next/link"; export function Client() { const trpc = useTRPC(); const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); // TODO: add skeleton if (!statusPages) return null; return ( <SectionGroup> <Note> <Palette /> Create your own custom themes for your status pages. <NoteButton variant="default" asChild> <Link href="https://themes.openstatus.dev" target="_blank"> Learn more </Link> </NoteButton> </Note> <SectionHeader> <SectionTitle>Status Pages</SectionTitle> <SectionDescription> Create and manage your status pages. </SectionDescription> <DataTable columns={columns} data={statusPages} paginationComponent={DataTablePaginationSimple} /> </SectionHeader> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/(list)/layout.tsx ================================================ import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/(list)/nav-actions.tsx ================================================ "use client"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; export function NavActions() { const [openDialog, setOpenDialog] = useState(false); const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); if (!workspace || !statusPages) return null; const limitReached = statusPages.length >= workspace.limits["status-pages"]; return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> {limitReached ? ( <Button size="sm" data-limited={limitReached} className="data-[limited=true]:opacity-80" onClick={() => setOpenDialog(true)} > Create Status Page </Button> ) : ( <Button size="sm" asChild> <Link href="/status-pages/create">Create Status Page</Link> </Button> )} <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} limit="status-pages" /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Client } from "./client"; export default async function Page() { const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.page.list.queryOptions()); return ( <HydrateClient> <Client /> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { PanelTop } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import { STATUS_PAGE_TABS } from "./constants"; export function Breadcrumb() { const { id } = useParams<{ id: string }>(); const pathname = usePathname(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); if (!statusPage) return null; const segments = pathname.split("/"); const currentTab = STATUS_PAGE_TABS.find((tab) => segments.includes(tab.value), ); return ( <NavBreadcrumb items={[ { type: "link", label: "Status Pages", href: "/status-pages", icon: PanelTop, }, { type: "link", label: statusPage.title, href: `/status-pages/${id}/status-reports`, }, ...(currentTab ? [ { type: "page" as const, label: currentTab.label, icon: currentTab.icon, }, ] : []), ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; import { Sidebar } from "../sidebar"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const { id } = await params; const queryClient = getQueryClient(); await Promise.all([ queryClient.prefetchQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ), queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), queryClient.prefetchQuery( trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), ), ]); return ( <HydrateClient> <SidebarProvider defaultOpen={false}> <div className="w-full flex-1">{children}</div> <div className="hidden lg:block"> <Sidebar /> </div> </SidebarProvider> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/page.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormComponentsUpdate } from "@/components/forms/components/update"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); if (!statusPage) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{statusPage.title}</SectionTitle> <SectionDescription> Configure your page components. </SectionDescription> </SectionHeader> <FormComponentsUpdate /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/constants.ts ================================================ import type { LucideIcon } from "lucide-react"; import { Cog, Hammer, LayoutTemplate, Megaphone, Users } from "lucide-react"; export const STATUS_PAGE_TABS: { value: string; label: string; icon: LucideIcon; }[] = [ { value: "status-reports", label: "Status Reports", icon: Megaphone }, { value: "maintenances", label: "Maintenances", icon: Hammer }, { value: "subscribers", label: "Subscribers", icon: Users }, { value: "components", label: "Components", icon: LayoutTemplate }, { value: "edit", label: "Settings", icon: Cog }, ]; ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/edit/page.tsx ================================================ "use client"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormStatusPageUpdate } from "@/components/forms/status-page/update"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); if (!statusPage) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{statusPage.title}</SectionTitle> <SectionDescription>Customize your status page.</SectionDescription> </SectionHeader> <FormStatusPageUpdate /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx ================================================ import { redirect } from "next/navigation"; import { AppHeader, AppHeaderActions, AppHeaderContent, } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { Breadcrumb } from "./breadcrumb"; import { NavActions } from "./nav-actions"; import { Tabs } from "./tabs"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const { id } = await params; const queryClient = getQueryClient(); const pageData = await queryClient.fetchQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); if (!pageData?.id) { redirect("/status-pages"); } await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); return ( <HydrateClient> <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> <AppHeaderActions> <NavActions /> </AppHeaderActions> </AppHeader> <Tabs /> <main className="flex-1">{children}</main> </div> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; import { Sidebar } from "../sidebar"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const { id } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.maintenance.list.queryOptions({ pageId: Number.parseInt(id), }), ); return ( <HydrateClient> <SidebarProvider defaultOpen={false}> <div className="w-full flex-1">{children}</div> <div className="hidden lg:block"> <Sidebar /> </div> </SidebarProvider> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionHeaderRow, SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/maintenances/columns"; import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: maintenances, refetch } = useQuery( trpc.maintenance.list.queryOptions({ pageId: Number.parseInt(id), }), ); const sendMaintenanceUpdateMutation = useMutation( trpc.emailRouter.sendMaintenance.mutationOptions(), ); const createMaintenanceMutation = useMutation( trpc.maintenance.new.mutationOptions({ onSuccess: (maintenance) => { // TODO: move to server if (maintenance.notifySubscribers) { sendMaintenanceUpdateMutation.mutateAsync({ id: maintenance.id }); } // refetch(); }, }), ); if (!statusPage || !maintenances) return null; return ( <SectionGroup> <Section> <SectionHeaderRow> <SectionHeader> <SectionTitle>{statusPage.title}</SectionTitle> <SectionDescription> List of all maintenances. Looking for{" "} <Link href={`/status-pages/${id}/status-reports`}> status reports </Link> ? </SectionDescription> </SectionHeader> <div> <FormSheetMaintenance pageComponents={statusPage.pageComponents} onSubmit={async (values) => { await createMaintenanceMutation.mutateAsync({ pageId: Number.parseInt(id), title: values.title, message: values.message, startDate: values.startDate, endDate: values.endDate, pageComponents: values.pageComponents, notifySubscribers: values.notifySubscribers, }); }} > <Button data-section="action" size="sm"> <Plus /> Create Maintenance </Button> </FormSheetMaintenance> </div> </SectionHeaderRow> <DataTable columns={columns} data={maintenances} /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { getActions } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Globe } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { toast } from "sonner"; export function NavActions() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const pathname = usePathname(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const deleteStatusPageMutation = useMutation( trpc.page.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); if (pathname.includes(`/status-pages/${id}`)) { router.push("/status-pages"); } }, }), ); const actions = getActions({ edit: () => router.push(`/status-pages/${id}/edit`), "copy-id": async () => { await navigator.clipboard.writeText(id); toast.success("Status Page ID copied to clipboard"); }, }); if (!statusPage) return null; return ( <div className="flex items-center gap-2 text-sm"> <NavFeedback /> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="sm" className="group h-7 w-7" asChild> <a href={`https://${ statusPage.customDomain || `${statusPage.slug}.openstatus.dev` }`} target="_blank" rel="noreferrer" > <Globe className="h-4 w-4 text-muted-foreground group-hover:text-foreground" /> </a> </Button> </TooltipTrigger> <TooltipContent>View Page</TooltipContent> </Tooltip> </TooltipProvider> <QuickActions actions={actions} deleteAction={{ confirmationValue: statusPage.title ?? "status page", submitAction: async () => { await deleteStatusPageMutation.mutateAsync({ id: Number.parseInt(id), }); }, }} /> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function Page({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; redirect(`/status-pages/${id}/status-reports`); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { SidebarRight } from "@/components/nav/sidebar-right"; import { useTRPC } from "@/lib/trpc/client"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useQuery } from "@tanstack/react-query"; import { ExternalLink } from "lucide-react"; import { useParams } from "next/navigation"; export function Sidebar() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const { copy } = useCopyToClipboard(); if (!statusPage) return null; const BADGE_URL = `https://${statusPage.slug}.openstatus.dev/badge/v2`; return ( <SidebarRight header="Status Page" metadata={[ { label: "Overview", items: [ { label: "Slug", value: ( <Link href={`https://${ statusPage.customDomain || `${statusPage.slug}.openstatus.dev` }`} target="_blank" > {statusPage.slug} </Link> ), }, { label: "Access Type", value: statusPage.accessType, }, { label: "Domain", value: statusPage.customDomain || "-" }, { label: "Favicon", value: statusPage.icon ? ( <div className="size-4 overflow-hidden rounded border bg-muted"> <img src={statusPage.icon} alt="favicon" /> </div> ) : ( "-" ), }, { label: "Badge", value: ( <TooltipProvider> <Tooltip> <TooltipTrigger className="align-middle"> <img className="h-5 rounded-sm border" src={BADGE_URL} alt="badge" /> </TooltipTrigger> <TooltipContent className="cursor-pointer" side="left" onClick={() => copy(BADGE_URL, { withToast: true })} > {BADGE_URL} </TooltipContent> </Tooltip> </TooltipProvider> ), }, ], }, { label: "Configuration", items: [ { label: "Theme", value: statusPage.configuration?.theme ?? "-", }, { label: "Bar Value", value: statusPage.configuration?.type ?? "-", }, { label: "Card Value", value: statusPage.configuration?.value ?? "-", }, { label: "Show Uptime", value: statusPage.configuration?.uptime ? "Yes" : "No", }, ], }, { label: "Monitors", items: statusPage.pageComponents.flatMap((component) => { const arr = []; arr.push({ label: "Name", value: ( <TableCellLink href={`/status-pages/${statusPage.id}/components`} value={component.name} /> ), }); arr.push({ label: "Type", value: component.type, isNested: true, }); return arr; }), }, ]} footerButton={{ onClick: () => typeof window !== "undefined" && window.open( `https://${ statusPage.customDomain || `${statusPage.slug}.openstatus.dev` }`, "_blank", ), children: ( <> <ExternalLink /> <span>Visit Status Page</span> </> ), }} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string; reportId: string }>; }) { const { id, reportId } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), ); await queryClient.prefetchQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateDescription, } from "@/components/content/empty-state"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionHeaderRow, SectionTitle, } from "@/components/content/section"; import { FormCardGroup } from "@/components/forms/form-card"; import { FormSheetWithDirtyProtection } from "@/components/forms/form-sheet"; import type { FormValues } from "@/components/forms/status-report-update/form"; import { FormStatusReportUpdateCard } from "@/components/forms/status-report-update/form-status-report"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; import { getNextStatus } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; export default function Page() { const { reportId } = useParams<{ id: string; reportId: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: statusReport, refetch } = useQuery( trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), ); const sendStatusReportUpdateMutation = useMutation( trpc.emailRouter.sendStatusReport.mutationOptions(), ); const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { if (update?.notifySubscribers) { sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); } refetch(); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); const updateStatusReportUpdateMutation = useMutation( trpc.statusReport.updateStatusReportUpdate.mutationOptions({ onSuccess: () => { refetch(); }, }), ); if (!statusReport) return null; const updates = [...statusReport.updates].sort( (a, b) => b.date.getTime() - a.date.getTime(), ); const affected = statusReport.pageComponents .map((component) => component.name) .join(", "); return ( <SectionGroup> <Section> <SectionHeaderRow> <SectionHeader> <SectionTitle>{statusReport.title}</SectionTitle> <SectionDescription> Manage updates for this status report. Affects{" "} <span className="text-foreground"> {affected ? affected : "zero"} </span>{" "} component(s). </SectionDescription> </SectionHeader> </SectionHeaderRow> <EmptyStateContainer className="my-8 border-dashed"> <EmptyStateDescription>Status Page Report</EmptyStateDescription> <FormSheetStatusReportUpdate defaultValues={{ status: getNextStatus(statusReport.status), }} onSubmit={async (values: FormValues) => { await createStatusReportUpdateMutation.mutateAsync({ statusReportId: statusReport.id, message: values.message, status: values.status, date: values.date, notifySubscribers: values.notifySubscribers, }); }} > <Button size="sm"> <Plus /> Create Status Update </Button> </FormSheetStatusReportUpdate> </EmptyStateContainer> <FormCardGroup> {updates.map((update, index) => ( <FormSheetWithDirtyProtection key={update.id}> <FormStatusReportUpdateCard id={`update-form-${update.id}`} index={index} update={update} defaultValues={{ status: update.status, message: update.message, date: update.date, }} onSubmit={async (values: FormValues) => { await updateStatusReportUpdateMutation.mutateAsync({ id: update.id, statusReportId: statusReport.id, message: values.message, status: values.status, date: values.date, }); }} /> </FormSheetWithDirtyProtection> ))} </FormCardGroup> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { SidebarProvider } from "@openstatus/ui/components/ui/sidebar"; import { Sidebar } from "../sidebar"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const queryClient = getQueryClient(); const { id } = await params; await queryClient.prefetchQuery( trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), ); return ( <HydrateClient> <SidebarProvider defaultOpen={false}> <div className="w-full flex-1">{children}</div> <div className="hidden lg:block"> <Sidebar /> </div> </SidebarProvider> </HydrateClient> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { Section, SectionDescription, SectionGroup, SectionHeader, SectionHeaderRow, SectionTitle, } from "@/components/content/section"; import { DataTable as UpdatesDataTable } from "@/components/data-table/status-report-updates/data-table"; import { columns } from "@/components/data-table/status-reports/columns"; import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: page } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: statusReports, refetch } = useQuery( trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), ); const sendStatusReportUpdateMutation = useMutation( trpc.emailRouter.sendStatusReport.mutationOptions(), ); const createStatusReportMutation = useMutation( trpc.statusReport.create.mutationOptions({ onSuccess: async (statusReport) => { // TODO: move to server if (statusReport.notifySubscribers) { await sendStatusReportUpdateMutation.mutateAsync({ id: statusReport.id, }); } // refetch(); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); if (!statusReports || !page) return null; const hasUnresolvedIssue = statusReports.some( (report) => report.status !== "resolved", ); return ( <SectionGroup> <Section> <SectionHeaderRow> <SectionHeader> <SectionTitle>{page.title}</SectionTitle> <SectionDescription> List of all status reports. Looking for{" "} <Link href={`/status-pages/${id}/maintenances`}> maintenances </Link> ? </SectionDescription> </SectionHeader> <div> <FormSheetStatusReport warning={ hasUnresolvedIssue ? ( <> An unresolved report already exists. Consider adding a{" "} <span className="font-semibold">status report update</span>{" "} instead. </> ) : undefined } pageComponents={page.pageComponents} onSubmit={async (values) => { // NOTE: for type safety, we need to check if the values have a date property // because of the union type if ("date" in values) { await createStatusReportMutation.mutateAsync({ title: values.title, status: values.status, pageId: Number.parseInt(id), pageComponents: values.pageComponents, date: values.date, message: values.message, notifySubscribers: values.notifySubscribers, }); } }} > <Button data-section="action" size="sm"> <Plus /> Create Status Report </Button> </FormSheetStatusReport> </div> </SectionHeaderRow> <DataTable columns={columns} data={statusReports} onRowClick={(row) => row.getCanExpand() ? row.toggleExpanded() : undefined } rowComponent={({ row }) => ( <UpdatesDataTable updates={row.original.updates} reportId={row.original.id} /> )} /> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string }>; }) { const queryClient = getQueryClient(); const { id } = await params; await queryClient.prefetchQuery( trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), ); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { BillingOverlay, BillingOverlayButton, BillingOverlayContainer, BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { SectionDescription, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { Section } from "@/components/content/section"; import { columns } from "@/components/data-table/subscribers/columns"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useQuery } from "@tanstack/react-query"; import { Lock } from "lucide-react"; import { useParams } from "next/navigation"; import { useState } from "react"; type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; const EXAMPLES = [ { id: 1, email: "max@openstatus.dev", createdAt: new Date(), pageId: 1, channelType: "email", acceptedAt: new Date(), unsubscribedAt: null, components: [], isEntirePage: true, webhookUrl: null, }, { id: 2, email: "thibault@openstatus.dev", createdAt: new Date(), pageId: 1, channelType: "email", acceptedAt: new Date(), unsubscribedAt: null, components: [], isEntirePage: true, webhookUrl: null, }, ] satisfies Subscriber[]; export default function Page() { const { id } = useParams<{ id: string }>(); const [openDialog, setOpenDialog] = useState(false); const trpc = useTRPC(); const { data: page } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: subscribers } = useQuery( trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), ); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); if (!workspace) return null; return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>{page?.title}</SectionTitle> <SectionDescription>List of all subscribers.</SectionDescription> </SectionHeader> </Section> <Section> {workspace.limits["status-subscribers"] === false ? ( <BillingOverlayContainer> <DataTable columns={columns} data={[...EXAMPLES, ...EXAMPLES, ...EXAMPLES]} /> <BillingOverlay> <BillingOverlayButton onClick={() => setOpenDialog(true)}> <Lock /> Upgrade </BillingOverlayButton> <BillingOverlayDescription> Keep your users in the loop with status page updates.{" "} <Link href="https://docs.openstatus.dev/reference/subscriber/" rel="noreferrer" target="_blank" > Learn more </Link> . </BillingOverlayDescription> </BillingOverlay> <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} limit="status-subscribers" /> </BillingOverlayContainer> ) : subscribers?.length ? ( <DataTable columns={columns} data={subscribers} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No subscribers</EmptyStateTitle> <EmptyStateDescription> No emails have been subscribed to this status page. </EmptyStateDescription> </EmptyStateContainer> )} </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/[id]/tabs.tsx ================================================ "use client"; import { NavTabs } from "@/components/nav/nav-tabs"; import { useParams } from "next/navigation"; import { STATUS_PAGE_TABS } from "./constants"; export function Tabs() { const { id } = useParams<{ id: string }>(); return ( <NavTabs items={STATUS_PAGE_TABS.map((tab) => ({ ...tab, href: `/status-pages/${id}/${tab.value}`, }))} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx ================================================ "use client"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; import { PanelTop } from "lucide-react"; export function Breadcrumb() { return ( <NavBreadcrumb items={[ { type: "link", label: "Status Pages", href: "/status-pages", icon: PanelTop, }, { type: "page", label: "Create Status Page" }, ]} /> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/create/client.tsx ================================================ "use client"; import { EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { EmptyStateContainer } from "@/components/content/empty-state"; import { Section, SectionGroup, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormGeneral } from "@/components/forms/status-page/form-general"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; export function Client() { const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const router = useRouter(); const queryClient = useQueryClient(); const { refetch } = useQuery(trpc.page.list.queryOptions()); const createStatusPageMutation = useMutation( trpc.page.new.mutationOptions({ onSuccess: (data) => { refetch(); // NOTE: invalidate workspace to update the usage queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); startTransition(() => { router.push(`/status-pages/${data.id}/edit`); }); }, }), ); return ( <SectionGroup> <Section> <SectionHeader> <SectionTitle>Create Status Page</SectionTitle> </SectionHeader> <FormGeneral disabled={isPending} onSubmit={async (values) => { await createStatusPageMutation.mutateAsync({ title: values.title, slug: values.slug, icon: values.icon, description: values.description, }); }} /> </Section> <Section> <EmptyStateContainer> <EmptyStateTitle>Create and start customizing</EmptyStateTitle> <EmptyStateDescription> Connect your <span className="text-foreground">monitors</span>, set up a <span className="text-foreground">custom domain</span>,{" "} <span className="text-foreground">password protect</span> it and more... </EmptyStateDescription> </EmptyStateContainer> </Section> </SectionGroup> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/create/layout.tsx ================================================ import { AppHeader, AppHeaderContent } from "@/components/nav/app-header"; import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; import { Breadcrumb } from "./breadcrumb"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <div> <AppHeader> <AppHeaderContent> <AppSidebarTrigger /> <Breadcrumb /> </AppHeaderContent> </AppHeader> <main className="w-full flex-1">{children}</main> </div> ); } ================================================ FILE: apps/dashboard/src/app/(dashboard)/status-pages/create/page.tsx ================================================ import { Client } from "./client"; export default function Page() { return <Client />; } ================================================ FILE: apps/dashboard/src/app/api/auth/[...nextauth]/route.ts ================================================ import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers; ================================================ FILE: apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts ================================================ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { createTRPCContext } from "@openstatus/api"; import { edgeRouter } from "@openstatus/api/src/edge"; export const runtime = "edge"; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: "/api/trpc/edge", router: edgeRouter, req: req, createContext: () => createTRPCContext({ req, auth }), onError: ({ error }) => { console.log("Error in tRPC handler (edge)"); console.error(error); }, }); export { handler as GET, handler as POST }; ================================================ FILE: apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts ================================================ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { createTRPCContext } from "@openstatus/api"; import { lambdaRouter } from "@openstatus/api/src/lambda"; // Stripe is incompatible with Edge runtimes due to using Node.js events // export const runtime = "edge"; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: "/api/trpc/lambda", router: lambdaRouter, req: req, createContext: () => createTRPCContext({ req, auth }), onError: ({ error }) => { console.log("Error in tRPC handler (lambda)"); console.error(error); }, }); export { handler as GET, handler as POST }; ================================================ FILE: apps/dashboard/src/app/global-error.tsx ================================================ "use client"; import * as Sentry from "@sentry/nextjs"; import NextError from "next/error"; import { useEffect } from "react"; export default function GlobalError({ error, }: { error: Error & { digest?: string }; }) { useEffect(() => { Sentry.captureException(error); }, [error]); return ( <html lang="en"> <body> {/* This is the default Next.js error component but it doesn't allow omitting the statusCode property yet. */} {/* biome-ignore lint/suspicious/noExplicitAny: <explanation> */} <NextError statusCode={undefined as any} /> </body> </html> ); } ================================================ FILE: apps/dashboard/src/app/globals.css ================================================ @import "tailwindcss"; @import "@openstatus/ui/globals"; @import "tw-animate-css"; @plugin "@tailwindcss/typography"; /* safelist */ @source inline("has-data-[slot=slider-range]:bg-red-500"); @theme { --breakpoint-xs: 30rem; } @theme inline { --font-cal: var(--font-cal-sans); --font-commit-mono: var(--font-commit-mono); --font-sans: var(--font-inter); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } ================================================ FILE: apps/dashboard/src/app/layout.tsx ================================================ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter } from "next/font/google"; import "./globals.css"; import { TailwindIndicator } from "@/components/tailwind-indicator"; import { ThemeProvider } from "@/components/theme-provider"; import { auth } from "@/lib/auth"; import { TRPCReactProvider } from "@/lib/trpc/client"; import { OpenPanelComponent } from "@openpanel/nextjs"; import { Toaster } from "@openstatus/ui/components/ui/sonner"; import { cn } from "@openstatus/ui/lib/utils"; import { SessionProvider } from "next-auth/react"; import LocalFont from "next/font/local"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { ogMetadata, twitterMetadata } from "./metadata"; import { defaultMetadata } from "./metadata"; const cal = LocalFont({ src: "../../public/fonts/CalSans-SemiBold.ttf", variable: "--font-cal-sans", }); const commitMono = LocalFont({ src: [ { path: "../../public/fonts/CommitMono-400-Regular.otf", weight: "400", style: "normal", }, { path: "../../public/fonts/CommitMono-400-Italic.otf", weight: "400", style: "italic", }, { path: "../../public/fonts/CommitMono-700-Regular.otf", weight: "700", style: "normal", }, { path: "../../public/fonts/CommitMono-700-Italic.otf", weight: "700", style: "italic", }, ], variable: "--font-commit-mono", }); const inter = Inter({ subsets: ["latin"], variable: "--font-inter", }); const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], }); export const metadata: Metadata = { ...defaultMetadata, twitter: { ...twitterMetadata, }, openGraph: { ...ogMetadata, }, }; // export const dynamic = "error"; export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const session = await auth(); return ( <html lang="en" suppressHydrationWarning> <body className={cn( geistSans.variable, geistMono.variable, cal.variable, commitMono.variable, inter.variable, "font-sans antialiased ", )} > <SessionProvider session={session}> <TRPCReactProvider> <NuqsAdapter> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} <TailwindIndicator /> <Toaster richColors expand /> {process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID && ( <OpenPanelComponent clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID} trackScreenViews trackOutgoingLinks trackAttributes sessionReplay={{ enabled: true }} /> )} </ThemeProvider> </NuqsAdapter> </TRPCReactProvider> </SessionProvider> </body> </html> ); } ================================================ FILE: apps/dashboard/src/app/login/_components/actions.ts ================================================ "use server"; import { signIn } from "@/lib/auth"; export async function signInWithResendAction(formData: FormData) { try { await signIn("resend", formData); } catch (e) { console.error(e); } } ================================================ FILE: apps/dashboard/src/app/login/_components/magic-link-form.tsx ================================================ "use client"; import { useFormStatus } from "react-dom"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { toast } from "sonner"; import { signInWithResendAction } from "./actions"; /** * @deprecated - only to be used in development mode */ export default function MagicLinkForm() { const { pending } = useFormStatus(); return ( <form action={async (formData) => { try { await signInWithResendAction(formData); toast.success("Check your terminal for the magic link."); } catch (e) { console.error(e); toast.error("Error sending magic link."); } }} className="grid gap-2" > <div className="grid gap-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" name="email" type="email" required /> </div> <Button variant="secondary" className="w-full"> {pending ? "Logging..." : "Log Magic Link"} </Button> </form> ); } ================================================ FILE: apps/dashboard/src/app/login/layout.tsx ================================================ import { redirect } from "next/navigation"; import { AuthLayout } from "@/components/layout/auth-layout"; import { auth } from "@/lib/auth"; export default async function Layout({ children, }: { children: React.ReactNode; }) { const session = await auth(); if (session) redirect("/"); return <AuthLayout>{children}</AuthLayout>; } ================================================ FILE: apps/dashboard/src/app/login/page.tsx ================================================ import type { Metadata } from "next"; import Link from "next/link"; import { signIn } from "@/lib/auth"; import { GitHubIcon } from "@openstatus/icons"; import { GoogleIcon } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import type { SearchParams } from "nuqs/server"; import MagicLinkForm from "./_components/magic-link-form"; import { searchParamsCache } from "./search-params"; export const metadata: Metadata = { title: "Sign In", description: "Sign in to openstatus. Monitor your services and keep your users informed.", robots: { index: true, follow: true, }, alternates: { canonical: "https://app.openstatus.dev/login", }, }; export default async function Page(props: { searchParams: Promise<SearchParams>; }) { const searchParams = await props.searchParams; const { redirectTo } = searchParamsCache.parse(searchParams); return ( <div className="my-4 grid w-full max-w-lg gap-6"> <div className="flex flex-col gap-1 text-center"> <h1 className="font-semibold text-3xl tracking-tight">Sign In</h1> <p className="text-muted-foreground text-sm"> Get started now. No credit card required. </p> </div> <div className="grid gap-3 p-4"> {process.env.NODE_ENV === "development" || process.env.SELF_HOST === "true" ? ( <div className="grid gap-3"> <MagicLinkForm /> <Separator /> </div> ) : null} <form action={async () => { "use server"; await signIn("github", { redirectTo: redirectTo ?? undefined }); }} className="w-full" > <Button type="submit" className="w-full"> Sign in with GitHub <GitHubIcon className="ml-2 h-4 w-4" /> </Button> </form> <form action={async () => { "use server"; await signIn("google", { redirectTo: redirectTo ?? undefined }); }} className="w-full" > <Button type="submit" className="w-full" variant="outline"> Sign in with Google <GoogleIcon className="ml-2 h-4 w-4" /> </Button> </form> </div> <p className="px-8 text-center text-muted-foreground text-sm"> By clicking continue, you agree to our{" "} <Link href="https://openstatus.dev/legal/terms" className="underline underline-offset-4 hover:text-primary hover:no-underline" > Terms of Service </Link>{" "} and{" "} <Link href="https://openstatus.dev/legal/privacy" className="underline underline-offset-4 hover:text-primary hover:no-underline" > Privacy Policy </Link> . </p> </div> ); } ================================================ FILE: apps/dashboard/src/app/login/search-params.ts ================================================ import { createSearchParamsCache, parseAsString } from "nuqs/server"; export const searchParamsParsers = { redirectTo: parseAsString, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/dashboard/src/app/metadata.ts ================================================ import type { Metadata } from "next"; export const TITLE = "openstatus"; export const DESCRIPTION = "Open-source platform to monitor your services and keep your users informed."; const OG_TITLE = "openstatus"; const OG_DESCRIPTION = "Monitor your services and keep your users informed."; const FOOTER = "app.openstatus.dev"; const IMAGE = "assets/og/dashboard-v2.png"; export const defaultMetadata: Metadata = { title: { template: `%s | ${TITLE}`, default: TITLE, }, description: DESCRIPTION, metadataBase: new URL("https://www.openstatus.dev"), robots: { index: false, follow: false, }, }; export const twitterMetadata: Metadata["twitter"] = { title: TITLE, description: DESCRIPTION, card: "summary_large_image", images: [ `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, ], }; export const ogMetadata: Metadata["openGraph"] = { title: TITLE, description: DESCRIPTION, type: "website", images: [ `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, ], }; ================================================ FILE: apps/dashboard/src/app/not-found.tsx ================================================ "use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Button } from "@openstatus/ui/components/ui/button"; export default function NotFound() { const router = useRouter(); return ( <main className="flex min-h-screen w-full flex-col space-y-6 bg-background p-4 md:p-8"> <div className="flex flex-1 flex-col items-center justify-center gap-8"> <div className="mx-auto max-w-xl rounded-lg border bg-card text-center"> <div className="flex flex-col gap-4 p-6 sm:p-12"> <div className="flex flex-col gap-1"> <p className="font-mono text-foreground">404 Page not found</p> <h2 className="font-cal text-2xl text-foreground"> Oops, something went wrong. </h2> <p className="text-muted-foreground text-sm sm:text-base"> The page you are looking for doesn't exist. </p> </div> <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> <Button variant="outline" size="lg" onClick={router.back} className="cursor-pointer" > Go Back </Button> <Button size="lg" asChild> <Link href="/">Home</Link> </Button> </div> </div> </div> </div> </main> ); } ================================================ FILE: apps/dashboard/src/app/react-table.d.ts ================================================ import "@tanstack/react-table"; declare module "@tanstack/react-table" { interface ColumnMeta { headerClassName?: string; cellClassName?: string; } } ================================================ FILE: apps/dashboard/src/app/robots.ts ================================================ import type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", disallow: "/", }, }; } ================================================ FILE: apps/dashboard/src/components/chart/chart-area-latency.tsx ================================================ "use client"; import { Area, AreaChart, CartesianGrid, Label, ReferenceLine, XAxis, YAxis, } from "recharts"; import { type PERCENTILES, mapLatency } from "@/data/metrics.client"; import { periodToFromDate } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { useQuery } from "@tanstack/react-query"; import { endOfDay } from "date-fns"; import { ChartTooltipNumber } from "./chart-tooltip-number"; const chartConfig = { latency: { label: "Latency", color: "var(--success)", }, } satisfies ChartConfig; // TODO: create new pipes for timing phase metrics export function ChartAreaLatency({ monitorId, degradedAfter, period, type, percentile, regions, }: { monitorId: string; degradedAfter: number | null; percentile: (typeof PERCENTILES)[number]; period: "1d" | "7d" | "14d"; type: "http" | "tcp"; regions: string[] | undefined; }) { const trpc = useTRPC(); const fromDate = periodToFromDate[period]; const toDate = endOfDay(new Date()); const { data: latency } = useQuery( trpc.tinybird.metricsLatency.queryOptions({ monitorId, period, type, regions, fromDate: fromDate.toISOString(), toDate: toDate.toISOString(), }), ); const refinedLatency = latency ? mapLatency(latency, percentile) : []; return ( <ChartContainer config={chartConfig} className="h-[250px] w-full"> <AreaChart accessibilityLayer data={refinedLatency}> <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" tickLine={false} axisLine={false} tickMargin={8} // tickFormatter={(value) => value.slice(0, 3)} /> <ChartTooltip cursor={false} content={ <ChartTooltipContent indicator="dot" formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> )} /> } /> <Area dataKey="latency" type="monotone" fill="var(--color-latency)" fillOpacity={0.4} stroke="var(--color-latency)" stackId="a" /> {degradedAfter ? ( <ReferenceLine y={degradedAfter} stroke="var(--warning)" strokeDasharray="3 3" > <Label value="Degraded" position="insideBottomRight" fill="var(--warning)" /> </ReferenceLine> ) : null} <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> <ChartLegend content={<ChartLegendContent />} /> </AreaChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-area-timing-phases.tsx ================================================ "use client"; import { Area, AreaChart, CartesianGrid, Label, ReferenceLine, XAxis, YAxis, } from "recharts"; import { type INTERVALS, type PERCENTILES, mapTimingPhases, } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { useQuery } from "@tanstack/react-query"; import { ChartTooltipNumber, ChartTooltipNumberRaw, } from "./chart-tooltip-number"; const chartConfig = { dns: { label: "DNS", color: "var(--chart-1)", }, connect: { label: "Connect", color: "var(--chart-2)", }, tls: { label: "TLS", color: "var(--chart-3)", }, ttfb: { label: "TTFB", color: "var(--chart-4)", }, transfer: { label: "Transfer", color: "var(--chart-5)", }, } satisfies ChartConfig; // TODO: create new pipes for timing phase metrics export function ChartAreaTimingPhases({ monitorId, degradedAfter, period, percentile, interval, type, regions, }: { monitorId: string; degradedAfter: number | null; period: "1d" | "7d" | "14d"; percentile: (typeof PERCENTILES)[number]; interval: (typeof INTERVALS)[number]; regions: string[] | undefined; type: "http"; }) { const trpc = useTRPC(); const { data: timingPhases } = useQuery( trpc.tinybird.metricsTimingPhases.queryOptions({ monitorId, period, type, interval, regions, }), ); const refinedTimingPhases = timingPhases ? mapTimingPhases(timingPhases, percentile) : []; return ( <ChartContainer config={chartConfig} className="h-[250px] w-full"> <AreaChart accessibilityLayer data={refinedTimingPhases}> <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" tickLine={false} axisLine={false} tickMargin={8} // tickFormatter={(value) => value.slice(0, 3)} /> <ChartTooltip cursor={false} content={ <ChartTooltipContent indicator="dot" formatter={(value, name, item, index) => { if (index !== 4) { return ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> ); } const total = item.payload?.dns + item.payload?.connect + item.payload?.tls + item.payload?.ttfb + item.payload?.transfer; return ( <> <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> <ChartTooltipNumberRaw value={total} label="Total" className="flex h-0 basis-full items-center border-t font-medium text-foreground text-xs" /> </> ); }} /> } /> <Area dataKey="dns" type="monotone" fill="var(--color-dns)" fillOpacity={0.4} stroke="var(--color-dns)" stackId="a" /> <Area dataKey="connect" type="monotone" fill="var(--color-connect)" fillOpacity={0.4} stroke="var(--color-connect)" stackId="a" /> <Area dataKey="tls" type="monotone" fill="var(--color-tls)" fillOpacity={0.4} stroke="var(--color-tls)" stackId="a" /> <Area dataKey="ttfb" type="monotone" fill="var(--color-ttfb)" fillOpacity={0.4} stroke="var(--color-ttfb)" stackId="a" /> <Area dataKey="transfer" type="monotone" fill="var(--color-transfer)" fillOpacity={0.4} stroke="var(--color-transfer)" stackId="a" /> {degradedAfter ? ( <ReferenceLine y={degradedAfter} stroke="var(--warning)" strokeDasharray="3 3" > <Label value="Degraded" position="insideBottomRight" fill="var(--warning)" /> </ReferenceLine> ) : null} <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> <ChartLegend content={<ChartLegendContent />} /> </AreaChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx ================================================ "use client"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Bar, BarChart, XAxis } from "recharts"; import { mapUptime } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import type { Region } from "@openstatus/db/src/schema/constants"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { useQuery } from "@tanstack/react-query"; // import { startOfDay, subDays } from "date-fns"; const chartConfig = { ok: { label: "Success", color: "var(--color-success)", }, degraded: { label: "Degraded", color: "var(--color-warning)", }, error: { label: "Error", color: "var(--color-destructive)", }, } satisfies ChartConfig; export function ChartBarUptimeLight({ monitorId, type, regions, }: { monitorId: string; type: "http" | "tcp"; regions?: Region[]; }) { const trpc = useTRPC(); const { data: uptime, isLoading } = useQuery( trpc.tinybird.uptime.queryOptions({ interval: 60 * 24, // fromDate: startOfDay(subDays(new Date(), 7)).toISOString(), // FIXME: period: "7d", monitorId, regions, type, }), ); if (isLoading) { return <Skeleton className=" my-auto h-5 w-full" />; } const refinedUptime = uptime ? mapUptime(uptime) : []; if (refinedUptime.length === 0) { return <span className="text-muted-foreground">-</span>; } return ( <ChartContainer config={chartConfig} className="h-[28px] w-full"> <BarChart accessibilityLayer data={refinedUptime} barCategoryGap={1}> <ChartTooltip cursor={false} allowEscapeViewBox={{ x: false, y: true }} wrapperStyle={{ zIndex: 1 }} content={<ChartTooltipContent indicator="dot" />} /> <Bar dataKey="ok" stackId="a" fill="var(--color-ok)" /> <Bar dataKey="error" stackId="a" fill="var(--color-error)" /> <Bar dataKey="degraded" stackId="a" fill="var(--color-degraded)" /> <XAxis dataKey="interval" tickLine={false} tickMargin={8} minTickGap={10} axisLine={false} hide /> </BarChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-bar-uptime.tsx ================================================ "use client"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { type PERIODS, mapUptime, periodToFromDate, periodToInterval, } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { useQuery } from "@tanstack/react-query"; import { endOfDay } from "date-fns"; const chartConfig = { ok: { label: "Success", color: "var(--color-success)", }, degraded: { label: "Degraded", color: "var(--color-warning)", }, error: { label: "Error", color: "var(--color-destructive)", }, } satisfies ChartConfig; export function ChartBarUptime({ monitorId, period, type, regions, }: { monitorId: string; period: (typeof PERIODS)[number]; type: "http" | "tcp"; regions: string[] | undefined; }) { const isMobile = useIsMobile(); const trpc = useTRPC(); const fromDate = periodToFromDate[period]; const toDate = endOfDay(new Date()); const interval = periodToInterval[period]; const { data: uptime } = useQuery( trpc.tinybird.uptime.queryOptions({ monitorId, fromDate: fromDate.toISOString(), toDate: toDate.toISOString(), regions, interval, type, }), ); const refinedUptime = uptime ? mapUptime(uptime) : []; return ( <ChartContainer config={chartConfig} className="h-[130px] w-full"> <BarChart accessibilityLayer data={refinedUptime} barCategoryGap={isMobile ? 0 : 2} > <CartesianGrid vertical={false} /> <ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dot" />} /> <Bar dataKey="ok" stackId="a" fill="var(--color-ok)" /> <Bar dataKey="error" stackId="a" fill="var(--color-error)" /> <Bar dataKey="degraded" stackId="a" fill="var(--color-degraded)" /> <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" /> <XAxis dataKey="interval" tickLine={false} tickMargin={8} minTickGap={10} axisLine={false} /> <ChartLegend content={<ChartLegendContent />} /> </BarChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-line-region.tsx ================================================ "use client"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { cn } from "@openstatus/ui/lib/utils"; import { ChartTooltipNumber } from "./chart-tooltip-number"; const chartConfig = { latency: { label: "Latency", color: "var(--success)", }, } satisfies ChartConfig; export type TrendPoint = { timestamp: number; // unix millis latency: number; // milliseconds }; export function ChartLineRegion({ className, data, }: { className?: string; data: TrendPoint[]; }) { const trendData = data ?? []; const chartData = trendData.map((d) => ({ timestamp: new Date(d.timestamp).toLocaleString("default", { hour: "numeric", minute: "numeric", day: "numeric", month: "short", }), latency: d.latency, })); return ( <ChartContainer config={chartConfig} className={cn("h-[100px] w-full", className)} > <LineChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12, }} > <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" hide /> <ChartTooltip cursor={false} content={ <ChartTooltipContent className="w-[180px]" formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> )} /> } /> <Line dataKey="latency" type="monotone" stroke="var(--color-latency)" strokeWidth={2} dot={false} /> <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> </LineChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-line-regions.tsx ================================================ "use client"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { getRegionColor } from "@/data/regions"; import { cn } from "@/lib/utils"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; import { getRegionInfo } from "@openstatus/regions"; import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { ChartTooltipNumber } from "./chart-tooltip-number"; function getChartConfig(privateLocations?: PrivateLocation[]) { return [ ...monitorRegions, ...(privateLocations?.map((location) => location.id.toString()) ?? []), ].reduce((config, region) => { const privateLocation = privateLocations?.find( (location) => String(location.id) === String(region), ); const regionInfo = getRegionInfo(region, { location: privateLocation?.name, }); const color = getRegionColor(region); if (regionInfo && color) { config[region] = { label: `${regionInfo.location} (${regionInfo.provider})`, color, }; } return config; }, {} as ChartConfig) satisfies ChartConfig; } export type TrendPoint = { timestamp: number; // unix millis [key: string]: number; // milliseconds }; export function ChartLineRegions({ className, data, regions, privateLocations, }: { className?: string; data: TrendPoint[]; regions: string[] | undefined; privateLocations?: PrivateLocation[]; }) { const isMobile = useIsMobile(); const trendData = data ?? []; const chartConfig = getChartConfig(privateLocations); const chartData = trendData.map((d) => ({ ...d, timestamp: new Date(d.timestamp).toLocaleString("default", { hour: "numeric", minute: "numeric", day: "numeric", month: "short", }), })); return ( <ChartContainer config={chartConfig} className={cn("h-[250px] w-full", className)} > <LineChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12, }} > <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" /> <ChartTooltip cursor={false} content={ <ChartTooltipContent formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> )} /> } /> {regions?.map((region) => { return ( <Line key={region} dataKey={region} type="monotone" stroke={`var(--color-${region})`} strokeWidth={2} dot={false} /> ); })} <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> {regions && regions.length <= 6 && !isMobile ? ( <ChartLegend className="flex-wrap" content={<ChartLegendContent className="text-nowrap" />} /> ) : null} </LineChart> </ChartContainer> ); } ================================================ FILE: apps/dashboard/src/components/chart/chart-tooltip-number.tsx ================================================ import type { ChartConfig } from "@openstatus/ui/components/ui/chart"; import { cn } from "@openstatus/ui/lib/utils"; import type { NameType, ValueType, } from "recharts/types/component/DefaultTooltipContent"; interface ChartTooltipNumberProps { chartConfig: ChartConfig; value: ValueType; name: NameType; } export function ChartTooltipNumber({ value, name, chartConfig, }: ChartTooltipNumberProps) { return ( <ChartTooltipNumberRaw value={value} label={chartConfig[name as keyof typeof chartConfig]?.label || name} style={ { "--color-bg": `var(--color-${name})`, } as React.CSSProperties } /> ); } export function ChartTooltipNumberRaw({ value, label, style, className, }: { value: ValueType; label: React.ReactNode; style?: React.CSSProperties; className?: string; }) { return ( <> <div className={cn( "h-2.5 w-2.5 shrink-0 rounded-[2px] bg-(--color-bg)", className, )} style={style} /> <span>{label}</span> <div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums"> {value} <span className="font-normal text-muted-foreground">ms</span> </div> </> ); } ================================================ FILE: apps/dashboard/src/components/common/code.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, Copy } from "lucide-react"; export function Code({ children, className, ...props }: React.ComponentProps<"pre">) { const { copy, isCopied } = useCopyToClipboard(); return ( <div className="relative"> <pre className={cn( "overflow-x-auto rounded-md border bg-muted p-2 text-xs", className, )} {...props} > {children} </pre> <Button variant="outline" size="icon" className="absolute top-1 right-1 size-6 p-1 backdrop-blur-md" onClick={() => copy(children?.toString() ?? "", { withToast: false, timeout: 1000, }) } > {isCopied ? <Check className="size-3" /> : <Copy className="size-3" />} </Button> </div> ); } ================================================ FILE: apps/dashboard/src/components/common/hover-card-timestamp.tsx ================================================ "use client"; import { UTCDate } from "@date-fns/utc"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@openstatus/ui/components/ui/hover-card"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { format, formatDistanceToNowStrict } from "date-fns"; import { Copy } from "lucide-react"; import { Check } from "lucide-react"; import type { ComponentPropsWithoutRef } from "react"; // TODO: move to TableCellDate? type HoverCardContentProps = ComponentPropsWithoutRef<typeof HoverCardContent>; interface HoverCardTimestampProps { date: Date; side?: HoverCardContentProps["side"]; sideOffset?: HoverCardContentProps["sideOffset"]; align?: HoverCardContentProps["align"]; alignOffset?: HoverCardContentProps["alignOffset"]; children?: React.ReactNode; } export function HoverCardTimestamp({ date, side = "right", align = "start", alignOffset = -4, sideOffset, children, }: HoverCardTimestampProps) { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; return ( <HoverCard openDelay={0} closeDelay={0}> <HoverCardTrigger asChild>{children}</HoverCardTrigger> <HoverCardContent className="z-10 w-auto p-2" {...{ side, align, alignOffset, sideOffset }} > <dl className="flex flex-col gap-1"> <Row value={String(date.getTime())} label="Timestamp" /> <Row value={format(new UTCDate(date), "LLL dd, y HH:mm:ss")} label="UTC" /> <Row value={format(date, "LLL dd, y HH:mm:ss")} label={timezone} /> <Row value={formatDistanceToNowStrict(date, { addSuffix: true })} label="Relative" /> </dl> </HoverCardContent> </HoverCard> ); } function Row({ value, label }: { value: string; label: string }) { const { copy, isCopied } = useCopyToClipboard(); return ( <div className="group flex items-center justify-between gap-4 text-sm" onClick={(e) => { e.stopPropagation(); copy(value, {}); }} > <dt className="text-muted-foreground">{label}</dt> <dd className="flex items-center gap-1 truncate font-mono"> <span className="invisible group-hover:visible"> {!isCopied ? ( <Copy className="h-3 w-3" /> ) : ( <Check className="h-3 w-3" /> )} </span> {value} </dd> </div> ); } ================================================ FILE: apps/dashboard/src/components/common/icon-cloud-provider.tsx ================================================ import { Fly, Koyeb, Railway } from "@openstatus/icons"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { Globe } from "lucide-react"; export function IconCloudProvider({ provider, className, }: React.ComponentProps<"svg"> & { provider: string; }) { switch (provider) { case "fly": return <Fly className={cn("size-4", className)} />; case "koyeb": return <Koyeb className={cn("size-4", className)} />; case "railway": return <Railway className={cn("size-4", className)} />; default: return <Globe className={cn("size-4", className)} />; } } export function IconCloudProviderTooltip( props: React.ComponentProps<typeof IconCloudProvider>, ) { return ( <TooltipProvider> <Tooltip delayDuration={0}> <TooltipTrigger type="button" className="rounded-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2" > <IconCloudProvider {...props} /> </TooltipTrigger> <TooltipContent className="capitalize">{props.provider}</TooltipContent> </Tooltip> </TooltipProvider> ); } ================================================ FILE: apps/dashboard/src/components/common/input-with-addons.tsx ================================================ import * as React from "react"; import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; export interface InputWithAddonsProps extends React.InputHTMLAttributes<HTMLInputElement> { leading?: React.ReactNode; trailing?: React.ReactNode; } // "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", // "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", // "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", const InputWithAddons = React.forwardRef< HTMLInputElement, InputWithAddonsProps >(({ leading, trailing, className, ...props }, ref) => { return ( <div className={cn("flex rounded-md shadow-xs", className)}> {leading ? ( <span className="inline-flex items-center rounded-s-md border border-input bg-muted px-3 text-muted-foreground text-sm"> {leading} </span> ) : null} <Input ref={ref} className={cn("z-1 shadow-none", { "-ms-px rounded-s-none": leading, "-me-px rounded-e-none": trailing, })} placeholder="google.com" type="text" {...props} /> {trailing ? ( <span className="inline-flex items-center rounded-e-md border border-input bg-muted px-3 text-muted-foreground text-sm"> {trailing} </span> ) : null} </div> ); }); InputWithAddons.displayName = "InputWithAddons"; export { InputWithAddons }; ================================================ FILE: apps/dashboard/src/components/common/kbd.tsx ================================================ import { cn } from "@/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; import type * as React from "react"; const kbdVariants = cva( "-me-1 ms-2 inline-flex h-5 max-h-full items-center rounded border px-1 font-[inherit] font-medium text-[0.625rem]", { variants: { variant: { default: "border-input bg-background text-muted-foreground/70", secondary: "bg-secondary text-secondary-foreground", ghost: "border-transparent", }, }, defaultVariants: { variant: "default", }, }, ); export function Kbd({ children, className, variant, ...props }: React.ComponentProps<"kbd"> & VariantProps<typeof kbdVariants>) { return ( <kbd className={cn(kbdVariants({ variant, className }))} {...props}> {children} </kbd> ); } ================================================ FILE: apps/dashboard/src/components/common/link.tsx ================================================ import { cn } from "@/lib/utils"; import NextLink from "next/link"; // TODO: we could add cva variants for the link export function Link({ children, className, ...props }: React.ComponentProps<typeof NextLink>) { const isExternal = props.href?.toString().startsWith("http"); const externalProps = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {}; return ( <NextLink className={cn("font-medium text-foreground", className)} {...externalProps} {...props} > {children} </NextLink> ); } ================================================ FILE: apps/dashboard/src/components/common/note.tsx ================================================ import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; const noteVariants = cva( "flex items-center gap-2 rounded-xl border [&>svg]:text-current [&>svg]:shrink-0", { variants: { variant: { default: "border-border", ghost: "border-none bg-transparent", }, color: { default: "text-foreground bg-sidebar", warning: "text-warning border-warning/50 bg-warning/5", error: "text-destructive border-destructive/50 bg-destructive/5", success: "text-success border-success/50 bg-success/5", info: "text-info border-info/50 bg-info/5", }, size: { default: "px-3 py-2 text-base [&>svg]:size-4", sm: "px-2.5 py-1.5 text-sm [&>svg]:size-3.5", }, }, defaultVariants: { variant: "default", color: "default", size: "default", }, }, ); export function Note({ children, className, variant = "default", color = "default", size = "default", ...props }: React.ComponentProps<"div"> & VariantProps<typeof noteVariants>) { return ( <div data-variant={variant} className={cn(noteVariants({ variant, color, size, className }))} {...props} > {children} </div> ); } export function NoteButton({ children, className, ...props }: React.ComponentProps<typeof Button>) { return ( <Button size="sm" className={cn("-mr-1 ml-auto shrink-0", className)} {...props} > {children} </Button> ); } ================================================ FILE: apps/dashboard/src/components/common/wheel-picker.tsx ================================================ "use client"; import { cn } from "@/lib/utils"; import * as React from "react"; // --------------------------------------------------------------------------------------------------------------------- // Context ------------------------------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- interface WheelPickerContextValue { items: string[]; currentIndex: number; onIndexChange: (index: number) => void; theta: number; // angle between two items radius: number; // translateZ distance count: number; // items including placeholders } const WheelPickerContext = React.createContext<WheelPickerContextValue | null>( null, ); const useWheelPickerContext = () => { const ctx = React.useContext(WheelPickerContext); if (!ctx) { throw new Error( "[WheelPicker] sub component must be rendered within <WheelPicker /> root", ); } return ctx; }; // --------------------------------------------------------------------------------------------------------------------- // WheelPicker (Root / Provider) --------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- export interface WheelPickerProps extends React.HTMLAttributes<HTMLDivElement> { items: string[]; /** 0-based index of the currently selected item. Defaults to 0. */ currentIndex: number; /** Callback that is invoked with the currently selected item (string) whenever the selection changes */ onIndexChange: (index: number) => void; /** Radius (in `px`) of the carousel – tweak to fit line height of text (Default: 28) */ radius?: number; } const WheelPicker = React.forwardRef<HTMLDivElement, WheelPickerProps>( ( { items, currentIndex, onIndexChange, radius: radiusProp = 28, className, children, ...props }, ref, ) => { // internal render count includes two placeholders at start & end const count = items.length + 2; const theta = (2 * Math.PI) / count; const contextValue = React.useMemo<WheelPickerContextValue>( () => ({ items, currentIndex, onIndexChange, theta, radius: radiusProp, count, }), [items, currentIndex, onIndexChange, theta, radiusProp, count], ); return ( <WheelPickerContext.Provider value={contextValue}> <div ref={ref} className={className} {...props}> {children} </div> </WheelPickerContext.Provider> ); }, ); WheelPicker.displayName = "WheelPicker"; // --------------------------------------------------------------------------------------------------------------------- // WheelPickerSelect --------------------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- export type WheelPickerSelectProps = React.HTMLAttributes<HTMLDivElement>; const WheelPickerSelect = React.forwardRef< HTMLDivElement, WheelPickerSelectProps >(({ className, children, ...props }, ref) => { const { items, currentIndex, onIndexChange } = useWheelPickerContext(); const moveBy = React.useCallback( (delta: number) => { const itemsLength = items.length; let newIndex = currentIndex + delta; // Handle wrapping if (newIndex < 0) { newIndex = itemsLength - 1; } else if (newIndex >= itemsLength) { newIndex = 0; } onIndexChange(newIndex); }, [currentIndex, items.length, onIndexChange], ); const handleKeyDown = React.useCallback( (e: React.KeyboardEvent<HTMLDivElement>) => { switch (e.key) { case "ArrowUp": case "ArrowLeft": e.preventDefault(); moveBy(1); break; case "ArrowDown": case "ArrowRight": e.preventDefault(); moveBy(-1); break; case "Home": e.preventDefault(); onIndexChange(0); break; case "End": e.preventDefault(); onIndexChange(items.length - 1); break; } }, [moveBy, onIndexChange, items.length], ); return ( <div ref={ref} data-slot="wheel-select" role="listbox" aria-label="Select option" aria-activedescendant={`wheel-option-${currentIndex}`} tabIndex={0} className={cn( "relative h-6 w-full rounded-md border border-transparent text-left focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/50", className, )} onKeyDown={handleKeyDown} {...props} > {children} </div> ); }); WheelPickerSelect.displayName = "WheelPickerSelect"; // --------------------------------------------------------------------------------------------------------------------- // WheelPickerOptions -------------------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- export type WheelPickerOptionsProps = React.HTMLAttributes<HTMLDivElement>; const WheelPickerOptions = React.forwardRef< HTMLDivElement, WheelPickerOptionsProps >(({ className, ...props }, ref) => { const { items, currentIndex, onIndexChange, theta, radius } = useWheelPickerContext(); return ( <div ref={ref} data-slot="wheel-options" className={cn( "h-full w-full rounded-md [perspective:1000px] [transform-style:preserve-3d]", className, )} {...props} > <div className="relative h-full w-full transition-transform duration-500 ease-out [transform-style:preserve-3d]" style={{ transform: `translateZ(-${radius}px) rotateX(${ -(currentIndex + 1) * theta }rad)`, }} > {/* First placeholder */} <WheelPickerEmpty position="first" /> {/* Real items */} {items.map((item, idx) => { // Render index = item index + 1 (accounting for first placeholder) const renderIdx = idx + 1; const angle = theta * renderIdx; const isSelected = idx === currentIndex; return ( <div key={item} data-slot="wheel-option" id={`wheel-option-${idx}`} role="option" aria-selected={isSelected} className={cn( "absolute inset-0 flex cursor-pointer select-none items-center justify-start transition-transform duration-500 ease-out [backface-visibility:hidden]", )} style={{ transform: `rotateX(${angle}rad) translateZ(${radius}px)`, transformStyle: "preserve-3d", }} onClick={(e) => { if (!isSelected) { e.preventDefault(); e.stopPropagation(); onIndexChange(idx); } }} > <span className={cn( "text-xs transition-colors", isSelected ? "text-foreground" : "text-muted-foreground/70", )} > {item} </span> </div> ); })} {/* Last placeholder */} <WheelPickerEmpty position="last" /> </div> </div> ); }); WheelPickerOptions.displayName = "WheelPickerOptions"; // --------------------------------------------------------------------------------------------------------------------- // WheelPickerEmpty (placeholders) ------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- type PlaceholderPosition = "first" | "last"; export interface WheelPickerEmptyProps extends React.HTMLAttributes<HTMLDivElement> { position: PlaceholderPosition; } const WheelPickerEmpty = React.forwardRef< HTMLDivElement, WheelPickerEmptyProps >(({ position, className, ...props }, ref) => { const { theta, radius, count, currentIndex } = useWheelPickerContext(); const renderIdx = position === "first" ? 0 : count - 1; const angle = theta * renderIdx; return ( <div ref={ref} data-slot="wheel-empty" id={`wheel-option-${renderIdx}`} role="option" aria-selected={currentIndex === renderIdx} aria-disabled className={cn( "absolute inset-0 select-none [backface-visibility:hidden]", className, )} style={{ transform: `rotateX(${angle}rad) translateZ(${radius}px)`, transformStyle: "preserve-3d", }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} {...props} /> ); }); WheelPickerEmpty.displayName = "WheelPickerEmpty"; // --------------------------------------------------------------------------------------------------------------------- // Exports ------------------------------------------------------------------------------------------------------------- // --------------------------------------------------------------------------------------------------------------------- export { WheelPicker, WheelPickerSelect, WheelPickerOptions, WheelPickerEmpty }; ================================================ FILE: apps/dashboard/src/components/content/action-card.tsx ================================================ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@openstatus/ui/components/ui/card"; import { cn } from "@openstatus/ui/lib/utils"; export function ActionCard({ children, className, ...props }: React.ComponentProps<"div">) { return ( <Card className={cn("group/action-card gap-4 py-4 shadow-none", className)} {...props} > {children} </Card> ); } export function ActionCardHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardHeader className={cn("px-4 [.border-b]:pb-4", className)} {...props}> {children} </CardHeader> ); } export function ActionCardGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("grid gap-4", className)} {...props}> {children} </div> ); } export function ActionCardTitle({ children, ...props }: React.ComponentProps<"div">) { return <CardTitle {...props}>{children}</CardTitle>; } export function ActionCardDescription({ children, ...props }: React.ComponentProps<"div">) { return <CardDescription {...props}>{children}</CardDescription>; } export function ActionCardContent({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardContent className={cn("px-4", className)} {...props}> {children} </CardContent> ); } export function ActionCardFooter({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardFooter className={cn("px-4 [.border-t]:pt-4", className)} {...props}> {children} </CardFooter> ); } ================================================ FILE: apps/dashboard/src/components/content/billing-addons.tsx ================================================ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Label } from "@openstatus/ui/components/ui/label"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons } from "@openstatus/db/src/schema/plan/schema"; import { getAddonPriceConfig } from "@openstatus/db/src/schema/plan/utils"; import { ButtonGroup } from "@openstatus/ui/components/ui/button-group"; import { Input } from "@openstatus/ui/components/ui/input"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { Check, MinusIcon, PlusIcon } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; type Workspace = RouterOutputs["workspace"]["get"]; interface BillingAddonsProps { label: string; description: React.ReactNode; addon: keyof Addons; workspace: Workspace; } interface PriceConfig { value: number; currency: string; locale: string; } export function BillingAddons({ label, description, addon, workspace, }: BillingAddonsProps) { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const queryClient = useQueryClient(); const checkoutSessionMutation = useMutation( trpc.stripeRouter.addAddon.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); const plan = workspace.plan; const defaultLimit = allPlans[workspace.plan].limits[addon]; const workspaceLimit = workspace.limits[addon]; const defaultValue = typeof workspaceLimit === "number" && typeof defaultLimit === "number" ? // current value - default value to evaluate the difference workspaceLimit - defaultLimit : workspaceLimit; const [value, setValue] = useState<number | boolean>(defaultValue); const price = getAddonPriceConfig(plan, addon, currency); // Reset value when modal opens useEffect(() => { if (open) { setValue(defaultValue); } }, [open, defaultValue]); function submitAction() { startTransition(async () => { try { // toggle the value if it's a boolean otherwise use the value const newValue = typeof value === "boolean" ? !value : value; const promise = checkoutSessionMutation.mutateAsync({ workspaceSlug: workspace.slug, feature: addon, value: newValue, }); toast.promise(promise, { loading: "Updating...", success: () => { setOpen(false); return "Billing information updated"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to update"; }, }); await promise; } catch (error) { console.error(error); } }); } const hasAddon = typeof defaultValue === "number" ? defaultValue > 0 : defaultValue !== defaultLimit; const isQuantity = typeof value === "number"; return ( <AlertDialog open={open} onOpenChange={setOpen}> <div className="flex flex-col gap-2"> <div className="grid grid-cols-3 gap-1.5 lg:grid-cols-5"> <div className="col-span-3 space-y-0.5 text-sm"> <Label>{label}</Label> <div className="text-muted-foreground">{description}</div> </div> <div className="flex items-center gap-1.5"> <span className="font-mono text-foreground text-sm"> {formatPrice(price)} {isQuantity ? "/mo./each" : "/mo."} </span> {hasAddon && !isQuantity ? ( <Check className="size-4 text-success" /> ) : null} {hasAddon && isQuantity ? ( <span className="font-mono text-success">+{defaultValue}</span> ) : null} </div> <div className="col-span-2 flex items-center justify-end gap-1.5 lg:col-span-1"> <AlertDialogTrigger asChild> <Button size="sm" variant="secondary"> {getButtonLabel(hasAddon, value)} </Button> </AlertDialogTrigger> </div> </div> </div> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{label}</AlertDialogTitle> <AlertDialogDescription> {getDialogDescription(label, price, value, hasAddon)} </AlertDialogDescription> </AlertDialogHeader> {isQuantity && typeof value === "number" && typeof defaultLimit === "number" ? ( <QuantityControl value={value} setValue={setValue} defaultLimit={defaultLimit} /> ) : null} <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(e) => { e.preventDefault(); submitAction(); }} disabled={ isPending || (typeof value === "number" && typeof defaultValue === "number" && value === defaultValue) } > {getButtonLabel(hasAddon, value, isPending)} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } // NOTE: could move to lib/formatter.ts function formatPrice(price: PriceConfig | null) { if (!price) return "N/A"; return new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value); } function getButtonLabel( hasAddon: boolean, value: number | boolean, isPending = false, ) { if (isPending) return "Updating..."; const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; if (isQuantity) return "Update"; if (isBoolean) { return hasAddon ? "Remove" : "Add"; } return null; } function getDialogDescription( label: string, price: PriceConfig | null, value: number | boolean, hasAddon: boolean, ) { const formattedPrice = formatPrice(price); const isBoolean = typeof value === "boolean"; const isQuantity = typeof value === "number"; const priceSuffix = isQuantity ? "/mo./each" : "/mo."; if (isBoolean) { if (hasAddon) { return `${label} will be removed from your subscription. You will save ${formattedPrice}${priceSuffix} on your next billing cycle.`; } return `${label} will be added to your subscription. You will be charged an additional ${formattedPrice}${priceSuffix} on your next billing cycle.`; } if (isQuantity) { return `${label} will be updated to ${value} on your next billing cycle. You will be charged ${formattedPrice}${priceSuffix} on your next billing cycle.`; } } function QuantityControl({ value, setValue, defaultLimit, }: { value: number; setValue: (value: number) => void; defaultLimit: number | boolean; }) { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = Number.parseInt(e.target.value); if (Number.isNaN(newValue)) { setValue(typeof defaultLimit === "number" ? defaultLimit : 0); } else { setValue( Math.max(typeof defaultLimit === "number" ? defaultLimit : 0, newValue), ); } }; return ( <div className="flex items-center justify-center gap-2 py-2"> <ButtonGroup aria-label="Quantity" className="h-fit"> <Button variant="outline" size="icon" onClick={() => setValue(value - 1)} disabled={value <= 0} > <MinusIcon /> </Button> <Input type="number" value={value} className="w-16 text-right" step={1} min={0} onChange={handleChange} /> <Button variant="outline" size="icon" onClick={() => setValue(value + 1)} > <PlusIcon /> </Button> </ButtonGroup> </div> ); } ================================================ FILE: apps/dashboard/src/components/content/billing-overlay.tsx ================================================ import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; export function BillingOverlayContainer({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("relative", className)} {...props}> {children} </div> ); } export function BillingOverlay({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gradient-to-b from-transparent to-50% to-background p-2", className, )} {...props} > {children} </div> ); } export function BillingOverlayButton({ children, ...props }: React.ComponentProps<typeof Button>) { return ( <Button size="sm" {...props}> {children} </Button> ); } export function BillingOverlayDescription({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn( "max-w-xs text-center text-muted-foreground text-sm", className, )} {...props} > {children} </p> ); } ================================================ FILE: apps/dashboard/src/components/content/billing-progress.tsx ================================================ import { Progress } from "@openstatus/ui/components/ui/progress"; interface BillingProgressProps { label: string; value: number; max: number; } export function BillingProgress({ label, value, max }: BillingProgressProps) { return ( <div className="flex flex-col gap-2"> <div className="flex flex-col gap-0.5"> <div className="flex justify-between text-muted-foreground text-sm"> <div className="font-medium">{label}</div> <div className="font-mono"> <span className="text-foreground"> {new Intl.NumberFormat("de-DE").format(value)} </span> /{new Intl.NumberFormat("de-DE").format(max)} </div> </div> <Progress value={(value / max) * 100} /> </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/content/block-wrapper.tsx ================================================ "use client"; import * as React from "react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { cn } from "@openstatus/ui/lib/utils"; export function BlockWrapper({ className, children, autoOpen, ...props }: React.HTMLAttributes<HTMLDivElement> & { autoOpen?: boolean; }) { const ref = React.useRef<HTMLDivElement>(null); const [isOpened, setIsOpened] = React.useState(false); React.useEffect(() => { if (ref.current && autoOpen) { const height = ref.current.scrollHeight; // NOTE: max-h-48 in tw equals 192px (48 * 4px) if (height <= 192) { setIsOpened(true); } } }, [autoOpen]); return ( <Collapsible open={isOpened} onOpenChange={setIsOpened}> <div className={cn("relative overflow-hidden", className)} {...props}> <CollapsibleContent forceMount ref={ref} className={cn("overflow-hidden", !isOpened && "max-h-48")} > {children} </CollapsibleContent> {!isOpened ? ( <div className={cn( "absolute flex items-center justify-center bg-gradient-to-b from-transparent to-90% to-background p-2", isOpened ? "inset-x-0 bottom-0 h-12" : "inset-0", )} > <CollapsibleTrigger asChild> <Button variant="outline" size="sm"> Expand </Button> </CollapsibleTrigger> </div> ) : null} </div> </Collapsible> ); } ================================================ FILE: apps/dashboard/src/components/content/empty-state.tsx ================================================ import { cn } from "@/lib/utils"; export function EmptyStateContainer({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "flex h-full flex-col items-center justify-center gap-2 rounded-lg border border-border border-dashed p-4", className, )} {...props} > {children} </div> ); } export function EmptyStateTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-foreground", className)} {...props}> {children} </p> ); } export function EmptyStateDescription({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-center text-muted-foreground text-sm", className)} {...props} > {children} </p> ); } ================================================ FILE: apps/dashboard/src/components/content/process-message.tsx ================================================ import type { AnchorHTMLAttributes } from "react"; import { Fragment, createElement } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; import rehypeReact from "rehype-react"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { unified } from "unified"; export function ProcessMessage({ value }: { value: string }) { const result = unified() .use(remarkParse) .use(remarkRehype) .use(rehypeReact, { createElement, Fragment, jsx, jsxs, components: { a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { return ( <a target="_blank" rel="noreferrer" className="underline" {...props} /> ); }, } as { [key: string]: React.ComponentType<unknown> }, }) .processSync(value).result; return result; } ================================================ FILE: apps/dashboard/src/components/content/section.tsx ================================================ import { cn } from "@/lib/utils"; export function Section({ children, className, ...props }: React.ComponentProps<"section">) { return ( <section className={cn("space-y-4", className)} {...props}> {children} </section> ); } export function SectionHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("flex flex-col gap-1.5", className)} {...props}> {children} </div> ); } export function SectionHeaderRow({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "flex flex-col gap-1.5 sm:flex-row sm:items-end sm:justify-between", className, )} {...props} > {children} </div> ); } export function SectionDescription({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn( "font-commit-mono text-muted-foreground text-sm tracking-tight", className, )} {...props} > {children} </p> ); } export function SectionTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-medium text-lg", className)} {...props}> {children} </p> ); } export function SectionGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("mx-auto w-full max-w-4xl space-y-8 px-4 py-8", className)} {...props} > {children} </div> ); } export function SectionGroupHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("space-y-1.5", className)} {...props}> {children} </div> ); } export function SectionGroupTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-bold text-4xl", className)} {...props}> {children} </p> ); } ================================================ FILE: apps/dashboard/src/components/controls-filter/.gitkeep ================================================ ================================================ FILE: apps/dashboard/src/components/controls-search/button-reset.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { X } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; export function ButtonReset({ only }: { only?: string[] }) { const searchParams = useSearchParams(); const router = useRouter(); // Determine if at least one parameter that should be reset is present (or any parameter if `only` is undefined) const hasParamsToReset = only ? only.some((key) => searchParams.has(key)) : !!searchParams.toString(); if (!hasParamsToReset) return null; const handleClick = () => { // Clone the current search params so we can mutate them const params = new URLSearchParams(searchParams.toString()); if (only && only.length > 0) { // Remove only the specified keys only.forEach((key) => params.delete(key)); const query = params.toString(); router.push( query ? `${window.location.pathname}?${query}` : window.location.pathname, ); } else { // No `only` prop provided – remove all query parameters router.push(window.location.pathname); } }; if (!hasParamsToReset) return null; return ( <Button variant="ghost" size="sm" onClick={handleClick}> <X /> Reset </Button> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/command-region.tsx ================================================ "use client"; import { IconCloudProvider } from "@/components/common/icon-cloud-provider"; import { Link } from "@/components/common/link"; import { BillingOverlay, BillingOverlayButton, BillingOverlayDescription, } from "@/components/content/billing-overlay"; import type { REGIONS } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; import { formatRegionCode, groupByContinent } from "@openstatus/regions"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@openstatus/ui/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Check, Globe, Lock } from "lucide-react"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; export function CommandRegion({ regions, privateLocations, }: { regions: (typeof REGIONS)[number][]; privateLocations?: { id: number; name: string }[]; }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const [selectedRegions, setSelectedRegions] = useQueryState( "regions", parseAsArrayOf(parseAsString).withDefault([ ...regions, ...(privateLocations?.map((location) => location.id.toString()) ?? []), ]), ); const limited = workspace?.plan === "free"; return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm"> {selectedRegions.length === regions.length ? "All Regions" : `${selectedRegions.length} Regions`} </Button> </PopoverTrigger> <PopoverContent align="start" className="relative w-[250px] overflow-hidden p-0" > <Command> <CommandInput placeholder="Search region..." disabled={limited} /> <CommandList> <CommandGroup forceMount> <CommandItem onSelect={() => { const items = document.querySelectorAll( '[data-slot="command-item"][data-disabled="false"]', ); const codes: (typeof REGIONS)[number][] = []; items.forEach((item) => { const code = item.getAttribute("data-value"); if (code && code !== "select-all") { codes.push(code as (typeof REGIONS)[number]); } }); if (codes.length === selectedRegions.length) { setSelectedRegions([]); } else { setSelectedRegions(codes); } }} value="select-all" disabled={limited} > Toggle selection </CommandItem> </CommandGroup> <CommandSeparator alwaysRender /> {Object.entries(groupByContinent).map( ([continent, continentRegions]) => { const allowedRegions = continentRegions.filter((region) => regions.includes(region.code), ); if (allowedRegions.length === 0) { return null; } return ( <CommandGroup key={continent} heading={continent}> {allowedRegions.map((region) => ( <CommandItem disabled={limited} key={region.code} value={region.code} keywords={[ region.code, region.location, region.continent, region.flag, ]} onSelect={() => { setSelectedRegions((prev) => prev.includes(region.code) ? prev.filter((r) => r !== region.code) : [...prev, region.code], ); }} > <span>{region.flag}</span> <IconCloudProvider provider={region.provider} className="size-3" /> <span className="font-mono"> {formatRegionCode(region.code)} </span> <span className="truncate text-muted-foreground text-xs"> {region.location} </span> <Check className={cn( "ml-auto", selectedRegions.includes(region.code) ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> ); }, )} {privateLocations && privateLocations.length > 0 ? ( <CommandGroup heading="Private Locations"> {privateLocations.map((location) => ( <CommandItem key={location.id} keywords={[location.name]} value={location.id.toString()} onSelect={() => { setSelectedRegions((prev) => prev.includes(location.id.toString()) ? prev.filter((r) => r !== location.id.toString()) : [...prev, location.id.toString()], ); }} > <Globe className="size-3" /> <span className="truncate font-mono">{location.name}</span> <Check className={cn( "ml-auto", selectedRegions.includes(location.id.toString()) ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> ) : null} <CommandEmpty>No region found.</CommandEmpty> </CommandList> </Command> {limited ? ( <BillingOverlay className="to-70%"> <BillingOverlayButton asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </BillingOverlayButton> <BillingOverlayDescription> Filter by region is only available on paid plans. </BillingOverlayDescription> </BillingOverlay> ) : null} </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/command-tags.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Check } from "lucide-react"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; export function CommandTags() { const trpc = useTRPC(); const { data: tags } = useQuery(trpc.monitorTag.list.queryOptions()); const [selectedTags, setSelectedTags] = useQueryState( "tags", parseAsArrayOf(parseAsString).withDefault([]).withOptions({ shallow: false, }), ); return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm"> {selectedTags.length === (tags?.length ?? 0) ? "All Tags" : `${selectedTags.length} Tags`} </Button> </PopoverTrigger> <PopoverContent align="start" className="relative w-[200px] overflow-hidden p-0" > <Command> <CommandInput placeholder="Search tag..." /> <CommandList> <CommandGroup> {tags?.map((tag) => ( <CommandItem key={tag.id} value={tag.name} keywords={[tag.name]} onSelect={() => { setSelectedTags((prev) => prev.includes(tag.name) ? prev.filter((r) => r !== tag.name) : [...prev, tag.name], ); }} > <div className="flex items-center gap-2"> <span className="size-2.5 rounded-full" style={{ backgroundColor: tag.color }} /> {tag.name} </div> <Check className={cn( "ml-auto", selectedTags.includes(tag.name) ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> <CommandEmpty>No tag found.</CommandEmpty> </CommandList> </Command> </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/dropdown-interval.tsx ================================================ "use client"; import { INTERVALS } from "@/data/metrics.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { Check } from "lucide-react"; import { parseAsNumberLiteral, useQueryState } from "nuqs"; const MAPPING = { 5: "5 minutes", 15: "15 minutes", 30: "30 minutes", 60: "1 hour", 120: "2 hours", 240: "4 hours", 480: "8 hours", 1440: "1 day", } as const; const parseInterval = parseAsNumberLiteral(INTERVALS).withDefault(30); export function DropdownInterval() { const [interval, setInterval] = useQueryState("interval", parseInterval); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> {MAPPING[interval]} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> Resolution </DropdownMenuLabel> {INTERVALS.map((item) => ( <DropdownMenuItem key={item} onSelect={() => setInterval(item)}> {MAPPING[item]} {interval === item ? ( <Check className="ml-auto shrink-0" /> ) : null} </DropdownMenuItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/dropdown-percentile.tsx ================================================ "use client"; import { PERCENTILES } from "@/data/metrics.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; const parsePercentile = parseAsStringLiteral(PERCENTILES).withDefault("p50"); export function DropdownPercentile() { const [percentile, setPercentile] = useQueryState( "percentile", parsePercentile, ); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="capitalize"> {percentile} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> Percentile </DropdownMenuLabel> {PERCENTILES.map((item) => ( <DropdownMenuItem key={item} onSelect={() => setPercentile(item)} className={cn("capitalize")} > {item} {percentile === item ? ( <Check className="ml-auto shrink-0" /> ) : null} </DropdownMenuItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/dropdown-period.tsx ================================================ "use client"; import { PERIODS } from "@/data/metrics.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; // TODO: where to move it? export const PERIOD_VALUES = [ { value: "1d", label: "Last day", }, { value: "7d", label: "Last 7 days", }, { value: "14d", label: "Last 14 days", }, ] satisfies { value: (typeof PERIODS)[number]; label: string }[]; const parsePeriod = parseAsStringLiteral(PERIODS).withDefault("1d"); export function DropdownPeriod() { const [period, setPeriod] = useQueryState("period", parsePeriod); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> {PERIOD_VALUES.find(({ value }) => value === period)?.label} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> Period </DropdownMenuLabel> {PERIOD_VALUES.map(({ value, label }) => ( <DropdownMenuItem key={value} onSelect={() => setPeriod(value)}> {label} {period === value ? <Check className="ml-auto shrink-0" /> : null} </DropdownMenuItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/dropdown-status.tsx ================================================ "use client"; import { STATUS } from "@/data/metrics.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; const parseStatus = parseAsStringLiteral(STATUS); export function DropdownStatus() { const [status, setStatus] = useQueryState("status", parseStatus); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="capitalize"> {status ?? "All Status"} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> Request Status </DropdownMenuLabel> {STATUS.map((item) => ( <DropdownMenuItem key={item} onSelect={() => setStatus(item)} className={cn("capitalize")} > {item} {status === item ? <Check className="ml-auto shrink-0" /> : null} </DropdownMenuItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/dropdown-trigger.tsx ================================================ "use client"; import { TRIGGER } from "@/data/metrics.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { cn } from "@openstatus/ui/lib/utils"; import { Check } from "lucide-react"; import { parseAsStringLiteral, useQueryState } from "nuqs"; const parseTrigger = parseAsStringLiteral(TRIGGER); export function DropdownTrigger() { const [trigger, setTrigger] = useQueryState("trigger", parseTrigger); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="capitalize"> {trigger ?? "All Trigger"} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> Trigger </DropdownMenuLabel> {TRIGGER.map((item) => ( <DropdownMenuItem key={item} onSelect={() => setTrigger(item)} className={cn("capitalize")} > {item === "cron" ? "Scheduled" : "API"} {trigger === item ? <Check className="ml-auto shrink-0" /> : null} </DropdownMenuItem> ))} </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/controls-search/popover-date.tsx ================================================ import { DatePicker } from "@/components/date-picker"; import { formatDateRange } from "@/lib/formatter"; import { Button } from "@openstatus/ui/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { endOfDay, startOfDay, subDays, subHours } from "date-fns"; import { parseAsIsoDateTime, useQueryState } from "nuqs"; import { useEffect, useMemo, useRef, useState } from "react"; import type { DateRange } from "react-day-picker"; export function PopoverDate() { const [open, setOpen] = useState(false); const today = useRef(new Date()); const [from, setFrom] = useQueryState( "from", parseAsIsoDateTime.withDefault(startOfDay(today.current)), ); const [to, setTo] = useQueryState( "to", parseAsIsoDateTime.withDefault(endOfDay(today.current)), ); const [range, setRange] = useState<DateRange>({ from, to }); // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> const presets = useMemo( () => [ { id: "today", label: "Today", values: { from: startOfDay(today.current), to: endOfDay(today.current), }, shortcut: "t", }, { id: "yesterday", label: "Yesterday", values: { from: startOfDay(subDays(today.current, 1)), to: endOfDay(subDays(today.current, 1)), }, shortcut: "y", }, { id: "lastHour", label: "Last hour", values: { from: subHours(today.current, 1), to: today.current, }, shortcut: "h", }, { id: "last6Hours", label: "Last 6 hours", values: { from: subHours(today.current, 5), to: today.current, }, shortcut: "s", }, { id: "last24Hours", label: "Last 24 hours", values: { from: subHours(today.current, 23), to: today.current, }, shortcut: "d", }, { id: "last7Days", label: "Last 7 days", values: { from: subDays(today.current, 6), to: today.current, }, shortcut: "w", }, { id: "last14Days", label: "Last 14 days", values: { from: subDays(today.current, 13), to: today.current, }, shortcut: "b", }, ], [today], ); // instead use `range` state const selected = presets.find((period) => { return ( from.getTime() === period.values.from.getTime() && to.getTime() === period.values.to.getTime() ); }); // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> useEffect(() => { if (!open) { setFrom(range.from ?? null); setTo(range.to ?? null); } }, [open]); useEffect(() => { const down = (e: KeyboardEvent) => { if (!open) return; presets.map((preset) => { if (preset.shortcut === e.key) { setFrom(preset.values.from); setTo(preset.values.to); setRange({ from: preset.values.from, to: preset.values.to }); } }); }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, [presets, open, setFrom, setTo]); return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" size="sm"> {selected?.label ?? formatDateRange(from, to)} </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0" side="bottom" align="start"> <DatePicker presets={presets} range={range} onSelect={setRange} /> </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/data-table/audit-logs/columns.tsx ================================================ "use client"; import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { config, getMetadata } from "@/data/audit-logs.client"; import { cn } from "@/lib/utils"; import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import type { ColumnDef } from "@tanstack/react-table"; type AuditLog = RouterOutputs["tinybird"]["auditLog"]["data"][number]; export function getColumns( privateLocations?: PrivateLocation[], ): ColumnDef<AuditLog>[] { const metadata = getMetadata(privateLocations); return [ { id: "icon", accessorFn: (row) => row.action, header: () => null, enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("action"); const { icon: Icon, color } = config[value as keyof typeof config]; if (!Icon) return null; return <Icon className={cn("size-4", color)} />; }, meta: { headerClassName: "w-7", }, }, { accessorKey: "action", header: "Action", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("action"); const { title } = config[value as keyof typeof config]; if (!title) return null; return <div>{title}</div>; }, }, { accessorKey: "metadata", header: "Information", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("metadata"); if (!value) return null; return ( <div className="flex flex-wrap gap-2"> {Object.entries(value) .filter(([key, value]) => metadata[key as keyof typeof metadata]?.visible(value), ) .map(([key, value]) => { return ( <Pill key={key} label={metadata[key as keyof typeof metadata].key} value={metadata[key as keyof typeof metadata]?.format( value, )} /> ); })} </div> ); }, }, { accessorKey: "timestamp", header: "Timestamp", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("timestamp"); if (value instanceof Date) { return ( <HoverCardTimestamp date={value}> <TableCellDate value={value} className="font-mono" /> </HoverCardTimestamp> ); } const date = new Date(Number(value)); if (Number.isNaN(date.getTime())) { return <div className="font-mono">{String(value)}</div>; } return ( <HoverCardTimestamp date={date}> <TableCellDate value={date} className="font-mono" /> </HoverCardTimestamp> ); }, }, ]; } function Pill({ label, value }: { label: string; value?: string }) { if (!value) return null; return ( <div className="inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3"> <div className="border-r bg-muted py-0.5 pr-1 pl-2 text-foreground/70"> {label} </div> <div className="py-0.5 pr-2 pl-1 font-mono"> {/* NOTE: if we have more number values, we might wanna change it */} {value} </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/audit-logs/wrapper.tsx ================================================ "use client"; import { BlockWrapper } from "@/components/content/block-wrapper"; import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { getColumns } from "./columns"; export function AuditLogsWrapper({ monitorId, interval, }: { monitorId: string; interval: number; }) { const trpc = useTRPC(); const { data: auditLogs, isLoading } = useQuery( trpc.tinybird.auditLog.queryOptions({ monitorId, interval }), ); const { data: privateLocations } = useQuery( trpc.privateLocation.list.queryOptions(), ); const columns = useMemo( () => getColumns(privateLocations), [privateLocations], ); if (isLoading) { return ( <div className="flex h-full max-h-48 min-h-48 flex-col"> <Skeleton className="h-full w-full flex-1" /> </div> ); } if (!auditLogs?.data || auditLogs.data.length === 0) { return ( <EmptyStateContainer> <EmptyStateTitle>No audit logs</EmptyStateTitle> <EmptyStateDescription> No audit logs found for this monitor. </EmptyStateDescription> </EmptyStateContainer> ); } return ( <BlockWrapper> <DataTable columns={columns} data={auditLogs.data} paginationComponent={DataTablePaginationSimple} /> </BlockWrapper> ); } export default AuditLogsWrapper; ================================================ FILE: apps/dashboard/src/components/data-table/billing/data-table.tsx ================================================ "use client"; import { Check } from "lucide-react"; import { Fragment, useTransition } from "react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { config as featureGroups, plans } from "@/data/plans"; import { getStripe } from "@/lib/stripe"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import type { WorkspacePlan } from "@openstatus/db/src/schema"; import { getAddonPriceConfig, getPriceConfig, } from "@openstatus/db/src/schema/plan/utils"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; const BASE_URL = process.env.NODE_ENV === "production" ? "https://app.openstatus.dev" : "http://localhost:3000"; export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const router = useRouter(); const [isPending, startTransition] = useTransition(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ onSuccess: async (data) => { if (!data) return; const stripe = await getStripe(); stripe?.redirectToCheckout({ sessionId: data.id }); }, }), ); const customerPortalMutation = useMutation( trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ onSuccess: (url) => { if (!url) return; router.push(url); }, }), ); if (!workspace) return null; const filteredPlans = Object.values(plans).filter((plan) => restrictTo ? restrictTo.includes(plan.id) : true, ); return ( <Table className="relative table-fixed"> <TableCaption> A list to compare the different features by plan. </TableCaption> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="p-2 align-bottom"> Features comparison </TableHead> {filteredPlans.map(({ id, ...plan }) => { const isCurrentPlan = workspace.plan === id; const price = getPriceConfig(id, currency); return ( <TableHead key={id} className={cn( "h-auto p-2 align-bottom text-foreground", id === "starter" ? "bg-muted/30" : "", )} > <div className="flex h-full flex-col justify-between gap-1"> <div className="flex flex-1 flex-col gap-1"> <p className="font-cal text-lg">{plan.title}</p> <p className="text-wrap font-normal text-muted-foreground text-xs"> {plan.description} </p> </div> <p className="text-right"> <span className="font-mono text-lg"> {new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} </span> <span className="text-muted-foreground text-sm"> /month </span> </p> <Button size="sm" type="button" variant={id === "starter" ? "default" : "outline"} onClick={() => { startTransition(async () => { if (id === "free") { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, returnUrl: `${BASE_URL}/settings/billing`, }); return; } await checkoutSessionMutation.mutateAsync({ plan: id, // TODO: move to the server as we have the current workspace workspaceSlug: workspace.slug, successUrl: `${BASE_URL}/settings/billing?success=true`, cancelUrl: `${BASE_URL}/settings/billing`, }); }); }} disabled={isPending || isCurrentPlan} > {isCurrentPlan ? "Current Plan" : isPending ? "Choosing..." : "Choose"} </Button> </div> </TableHead> ); })} </TableRow> </TableHeader> <TableBody> {Object.entries(featureGroups).map( ([groupKey, { label, features }]) => ( <Fragment key={groupKey}> <TableRow className="bg-muted/50"> <TableCell colSpan={filteredPlans.length + 1} className="font-medium" > {label} </TableCell> </TableRow> {features.map( ({ value, label: featureLabel, monthly, badge }) => ( <TableRow key={groupKey + value}> <TableCell> <div className="flex items-center gap-2 text-wrap"> {featureLabel}{" "} {badge ? ( <Badge variant="outline">{badge}</Badge> ) : null} </div> </TableCell> {filteredPlans.map((plan) => { const limitValue = plan.limits[value as keyof typeof plan.limits]; const isAddon = value in plan.addons; function renderContent() { if (isAddon) { const price = getAddonPriceConfig( plan.id, value as keyof typeof plan.addons, currency, ); if (!price) return null; const isNumber = typeof limitValue === "number"; return ( <div> <span> {isNumber ? new Intl.NumberFormat("us") .format(limitValue) .toString() : null} </span> <span> <span className="text-muted-foreground"> {isNumber ? " + " : ""} </span> <span> {new Intl.NumberFormat(price.locale, { style: "currency", currency: price.currency, }).format(price.value)} {isNumber ? "/mo./each" : "/mo."} </span> </span> </div> ); } if (typeof limitValue === "boolean") { return limitValue ? ( <Check className="h-4 w-4 text-foreground" /> ) : ( <span className="text-muted-foreground/50"> ‐ </span> ); } if (typeof limitValue === "number") { return new Intl.NumberFormat("us") .format(limitValue) .toString(); } // TODO: create a format function for this in @data/plans if (value === "regions" && Array.isArray(limitValue)) { return limitValue?.length ?? 0; } if ( Array.isArray(limitValue) && limitValue.length > 0 ) { return limitValue[0]; } return limitValue; } return ( <TableCell key={plan.id + value} className={cn( "font-mono", plan.id === "starter" && "bg-muted/30", )} > {renderContent()} {monthly ? "/mo." : ""} </TableCell> ); })} </TableRow> ), )} </Fragment> ), )} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/data-table/dable-cell-skeleton.tsx ================================================ import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; export function TableCellSkeleton({ className, ...props }: React.ComponentProps<typeof Skeleton>) { return <Skeleton className={cn("h-5 w-12", className)} {...props} />; } ================================================ FILE: apps/dashboard/src/components/data-table/data-table-sheet.tsx ================================================ "use client"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@openstatus/ui/components/ui/sheet"; import { cn } from "@openstatus/ui/lib/utils"; // TODO: rename to DataTableViewer? export function DataTableSheetContent({ children, className, ...props }: React.ComponentProps<typeof SheetContent>) { return ( <SheetContent className={cn("max-h-screen gap-0", className)} {...props}> {children} </SheetContent> ); } export function DataTableSheetHeader({ children, className, ...props }: React.ComponentProps<typeof SheetHeader>) { return ( <SheetHeader className={cn("sticky top-0 border-b bg-background", className)} {...props} > {children} </SheetHeader> ); } export function DataTableSheetFooter({ children, className, ...props }: React.ComponentProps<typeof SheetFooter>) { return ( <SheetFooter className={cn("sticky bottom-0 border-t bg-background", className)} {...props} > {children} </SheetFooter> ); } export function DataTableSheetFooterInfo({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-muted-foreground/70 text-xs", className)} {...props}> {children} </p> ); } export { SheetTitle as DataTableSheetTitle, SheetDescription as DataTableSheetDescription, SheetTrigger as DataTableSheetTrigger, Sheet as DataTableSheet, }; ================================================ FILE: apps/dashboard/src/components/data-table/incidents/columns.tsx ================================================ "use client"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import { formatDistanceStrict } from "date-fns"; import { DataTableRowActions } from "./data-table-row-actions"; type Incident = RouterOutputs["incident"]["list"][number]; export const columns: ColumnDef<Incident>[] = [ { id: "monitor", accessorFn: (row) => row.monitor.name, header: "Monitor", enableSorting: false, enableHiding: false, cell: ({ row }) => { return ( <TableCellLink value={row.getValue("monitor")} href={`/monitors/${row.original.monitor.id}/overview`} /> ); }, meta: { cellClassName: "max-w-[150px] min-w-max", }, }, { accessorKey: "startedAt", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Started At" /> ), cell: ({ row }) => <TableCellDate value={row.getValue("startedAt")} />, enableHiding: false, }, { accessorKey: "acknowledgedAt", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Acknowledged" /> ), cell: ({ row }) => <TableCellDate value={row.getValue("acknowledgedAt")} />, enableHiding: false, }, { accessorKey: "resolvedAt", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Resolved At" /> ), cell: ({ row }) => <TableCellDate value={row.getValue("resolvedAt")} />, enableHiding: false, }, { id: "duration", accessorFn: (row) => row.resolvedAt ? formatDistanceStrict(row.startedAt, row.resolvedAt) : "ongoing", header: "Duration", cell: ({ row }) => { const value = row.getValue("duration"); if (typeof value === "string") { const [amount, unit] = value.split(" "); return <TableCellNumber value={amount} unit={unit} />; } return <TableCellNumber value={value} />; }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx ================================================ "use client"; import type { Row } from "@tanstack/react-table"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/incidents.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@openstatus/ui/components/ui/alert-dialog"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; type Incident = RouterOutputs["incident"]["list"][number]; interface DataTableRowActionsProps { row: Row<Incident>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const queryClient = useQueryClient(); const acknowledgeIncidentMutation = useMutation( trpc.incident.acknowledge.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.incident.list.queryKey({ monitorId: row.original.monitorId, }), }); }, }), ); const resolveIncidentMutation = useMutation( trpc.incident.resolve.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.incident.list.queryKey({ monitorId: row.original.monitorId, }), }); }, }), ); const deleteIncidentMutation = useMutation( trpc.incident.delete.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.incident.list.queryKey({ monitorId: row.original.monitorId, }), }); }, }), ); const [type, setType] = useState<"acknowledge" | "resolve" | null>(null); const open = useMemo(() => type !== null, [type]); const actions = getActions({ acknowledge: row.original.acknowledgedAt ? undefined : () => setType("acknowledge"), resolve: row.original.resolvedAt ? undefined : () => setType("resolve"), }); const handleConfirm = async () => { try { startTransition(async () => { const promise = type === "acknowledge" ? acknowledgeIncidentMutation.mutateAsync({ id: row.original.id, }) : resolveIncidentMutation.mutateAsync({ id: row.original.id, }); toast.promise(promise, { loading: "Confirming...", success: "Confirmed", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to confirm"; }, }); await promise; setType(null); }); } catch (error) { console.error("Failed to confirm:", error); } }; return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.title || "incident", submitAction: async () => { await deleteIncidentMutation.mutateAsync({ id: row.original.id, }); }, }} /> <AlertDialog open={open} onOpenChange={() => setType(null)}> <AlertDialogContent onCloseAutoFocus={(event) => { // NOTE: bug where the body is not clickable after closing the alert dialog event.preventDefault(); document.body.style.pointerEvents = ""; }} > <AlertDialogHeader> <AlertDialogTitle>Confirm your action</AlertDialogTitle> <AlertDialogDescription> You are about to <span className="font-semibold">{type}</span>{" "} this incident. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(e) => { e.preventDefault(); handleConfirm(); }} disabled={isPending} > {isPending ? "Confirming..." : "Confirm"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/maintenances/columns.tsx ================================================ "use client"; import { ProcessMessage } from "@/components/content/process-message"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import { formatDistanceStrict } from "date-fns"; import { DataTableRowActions } from "./data-table-row-actions"; type Maintenance = RouterOutputs["maintenance"]["list"][number]; export const columns: ColumnDef<Maintenance>[] = [ { accessorKey: "title", header: "Title", enableSorting: false, enableHiding: false, meta: { cellClassName: "max-w-[200px] truncate", }, }, { accessorKey: "message", header: "Message", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = String(row.getValue("message")); return ( <div className="prose dark:prose-invert prose-sm line-clamp-3 max-w-[200px] truncate text-muted-foreground"> <ProcessMessage value={value} /> </div> ); }, }, { accessorKey: "from", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Start Date" /> ), cell: ({ row }) => <TableCellDate value={row.getValue("from")} />, enableHiding: false, }, { id: "duration", accessorFn: (row) => formatDistanceStrict(row.from, row.to), header: "Duration", cell: ({ row }) => { const value = row.getValue("duration"); if (typeof value === "string") { const [amount, unit] = value.split(" "); return <TableCellNumber value={amount} unit={unit} />; } return <TableCellNumber value={value} />; }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { getActions } from "@/data/maintenances.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRef } from "react"; type Maintenance = RouterOutputs["maintenance"]["list"][number]; interface DataTableRowActionsProps { row: Row<Maintenance>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const trpc = useTRPC(); const buttonRef = useRef<HTMLButtonElement>(null); const actions = getActions({ edit: () => buttonRef.current?.click(), }); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: row.original.pageId ?? 0 }), ); const queryClient = useQueryClient(); const updateMaintenanceMutation = useMutation( trpc.maintenance.update.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.maintenance.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); queryClient.invalidateQueries({ queryKey: trpc.maintenance.list.queryKey({ period: "7d", }), }); }, }), ); const deleteMaintenanceMutation = useMutation( trpc.maintenance.delete.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.maintenance.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); queryClient.invalidateQueries({ queryKey: trpc.maintenance.list.queryKey({ period: "7d", }), }); }, }), ); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.title ?? "maintenance", submitAction: async () => { await deleteMaintenanceMutation.mutateAsync({ id: row.original.id, }); }, }} /> <FormSheetMaintenance pageComponents={statusPage?.pageComponents ?? []} defaultValues={{ title: row.original.title, message: row.original.message, startDate: row.original.from, endDate: row.original.to, pageComponents: row.original.pageComponents?.map((c) => c.id) ?? [], }} onSubmit={async (values) => { await updateMaintenanceMutation.mutateAsync({ id: row.original.id, title: values.title, message: values.message, startDate: values.startDate, endDate: values.endDate, pageComponents: values.pageComponents, }); }} > <button ref={buttonRef} type="button" className="sr-only"> Open sheet </button> </FormSheetMaintenance> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/monitors/columns.tsx ================================================ "use client"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import type { ColumnDef } from "@tanstack/react-table"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RouterOutputs } from "@openstatus/api"; import { formatDistanceToNow } from "date-fns"; import { TableCellSkeleton } from "../dable-cell-skeleton"; import { TableCellDate } from "../table-cell-date"; import { TableCellNumber } from "../table-cell-number"; import { TableCellUnavailable } from "../table-cell-unavailable"; type Monitor = RouterOutputs["monitor"]["list"][number] & { globalMetrics?: | RouterOutputs["tinybird"]["globalMetrics"]["data"][number] // NOTE: after loading the data, if the monitor has no metrics, the value will be `false` | false; }; export const columns: ColumnDef<Monitor>[] = [ { id: "select", header: ({ table }) => ( <Checkbox checked={ table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate") } onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }, { accessorKey: "name", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Name" /> ), cell: ({ row }) => { return ( <TableCellLink value={row.getValue("name")} href={`/monitors/${row.original.id}/overview`} /> ); }, enableHiding: false, meta: { cellClassName: "max-w-[150px] min-w-max", }, }, { accessorKey: "url", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Endpoint" /> ), cell: ({ row }) => { return ( <TableCellLink value={row.getValue("url")} href={row.original.url} /> ); }, enableHiding: true, meta: { cellClassName: "max-w-[150px] min-w-max", }, }, { accessorKey: "jobType", header: "Type", enableHiding: true, }, { id: "status", accessorFn: (row) => { console.log(row); return row.active ? row.status : "inactive"; }, header: "Status", cell: ({ row }) => { const value = String(row.getValue("status")); switch (value) { case "active": return <div className="font-mono text-success">{value}</div>; case "degraded": return <div className="font-mono text-warning">{value}</div>; case "error": return <div className="font-mono text-destructive">{value}</div>; default: return <div className="font-mono text-muted-foreground">{value}</div>; } }, filterFn: (row, _, value) => { if (Array.isArray(value)) { if (value.includes("inactive")) { return !row.original.active; } if (value.includes("active")) { return !!row.original.active && row.original.status === "active"; } return value.includes(row.original.status); } return row.original.status === value; }, enableSorting: false, enableHiding: false, enableGlobalFilter: false, }, { accessorKey: "active", enableHiding: true, enableGlobalFilter: false, }, { accessorKey: "tags", header: "Tags", cell: ({ row }) => { const value = row.getValue("tags"); if (!Array.isArray(value)) return null; if (value.length === 0) { return <div className="text-muted-foreground">-</div>; } return ( <div className="group/badges -space-x-2 flex flex-wrap"> {value.map((tag) => ( <Badge key={tag.id} variant="outline" className="relative flex translate-x-0 items-center gap-1.5 rounded-full bg-background transition-transform hover:z-10 hover:translate-x-1" > <div className="size-2.5 rounded-full" style={{ backgroundColor: tag.color }} /> <span>{tag.name}</span> </Badge> ))} </div> ); }, filterFn: (row, _, value) => { const tagIds = row.original.tags.map((tag) => tag.id); if (Array.isArray(value)) { return value.some((v) => tagIds.includes(v)); } return tagIds.includes(value); }, getUniqueValues: (row) => row.tags.map((tag) => tag.id), enableSorting: false, enableHiding: false, enableGlobalFilter: false, }, { id: "lastIncident", header: "Last Incident", accessorFn: (row) => row.incidents?.[0]?.createdAt, cell: ({ row }) => { const value = row.getValue("lastIncident"); return <TableCellDate value={value} formatStr="LLL dd, y" />; }, enableHiding: false, enableGlobalFilter: false, }, // { // id: "uptime", // accessorFn: (row) => `uptime-${row.id}`, // header: "Last Week", // cell: ({ row }) => { // return ( // <ChartBarUptimeLight // monitorId={String(row.original.id)} // type={row.original.jobType as "http" | "tcp"} // /> // ); // }, // enableHiding: false, // enableGlobalFilter: false, // }, { id: "lastTimestamp", header: "Last Checked", accessorFn: (row) => typeof row.globalMetrics === "object" ? row.globalMetrics.lastTimestamp : row.globalMetrics, cell: ({ row }) => { const value = row.getValue("lastTimestamp"); if (value === undefined) return <TableCellSkeleton className="w-full" />; return ( <TableCellDate value={ typeof value === "number" ? formatDistanceToNow(new Date(value), { addSuffix: true }) : value } /> ); }, enableHiding: false, enableGlobalFilter: false, }, { id: "p50", accessorFn: (row) => typeof row.globalMetrics === "object" ? row.globalMetrics.p50Latency : row.globalMetrics, header: ({ column }) => ( <DataTableColumnHeader column={column} title="P50" /> ), cell: ({ row }) => { const value = row.getValue("p50"); if (value === undefined) return <TableCellSkeleton />; if (!value) return <TableCellUnavailable />; return <TableCellNumber value={value} unit="ms" />; }, enableHiding: false, }, { id: "p90", accessorFn: (row) => typeof row.globalMetrics === "object" ? row.globalMetrics.p90Latency : row.globalMetrics, header: ({ column }) => ( <DataTableColumnHeader column={column} title="P90" /> ), cell: ({ row }) => { const value = row.getValue("p90"); if (value === undefined) return <TableCellSkeleton />; if (!value) return <TableCellUnavailable />; return <TableCellNumber value={value} unit="ms" />; }, enableHiding: false, enableGlobalFilter: false, }, { id: "p95", accessorFn: (row) => typeof row.globalMetrics === "object" ? row.globalMetrics.p95Latency : row.globalMetrics, header: ({ column }) => ( <DataTableColumnHeader column={column} title="P95" /> ), cell: ({ row }) => { const value = row.getValue("p95"); if (value === undefined) return <TableCellSkeleton />; if (!value) return <TableCellUnavailable />; return <TableCellNumber value={value} unit="ms" />; }, enableHiding: false, enableGlobalFilter: false, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx ================================================ "use client"; import { SelectTrigger } from "@radix-ui/react-select"; import type { Table } from "@tanstack/react-table"; import { Check, CheckCircle2, Copy, Trash2 } from "lucide-react"; import * as React from "react"; import { DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection, } from "@/components/ui/data-table/data-table-action-bar"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { Select, SelectContent, SelectGroup, SelectItem, } from "@openstatus/ui/components/ui/select"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; type Monitor = RouterOutputs["monitor"]["list"][number]; const ACTIVE = [ { label: "active", value: true }, { label: "inactive", value: false }, ]; interface MonitorDataTableActionBarProps { table: Table<Monitor>; } export function MonitorDataTableActionBar({ table, }: MonitorDataTableActionBarProps) { const [open, setOpen] = React.useState(false); const [isPending, startTransition] = React.useTransition(); const [value, setValue] = React.useState(""); const { copy, isCopied } = useCopyToClipboard(); const rows = table.getFilteredSelectedRowModel().rows; const trpc = useTRPC(); const queryClient = useQueryClient(); const deleteMonitorsMutation = useMutation( trpc.monitor.deleteMonitors.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); // Clear selection once deletion succeeds table.toggleAllRowsSelected(false); }, }), ); const updateMonitorsMutation = useMutation( trpc.monitor.updateMonitors.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); }, }), ); const confirmationValue = React.useMemo( () => rows.map((row) => row.original.name).join(", "), [rows], ); const handleDelete = async () => { try { startTransition(async () => { const promise = deleteMonitorsMutation.mutateAsync({ ids: rows.map((row) => row.original.id), }); toast.promise(promise, { loading: "Deleting...", success: "Deleted", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to delete"; }, }); await promise; setOpen(false); }); } catch (error) { console.error("Failed to delete:", error); } }; return ( <DataTableActionBar table={table} visible={rows.length > 0}> <DataTableActionBarSelection table={table} /> <Separator orientation="vertical" className="hidden data-[orientation=vertical]:h-5 sm:block" /> <div className="flex items-center gap-1.5"> <Select onValueChange={(v) => { toast.promise( updateMonitorsMutation.mutateAsync({ ids: rows.map((row) => row.original.id), active: v === "active", }), { loading: "Updating...", success: "Updated", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to update"; }, }, ); }} > <SelectTrigger asChild> <DataTableActionBarAction size="icon" tooltip="Update status"> <CheckCircle2 /> </DataTableActionBarAction> </SelectTrigger> <SelectContent align="center"> <SelectGroup> {ACTIVE.map((status) => ( <SelectItem key={status.label} value={status.label} className="capitalize" > {status.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger asChild> <DataTableActionBarAction size="icon" tooltip="Delete monitors" isPending={isPending || deleteMonitorsMutation.isPending} > <Trash2 /> </DataTableActionBarAction> </AlertDialogTrigger> <AlertDialogContent onCloseAutoFocus={(event) => { // Work-around: body becomes unclickable after closing the dialog event.preventDefault(); document.body.style.pointerEvents = ""; }} > <AlertDialogHeader> <AlertDialogTitle> Delete {rows.length} monitor{rows.length > 1 ? "s" : ""}? </AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. This will permanently remove the selected monitor(s) from the database. </AlertDialogDescription> </AlertDialogHeader> <form id="form-alert-dialog" className="space-y-1.5"> <p className="text-muted-foreground text-sm"> Type{" "} <Button variant="secondary" size="sm" type="button" className="font-normal [&_svg]:size-3" onClick={() => copy(confirmationValue, { withToast: false })} > {confirmationValue} {isCopied ? <Check /> : <Copy />} </Button>{" "} to confirm </p> <Input value={value} onChange={(e) => setValue(e.target.value)} /> </form> <AlertDialogFooter> <AlertDialogCancel onClick={(e) => e.stopPropagation()}> Cancel </AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" disabled={value !== confirmationValue || isPending} form="form-alert-dialog" type="submit" onClick={(e) => { e.preventDefault(); handleDelete(); }} > {isPending ? "Deleting..." : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </DataTableActionBar> ); } ================================================ FILE: apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx ================================================ "use client"; import { ExportCodeDialog } from "@/components/dialogs/export-code"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/monitors.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; type Monitor = RouterOutputs["monitor"]["list"][number]; interface DataTableRowActionsProps { row: Row<Monitor>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const [openDialog, setOpenDialog] = useState(false); const trpc = useTRPC(); const queryClient = useQueryClient(); const deleteMonitorMutation = useMutation( trpc.monitor.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries(trpc.monitor.list.queryOptions()); }, }), ); const router = useRouter(); const actions = getActions({ edit: () => router.push(`/monitors/${row.original.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(row.original.id.toString()); toast.success("Monitor ID copied to clipboard"); }, // export: () => setOpenDialog(true), }); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.name ?? "monitor", submitAction: async () => { await deleteMonitorMutation.mutateAsync({ id: row.original.id, }); }, }} /> <ExportCodeDialog open={openDialog} onOpenChange={setOpenDialog} /> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx ================================================ "use client"; import type { Table } from "@tanstack/react-table"; import { X } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; import type { RouterOutputs } from "@openstatus/api"; type Monitor = RouterOutputs["monitor"]["list"][number]; type MonitorTag = RouterOutputs["monitorTag"]["list"][number]; export interface MonitorDataTableToolbarProps { table: Table<Monitor>; tags: MonitorTag[]; } export function MonitorDataTableToolbar({ table, tags, }: MonitorDataTableToolbarProps) { const isFiltered = table.getState().columnFilters.length > 0; return ( <div className="flex items-center justify-between"> <div className="flex flex-1 flex-wrap items-center space-x-2"> <Input placeholder="Filter by name, url, type..." value={(table.getState().globalFilter as string) ?? ""} onChange={(event) => table.setGlobalFilter(event.target.value)} className="h-8 w-[150px] lg:w-[250px]" /> {table.getColumn("tags") && ( <DataTableFacetedFilter column={table.getColumn("tags")} title="Tags" options={tags.map((tag) => ({ label: tag.name, value: tag.id, }))} /> )} {isFiltered && ( <Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3" > Reset <X /> </Button> )} </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/notifications/columns.tsx ================================================ "use client"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import { type NotifierProvider, config } from "@/data/notifications.client"; import type { RouterOutputs } from "@openstatus/api"; import { Badge } from "@openstatus/ui/components/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; import { TableCellBadge } from "../table-cell-badge"; import { DataTableRowActions } from "./data-table-row-actions"; type Notifier = RouterOutputs["notification"]["list"][number]; export const columns: ColumnDef<Notifier>[] = [ { accessorKey: "name", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Name" /> ), enableHiding: false, }, { accessorKey: "provider", header: "Provider", enableSorting: false, enableHiding: false, cell: ({ row }) => { const provider = row.getValue("provider") as NotifierProvider; const Icon = config[provider].icon; return ( <Badge variant="secondary" className="px-1.5 font-mono text-[10px]"> <Icon className="size-2.5" /> {config[provider].label} </Badge> ); }, }, { accessorKey: "monitors", header: "Monitors", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("monitors"); if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { return ( <div className="flex flex-wrap gap-1"> {value.map((m) => ( <Link href={`/monitors/${m.id}`} key={m.id}> <TableCellBadge value={m.name} /> </Link> ))} </div> ); } return <span className="text-muted-foreground">-</span>; }, meta: { cellClassName: "tabular-nums font-mono", }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/notifications/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetNotifier } from "@/components/forms/notifications/sheet"; import { getActions } from "@/data/notifications.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRef } from "react"; type Notifier = RouterOutputs["notification"]["list"][number]; interface DataTableRowActionsProps { row: Row<Notifier>; } export function DataTableRowActions(props: DataTableRowActionsProps) { const buttonRef = useRef<HTMLButtonElement>(null); const actions = getActions({ edit: () => buttonRef.current?.click(), }); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const updateNotifierMutation = useMutation( trpc.notification.updateNotifier.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.notification.list.queryKey(), }); }, }), ); const deleteNotifierMutation = useMutation( trpc.notification.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.notification.list.queryKey(), }); }, }), ); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: props.row.original.name ?? "notifier", submitAction: async () => { await deleteNotifierMutation.mutateAsync({ id: props.row.original.id, }); }, }} /> <FormSheetNotifier provider={props.row.original.provider} defaultValues={{ name: props.row.original.name, provider: props.row.original.provider, // TBD: parse it? data: JSON.parse(props.row.original.data ?? "{}"), monitors: props.row.original.monitors.map((m) => m.id), }} monitors={monitors ?? []} onSubmit={async (values) => { await updateNotifierMutation.mutateAsync({ id: props.row.original.id, name: values.name, data: { [values.provider]: values.data }, monitors: values.monitors, }); }} > <button ref={buttonRef} type="button" className="sr-only"> Open sheet </button> </FormSheetNotifier> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/page-components/columns.tsx ================================================ "use client"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import { DataTableRowActions } from "./data-table-row-actions"; type PageComponent = RouterOutputs["pageComponent"]["list"][number]; export const columns: ColumnDef<PageComponent>[] = [ { accessorKey: "name", header: "Name", enableSorting: false, enableHiding: false, }, { accessorKey: "description", header: "Description", enableSorting: false, cell: ({ row }) => { const value = row.getValue("description"); return ( <span className="max-w-[200px] truncate text-muted-foreground"> {value ? String(value) : "-"} </span> ); }, }, { accessorKey: "type", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Type" /> ), cell: ({ row }) => { const value = row.getValue("type"); return <span className="capitalize">{String(value)}</span>; }, }, { accessorKey: "order", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Order" /> ), cell: ({ row }) => { const value = row.getValue("order"); return <span>{value != null ? String(value) : "-"}</span>; }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/page-components/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/page-components.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; type PageComponent = RouterOutputs["pageComponent"]["list"][number]; interface DataTableRowActionsProps { row: Row<PageComponent>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const trpc = useTRPC(); const actions = getActions({}); const queryClient = useQueryClient(); const deletePageComponentMutation = useMutation( trpc.pageComponent.delete.mutationOptions({ onSuccess: () => { queryClient.refetchQueries({ queryKey: trpc.pageComponent.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); }, }), ); return ( <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.name ?? "component", submitAction: async () => { await deletePageComponentMutation.mutateAsync({ id: row.original.id, }); }, }} /> ); } ================================================ FILE: apps/dashboard/src/components/data-table/private-locations/columns.tsx ================================================ "use client"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; import { TableCellBadge } from "../table-cell-badge"; import { TableCellDate } from "../table-cell-date"; import { DataTableRowActions } from "./data-table-row-actions"; type PrivateLocation = RouterOutputs["privateLocation"]["list"][number]; export const columns: ColumnDef<PrivateLocation>[] = [ { accessorKey: "name", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Name" /> ), enableHiding: false, }, { accessorKey: "lastSeenAt", header: ({ column }) => ( <DataTableColumnHeader column={column} title="Last Seen At" /> ), enableHiding: false, cell: ({ row }) => { const value = row.getValue("lastSeenAt"); return <TableCellDate value={value} />; }, }, { accessorKey: "monitors", header: "Monitors", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("monitors"); if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { return ( <div className="flex flex-wrap gap-1"> {value.map((m) => ( <Link href={`/monitors/${m.id}`} key={m.id}> <TableCellBadge value={m.name} /> </Link> ))} </div> ); } return <span className="text-muted-foreground">-</span>; }, meta: { cellClassName: "tabular-nums font-mono", }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/private-locations/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetPrivateLocation } from "@/components/forms/private-location/sheet"; import { getActions } from "@/data/notifications.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRef } from "react"; type PrivateLocation = RouterOutputs["privateLocation"]["list"][number]; interface DataTableRowActionsProps { row: Row<PrivateLocation>; } export function DataTableRowActions(props: DataTableRowActionsProps) { const buttonRef = useRef<HTMLButtonElement>(null); const actions = getActions({ edit: () => buttonRef.current?.click(), }); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const updatePrivateLocationMutation = useMutation( trpc.privateLocation.update.mutationOptions({ onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: trpc.privateLocation.list.queryKey(), }); }, }), ); const deletePrivateLocationMutation = useMutation( trpc.privateLocation.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.privateLocation.list.queryKey(), }); }, }), ); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: props.row.original.name ?? "private location", submitAction: async () => { await deletePrivateLocationMutation.mutateAsync({ id: props.row.original.id, }); }, }} /> <FormSheetPrivateLocation defaultValues={{ name: props.row.original.name, token: props.row.original.token.toString(), monitors: props.row.original.monitors.map((m) => m.id), }} monitors={monitors ?? []} onSubmit={async (values) => { await updatePrivateLocationMutation.mutateAsync({ id: props.row.original.id, name: values.name, monitors: values.monitors, }); }} > <button ref={buttonRef} type="button" className="sr-only"> Open sheet </button> </FormSheetPrivateLocation> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/columns.tsx ================================================ "use client"; import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { getStatusCodeVariant, textColors } from "@/data/status-codes"; import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { getRegionInfo } from "@openstatus/regions"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@openstatus/ui/components/ui/hover-card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import type { ColumnDef } from "@tanstack/react-table"; import { Clock, Workflow } from "lucide-react"; type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; // export const columns: ColumnDef<ResponseLog>[] = export function getColumns( privateLocations: PrivateLocation[], ): ColumnDef<ResponseLog>[] { return [ { accessorKey: "requestStatus", header: () => null, enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("requestStatus"); if (value === "error") { return <div className="h-2.5 w-2.5 rounded-[2px] bg-destructive" />; } if (value === "degraded") { return <div className="h-2.5 w-2.5 rounded-[2px] bg-warning" />; } if (value === "success") { return <div className="h-2.5 w-2.5 rounded-[2px] bg-success" />; } return <div className="text-muted-foreground">-</div>; }, }, { accessorKey: "timestamp", header: "Timestamp", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = new Date(row.getValue("timestamp")); return ( <HoverCardTimestamp date={value}> <TableCellDate value={value} className="font-mono text-foreground" /> </HoverCardTimestamp> ); }, }, { accessorKey: "statusCode", header: "Status", enableSorting: false, enableHiding: false, cell: ({ row }) => { const log = row.original; if (log.type === "http") { const value = log.statusCode; const variant = getStatusCodeVariant(value); return ( <TableCellNumber value={value} className={textColors[variant]} /> ); } return <div className="text-muted-foreground">-</div>; }, }, { accessorKey: "latency", header: "Latency", enableSorting: false, enableHiding: false, cell: ({ row }) => { return <TableCellNumber value={row.getValue("latency")} unit="ms" />; }, }, { accessorKey: "region", header: "Region", cell: ({ row }) => { const value = row.getValue("region"); if (typeof value !== "string") { return <div className="text-muted-foreground">-</div>; } const regionConfig = getRegionInfo(value, { location: privateLocations.find( (location) => String(location.id) === String(value), )?.name, }); return ( <div> {regionConfig.location}{" "} <span className="text-muted-foreground/70 text-xs"> ({regionConfig.provider}) </span> </div> ); }, enableSorting: false, enableHiding: false, filterFn: "arrIncludesSome", meta: { cellClassName: "text-muted-foreground font-mono", }, }, { accessorKey: "timing", header: "Timing", cell: ({ row }) => { const log = row.original; if (log.type === "http" && log.timing) { return <HoverCardTiming timing={log.timing} latency={log.latency} />; } return <div className="text-muted-foreground">-</div>; }, enableSorting: false, enableHiding: false, }, { accessorKey: "trigger", header: "Trigger", cell: ({ row }) => { const value = row.getValue("trigger"); if (value === "cron" || value === "api") { const Icon = value === "cron" ? Clock : Workflow; const label = value === "cron" ? "Scheduled" : "API"; return ( <TooltipProvider> <Tooltip> <TooltipTrigger> <Icon className="size-3 text-muted-foreground" /> </TooltipTrigger> <TooltipContent side="right"> <p>{label}</p> </TooltipContent> </Tooltip> </TooltipProvider> ); } return <div className="text-muted-foreground">-</div>; }, enableSorting: false, enableHiding: false, meta: { cellClassName: "font-mono", headerClassName: "sr-only", }, }, ]; } function HoverCardTiming({ timing, latency, }: { timing: NonNullable<Extract<ResponseLog, { type: "http" }>["timing"]>; latency: number; }) { return ( <HoverCard openDelay={50} closeDelay={50}> <HoverCardTrigger className="opacity-70 hover:opacity-100 data-[state=open]:opacity-100" asChild > <div className="flex"> {Object.entries(timing).map(([key, value], index) => ( <div key={key} className={cn("h-4")} style={{ width: `${(value / latency) * 100}%`, backgroundColor: `var(--chart-${index + 1})`, }} /> ))} </div> </HoverCardTrigger> <HoverCardContent side="bottom" align="end" className="z-10 w-auto p-2"> <HoverCardTimingContent {...{ latency, timing }} /> </HoverCardContent> </HoverCard> ); } function HoverCardTimingContent({ timing, latency, }: { timing: NonNullable<Extract<ResponseLog, { type: "http" }>["timing"]>; latency: number; }) { return ( <div className="flex flex-col gap-1"> {Object.entries(timing).map(([key, value], index) => { return ( <div key={key} className="grid grid-cols-2 gap-4 text-xs"> <div className="flex items-center gap-2"> <div className={cn("h-2 w-2 rounded-full")} style={{ backgroundColor: `var(--chart-${index + 1})` }} /> <div className="font-mono text-accent-foreground uppercase"> {key} </div> </div> <div className="flex items-center justify-between gap-4"> <div className="font-mono text-muted-foreground"> {`${new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, }).format((value / latency) * 100)}%`} </div> <div className="font-mono"> {new Intl.NumberFormat("en-US", { maximumFractionDigits: 3, }).format(value)} <span className="text-muted-foreground">ms</span> </div> </div> </div> ); })} </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx ================================================ "use client"; import { IconCloudProvider } from "@/components/common/icon-cloud-provider"; import { BlockWrapper } from "@/components/content/block-wrapper"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { getStatusCodeVariant, textColors } from "@/data/status-codes"; import { formatMilliseconds, formatPercentage } from "@/lib/formatter"; import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { getRegionInfo } from "@openstatus/regions"; import { Table, TableBody, TableCell, TableHead, TableRow, } from "@openstatus/ui/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; import { Braces, TableProperties } from "lucide-react"; type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; export function DataTableBasics({ data, privateLocations, }: { data: ResponseLog; privateLocations?: PrivateLocation[]; }) { if (data.type === "http") { return ( <DataTableBasicsHTTP data={data} privateLocations={privateLocations} /> ); } if (data.type === "tcp") { return ( <DataTableBasicsTCP data={data} privateLocations={privateLocations} /> ); } if (data.type === "dns") { return ( <DataTableBasicsDNS data={data} privateLocations={privateLocations} /> ); } return null; } export function DataTableBasicsHTTP({ data, privateLocations, }: { data: Extract<ResponseLog, { type: "http" }> & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; }) { const privateLocataion = privateLocations?.find( (location) => String(location.id) === String(data.region), ); const regionConfig = getRegionInfo(data.region, { location: privateLocataion?.name, }); return ( <Table className="table-fixed"> <colgroup> <col className="w-1/3" /> <col className="w-2/3" /> </colgroup> <TableBody> <TableRow> <TableHead colSpan={2}>Request</TableHead> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Result </TableHead> {/* TODO: add colored square like list (see columns) */} <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <div className="flex items-center gap-2"> <div className={cn("h-2.5 w-2.5 rounded-[2px] bg-muted", { "bg-destructive": data?.requestStatus === "error", "bg-warning": data?.requestStatus === "degraded", "bg-success": data?.requestStatus === "success", })} /> <div className="capitalize"> {data?.requestStatus ?? "unknown"} </div> </div> </TableCell> </TableRow> {data.id ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> ID </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.id} </TableCell> </TableRow> ) : null} <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Timestamp </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellDate value={new Date(data.cronTimestamp)} className="text-foreground" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> URL </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.url} </TableCell> </TableRow> {/* TODO: store method in TB 🤦 */} {/* <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Method </TableHead> <TableCell className="whitespace-normal font-mono"> {data?.method} </TableCell> </TableRow> */} <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Status </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellNumber value={data.statusCode} className={textColors[getStatusCodeVariant(data.statusCode)]} /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Latency </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellNumber value={data?.latency} unit="ms" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Region </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {regionConfig?.code}{" "} <span className="text-muted-foreground text-xs"> {regionConfig?.location} {regionConfig?.flag} </span> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Cloud Provider </TableHead> <TableCell className="inline-flex max-w-full overflow-x-auto whitespace-normal font-mono"> <IconCloudProvider provider={regionConfig?.provider} className="mt-0.5" /> <span className="ml-1 text-muted-foreground"> {regionConfig?.provider} </span> </TableCell> </TableRow> {data.trigger ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Trigger </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data?.trigger} </TableCell> </TableRow> ) : null} {data.headers ? ( <> <TableRow> <TableHead colSpan={2}>Headers</TableHead> </TableRow> <TableRow className="hover:bg-transparent"> <TableCell colSpan={2} className="p-0"> <Tabs defaultValue="table" className="w-full gap-0"> <TabsList className="w-full justify-start rounded-none border-b px-2"> <TabsTrigger value="table"> <TableProperties className="size-3 rotate-180" /> </TabsTrigger> <TabsTrigger value="raw"> <Braces className="size-3" /> </TabsTrigger> </TabsList> <TabsContent value="table"> <Table className="table-fixed"> <colgroup> <col className="w-1/3" /> <col className="w-2/3" /> </colgroup> <TableBody> {Object.entries(data?.headers ?? {}).map( ([key, value]) => ( <TableRow key={key} className="[&>:not(:last-child)]:border-r" > <TableHead className="overflow-x-auto bg-muted/50 font-normal text-muted-foreground"> {key} </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {value} </TableCell> </TableRow> ), )} </TableBody> </Table> </TabsContent> <TabsContent value="raw"> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-4 font-mono text-sm"> {JSON.stringify(data?.headers, null, 2)} </pre> </TabsContent> </Tabs> </TableCell> </TableRow> </> ) : null} {data.timing ? ( <> <TableRow> <TableHead colSpan={2}>Timing</TableHead> </TableRow> {Object.entries(data?.timing ?? {}).map(([key, value], index) => ( <TableRow key={key} className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> <span className="uppercase">{key}</span> </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <div className="flex items-center justify-between gap-2"> <div className="flex-1"> <span className="text-muted-foreground"> {formatPercentage(value / (data?.latency || 100))} </span> </div> <div className="flex w-full flex-1 items-center justify-end gap-2"> <span className="text-nowrap text-muted-foreground"> {formatMilliseconds(value)} </span> <div className="h-4" style={{ width: `${(value / (data?.latency || 100)) * 100}%`, backgroundColor: `var(--chart-${index + 1})`, }} /> </div> </div> </TableCell> </TableRow> ))} </> ) : null} {data?.message ? ( <> <TableRow> <TableHead colSpan={2}>Message</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {data.message} </pre> </TableCell> </TableRow> </> ) : null} {data.body ? ( <> <TableRow> <TableHead colSpan={2}>Body</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> <BlockWrapper autoOpen> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {data.body} </pre> </BlockWrapper> </TableCell> </TableRow> </> ) : null} {data.assertions ? ( <> <TableRow> <TableHead colSpan={2}>Assertions</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> {!data.assertions || data.assertions === "[]" ? ( <div className="p-2 font-mono text-muted-foreground text-sm"> Default status code 2xx assertion </div> ) : ( <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {JSON.stringify(data.assertions, null, 2)} </pre> )} </TableCell> </TableRow> </> ) : null} </TableBody> </Table> ); } export function DataTableBasicsTCP({ data, privateLocations, }: { data: Extract<ResponseLog, { type: "tcp" }> & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; }) { const privateLocataion = privateLocations?.find( (location) => String(location.id) === String(data.region), ); const regionConfig = getRegionInfo(data.region, { location: privateLocataion?.name, }); return ( <Table className="table-fixed"> <colgroup> <col className="w-1/3" /> <col className="w-2/3" /> </colgroup> <TableBody> <TableRow> <TableHead colSpan={2}>Request</TableHead> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Result </TableHead> {/* TODO: add colored square like list (see columns) */} <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <div className="flex items-center gap-2"> <div className={cn("h-2.5 w-2.5 rounded-[2px] bg-muted", { "bg-destructive": data?.requestStatus === "error", "bg-warning": data?.requestStatus === "degraded", "bg-success": data?.requestStatus === "success", })} /> <div className="capitalize"> {data?.requestStatus ?? "unknown"} </div> </div> </TableCell> </TableRow> {data.id ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> ID </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.id} </TableCell> </TableRow> ) : null} <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Timestamp </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellDate value={new Date(data.cronTimestamp)} className="text-foreground" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> URI </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.uri} </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Latency </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellNumber value={data?.latency} unit="ms" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Region </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {regionConfig?.flag} {regionConfig?.code}{" "} <span className="text-muted-foreground"> {regionConfig?.location} </span> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Cloud Provider </TableHead> <TableCell className="inline-flex max-w-full overflow-x-auto whitespace-normal font-mono"> <IconCloudProvider provider={regionConfig?.provider} className="mt-0.5" /> <span className="ml-1 text-muted-foreground"> {regionConfig?.provider} </span> </TableCell> </TableRow> {data.trigger ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Trigger </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data?.trigger} </TableCell> </TableRow> ) : null} {data?.errorMessage ? ( <> <TableRow> <TableHead colSpan={2}>Error Message</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {data.errorMessage} </pre> </TableCell> </TableRow> </> ) : null} </TableBody> </Table> ); } export function DataTableBasicsDNS({ data, privateLocations, }: { data: Extract<ResponseLog, { type: "dns" }> & { trigger?: "cron" | "api" | "test" | null; }; privateLocations?: PrivateLocation[]; }) { const privateLocataion = privateLocations?.find( (location) => String(location.id) === String(data.region), ); const regionConfig = getRegionInfo(data.region, { location: privateLocataion?.name, }); return ( <Table className="table-fixed"> <colgroup> <col className="w-1/3" /> <col className="w-2/3" /> </colgroup> <TableBody> <TableRow> <TableHead colSpan={2}>Request</TableHead> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Result </TableHead> {/* TODO: add colored square like list (see columns) */} <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <div className="flex items-center gap-2"> <div className={cn("h-2.5 w-2.5 rounded-[2px] bg-muted", { "bg-destructive": data?.requestStatus === "error", "bg-warning": data?.requestStatus === "degraded", "bg-success": data?.requestStatus === "success", })} /> <div className="capitalize"> {data?.requestStatus ?? "unknown"} </div> </div> </TableCell> </TableRow> {data.id ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> ID </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.id} </TableCell> </TableRow> ) : null} <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Timestamp </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellDate value={new Date(data.cronTimestamp)} className="text-foreground" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> URI </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data.uri} </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Latency </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> <TableCellNumber value={data?.latency} unit="ms" /> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Region </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {regionConfig?.flag} {regionConfig?.code}{" "} <span className="text-muted-foreground"> {regionConfig?.location} </span> </TableCell> </TableRow> <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Cloud Provider </TableHead> <TableCell className="inline-flex max-w-full overflow-x-auto whitespace-normal font-mono"> <IconCloudProvider provider={regionConfig?.provider} className="mt-0.5" /> <span className="ml-1 text-muted-foreground"> {regionConfig?.provider} </span> </TableCell> </TableRow> {data.trigger ? ( <TableRow className="[&>:not(:last-child)]:border-r"> <TableHead className="bg-muted/50 font-normal text-muted-foreground"> Trigger </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {data?.trigger} </TableCell> </TableRow> ) : null} {data?.records ? ( <> <TableRow> <TableHead colSpan={2}>Records</TableHead> </TableRow> <TableRow className="hover:bg-transparent"> <TableCell colSpan={2} className="p-0"> <Tabs defaultValue="table" className="w-full gap-0"> <TabsList className="w-full justify-start rounded-none border-b px-2"> <TabsTrigger value="table"> <TableProperties className="size-3 rotate-180" /> </TabsTrigger> <TabsTrigger value="raw"> <Braces className="size-3" /> </TabsTrigger> </TabsList> <TabsContent value="table"> <Table className="table-fixed"> <colgroup> <col className="w-1/3" /> <col className="w-2/3" /> </colgroup> <TableBody> {Object.entries(data?.records ?? {}).map( ([key, value]) => ( <TableRow key={key} className="[&>:not(:last-child)]:border-r" > <TableHead className="overflow-x-auto bg-muted/50 font-normal text-muted-foreground"> {key.toUpperCase()} </TableHead> <TableCell className="max-w-full overflow-x-auto whitespace-normal font-mono"> {Array.isArray(value) ? value.join(", ") : value} </TableCell> </TableRow> ), )} </TableBody> </Table> </TabsContent> <TabsContent value="raw"> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-4 font-mono text-sm"> {JSON.stringify(data?.records, null, 2)} </pre> </TabsContent> </Tabs> </TableCell> </TableRow> </> ) : null} {data?.errorMessage ? ( <> <TableRow> <TableHead colSpan={2}>Error Message</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {data.errorMessage} </pre> </TableCell> </TableRow> </> ) : null} {data.assertions ? ( <> <TableRow> <TableHead colSpan={2}>Assertions</TableHead> </TableRow> <TableRow> <TableCell colSpan={2} className="p-0"> {!data.assertions || data.assertions === "[]" ? ( <div className="p-2 font-mono text-muted-foreground text-sm"> No assertions </div> ) : ( <pre className="max-w-full overflow-x-auto whitespace-pre-wrap rounded-none bg-muted/50 p-2 font-mono text-sm"> {JSON.stringify(data.assertions, null, 2)} </pre> )} </TableCell> </TableRow> </> ) : null} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx ================================================ "use client"; import { DataTableSheet, DataTableSheetContent, DataTableSheetHeader, DataTableSheetTitle, } from "@/components/data-table/data-table-sheet"; import type { RouterOutputs } from "@openstatus/api"; import { DataTableBasics } from "./data-table-basics"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; type Monitor = NonNullable<RouterOutputs["monitor"]["get"]>; export function DataTableSheetTest({ data, monitor, onClose, }: { data: TestTCP | TestHTTP | TestDNS | null; monitor: Monitor; onClose: () => void; }) { if (!data) return null; const _data = mapping(data, monitor); if (!_data) return null; return ( <DataTableSheet defaultOpen> {/* NOTE: we are using onCloseAutoFocus to reset with a delay to avoid abrupt closing of the sheet */} <DataTableSheetContent className="sm:max-w-lg" onCloseAutoFocus={onClose}> <DataTableSheetHeader className="px-2"> <DataTableSheetTitle>Test Result</DataTableSheetTitle> </DataTableSheetHeader> <DataTableBasics data={_data} /> </DataTableSheetContent> </DataTableSheet> ); } function mapping(data: TestTCP | TestHTTP | TestDNS, monitor: Monitor) { switch (data.type) { case "http": return { id: null, trigger: null, timestamp: data.timestamp, cronTimestamp: data.timestamp, type: data.type, requestStatus: "success", statusCode: data.status, headers: data.headers, region: data.region, latency: data.latency, timing: { dns: data.timing.dnsDone - data.timing.dnsStart, connect: data.timing.connectDone - data.timing.connectStart, tls: data.timing.tlsHandshakeDone - data.timing.tlsHandshakeStart, ttfb: data.timing.firstByteDone - data.timing.firstByteStart, transfer: data.timing.transferDone - data.timing.transferStart, }, url: monitor.url, workspaceId: String(monitor.workspaceId), error: false, monitorId: String(monitor.id), assertions: monitor.assertions ?? null, message: null, body: data.body ?? null, } as const; case "tcp": return { id: null, trigger: null, timestamp: data.timestamp, cronTimestamp: data.timestamp, region: data.region, type: data.type, requestStatus: "success", error: false, latency: data.latency ?? 0, uri: monitor.url, monitorId: String(monitor.id), errorMessage: null, assertions: null, } as const; // FIXM: add DNS props case "dns": return { id: null, trigger: null, timestamp: data.timestamp, cronTimestamp: data.timestamp, region: data.region, type: data.type, requestStatus: "success", monitorId: String(monitor.id), error: false, uri: monitor.url, latency: data.latency ?? 0, records: data.records, errorMessage: null, assertions: null, } as const; default: return null; } } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx ================================================ "use client"; import { DataTableSheet, DataTableSheetContent, DataTableSheetFooter, DataTableSheetHeader, DataTableSheetTitle, } from "@/components/data-table/data-table-sheet"; import type { RouterOutputs } from "@openstatus/api"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { Check, Copy } from "lucide-react"; import { DataTableBasics } from "./data-table-basics"; type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; export function Sheet({ data, privateLocations, onClose, }: { data: ResponseLog | null; privateLocations?: PrivateLocation[]; onClose: () => void; }) { const { copy, isCopied } = useCopyToClipboard(); if (!data) return null; return ( <DataTableSheet defaultOpen onOpenChange={(open) => !open && onClose()}> <DataTableSheetContent className="sm:max-w-lg"> <DataTableSheetHeader className="px-2"> <DataTableSheetTitle>Response Logs</DataTableSheetTitle> </DataTableSheetHeader> <DataTableBasics data={data} privateLocations={privateLocations} /> <Separator /> <DataTableSheetFooter> <Button variant="outline" onClick={() => { if (typeof window !== "undefined") { copy(window.location.href, { withToast: false, }); } }} > Copy Request Log URL {isCopied ? <Check /> : <Copy />} </Button> </DataTableSheetFooter> </DataTableSheetContent> </DataTableSheet> ); } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx ================================================ "use client"; import type { Table } from "@tanstack/react-table"; import { X } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; import { regions } from "@/data/regions"; import { statusCodes } from "@/data/status-codes"; import type { RouterOutputs } from "@openstatus/api"; type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; export interface ResponseLogsDataTableToolbarProps { table: Table<ResponseLog>; } export function ResponseLogsDataTableToolbar({ table, }: ResponseLogsDataTableToolbarProps) { const isFiltered = table.getState().columnFilters.length > 0; return ( <div className="flex items-center justify-between"> <div className="flex flex-1 flex-warp flex-wrap items-center gap-2"> {table.getColumn("status") && ( <DataTableFacetedFilter column={table.getColumn("status")} title="Status" options={statusCodes.map((code) => ({ label: code.code.toString(), value: code.code.toString(), }))} /> )} {table.getColumn("region") && ( <DataTableFacetedFilter column={table.getColumn("region")} title="Region" options={regions.map((region) => ({ label: region.location, value: region.code, }))} /> )} {table.getColumn("error") && ( <DataTableFacetedFilter column={table.getColumn("error")} title="Error" options={[ { label: "Yes", value: "true" }, { label: "No", value: "false" }, ]} /> )} {isFiltered && ( <Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3" > Reset <X /> </Button> )} </div> {/* <DataTableViewOptions table={table} /> */} </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/response-logs/regions/columns.tsx ================================================ "use client"; import { ChartLineRegion } from "@/components/chart/chart-line-region"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import type { RegionMetric } from "@/data/region-metrics"; import { getActions } from "@/data/region-metrics.client"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { formatRegionCode, getRegionInfo } from "@openstatus/regions"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import type { ColumnDef } from "@tanstack/react-table"; // import { toast } from "sonner"; import { useRouter } from "next/navigation"; function TrendCell({ trend }: { trend: RegionMetric["trend"] }) { return <ChartLineRegion className="h-[50px]" data={trend} />; } export function getColumns( privateLocations: PrivateLocation[], ): ColumnDef<RegionMetric>[] { return [ { accessorKey: "region", header: "Region", cell: ({ row }) => { const value = row.getValue("region"); if (typeof value === "string") { const region = getRegionInfo(value, { location: privateLocations.find( (location) => String(location.id) === String(value), )?.name, }); return ( <TooltipProvider> <Tooltip> <TooltipTrigger className="flex h-[50px] items-center gap-1"> {region.flag}{" "} <span className="max-w-[90px] truncate"> {formatRegionCode(region.code)} </span> </TooltipTrigger> <TooltipContent side="left"> {region.location} ({region.provider}) </TooltipContent> </Tooltip> </TooltipProvider> ); } return null; }, enableSorting: false, enableHiding: false, meta: { cellClassName: "w-24 font-mono", }, }, { accessorKey: "trend", header: "Trend", cell: ({ row }) => { return <TrendCell trend={row.original.trend} />; }, enableSorting: false, enableHiding: false, meta: { cellClassName: "w-full min-w-[200px] max-w-full", }, }, { accessorKey: "p50", header: ({ column }) => ( <DataTableColumnHeader column={column} title="P50" /> ), cell: ({ row }) => { return <TableCellNumber value={row.getValue("p50")} unit="ms" />; }, enableHiding: false, meta: { cellClassName: "w-12", }, }, { accessorKey: "p90", header: ({ column }) => ( <DataTableColumnHeader column={column} title="P90" /> ), cell: ({ row }) => { return <TableCellNumber value={row.getValue("p90")} unit="ms" />; }, enableHiding: false, meta: { cellClassName: "w-12", }, }, { accessorKey: "p99", header: ({ column }) => ( <DataTableColumnHeader column={column} title="P99" /> ), cell: ({ row }) => { return <TableCellNumber value={row.getValue("p99")} unit="ms" />; }, enableHiding: false, meta: { cellClassName: "w-12", }, }, { id: "actions", cell: ({ row }) => { // NOTE: works, but is not very react-esque // eslint-disable-next-line react-hooks/rules-of-hooks const router = useRouter(); const actions = getActions({ filter: async () => { router.push(`?regions=${row.original.region}`); }, // TODO: add triggerById in TRPC client // trigger: async () => { // console.log(row.original); // const promise = new Promise((resolve) => setTimeout(resolve, 1000)); // toast.promise(promise, { // loading: "Checking...", // success: "Success", // error: "Failed", // }); // await promise; // }, }); return <QuickActions actions={actions} />; }, meta: { headerClassName: "w-12", cellClassName: "text-right", }, }, ]; } ================================================ FILE: apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx ================================================ import { QuickActions } from "@/components/dropdowns/quick-actions"; import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { useMutation } from "@tanstack/react-query"; type ApiKey = RouterOutputs["apiKeyRouter"]["getAll"][number]; export function DataTable({ apiKeys, refetch, }: { apiKeys: ApiKey[]; refetch: () => void; }) { const trpc = useTRPC(); const revokeApiKeyMutation = useMutation( trpc.apiKeyRouter.revoke.mutationOptions({ onSuccess: () => refetch(), }), ); return ( <div className="overflow-x-auto"> <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Description</TableHead> <TableHead>Prefix</TableHead> <TableHead>Expires</TableHead> <TableHead> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {apiKeys.map((apiKey) => ( <TableRow key={apiKey.id}> <TableCell className="font-medium">{apiKey.name}</TableCell> <TableCell className="max-w-[200px] truncate text-muted-foreground"> {apiKey.description ?? "-"} </TableCell> <TableCell> <code className="text-xs">{apiKey.prefix}...</code> </TableCell> <TableCell className="text-sm"> {apiKey.expiresAt ? formatDate(apiKey.expiresAt) : "-"} </TableCell> <TableCell> <div className="flex justify-end"> <QuickActions deleteAction={{ confirmationValue: apiKey.name ?? "api key", submitAction: async () => await revokeApiKeyMutation.mutateAsync({ keyId: apiKey.id, }), }} /> </div> </TableCell> </TableRow> ))} </TableBody> </Table> </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx ================================================ import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { useMutation, useQuery } from "@tanstack/react-query"; export function DataTable() { const trpc = useTRPC(); const { data: invitations, refetch } = useQuery( trpc.invitation.list.queryOptions(), ); const deleteInvitationMutation = useMutation( trpc.invitation.delete.mutationOptions({ onSuccess: () => refetch(), }), ); if (!invitations) return null; if (invitations.length === 0) { return ( <EmptyStateContainer> <EmptyStateTitle>No pending invitations</EmptyStateTitle> <EmptyStateDescription> Only active invitations are shown here. </EmptyStateDescription> </EmptyStateContainer> ); } return ( <Table> <TableHeader> <TableRow> <TableHead>Email</TableHead> <TableHead>Role</TableHead> <TableHead>Created At</TableHead> <TableHead>Expires At</TableHead> <TableHead>Accepted At</TableHead> <TableHead> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {invitations.map((item) => ( <TableRow key={item.id}> <TableCell>{item.email}</TableCell> <TableCell>{item.role}</TableCell> <TableCell> {item.createdAt ? formatDate(item.createdAt) : "-"} </TableCell> <TableCell>{formatDate(item.expiresAt)}</TableCell> <TableCell> {item.acceptedAt ? formatDate(item.acceptedAt) : "-"} </TableCell> <TableCell> <div className="flex justify-end"> <QuickActions deleteAction={{ confirmationValue: item.email ?? "invitation", submitAction: async () => deleteInvitationMutation.mutateAsync({ id: item.id }), }} /> </div> </TableCell> </TableRow> ))} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/data-table/settings/members/data-table.tsx ================================================ import { QuickActions } from "@/components/dropdowns/quick-actions"; import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { useMutation, useQuery } from "@tanstack/react-query"; export function DataTable() { const trpc = useTRPC(); const { data: members, refetch } = useQuery(trpc.member.list.queryOptions()); const deleteMemberMutation = useMutation( trpc.member.delete.mutationOptions({ onSuccess: () => refetch(), }), ); if (!members) return null; return ( <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Email</TableHead> <TableHead>Role</TableHead> <TableHead>Created</TableHead> <TableHead> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {members.map((item) => ( <TableRow key={item.user.id}> <TableCell> {item.user.name ?? ( <span className="text-muted-foreground">-</span> )} </TableCell> <TableCell>{item.user.email}</TableCell> <TableCell>{item.role}</TableCell> <TableCell> {formatDate(item.user.createdAt ?? item.createdAt)} </TableCell> <TableCell> <div className="flex justify-end"> <QuickActions deleteAction={{ confirmationValue: item.user.email ?? "user", // FIXME: when deleting myself, throws an error, should have been caught by the toast.error submitAction: async () => await deleteMemberMutation.mutateAsync({ id: item.user.id, }), }} /> </div> </TableCell> </TableRow> ))} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/data-table/status-pages/columns.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import { DataTableRowActions } from "./data-table-row-actions"; type StatusPage = RouterOutputs["page"]["list"][number]; export const columns: ColumnDef<StatusPage>[] = [ { accessorKey: "title", header: "Title", cell: ({ row }) => { return ( <TableCellLink href={`/status-pages/${row.original.id}/status-reports`} value={row.getValue("title")} /> ); }, enableSorting: false, enableHiding: false, meta: { cellClassName: "max-w-[150px] min-w-max", }, }, { accessorKey: "icon", header: "Favicon", cell: ({ row }) => { const value = row.getValue("icon"); if (!value || typeof value !== "string") return <span className="text-muted-foreground">-</span>; return ( <img src={`${value}`} alt={`Favicon for ${row.getValue("title")}`} className="h-4 w-4 rounded border bg-muted" /> ); }, enableSorting: false, enableHiding: false, }, { accessorKey: "slug", header: "Slug", cell: ({ row }) => { const domain = row.getValue("domain"); const slug = row.getValue("slug"); return ( <TableCellLink href={domain ? `https://${domain}` : `https://${slug}.openstatus.dev`} value={slug} /> ); }, enableSorting: false, enableHiding: false, meta: { cellClassName: "font-mono", }, }, { accessorKey: "domain", accessorFn: (row) => row.customDomain, header: "Domain", cell: ({ row }) => { const value = row.getValue("domain"); if (typeof value !== "string") return <span className="text-muted-foreground">-</span>; return ( <Link href={"#"} className="font-mono"> {value} </Link> ); }, enableSorting: false, enableHiding: false, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/status-pages/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; type StatusPage = RouterOutputs["page"]["list"][number]; interface DataTableRowActionsProps { row: Row<StatusPage>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); const deleteStatusPageMutation = useMutation( trpc.page.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); const actions = getActions({ edit: () => router.push(`/status-pages/${row.original.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(row.original.id.toString()); toast.success("Monitor ID copied to clipboard"); }, }); return ( <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.title ?? "status page", submitAction: async () => { await deleteStatusPageMutation.mutateAsync({ id: row.original.id, }); }, }} /> ); } ================================================ FILE: apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; import { getActions } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useRef } from "react"; type StatusReportUpdate = RouterOutputs["statusReport"]["list"][number]["updates"][number]; interface DataTableRowActionsProps { row: StatusReportUpdate; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const buttonRef = useRef<HTMLButtonElement>(null); const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); const updateStatusReportUpdateMutation = useMutation( trpc.statusReport.updateStatusReportUpdate.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: Number.parseInt(id), }), }); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); const deleteStatusReportUpdateMutation = useMutation( trpc.statusReport.deleteUpdate.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: Number.parseInt(id), }), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); const actions = getActions({ edit: () => buttonRef.current?.click(), }); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: row.status ?? "status report update", submitAction: async () => { await deleteStatusReportUpdateMutation.mutateAsync({ id: row.id, }); }, }} /> <FormSheetStatusReportUpdate defaultValues={{ message: row.message, date: row.date, status: row.status, }} onSubmit={async (values) => { await updateStatusReportUpdateMutation.mutateAsync({ id: row.id, statusReportId: row.statusReportId, message: values.message, status: values.status, date: values.date, }); }} > <button ref={buttonRef} type="button" className="sr-only"> Open sheet </button> </FormSheetStatusReportUpdate> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx ================================================ "use client"; import { ProcessMessage } from "@/components/content/process-message"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; import { icons } from "@/data/icons"; import { colors, getNextStatus } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import type { RouterOutputs } from "@openstatus/api"; import { Button } from "@openstatus/ui/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useParams } from "next/navigation"; import { DataTableRowActions } from "./data-table-row-actions"; type StatusReportUpdates = RouterOutputs["statusReport"]["list"][number]["updates"]; export function DataTable({ updates, reportId, }: { updates: StatusReportUpdates; reportId: number; }) { const trpc = useTRPC(); const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const sendStatusReportUpdateMutation = useMutation( trpc.emailRouter.sendStatusReport.mutationOptions(), ); const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { // TODO: move to server if (update?.notifySubscribers) { sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); } // queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: Number.parseInt(id), }), }); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); return ( <Table className="w-full"> <TableHeader> <TableRow> <TableHead className="w-7"> <span className="sr-only">Status</span> </TableHead> <TableHead>Message</TableHead> <TableHead>Date</TableHead> <TableHead className="w-[px]"> <TooltipProvider> <Tooltip> <FormSheetStatusReportUpdate defaultValues={{ status: getNextStatus(updates[updates.length - 1].status), }} onSubmit={async (values) => { await createStatusReportUpdateMutation.mutateAsync({ statusReportId: reportId, message: values.message, status: values.status, date: values.date, notifySubscribers: values.notifySubscribers, }); }} > <TooltipTrigger asChild> <Button size="icon" className="ml-auto flex h-7 w-7 p-0"> <Plus /> <span className="sr-only">Create Report Update</span> </Button> </TooltipTrigger> </FormSheetStatusReportUpdate> <TooltipContent side="left" align="center"> Create Report Update </TooltipContent> </Tooltip> </TooltipProvider> </TableHead> </TableRow> </TableHeader> <TableBody> {updates.map((update) => { const Icon = icons.status[update.status]; return ( <TableRow key={update.id}> <TableCell> <div className="p-1"> <Icon className={cn(colors[update.status])} size={20} /> </div> </TableCell> <TableCell> <div className="prose dark:prose-invert prose-sm line-clamp-3 text-wrap text-muted-foreground"> <ProcessMessage value={update.message} /> </div> </TableCell> <TableCell className="w-[170px] text-muted-foreground"> <TableCellDate value={update.date} /> </TableCell> <TableCell className="w-8"> <DataTableRowActions row={update} /> </TableCell> </TableRow> ); })} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/data-table/status-reports/columns.tsx ================================================ "use client"; import { TableCellBadge } from "@/components/data-table/table-cell-badge"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import { colors } from "@/data/status-report-updates.client"; import type { RouterOutputs } from "@openstatus/api"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import type { ColumnDef } from "@tanstack/react-table"; import { ChevronDown, ChevronUp } from "lucide-react"; import Link from "next/link"; import { DataTableRowActions } from "./data-table-row-actions"; type StatusReport = RouterOutputs["statusReport"]["list"][number]; export const columns: ColumnDef<StatusReport>[] = [ { id: "expander", header: () => null, cell: ({ row }) => { return row.getCanExpand() ? ( <Button {...{ className: "size-7 shadow-none text-muted-foreground", onClick: (e) => { e.stopPropagation(); row.toggleExpanded(); }, "aria-expanded": row.getIsExpanded(), "aria-label": row.getIsExpanded() ? `Collapse details for ${row.original.title}` : `Expand details for ${row.original.title}`, size: "icon", variant: "ghost", }} > {row.getIsExpanded() ? ( <ChevronUp className="opacity-60" size={16} aria-hidden="true" /> ) : ( <ChevronDown className="opacity-60" size={16} aria-hidden="true" /> )} </Button> ) : undefined; }, meta: { headerClassName: "w-7", }, }, { accessorKey: "title", header: "Title", cell: ({ row }) => { const { id, pageId } = row.original; return ( <TableCellLink href={`/status-pages/${pageId}/status-reports/${id}`} onClick={(e) => { // avoid expanding the row e.stopPropagation(); }} value={row.getValue("title")} /> ); }, enableSorting: false, enableHiding: false, meta: { cellClassName: "max-w-[200px] truncate", }, }, { accessorKey: "status", header: "Current Status", cell: ({ row }) => { const value = String(row.getValue("status")); return ( <div className={cn( "font-mono capitalize", colors[value as keyof typeof colors], )} > {value} </div> ); }, enableSorting: false, enableHiding: false, }, { id: "updates", accessorFn: (row) => row.updates.length, header: "Total Updates", cell: ({ row }) => { const value = row.getValue("updates"); return <TableCellNumber value={value} />; }, }, { id: "pageComponents", accessorFn: (row) => row?.pageComponents, header: "Affected", cell: ({ row }) => { const value = row.getValue("pageComponents"); if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { return ( <div className="flex flex-wrap gap-1"> {value.map((m) => m.monitorId ? ( <Link href={`/monitors/${m.monitorId}/overview`} key={m.id}> <TableCellBadge value={m.name} /> </Link> ) : ( <TableCellBadge value={m.name} key={m.id} /> ), )} </div> ); } return <div className="text-muted-foreground">-</div>; }, }, { id: "startedAt", accessorFn: (row) => row.updates.sort((a, b) => a.date.getTime() - b.date.getTime())[0]?.date, header: ({ column }) => ( <DataTableColumnHeader column={column} title="Started At" /> ), cell: ({ row }) => <TableCellDate value={row.getValue("startedAt")} />, enableHiding: false, meta: { cellClassName: "w-[170px]", }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; import { getNextStatus } from "@/data/status-report-updates.client"; import { getActions } from "@/data/status-reports.client"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; import { useRef } from "react"; type StatusReport = RouterOutputs["statusReport"]["list"][number]; interface DataTableRowActionsProps { row: Row<StatusReport>; } // NOTE: avoid using useParams to get status page :id // because we are using the table in the /overview page export function DataTableRowActions({ row }: DataTableRowActionsProps) { if (!row.original.pageId) return null; const buttonCreateRef = useRef<HTMLButtonElement>(null); const buttonUpdateRef = useRef<HTMLButtonElement>(null); const actions = getActions({ "create-update": () => buttonCreateRef.current?.click(), edit: () => buttonUpdateRef.current?.click(), "view-report": () => { if (typeof window !== "undefined") { window.open( `https://${ row.original.page.customDomain || `${row.original.page.slug}.openstatus.dev` }/events/report/${row.original.id}`, "_blank", ); } }, }); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: page } = useQuery( trpc.page.get.queryOptions({ id: row.original.pageId }), ); const sendStatusReportUpdateMutation = useMutation( trpc.emailRouter.sendStatusReport.mutationOptions(), ); const updateStatusReportMutation = useMutation( trpc.statusReport.updateStatus.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); const createStatusReportUpdateMutation = useMutation( trpc.statusReport.createStatusReportUpdate.mutationOptions({ onSuccess: (update) => { // TODO: move to server if (update) { sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); } // queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); const deleteStatusReportMutation = useMutation( trpc.statusReport.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ pageId: row.original.pageId ?? undefined, }), }); queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); queryClient.invalidateQueries({ queryKey: trpc.statusReport.list.queryKey({ period: "7d", }), }); }, }), ); return ( <> <QuickActions actions={actions} deleteAction={{ confirmationValue: row.original.title ?? "status report", submitAction: async () => { await deleteStatusReportMutation.mutateAsync({ id: row.original.id, }); }, }} /> <FormSheetStatusReport pageComponents={page?.pageComponents ?? []} defaultValues={{ title: row.original.title, status: row.original.status, pageComponents: row.original.pageComponents?.map((c) => c.id) ?? [], }} onSubmit={async (values) => { await updateStatusReportMutation.mutateAsync({ id: row.original.id, pageComponents: values.pageComponents, title: values.title, status: values.status, }); }} > <button ref={buttonUpdateRef} type="button" className="sr-only"> Open sheet </button> </FormSheetStatusReport> <FormSheetStatusReportUpdate defaultValues={{ status: getNextStatus(row.original.status), }} onSubmit={async (values) => { await createStatusReportUpdateMutation.mutateAsync({ statusReportId: row.original.id, message: values.message, status: values.status, date: values.date, }); }} > <button ref={buttonCreateRef} type="button" className="sr-only"> Open sheet </button> </FormSheetStatusReportUpdate> </> ); } ================================================ FILE: apps/dashboard/src/components/data-table/subscribers/columns.tsx ================================================ "use client"; import { formatDate } from "@/lib/formatter"; import type { RouterOutputs } from "@openstatus/api"; import { Badge } from "@openstatus/ui/components/ui/badge"; import type { ColumnDef } from "@tanstack/react-table"; import { DataTableRowActions } from "./data-table-row-actions"; type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; export const columns: ColumnDef<Subscriber>[] = [ { accessorKey: "email", header: "Email", enableSorting: false, enableHiding: false, }, { id: "status", header: "Status", enableSorting: false, enableHiding: false, cell: ({ row }) => { const unsubscribedAt = row.original.unsubscribedAt; const acceptedAt = row.original.acceptedAt; if (unsubscribedAt) { return <Badge variant="destructive">Unsubscribed</Badge>; } if (!acceptedAt) { return <Badge variant="outline">Pending</Badge>; } return <Badge variant="secondary">Active</Badge>; }, }, { accessorKey: "createdAt", header: "Created At", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("createdAt"); if (value instanceof Date) return formatDate(value); if (!value) return "-"; return value; }, meta: { cellClassName: "font-mono", }, }, { accessorKey: "acceptedAt", header: "Accepted At", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("acceptedAt"); if (value instanceof Date) return formatDate(value); if (!value) return "-"; return value; }, meta: { cellClassName: "font-mono", }, }, { accessorKey: "unsubscribedAt", header: "Unsubscribed At", enableSorting: false, enableHiding: false, cell: ({ row }) => { const value = row.getValue("unsubscribedAt"); if (value instanceof Date) return formatDate(value); if (!value) return "-"; return value; }, meta: { cellClassName: "font-mono", }, }, { id: "actions", cell: ({ row }) => <DataTableRowActions row={row} />, meta: { cellClassName: "w-8", }, }, ]; ================================================ FILE: apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx ================================================ "use client"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { useMutation, useQuery } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; interface DataTableRowActionsProps { row: Row<Subscriber>; } export function DataTableRowActions({ row }: DataTableRowActionsProps) { const trpc = useTRPC(); const { refetch } = useQuery( trpc.pageSubscriber.list.queryOptions({ pageId: row.original.pageId, }), ); const deleteAction = useMutation( trpc.pageSubscriber.delete.mutationOptions({ onSuccess: () => refetch(), }), ); return ( <QuickActions actions={[]} deleteAction={{ confirmationValue: row.original.email ?? "subscriber", submitAction: async () => { await deleteAction.mutateAsync({ id: row.original.id, pageId: row.original.pageId, }); }, }} /> ); } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-badge.tsx ================================================ import { cn } from "@/lib/utils"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useEffect, useRef, useState } from "react"; export function TableCellBadge({ value, className, ...props }: React.ComponentProps<typeof Badge> & { value: unknown }) { const ref = useRef<HTMLSpanElement>(null); const [isTruncated, setIsTruncated] = useState(false); const [open, setOpen] = useState(false); // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> useEffect(() => { if (ref.current) { setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth); } }, [ref]); return ( <Badge variant="outline" className={cn( "max-w-16 truncate font-mono", value ? "text-foreground" : "text-foreground/70", className, )} {...props} > <TooltipProvider> {isTruncated ? ( <Tooltip open={open} onOpenChange={setOpen}> <TooltipTrigger onPointerDown={(event) => event.preventDefault()} asChild > <span ref={ref} className="block truncate"> {String(value)} </span> </TooltipTrigger> <TooltipContent>{String(value)}</TooltipContent> </Tooltip> ) : ( <span ref={ref} className="truncate"> {String(value)} </span> )} </TooltipProvider> </Badge> ); } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-boolean.tsx ================================================ import { cn } from "@/lib/utils"; export function TableCellBoolean({ value, className, ...props }: React.ComponentProps<"div"> & { value: unknown }) { const _value = Boolean(value); return ( <div className={cn( "font-mono", _value ? "text-foreground" : "text-foreground/70", className, )} {...props} > {String(_value)} </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-date.tsx ================================================ import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; import { cn } from "@/lib/utils"; import { format } from "date-fns"; export function TableCellDate({ value, className, formatStr = "LLL dd, y HH:mm:ss", ...props }: React.ComponentProps<"div"> & { value: unknown; formatStr?: string }) { if (value instanceof Date) { return ( <HoverCardTimestamp date={value}> <div className={cn("text-muted-foreground", className)} {...props}> {format(value, formatStr)} </div> </HoverCardTimestamp> ); } if (typeof value === "string") { return ( <div className={cn("text-muted-foreground", className)} {...props}> {value} </div> ); } return ( <div className={cn("text-muted-foreground", className)} {...props}> - </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-link.tsx ================================================ import { Link } from "@/components/common/link"; import { cn } from "@/lib/utils"; import { ArrowUpRight, ChevronRight } from "lucide-react"; export function TableCellLink({ value, className, ...props }: React.ComponentProps<typeof Link> & { value: unknown; }) { if (typeof value === "string") { const isExternal = props.href?.toString().startsWith("http"); const externalProps = isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {}; const Icon = isExternal ? ArrowUpRight : ChevronRight; return ( <Link className={cn( "group/link flex w-full items-center justify-between gap-2 hover:underline", className, )} {...externalProps} {...props} > <span className="truncate">{value}</span> <Icon className="size-4 flex-shrink-0 text-muted-foreground group-hover/link:text-foreground" /> </Link> ); } return <div className="text-muted-foreground">-</div>; } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-number.tsx ================================================ import { cn } from "@/lib/utils"; export function TableCellNumber({ value, className, unit, ...props }: React.ComponentProps<"div"> & { value: unknown; unit?: string }) { const _value = Number(value); if (Number.isNaN(_value)) { return <div className="font-mono text-muted-foreground">N/A</div>; } return ( <div className={cn("font-mono text-foreground", className)} {...props}> {_value} {unit && <span className="p-0.5 text-muted-foreground">{unit}</span>} </div> ); } ================================================ FILE: apps/dashboard/src/components/data-table/table-cell-unavailable.tsx ================================================ import { cn } from "@/lib/utils"; export function TableCellUnavailable({ className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("text-muted-foreground", className)} {...props}> N/A </div> ); } ================================================ FILE: apps/dashboard/src/components/date-picker.tsx ================================================ "use client"; import { useState } from "react"; import type { DateRange } from "react-day-picker"; import { Kbd } from "@/components/common/kbd"; import { formatDateForInput } from "@/lib/formatter"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { endOfDay } from "date-fns"; type DatePickerProps = { range: DateRange; onSelect: (range: DateRange) => void; presets: { id: string; label: string; values: DateRange; shortcut: string }[]; }; export function DatePicker({ range, onSelect, presets }: DatePickerProps) { const [today] = useState(new Date()); const disableBefore = presets[presets.length - 1]?.values?.from; return ( <div> <div className="flex flex-row"> <div className="relative py-4"> <div className="h-full"> <div className="flex flex-col px-1"> <div className="px-3 py-1 font-medium text-muted-foreground text-xs"> Presets </div> {presets.map((preset) => { const isSelected = range.from?.getTime() === preset.values.from?.getTime() && range.to?.getTime() === preset.values.to?.getTime(); return ( <Button key={preset.id} variant={isSelected ? "outline" : "ghost"} size="sm" className="w-full justify-between border border-transparent" onClick={() => { onSelect(preset.values); }} > <span>{preset.label}</span> <Kbd className="font-mono uppercase">{preset.shortcut}</Kbd> </Button> ); })} </div> </div> </div> <Separator orientation="vertical" className="h-auto! w-px" /> <div className="flex flex-1 items-center justify-center"> <Calendar mode="range" selected={range} onSelect={(newDate) => { if (newDate) { onSelect({ ...newDate, to: newDate.to ? endOfDay(newDate.to) : undefined, }); } }} className="p-2" disabled={[ { after: today }, // Dates before today { before: disableBefore ?? today }, // Dates before last action ]} /> </div> </div> <Separator /> <div className="flex flex-col gap-2 px-3 py-4"> <p className="px-1 font-medium text-muted-foreground text-xs"> Custom Range </p> <div className="grid gap-2 sm:grid-cols-2"> <div className="grid w-full gap-1.5"> <Label htmlFor="from" className="px-1"> Start </Label> <Input type="datetime-local" id="from" name="from" min={formatDateForInput(disableBefore ?? today)} max={formatDateForInput(today)} value={range.from ? formatDateForInput(range.from) : ""} onChange={(e) => { const newDate = new Date(e.target.value); if (!Number.isNaN(newDate.getTime())) { onSelect({ ...range, from: newDate }); } }} disabled={!range.from} /> </div> <div className="grid w-full gap-1.5"> <Label htmlFor="to" className="px-1"> End </Label> <Input type="datetime-local" id="to" name="to" min={formatDateForInput(range.from ?? today)} max={formatDateForInput(today)} value={range.to ? formatDateForInput(range.to) : ""} onChange={(e) => { const newDate = new Date(e.target.value); if (!Number.isNaN(newDate.getTime())) { onSelect({ ...range, to: newDate }); } }} disabled={!range.to} /> </div> </div> </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/development-indicator.tsx ================================================ "use client"; import { Kbd } from "@openstatus/ui/components/ui/kbd"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import * as Portal from "@radix-ui/react-portal"; export function DevelopmentIndicator() { const isMobile = useIsMobile(); if (process.env.NODE_ENV !== "production") return null; return ( <Portal.Root> <div className="pointer-events-none fixed inset-0 z-[9999] border-2 border-destructive" /> <div className="fixed inset-x-0 bottom-0 z-[9999] select-none"> <div className="flex items-center justify-center"> <TooltipProvider delayDuration={0}> <Tooltip> <TooltipTrigger> <div className="w-fit rounded-t bg-destructive px-2 py-1 font-mono text-background text-xs"> In Beta </div> </TooltipTrigger> <TooltipContent side="top"> {!isMobile ? ( <p> Press <Kbd className="-me-0 ms-0">F</Kbd> key to provide feedback. </p> ) : ( <p>Use a larger screen to provide feedback.</p> )} </TooltipContent> </Tooltip> </TooltipProvider> </div> </div> </Portal.Root> ); } ================================================ FILE: apps/dashboard/src/components/dialogs/export-code.tsx ================================================ import { Link } from "@/components/common/link"; import { Button } from "@openstatus/ui/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@openstatus/ui/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import type { DialogProps } from "@radix-ui/react-dialog"; import { Check, Copy } from "lucide-react"; // TODL: make it dynamic const YML = `openstatus-marketing: name: OpenStatus Marketing description: Marketing website for OpenStatus active: true public: false frequency: "10m" regions: ["ams", "fra", "gru", "sin", "iad"] kind: "http" request: url: https://api.openstatus.dev method: GET`; export function ExportCodeDialog(props: DialogProps) { const { copy, isCopied } = useCopyToClipboard(); return ( <Dialog {...props}> <DialogContent> <DialogHeader> <DialogTitle>Export Configuration</DialogTitle> <DialogDescription> Export and manage your monitor configuration using Infra as Code. </DialogDescription> </DialogHeader> <Tabs defaultValue="yml"> <TabsList> <TabsTrigger value="yml">YAML</TabsTrigger> <TabsTrigger value="terraform">Terraform</TabsTrigger> </TabsList> <TabsContent value="yml" className="space-y-2"> <pre className="relative rounded border bg-muted p-2 text-xs"> {YML} <Button variant="outline" size="icon" className="absolute top-2 right-2 size-7 p-1" onClick={() => copy(YML, { withToast: false, timeout: 1000 })} > {isCopied ? ( <Check className="size-3" /> ) : ( <Copy className="size-3" /> )} </Button> </pre> <p className="text-muted-foreground text-xs"> Use a <code>monitor.openstatus.yml</code> file to configure your monitors. <Link href="#">Read more.</Link> </p> </TabsContent> <TabsContent value="terraform" className="space-y-2"> <pre className="relative rounded border bg-muted p-2 text-xs"> TODO: </pre> {/* TODO: only showcase if there are any assertions */} <p className="text-destructive text-xs"> The Terraform provider does not support assertions yet. </p> <p className="text-muted-foreground text-xs"> Use a Terraform provider to manage your monitors.{" "} <Link href="#">Read more.</Link> </p> </TabsContent> </Tabs> </DialogContent> </Dialog> ); } ================================================ FILE: apps/dashboard/src/components/dialogs/upgrade.tsx ================================================ import { Link } from "@/components/common/link"; import { Note, NoteButton } from "@/components/common/note"; import { BillingAddons } from "@/components/content/billing-addons"; import { DataTable } from "@/components/data-table/billing/data-table"; import { useTRPC } from "@/lib/trpc/client"; import type { WorkspacePlan } from "@openstatus/db/src/schema"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Addons, Limits } from "@openstatus/db/src/schema/plan/schema"; import { getPlansForLimit } from "@openstatus/db/src/schema/plan/utils"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@openstatus/ui/components/ui/dialog"; import { Separator } from "@openstatus/ui/components/ui/separator"; import type { DialogProps } from "@radix-ui/react-dialog"; import { useQuery } from "@tanstack/react-query"; import { CalendarClock } from "lucide-react"; const PLANS = { free: ["starter", "team"], starter: ["team"], team: [], } satisfies Record<WorkspacePlan, WorkspacePlan[]>; export function UpgradeDialog( props: DialogProps & { limit?: keyof Limits; restrictTo?: WorkspacePlan[]; }, ) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); if (!workspace) return null; const planAddons = allPlans[workspace.plan].addons; const getRestrictTo = () => { if (props.restrictTo) return props.restrictTo; if (props.limit) return getPlansForLimit(workspace.plan, props.limit); return PLANS[workspace.plan]; }; const restrictTo = getRestrictTo(); const addon = props.limit && Object.prototype.hasOwnProperty.call(planAddons, props.limit) ? (props.limit as keyof Addons) : null; return ( <Dialog {...props}> <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-2xl"> <DialogHeader> <DialogTitle>Upgrade Workspace</DialogTitle> <DialogDescription> Upgrade your workspace to support more monitors, status pages, regions, and much more. Get an overview within your{" "} <Link onClick={() => props.onOpenChange?.(false)} href="/settings/billing" > billing settings </Link> . </DialogDescription> </DialogHeader> {addon && planAddons[addon] ? ( <> <BillingAddons label={planAddons[addon].title} description={planAddons[addon].description} addon={addon} workspace={workspace} /> <Separator /> </> ) : null} {restrictTo.length === 0 ? ( <Note> <CalendarClock /> Please contact us to upgrade your plan. <NoteButton variant="outline" asChild> <a href="https://openstatus.dev/cal" target="_blank" rel="noreferrer" className="text-nowrap" > Book a call </a> </NoteButton> </Note> ) : ( <DataTable restrictTo={restrictTo} /> )} </DialogContent> </Dialog> ); } ================================================ FILE: apps/dashboard/src/components/domains/domain-configuration.tsx ================================================ "use client"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; import { Note } from "@/components/common/note"; import { getSubdomain } from "@/lib/domains"; import { CircleCheck } from "lucide-react"; import DomainStatusIcon from "./domain-status-icon"; import { useDomainStatus } from "./use-domain-status"; export const InlineSnippet = ({ className, children, }: { className?: string; children?: string; }) => { return ( <span className={cn( "inline-block rounded-md bg-muted px-1 py-0.5 font-mono", className, )} > {children} </span> ); }; // FIXME: add loading state! export default function DomainConfiguration({ domain }: { domain: string }) { const { status, domainJson, isLoading } = useDomainStatus(domain); if (!status || !domainJson) return null; if (status === "Valid Configuration") return ( <Note color="success"> <CircleCheck /> Your domain is configured and you can use it to access your status page. </Note> ); const subdomain = domainJson?.name && domainJson?.apexName ? getSubdomain(domainJson.name, domainJson.apexName) : null; const txtVerification = (status === "Pending Verification" && domainJson?.verification?.find((x) => x.type === "TXT")) || null; return ( <div> <div className="mb-4 flex items-center space-x-2"> <DomainStatusIcon status={status} loading={isLoading} /> <p className="font-semibold">{status}</p> <Badge variant="secondary">{domain}</Badge> </div> {txtVerification ? ( <> <p className="text-sm"> Please set the following TXT record on{" "} <InlineSnippet>{domainJson.apexName}</InlineSnippet> to prove ownership of <InlineSnippet>{domainJson.name}</InlineSnippet>: </p> <div className="my-5 flex items-start justify-start space-x-10 rounded-md bg-muted p-2"> <div> <p className="font-bold text-sm">Type</p> <p className="mt-2 font-mono text-sm">{txtVerification.type}</p> </div> <div> <p className="font-bold text-sm">Name</p> <p className="mt-2 font-mono text-sm"> {txtVerification.domain.slice( 0, txtVerification.domain.length - (domainJson?.apexName?.length || 0) - 1, )} </p> </div> <div> <p className="font-bold text-sm">Value</p> <p className="mt-2 font-mono text-sm"> <span className="text-ellipsis">{txtVerification.value}</span> </p> </div> </div> <p className="text-muted-foreground text-sm"> Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record. </p> </> ) : status === "Unknown Error" ? ( <p className="mb-5 text-sm">{domainJson?.error?.message}</p> ) : ( <> <Tabs defaultValue={subdomain ? "CNAME" : "A"}> <TabsList> <TabsTrigger value="A"> A Record{!subdomain && " (recommended)"} </TabsTrigger> <TabsTrigger value="CNAME"> CNAME Record{subdomain && " (recommended)"} </TabsTrigger> </TabsList> <TabsContent value="A" className="space-y-2"> <p className="text-sm"> To configure your apex domain ( <InlineSnippet>{domainJson.apexName}</InlineSnippet> ), set the following A record on your DNS provider to continue: </p> <div className="flex items-center justify-start space-x-10 rounded-md bg-muted p-2"> <div> <p className="font-bold text-sm">Type</p> <p className="mt-2 font-mono text-sm">A</p> </div> <div> <p className="font-bold text-sm">Name</p> <p className="mt-2 font-mono text-sm">@</p> </div> <div> <p className="font-bold text-sm">Value</p> <p className="mt-2 font-mono text-sm">76.76.21.21</p> </div> <div> <p className="font-bold text-sm">TTL</p> <p className="mt-2 font-mono text-sm">86400</p> </div> </div> </TabsContent> <TabsContent value="CNAME"> <div className="flex items-center justify-start space-x-10 rounded-md bg-muted p-2"> <div> <p className="font-bold text-sm">Type</p> <p className="mt-2 font-mono text-sm">CNAME</p> </div> <div> <p className="font-bold text-sm">Name</p> <p className="mt-2 font-mono text-sm">{subdomain ?? "www"}</p> </div> <div> <p className="font-bold text-sm">Value</p> <p className="mt-2 font-mono text-sm">cname.vercel-dns.com</p> </div> <div> <p className="font-bold text-sm">TTL</p> <p className="mt-2 font-mono text-sm">86400</p> </div> </div> </TabsContent> </Tabs> <p className="muted-foreground mt-5 text-sm"> Note: for TTL, if <InlineSnippet>86400</InlineSnippet> is not available, set the highest value possible. Also, domain propagation can take up to an hour. </p> </> )} </div> ); } ================================================ FILE: apps/dashboard/src/components/domains/domain-status-icon.tsx ================================================ "use client"; import { AlertCircle, CheckCircle2, LoaderCircle, XCircle } from "lucide-react"; import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain"; export default function DomainStatusIcon({ status, loading, }: { status: DomainVerificationStatusProps; loading?: boolean; }) { return loading ? ( <LoaderCircle className="animate-spin text-muted-foreground" stroke="currentColor" /> ) : status === "Valid Configuration" ? ( <CheckCircle2 fill="#22c55e" stroke="currentColor" className="text-background" /> ) : status === "Pending Verification" ? ( <AlertCircle fill="#eab308" stroke="currentColor" className="text-background" /> ) : ( <XCircle fill="#ef4444" stroke="currentColor" className="text-background" /> ); } ================================================ FILE: apps/dashboard/src/components/domains/use-domain-status.ts ================================================ import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; export function useDomainStatus(domain?: string) { const trpc = useTRPC(); const { data: domainJson, refetch: refetchDomain, isLoading: isLoadingDomain, isRefetching: isRefetchingDomain, } = useQuery(trpc.domain.getDomainResponse.queryOptions({ domain })); const { data: configJson, refetch: refetchConfig, isLoading: isLoadingConfig, isRefetching: isRefetchingConfig, } = useQuery(trpc.domain.getConfigResponse.queryOptions({ domain })); const { data: verificationJson, refetch: refetchVerification, isLoading: isLoadingVerification, isRefetching: isRefetchingVerification, } = useQuery( trpc.domain.verifyDomain.queryOptions( { domain }, { enabled: !domainJson?.verified }, ), ); const refreshAll = useCallback(() => { refetchDomain(); refetchConfig(); refetchVerification(); }, [refetchDomain, refetchConfig, refetchVerification]); let status: DomainVerificationStatusProps = "Valid Configuration"; if (domainJson?.error?.code === "not_found") { // domain not found on Vercel project status = "Domain Not Found"; // unknown error } else if (domainJson?.error) { status = "Unknown Error"; // if domain is not verified, we try to verify now } else if (!domainJson?.verified) { status = "Pending Verification"; // domain was just verified if (verificationJson?.verified) { status = "Valid Configuration"; } } else if (configJson?.misconfigured) { status = "Invalid Configuration"; } else { status = "Valid Configuration"; } return { status, domainJson, refresh: refreshAll, isLoading: isLoadingDomain || isLoadingConfig || isLoadingVerification || isRefetchingDomain || isRefetchingConfig || isRefetchingVerification, }; } ================================================ FILE: apps/dashboard/src/components/dropdowns/quick-actions.tsx ================================================ "use client"; import type * as React from "react"; import { useState, useTransition } from "react"; import { Check, Copy, type LucideIcon, MoreHorizontal, Trash2, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { Input } from "@openstatus/ui/components/ui/input"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; import { isTRPCClientError } from "@trpc/client"; import { toast } from "sonner"; interface QuickActionsProps extends React.ComponentProps<typeof Button> { align?: DropdownMenuContentProps["align"]; side?: DropdownMenuContentProps["side"]; actions?: { id: string; label: string; icon: LucideIcon; variant: "default" | "destructive"; onClick?: () => Promise<void> | void; }[]; deleteAction?: { /** * The value that must be typed to confirm deletion. Also used in the dialog title. */ confirmationValue: string; submitAction?: () => Promise<void>; }; } export function QuickActions({ align = "end", side, className, actions, deleteAction, children, ...props }: QuickActionsProps) { const [value, setValue] = useState(""); const [isPending, startTransition] = useTransition(); const [open, setOpen] = useState(false); const { copy, isCopied } = useCopyToClipboard(); const handleDelete = async () => { startTransition(async () => { if (!deleteAction?.submitAction) return; const promise = deleteAction.submitAction(); toast.promise(promise, { loading: "Deleting...", success: "Deleted", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to delete"; }, }); try { await promise; } catch (error) { console.error("Failed to delete:", error); } finally { setOpen(false); } }); }; return ( <AlertDialog open={open} onOpenChange={setOpen}> <DropdownMenu> <DropdownMenuTrigger asChild> {children ?? ( <Button variant="ghost" size="icon" className={className ?? "h-7 w-7 data-[state=open]:bg-accent"} {...props} > <MoreHorizontal /> </Button> )} </DropdownMenuTrigger> <DropdownMenuContent align={align} side={side} className="w-36"> <DropdownMenuLabel className="sr-only"> Quick Actions </DropdownMenuLabel> {actions ?.filter((item) => item.id !== "delete") .map((item) => ( <DropdownMenuGroup key={item.id}> <DropdownMenuItem variant={item.variant} disabled={!item.onClick} onClick={(e) => { e.stopPropagation(); item.onClick?.(); }} > <item.icon className="text-muted-foreground" /> <span className="truncate">{item.label}</span> </DropdownMenuItem> </DropdownMenuGroup> ))} {deleteAction && ( <> {/* NOTE: add a separator only if actions exist */} {actions?.length ? <DropdownMenuSeparator /> : null} <AlertDialogTrigger asChild> <DropdownMenuItem variant="destructive"> <Trash2 className="text-muted-foreground" /> Delete </DropdownMenuItem> </AlertDialogTrigger> </> )} </DropdownMenuContent> </DropdownMenu> <AlertDialogContent onCloseAutoFocus={(event) => { // NOTE: bug where the body is not clickable after closing the alert dialog event.preventDefault(); document.body.style.pointerEvents = ""; }} > <AlertDialogHeader> <AlertDialogTitle> Are you sure about deleting `{deleteAction?.confirmationValue}`? </AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. This will permanently remove the entry from the database. </AlertDialogDescription> </AlertDialogHeader> {deleteAction?.confirmationValue && ( <form id="form-alert-dialog" className="space-y-1.5"> <p className="text-muted-foreground text-sm"> Type{" "} <Button variant="secondary" size="sm" type="button" className="font-normal [&_svg]:size-3" onClick={() => copy(deleteAction.confirmationValue || "", { withToast: false, }) } > {deleteAction.confirmationValue} {isCopied ? <Check /> : <Copy />} </Button>{" "} to confirm </p> <Input value={value} onChange={(e) => setValue(e.target.value)} /> </form> )} <AlertDialogFooter> <AlertDialogCancel onClick={(e) => e.stopPropagation()}> Cancel </AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" disabled={ (deleteAction?.confirmationValue && value !== deleteAction?.confirmationValue) || isPending } form="form-alert-dialog" type="submit" onClick={(e) => { e.preventDefault(); handleDelete(); }} > {isPending ? "Deleting..." : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/form-components.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { STATUS } from "@/components/nav/nav-monitors"; import { Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay, } from "@/components/ui/sortable"; import { cn } from "@/lib/utils"; import type { UniqueIdentifier } from "@dnd-kit/core"; import { zodResolver } from "@hookform/resolvers/zod"; import type { RouterOutputs } from "@openstatus/api"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { isTRPCClientError } from "@trpc/client"; import { Check, Eye, EyeOff, GripVertical, Link2, Link2Off, Plug, Plus, Trash2, } from "lucide-react"; import { useCallback, useEffect, useState, useTransition } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; type PageComponent = RouterOutputs["pageComponent"]["list"][number]; type Monitor = RouterOutputs["monitor"]["list"][number]; type Workspace = RouterOutputs["workspace"]["get"]; type ComponentGroup = { id: number; name: string; components: PageComponent[]; }; const componentSchema = z.object({ id: z.number(), monitorId: z.number().nullish(), order: z.number(), name: z.string().min(1, { message: "Name is required" }), description: z.string().optional(), type: z.enum(["monitor", "static"]), }); const schema = z.object({ components: z.array(componentSchema), groups: z.array( z.object({ id: z.number(), order: z.number(), name: z.string(), components: z.array(componentSchema).min(1, { message: "At least one component is required", }), }), ), }); const getSortedComponents = ( components: PageComponent[], componentData: { id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }[], monitors: Monitor[], ) => { const orderMap = new Map(componentData?.map((c) => [c.id, c.order]) ?? []); // Create a map of existing components const componentMap = new Map(components.map((c) => [c.id, c])); // Create a map of monitors for lookup const monitorMap = new Map(monitors.map((m) => [m.id, m])); // Create synthetic components for any in componentData that don't exist in components componentData.forEach((c) => { if (!componentMap.has(c.id)) { // Look up monitor data if this is a monitor component const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null; // Create synthetic PageComponent componentMap.set(c.id, { id: c.id, name: c.name ?? "", type: c.type ?? "static", monitorId: c.monitorId ?? null, monitor: monitor ?? null, groupId: null, groupOrder: null, order: c.order, } as PageComponent); } }); return Array.from(componentMap.values()) .filter((component) => orderMap.has(component.id)) .sort((a, b) => { const aOrder = orderMap.get(a.id) ?? 0; const bOrder = orderMap.get(b.id) ?? 0; return aOrder - bOrder; }); }; const getSortedItems = ( components: PageComponent[], componentData: { id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }[], groups: Array<{ id: number; order: number; name: string; components: Array<{ id: number; order: number; name?: string; type?: "monitor" | "static"; monitorId?: number | null; }>; }>, monitors: Monitor[], ): (PageComponent | ComponentGroup)[] => { // Create map of component orders const componentOrderMap = new Map(componentData.map((c) => [c.id, c.order])); // Create a map of existing components const componentMap = new Map(components.map((c) => [c.id, c])); // Create a map of monitors for lookup const monitorMap = new Map(monitors.map((m) => [m.id, m])); // Create synthetic components for any in componentData that don't exist in components componentData.forEach((c) => { if (!componentMap.has(c.id)) { // Look up monitor data if this is a monitor component const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null; // Create synthetic PageComponent componentMap.set(c.id, { id: c.id, name: c.name ?? "", type: c.type ?? "static", monitorId: c.monitorId ?? null, monitor: monitor ?? null, groupId: null, groupOrder: null, order: c.order, } as PageComponent); } }); // Get all enhanced components (including synthetic ones) const enhancedComponents = Array.from(componentMap.values()); // Create array of components with their orders const componentsWithOrder = enhancedComponents .filter((component) => componentOrderMap.has(component.id)) .map((component) => ({ item: component, order: componentOrderMap.get(component.id) ?? 0, })); // Create array of groups with their orders const groupsWithOrder = groups.map((group) => ({ item: { id: group.id, name: group.name, components: getSortedComponents( enhancedComponents, group.components, monitors, ), } as ComponentGroup, order: group.order, })); // Combine and sort by order return [...componentsWithOrder, ...groupsWithOrder] .sort((a, b) => a.order - b.order) .map((entry) => entry.item); }; type FormValues = z.infer<typeof schema>; export function FormComponents({ defaultValues, onSubmit, pageComponents, allPageComponents, monitors, workspace, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; /** Page components available for selection (standalone, not in groups) */ pageComponents: PageComponent[]; /** All page components for the page (including those in groups) */ allPageComponents: PageComponent[]; /** Monitors available for selection */ monitors: Monitor[]; /** * The workspace the page belongs to */ workspace: Workspace; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { components: [], groups: [] }, }); const [isPending, startTransition] = useTransition(); const watchComponents = form.watch("components"); const watchGroups = form.watch("groups"); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const [data, setData] = useState<(PageComponent | ComponentGroup)[]>( getSortedItems( allPageComponents, defaultValues?.components ?? [], defaultValues?.groups ?? [], monitors, ), ); // Get all monitor IDs that are already used (in standalone components or groups) const usedMonitorIds = new Set([ ...(watchComponents ?? []) .filter((c) => c.monitorId) .map((c) => c.monitorId), ...(watchGroups ?? []) .flatMap((g) => g.components) .filter((c) => c.monitorId) .map((c) => c.monitorId), ]); useEffect(() => { const sortedItems = getSortedItems( allPageComponents, watchComponents, watchGroups ?? [], monitors, ); setData(sortedItems); }, [watchComponents, watchGroups, allPageComponents, monitors]); const validateLimit = useCallback(() => { const limitReached = workspace.limits["page-components"] <= data.length; if (limitReached) { setOpenUpgradeDialog(true); return false; } return true; }, [workspace, data.length]); const onValueChange = useCallback( (newItems: (PageComponent | ComponentGroup)[]) => { setData(newItems); // Update components with their position in the overall list const existingComponents = form.getValues("components") ?? []; const components = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: PageComponent; index: number } => "type" in entry.item, ) .map(({ item, index }) => { const existingComponent = existingComponents.find( (c) => c.id === item.id, ); return { id: item.id, monitorId: item.monitorId, order: index, name: existingComponent?.name ?? item.name, description: existingComponent?.description ?? "", type: item.type, }; }); form.setValue("components", components); // Update groups with their position in the overall list const existingGroups = form.getValues("groups") ?? []; const groups = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: ComponentGroup; index: number } => "components" in entry.item && !("type" in entry.item), ) .map(({ item, index }) => { const existingGroup = existingGroups.find((g) => g.id === item.id); return existingGroup ? { ...existingGroup, order: index, } : { id: item.id, order: index, name: item.name, components: [], }; }); form.setValue("groups", groups); }, [form], ); const getItemValue = useCallback( (item: PageComponent | ComponentGroup) => item.id, [], ); const handleAddGroup = useCallback(() => { if (!validateLimit()) return; const newGroupId = Date.now(); const existingGroups = form.getValues("groups") ?? []; const existingComponents = form.getValues("components") ?? []; const order = existingGroups.length + existingComponents.length; const newGroups = [ ...existingGroups, { id: newGroupId, order, name: "", components: [] }, ]; form.setValue("groups", newGroups); setData((prev) => [...prev, { id: newGroupId, name: "", components: [] }]); }, [form, validateLimit]); const handleDeleteGroup = useCallback( (groupId: number) => { const existingGroups = form.getValues("groups") ?? []; form.setValue( "groups", existingGroups.filter((g) => g.id !== groupId), ); setData((prev) => prev.filter((item) => item.id !== groupId)); }, [form], ); const handleDeleteComponent = useCallback( (componentId: number) => { const existingComponents = form.getValues("components") ?? []; form.setValue( "components", existingComponents.filter((c) => c.id !== componentId), ); setData((prev) => prev.filter((item) => item.id !== componentId)); }, [form], ); const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const index = data.findIndex((item) => item.id === value); if (index === -1) return null; const item = data[index]; if ("type" in item) { return ( <ComponentRow component={item} form={form} className="border-transparent border-x px-2" onDelete={handleDeleteComponent} // FIXME: this is used to show an input instead of the name when dragging a component // fieldNamePrefix={`components.${index}`} /> ); } const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex((g) => g.id === item.id); return ( <ComponentGroupRow group={item} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} allPageComponents={allPageComponents} monitors={monitors} validateLimit={validateLimit} /> ); }, [ data, handleDeleteGroup, form, allPageComponents, monitors, handleDeleteComponent, validateLimit, ], ); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <> <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Components</FormCardTitle> <FormCardDescription> Manage your page components </FormCardDescription> </FormCardHeader> <FormCardContent className="flex flex-row gap-2"> <Button variant="outline" type="button" onClick={handleAddGroup}> <Plus /> Add Component Group </Button> <FormField control={form.control} name="components" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel className="sr-only">Components</FormLabel> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="w-full"> <Plus /> Add Component </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuItem onClick={() => { if (!validateLimit()) return; form.setValue("components", [ ...field.value, { id: Date.now(), monitorId: null, order: watchComponents.length, name: "", description: "", type: "static" as const, }, ]); }} > <Link2Off className="text-muted-foreground" /> Add Static Component </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> <Link2 className="text-muted-foreground" /> Add Monitor Component </DropdownMenuSubTrigger> <DropdownMenuSubContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty> No monitors found. </CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const isUsed = usedMonitorIds.has( monitor.id, ); const isSelected = field.value.some( (c) => c.monitorId === monitor.id, ); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isUsed} onSelect={() => { if (isSelected) { form.setValue( "components", field.value.filter( (c) => c.monitorId !== monitor.id, ), ); } else { if (!validateLimit()) return; form.setValue("components", [ ...field.value, { id: Date.now(), monitorId: monitor.id, order: watchComponents.length, name: monitor.name, description: monitor.description, type: "monitor" as const, }, ]); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuItem disabled> <Plug className="text-muted-foreground" /> Add Third-Party Component </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item) => { if ("type" in item) { const components = form.getValues("components") ?? []; const componentIndex = components.findIndex( (c) => c.id === item.id, ); return ( <ComponentRow key={`${item.id}-component`} className="border-transparent border-x px-2" component={item} form={form} onDelete={handleDeleteComponent} fieldNamePrefix={ componentIndex >= 0 ? `components.${componentIndex}` : undefined } /> ); } const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex( (g) => g.id === item.id, ); return ( <ComponentGroupRow key={`${item.id}-group`} group={item} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} allPageComponents={allPageComponents} monitors={monitors} validateLimit={validateLimit} /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No components selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/status-page/#page-components"> page components </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> <UpgradeDialog limit="page-components" open={openUpgradeDialog} onOpenChange={setOpenUpgradeDialog} /> </> ); } interface ComponentRowProps extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { component: PageComponent; form: UseFormReturn<FormValues>; onDelete: (componentId: number) => void; /** The form field name prefix, e.g. "components.0" or "groups.0.components.1" */ fieldNamePrefix?: string; } function ComponentRow({ component, className, onDelete, form, fieldNamePrefix, ...props }: ComponentRowProps) { return ( <SortableItem value={component.id} asChild className={cn("rounded-md", className)} {...props} > <div className="grid h-9 grid-cols-4 gap-2"> <div className="flex flex-row items-center gap-1 self-center"> <SortableItemHandle> <GripVertical size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> {fieldNamePrefix ? ( <FormField key={`${component.id}-name-${fieldNamePrefix}`} control={form.control} name={`${fieldNamePrefix}.name` as "components.0.name"} render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only">Component name</FormLabel> <FormControl> <Input placeholder="Name" className="w-full bg-background" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> ) : ( <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> {component.name} </span> )} </div> <div className="flex flex-row items-center gap-1 self-center"> {fieldNamePrefix ? ( <FormField key={`${component.id}-description-${fieldNamePrefix}`} control={form.control} name={ `${fieldNamePrefix}.description` as "components.0.description" } render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only"> Component description </FormLabel> <FormControl> <Input placeholder="Description" className="w-full bg-background" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> ) : ( <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> {component.description} </span> )} </div> <div className="flex items-center gap-2 self-center text-muted-foreground text-sm"> {component.monitor && component.type === "monitor" ? ( <Link href={`/monitors/${component.monitorId}/overview`} onClick={(e) => e.stopPropagation()} className="flex w-full items-center gap-2 truncate py-1.5 text-sm" > <Link2 className="size-4 shrink-0" />{" "} <span className="truncate">{component.monitor.name}</span> </Link> ) : ( <span className="flex items-center gap-2 text-muted-foreground text-sm"> <Link2Off className="size-4 shrink-0" />{" "} <span className="truncate">Static Component</span> </span> )} </div> <div className="flex justify-between"> <div className="flex flex-1 items-center gap-2.5"> {component.monitor && component.type === "monitor" ? ( <div className="flex items-center gap-2"> <TooltipProvider delayDuration={0}> {component.monitor.public ? ( <Tooltip> <TooltipTrigger> <Eye className="size-4 text-muted-foreground" /> </TooltipTrigger> <TooltipContent>Public</TooltipContent> </Tooltip> ) : ( <Tooltip> <TooltipTrigger> <EyeOff className="size-4 text-muted-foreground" /> </TooltipTrigger> <TooltipContent>Private</TooltipContent> </Tooltip> )} </TooltipProvider> </div> ) : null} {component.monitor && component.type === "monitor" ? ( <div className={cn( "size-2 rounded-full", STATUS[ component.monitor.active ? component.monitor.status : "inactive" ], )} /> ) : null} </div> <AlertDialog> <AlertDialogTrigger asChild> <Button type="button" variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:size-4 [&_svg]:text-destructive" > <Trash2 /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription> Once saved, this will unlink the component from attached status reports and maintenances. </AlertDialogDescription> <ComponentAttachments statusReports={component.statusReports ?? []} maintenances={component.maintenances ?? []} /> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" onClick={() => onDelete(component.id)} > Remove </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </div> </SortableItem> ); } interface ComponentGroupRowProps extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { group: ComponentGroup; groupIndex: number; onDeleteGroup: (groupId: number) => void; form: UseFormReturn<FormValues>; allPageComponents: PageComponent[]; monitors: Monitor[]; validateLimit: () => boolean; } function ComponentGroupRow({ group, groupIndex, onDeleteGroup, form, allPageComponents, monitors, validateLimit, }: ComponentGroupRowProps) { const watchGroup = form.watch(`groups.${groupIndex}`); const watchComponents = form.watch("components"); const watchGroups = form.watch("groups"); const [data, setData] = useState<PageComponent[]>(group.components); // Calculate taken monitor IDs (in main list or other groups) const takenMonitorIds = new Set([ ...watchComponents.filter((c) => c.monitorId).map((c) => c.monitorId), ...watchGroups .filter((g) => g.id !== group.id) .flatMap((g) => g.components.filter((c) => c.monitorId).map((c) => c.monitorId), ), ]); // FIXME: order is not being updated in the form const onValueChange = useCallback( (newComponents: PageComponent[]) => { setData(newComponents); // Update the form with the new component order const existingComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; form.setValue( `groups.${groupIndex}.components`, newComponents.map((c, index) => { const existingComponent = existingComponents.find( (ec) => ec.id === c.id, ); return { id: c.id, monitorId: c.monitorId, order: index, name: existingComponent?.name ?? c.name, description: existingComponent?.description ?? "", type: c.type, }; }), ); }, [form, groupIndex], ); useEffect(() => { setData( getSortedComponents(allPageComponents, watchGroup.components, monitors), ); }, [watchGroup.components, allPageComponents, monitors]); const getItemValue = useCallback((item: PageComponent) => item.id, []); const handleDeleteComponent = useCallback( (componentId: number) => { const existingComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; form.setValue( `groups.${groupIndex}.components`, existingComponents.filter((c) => c.id !== componentId), ); setData((prev) => prev.filter((item) => item.id !== componentId)); }, [form, groupIndex], ); const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const component = data.find((item) => item.id === value); if (!component) return null; return ( <ComponentRow component={component} form={form} onDelete={handleDeleteComponent} /> ); }, [data, form, handleDeleteComponent], ); return ( <SortableItem value={group.id} className="rounded-md border bg-muted"> <div className="grid grid-cols-4 gap-2 px-2 pt-2"> <div className="flex flex-row items-center gap-1 self-center"> <SortableItemHandle> <GripVertical size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> <FormField key={`${group.id}-name-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.name` as const} render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only">Group name</FormLabel> <FormControl> <Input placeholder="Group Name" className="w-full bg-background" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField key={`${group.id}-components-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.components` as const} render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel className="sr-only">Components</FormLabel> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="w-full"> <Plus /> Add Component </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuGroup> <DropdownMenuItem onClick={() => { if (!validateLimit()) return; const current = field.value ?? []; form.setValue(`groups.${groupIndex}.components`, [ ...current, { id: Date.now(), monitorId: null, order: current.length, name: "", description: "", type: "static" as const, }, ]); }} > <Link2Off className="text-muted-foreground" /> Add Static Component </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> <Link2 className="text-muted-foreground" /> Add Monitor Component </DropdownMenuSubTrigger> <DropdownMenuSubContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty>No monitors found.</CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const current = field.value ?? []; const isTaken = takenMonitorIds.has(monitor.id); const isSelected = current.some( (c) => c.monitorId === monitor.id, ); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isTaken || isSelected} onSelect={() => { if (isSelected) { form.setValue( `groups.${groupIndex}.components`, current.filter( (c) => c.monitorId !== monitor.id, ), ); } else { if (!validateLimit()) return; form.setValue( `groups.${groupIndex}.components`, [ ...current, { id: Date.now(), monitorId: monitor.id, order: current.length, name: monitor.name, description: monitor.description, type: "monitor" as const, }, ], ); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuItem disabled> <Plug className="text-muted-foreground" /> Add Third-Party Component </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <FormMessage /> </FormItem> )} /> <div /> <div className="flex justify-end"> <AlertDialog> <AlertDialogTrigger asChild> <Button type="button" variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:size-4 [&_svg]:text-destructive" // NOTE: delete directly if no components are in the group {...(data.length === 0 ? { onClick: () => onDeleteGroup(group.id) } : {})} > <Trash2 /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription> Once saved, this will delete all components in the group and unlink them from attached status reports and maintenances. </AlertDialogDescription> <ComponentAttachments statusReports={Array.from( new Map( group.components .flatMap((c) => c.statusReports ?? []) .map((sr) => [sr.id, sr]), ).values(), )} maintenances={Array.from( new Map( group.components .flatMap((c) => c.maintenances ?? []) .map((m) => [m.id, m]), ).values(), )} /> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" onClick={() => onDeleteGroup(group.id)} > Remove </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </div> <div className="mt-2 border-t px-2 pt-2 pb-2"> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item, _index) => { const groupComponents = form.getValues(`groups.${groupIndex}.components`) ?? []; const componentIndex = groupComponents.findIndex( (c) => c.id === item.id, ); return ( <ComponentRow key={`${item.id}-component`} component={item} form={form} onDelete={handleDeleteComponent} fieldNamePrefix={ componentIndex >= 0 ? `groups.${groupIndex}.components.${componentIndex}` : undefined } /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No components selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </div> </SortableItem> ); } function ComponentAttachments({ statusReports, maintenances, }: { statusReports: Array<{ id: number; title: string }>; maintenances: Array<{ id: number; title: string }>; }) { if (statusReports.length === 0 && maintenances.length === 0) { return null; } const allItems = [ ...statusReports.map((report) => ({ id: `report-${report.id}`, title: report.title, type: "report" as const, })), ...maintenances.map((maintenance) => ({ id: `maintenance-${maintenance.id}`, title: maintenance.title, type: "maintenance" as const, })), ]; const displayLimit = 3; const displayedItems = allItems.slice(0, displayLimit); const remainingCount = allItems.length - displayLimit; return ( <ul className="list-inside list-disc space-y-1 text-foreground text-sm"> {displayedItems.map((item) => ( <li key={item.id}> {item.title}{" "} <span className="text-muted-foreground">({item.type})</span> </li> ))} {remainingCount > 0 && ( <li className="text-muted-foreground">+ {remainingCount} more</li> )} </ul> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/form-import.tsx ================================================ "use client"; import { Note } from "@/components/common/note"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { StatuspageIcon } from "@openstatus/icons"; import type { ImportSummary } from "@openstatus/importers/types"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { RadioGroup, RadioGroupItem, } from "@openstatus/ui/components/ui/radio-group"; import { Switch } from "@openstatus/ui/components/ui/switch"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { AlertTriangle } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ provider: z.enum(["statuspage"]), apiKey: z.string().min(1, "API key is required"), statuspagePageId: z.string().optional(), includeStatusReports: z.boolean(), includeSubscribers: z.boolean(), includeComponents: z.boolean(), }); export type ImportFormValues = z.input<typeof schema>; function getPhaseCount(preview: ImportSummary, phase: string): number { return preview.phases.find((p) => p.phase === phase)?.resources.length ?? 0; } const PHASE_LABELS: Record<string, string> = { componentGroups: "Component Groups", components: "Components", incidents: "Status Reports", maintenances: "Maintenances", subscribers: "Subscribers", }; export function FormImport({ pageId, onSubmit, }: { pageId: number; onSubmit: (values: ImportFormValues) => Promise<ImportSummary>; }) { const form = useForm<ImportFormValues>({ resolver: zodResolver(schema), defaultValues: { provider: undefined, apiKey: "", statuspagePageId: "", includeStatusReports: true, includeSubscribers: false, includeComponents: true, }, }); const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const watchProvider = form.watch("provider"); const watchApiKey = form.watch("apiKey"); const watchStatuspagePageId = form.watch("statuspagePageId"); const previewMutation = useMutation( trpc.import.preview.mutationOptions({ onError: (error) => { if (isTRPCClientError(error)) { toast.error(error.message); } else { toast.error("Failed to preview import"); } }, }), ); async function runPreview() { const apiKey = form.getValues("apiKey"); if (!apiKey) { form.setError("apiKey", { message: "API key is required" }); return; } previewMutation.mutate({ provider: "statuspage", apiKey: watchApiKey, statuspagePageId: watchStatuspagePageId || undefined, pageId, }); } function submitAction(values: ImportFormValues) { if (isPending || !previewMutation.data) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Importing...", success: (result) => { if (result.status === "partial") return "Import completed with warnings"; return "Import completed"; }, error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Import failed"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)}> <FormCard> <FormCardHeader> <FormCardTitle>Import</FormCardTitle> <FormCardDescription> Import components, incidents, and subscribers from an external status page provider. </FormCardDescription> </FormCardHeader> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="provider" render={({ field }) => ( <FormItem> <FormLabel>Provider</FormLabel> <FormControl> <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="grid grid-cols-2 gap-4 sm:grid-cols-4" > <FormItem className="relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50"> <FormControl> <RadioGroupItem value="statuspage" className="sr-only" /> </FormControl> <StatuspageIcon className="size-4 shrink-0 text-foreground" aria-hidden="true" /> <FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0"> Atlassian Statuspage </FormLabel> </FormItem> <div className="col-span-1 self-end text-muted-foreground text-xs sm:place-self-end"> Missing a provider?{" "} <a href="mailto:ping@openstatus.dev">Contact us</a> </div> </RadioGroup> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> {watchProvider ? ( <> <FormCardSeparator /> <FormCardContent className="grid gap-4"> <FormField control={form.control} name="apiKey" render={({ field }) => ( <FormItem> <FormLabel>API Key</FormLabel> <FormControl> <Input type="password" placeholder="OAuth API key" {...field} /> </FormControl> <FormMessage /> <FormDescription> Your Statuspage API key. Found in your Statuspage account under Manage Account > API. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="statuspagePageId" render={({ field }) => ( <FormItem> <FormLabel>Page ID (optional)</FormLabel> <FormControl> <Input placeholder="e.g. abc123def456" {...field} /> </FormControl> <FormDescription> Import a specific page. Leave empty to import across pages. </FormDescription> </FormItem> )} /> <Button type="button" variant="secondary" onClick={runPreview} disabled={previewMutation.isPending} > {previewMutation.isPending ? "Loading preview..." : "Preview Import"} </Button> </FormCardContent> </> ) : null} {previewMutation.data ? ( <> <FormCardSeparator /> <FormCardContent className="grid gap-4"> <div> <FormLabel>Preview</FormLabel> <div className="mt-2 flex flex-wrap gap-2"> {Object.entries(PHASE_LABELS).map(([key, label]) => { const count = getPhaseCount(previewMutation.data, key); if (count === 0) return null; return ( <Badge key={key} variant="secondary"> {label}: {count} </Badge> ); })} </div> </div> {previewMutation.data.errors.length > 0 ? ( <Note color="error" size="sm"> <AlertTriangle /> <p className="text-sm"> {previewMutation.data.errors.join(" ")} </p> </Note> ) : null} <FormField control={form.control} name="includeStatusReports" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel>Status Reports & Maintenances</FormLabel> <FormDescription> Import incidents as status reports and scheduled maintenances. </FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} /> <FormField control={form.control} name="includeComponents" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel>Components</FormLabel> <FormDescription> Import components and groups. </FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} /> <FormField control={form.control} name="includeSubscribers" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel>Subscribers</FormLabel> <FormDescription> Import email subscribers. </FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} /> </FormCardContent> </> ) : null} <FormCardFooter> <Button type="submit" disabled={ !previewMutation.data || isPending || previewMutation.data.errors.length > 0 } > {isPending ? "Importing..." : "Import"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/telegram-connection-flow.tsx ================================================ "use client"; import { useTelegramConnection } from "@/hooks/use-telegram-connection"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import type { UseFormReturn } from "react-hook-form"; import type { FormValues } from "../notifications/form-telegram"; import { TelegramManualInput } from "./telegram-manual-input"; import { TelegramQRConnection } from "./telegram-qr-connection"; interface TelegramConnectionFlowProps { form: UseFormReturn<FormValues>; mode: "qr" | "manual" | null; onModeChange: (mode: "qr" | "manual" | null) => void; } export function TelegramConnectionFlow({ form, mode, onModeChange, }: TelegramConnectionFlowProps) { const { tokenData, isTokenLoading, flowStep, privateChatId, userName, groupTitle, isPolling, resetConnection, confirmPrivateChat, } = useTelegramConnection({ form, mode }); return ( <Tabs value={mode ?? "qr"} onValueChange={(v) => onModeChange(v as "qr" | "manual")} > <TabsList className="w-full"> <TabsTrigger value="qr" className="flex-1"> Connect with QR </TabsTrigger> <TabsTrigger value="manual" className="flex-1"> Enter ChatID manually </TabsTrigger> </TabsList> <TabsContent value="qr"> <TelegramQRConnection form={form} token={tokenData?.token} isLoading={isTokenLoading} isPolling={isPolling} flowStep={flowStep} privateChatId={privateChatId} userName={userName} groupTitle={groupTitle} onReset={resetConnection} onConfirmPrivateChat={confirmPrivateChat} /> </TabsContent> <TabsContent value="manual"> <TelegramManualInput form={form} /> </TabsContent> </Tabs> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/telegram-form-actions.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import type { UseFormReturn } from "react-hook-form"; import { toast } from "sonner"; import type { FormValues } from "../notifications/form-telegram"; interface TelegramFormActionsProps { form: UseFormReturn<FormValues>; isPending: boolean; } export function TelegramFormActions({ form, isPending, }: TelegramFormActionsProps) { const [_, startTransition] = useTransition(); const trpc = useTRPC(); const sendTestMutation = useMutation( trpc.notification.sendTest.mutationOptions(), ); function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { telegram: { chatId: data.chatId }, }, }); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (isTRPCClientError(error)) { return error.message; } if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <div> <Button variant="outline" size="sm" type="button" onClick={testAction} disabled={isPending} > Send Test </Button> </div> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/telegram-manual-input.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import type { UseFormReturn } from "react-hook-form"; import type { FormValues } from "../notifications/form-telegram"; interface TelegramManualInputProps { form: UseFormReturn<FormValues>; successMsg?: string; showDescription?: boolean; } export function TelegramManualInput({ form, successMsg, showDescription = true, }: TelegramManualInputProps) { return ( <FormField control={form.control} name="data.chatId" render={({ field }) => ( <FormItem> <FormLabel>Telegram Chat ID</FormLabel> <FormControl> <Input placeholder="1234567890" {...field} /> </FormControl> <FormMessage /> {successMsg && ( <div className="font-medium text-green-600 text-sm"> {successMsg} </div> )} {showDescription && ( <FormDescription> Enter the Telegram chat ID to send notifications to.{" "} <Link href="https://docs.openstatus.dev/reference/notification/#telegram" rel="noreferrer" target="_blank" > Learn more </Link> </FormDescription> )} </FormItem> )} /> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/telegram-qr-connection.tsx ================================================ import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import type { UseFormReturn } from "react-hook-form"; import type { FormValues } from "../notifications/form-telegram"; import { TelegramManualInput } from "./telegram-manual-input"; import TelegramQRCode from "./telegram-qrcode"; interface TelegramQRConnectionProps { form: UseFormReturn<FormValues>; token?: string; isLoading: boolean; isPolling?: boolean; flowStep: "private" | "group"; privateChatId: string | null; userName?: string | null; groupTitle?: string | null; onReset?: () => void; onConfirmPrivateChat?: () => void; } export function TelegramQRConnection({ form, token, isLoading, isPolling, flowStep, privateChatId, userName, groupTitle, onReset, onConfirmPrivateChat, }: TelegramQRConnectionProps) { const chatId = form.watch("data.chatId"); const isGroup = !!groupTitle; // When we have a chat ID (group or private), show the manual input with connection info if (chatId) { const successMsg = isGroup ? `Connected to ${groupTitle}` : `Connected to ${userName || "Unknown"}'s private chat`; return ( <div className="flex flex-col gap-2"> <TelegramManualInput form={form} successMsg={successMsg} showDescription={false} /> <Button type="button" variant="outline" size="sm" onClick={onReset} className="w-full" > {isGroup ? "Reset Group ID" : "Add Group"} </Button> </div> ); } // When we have a private chat ID, show read-only info with second QR code if (privateChatId && flowStep === "group") { return ( <div className="flex flex-col gap-2"> {/* Show read-only private chat info */} <div className="space-y-2"> <Label>Private Chat ID</Label> <Input value={privateChatId} readOnly className="bg-muted" /> {userName && ( <div className="font-medium text-green-600 text-sm"> {`Connected to: ${userName}`} </div> )} </div> {/* Show second QR code for group connection */} <div className="text-muted-foreground text-sm"> Step 2 of 2: Add bot to your group </div> <TelegramQRCode chatType="group" token={token} isLoading={isLoading} isPolling={isPolling} /> <Button type="button" variant="outline" size="sm" onClick={onConfirmPrivateChat} className="w-full" > Use private chat only </Button> </div> ); } // Initial state: show first QR code for private chat connection return ( <div className="flex flex-col gap-2"> <div className="text-muted-foreground text-sm"> Step 1 of 2: Connect your Telegram account </div> <TelegramQRCode chatType="private" token={token} isLoading={isLoading} isPolling={isPolling} /> </div> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/telegram-qrcode.tsx ================================================ import { QRCode } from "@openstatus/ui/components/ui/qr-code"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Loader2 } from "lucide-react"; export default function TelegramQRCode({ chatType, token, isLoading, isPolling, }: { chatType: "group" | "private"; token?: string | undefined; isLoading: boolean; isPolling?: boolean; }) { const telegramBotUserName = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME; // Grpoup : t.me/<bot_username>?startgroup=<parameter>&admin=<permissions> // Private Chat: t.me/<bot_username>?start=<parameter> const qrURL = chatType === "group" ? `https://t.me/${telegramBotUserName}?startgroup=${token}&admin=post_messages` : `https://t.me/${telegramBotUserName}?start=${token}`; return ( <div className="flex flex-col items-center justify-center gap-2"> {isLoading ? ( <Skeleton className="h-[200px] w-[200px]" /> ) : token ? ( <QRCode data={qrURL} className="overflow-hidden rounded-md" /> ) : null} <div className="flex items-center gap-2 text-muted-foreground text-sm"> {isLoading ? ( "Generating QR Code..." ) : isPolling ? ( <> <Loader2 className="h-3 w-3 animate-spin" /> {chatType === "private" ? "Retrieving your account..." : "Waiting for group connection..."} </> ) : chatType === "private" ? ( "Scan the QR code to connect your account" ) : ( "Scan to add the bot to your group" )} </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/forms/components/update.tsx ================================================ "use client"; import { FormComponents } from "@/components/forms/components/form-components"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useState } from "react"; import { FormCardGroup } from "../form-card"; import { FormConfiguration } from "../status-page/form-configuration"; import { FormImport, type ImportFormValues } from "./form-import"; export function FormComponentsUpdate() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const [formKey, setFormKey] = useState(0); const { data: statusPage, refetch } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: pageComponents, refetch: refetchComponents } = useQuery( trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), ); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const updateComponentsMutation = useMutation( trpc.pageComponent.updateOrder.mutationOptions({ onSuccess: () => { refetch(); refetchComponents(); }, }), ); const updatePageConfigurationMutation = useMutation( trpc.page.updatePageConfiguration.mutationOptions({ onSuccess: () => refetch(), }), ); const importMutation = useMutation( trpc.import.run.mutationOptions({ onSuccess: async () => { await Promise.all([refetch(), refetchComponents()]); setFormKey((k) => k + 1); }, }), ); if (!statusPage || !pageComponents || !monitors || !workspace) return null; // Separate standalone components from grouped components const standaloneComponents = pageComponents.filter((c) => !c.groupId); const groupedComponents = pageComponents.filter((c) => c.groupId); // Build groups from pageComponentGroups const groups = statusPage.pageComponentGroups.map((group) => { const componentsInGroup = groupedComponents.filter( (c) => c.groupId === group.id, ); // Find the order of the group (use the first component's order) const firstComponent = componentsInGroup[0]; return { id: group.id, order: firstComponent?.order ?? 0, name: group.name, components: componentsInGroup.map((c) => ({ id: c.id, monitorId: c.monitorId, order: c.groupOrder ?? 0, name: c.name, description: c.description ?? "", type: c.type, })), }; }); // Build default values for the form const defaultValues = { components: standaloneComponents.map((c) => ({ id: c.id, monitorId: c.monitorId, order: c.order ?? 0, name: c.name, description: c.description ?? "", type: c.type, })), groups, }; const configLink = `https://${ statusPage.slug }.stpg.dev?configuration-token=${statusPage.createdAt?.getTime().toString()}`; return ( <FormCardGroup> {/* key forces remount after import so useForm picks up new defaultValues */} <FormComponents key={formKey} pageComponents={standaloneComponents} monitors={monitors} allPageComponents={pageComponents} defaultValues={defaultValues} workspace={workspace} onSubmit={async (values) => { await updateComponentsMutation.mutateAsync({ pageId: Number.parseInt(id), components: values.components, groups: values.groups.map(({ id: _groupId, ...rest }) => rest), }); }} /> <FormConfiguration defaultValues={{ configuration: statusPage.configuration ?? {}, }} onSubmit={async (values) => { await updatePageConfigurationMutation.mutateAsync({ id: Number.parseInt(id), configuration: { uptime: typeof values.configuration.uptime === "boolean" ? values.configuration.uptime : values.configuration.uptime === "true", value: values.configuration.value ?? "duration", type: values.configuration.type ?? "absolute", theme: values.configuration.theme ?? undefined, }, }); }} configLink={configLink} /> <FormImport pageId={statusPage.id} onSubmit={async (values: ImportFormValues) => { return await importMutation.mutateAsync({ provider: values.provider, apiKey: values.apiKey, pageId: statusPage.id, statuspagePageId: values.statuspagePageId ?? undefined, options: { includeStatusReports: values.includeStatusReports, includeSubscribers: values.includeSubscribers, includeComponents: values.includeComponents, }, }); }} /> </FormCardGroup> ); } ================================================ FILE: apps/dashboard/src/components/forms/form-alert-dialog.tsx ================================================ "use client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { isTRPCClientError } from "@trpc/client"; import { Check, Copy } from "lucide-react"; import { useState, useTransition } from "react"; import { toast } from "sonner"; interface FormAlertDialogProps { confirmationValue: string; submitAction: () => Promise<void>; children?: React.ReactNode; } export function FormAlertDialog({ confirmationValue, submitAction, children, }: FormAlertDialogProps) { const [value, setValue] = useState(""); const [isPending, startTransition] = useTransition(); const { copy, isCopied } = useCopyToClipboard(); const [open, setOpen] = useState(false); const handleDelete = async () => { try { startTransition(async () => { const promise = submitAction(); toast.promise(promise, { loading: "Deleting...", success: "Deleted", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to delete"; }, }); await promise; setOpen(false); }); } catch (error) { console.error("Failed to revoke:", error); } }; return ( <AlertDialog open={open} onOpenChange={setOpen}> <AlertDialogTrigger asChild> {children ?? ( <Button variant="destructive" size="sm"> Delete </Button> )} </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> Are you sure about delete `{confirmationValue}`? </AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. This will permanently delete the item. </AlertDialogDescription> </AlertDialogHeader> <form id="form-alert-dialog" className="space-y-1.5"> <p className="text-muted-foreground text-sm"> Type{" "} <Button variant="secondary" size="sm" type="button" className="font-normal [&_svg]:size-3" onClick={() => copy(confirmationValue, { withToast: false })} > {confirmationValue} {isCopied ? <Check /> : <Copy />} </Button>{" "} to confirm </p> <Input value={value} onChange={(e) => setValue(e.target.value)} /> </form> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" disabled={value !== confirmationValue || isPending} form="form-alert-dialog" type="submit" onClick={(e) => { e.preventDefault(); handleDelete(); }} > {isPending ? "Deleting..." : "Delete"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } ================================================ FILE: apps/dashboard/src/components/forms/form-card.tsx ================================================ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@openstatus/ui/components/ui/card"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; // py-0 const formCardVariants = cva( "group relative w-full overflow-hidden py-0 shadow-none gap-4", { variants: { variant: { default: "", destructive: "border-destructive", info: "border-info", }, defaultVariants: { variant: "default", }, }, }, ); // NOTE: Add a formcardprovider to share the variant prop export function FormCard({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof formCardVariants>) { return ( <Card className={cn(formCardVariants({ variant }), className)} {...props}> {children} </Card> ); } export function FormCardHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardHeader className={cn( "px-4 pt-4 group-has-data-[slot=card-upgrade]:pointer-events-none group-has-data-[slot=card-upgrade]:opacity-50 [.border-b]:pb-4", className, )} {...props} > {children} </CardHeader> ); } export function FormCardTitle({ children }: { children: React.ReactNode }) { return <CardTitle>{children}</CardTitle>; } export function FormCardDescription({ children, }: { children: React.ReactNode; }) { return <CardDescription>{children}</CardDescription>; } export function FormCardContent({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardContent className={cn( "px-4 group-has-data-[slot=card-upgrade]:pointer-events-none group-has-data-[slot=card-upgrade]:opacity-50", "has-data-[slot=card-content-upgrade]:pointer-events-none has-data-[slot=card-content-upgrade]:opacity-50", className, )} {...props} > {children} </CardContent> ); } export function FormCardSeparator({ ...props }: React.ComponentProps<typeof Separator>) { return <Separator {...props} />; } const formCardFooterVariants = cva( "border-t flex items-center gap-2 pb-4 px-4 [&>:last-child]:ml-auto [.border-t]:pt-4", { variants: { variant: { default: "", destructive: "border-destructive bg-destructive/5", info: "border-info bg-info/5", }, defaultVariants: { variant: "default", }, }, }, ); export function FormCardFooter({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof formCardFooterVariants>) { return ( <CardFooter className={cn(formCardFooterVariants({ variant }), className)} {...props} > {children} </CardFooter> ); } export function FormCardFooterInfo({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-footer-info" className={cn("text-muted-foreground text-sm", className)} {...props} > {children} </div> ); } export function FormCardGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-group" className={cn("flex flex-col gap-4", className)} {...props} > {children} </div> ); } export function FormCardUpgrade({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-upgrade" className={cn("hidden", className)} {...props} > {children} </div> ); } // NOTE; this is for a very specific case where we don't want to disable the whole content // and instead disable specpfic card content (e.g. for add-ons) export function FormCardContentUpgrade({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-content-upgrade" className={cn("hidden", className)} {...props} > {children} </div> ); } export function FormCardEmpty({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-empty" className={cn( "pointer-events-none absolute inset-0 z-10 bg-background opacity-70 blur", className, )} {...props} > {children} </div> ); } ================================================ FILE: apps/dashboard/src/components/forms/form-sheet.tsx ================================================ "use client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@openstatus/ui/components/ui/alert-dialog"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@openstatus/ui/components/ui/sheet"; import { cn } from "@openstatus/ui/lib/utils"; import React, { createContext, useContext, useEffect, useRef, useState, } from "react"; export function FormSheetContent({ children, className, ...props }: React.ComponentProps<typeof SheetContent>) { return ( <SheetContent className={cn("max-h-screen gap-0", className)} {...props}> {children} </SheetContent> ); } export function FormSheetHeader({ children, className, ...props }: React.ComponentProps<typeof SheetHeader>) { return ( <SheetHeader className={cn("sticky top-0 border-b bg-background", className)} {...props} > {children} </SheetHeader> ); } export function FormSheetFooter({ children, className, ...props }: React.ComponentProps<typeof SheetFooter>) { return ( <SheetFooter className={cn("sticky bottom-0 border-t bg-background", className)} {...props} > {children} </SheetFooter> ); } export function FormSheetFooterInfo({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-muted-foreground/70 text-xs", className)} {...props}> {children} </p> ); } export function FormSheetTrigger({ children, className, disabled, ...props }: React.ComponentProps<typeof SheetTrigger>) { return ( <SheetTrigger className={cn( "cursor-pointer data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", className, )} data-disabled={disabled} disabled={disabled} {...props} > {children} </SheetTrigger> ); } export function FormSheetAlertDialog({ onConfirm, ...props }: React.ComponentProps<typeof AlertDialog> & { onConfirm: () => void; }) { return ( <AlertDialog {...props}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Discard changes?</AlertDialogTitle> <AlertDialogDescription> You have unsaved changes. Are you sure you want to discard them? </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Continue editing</AlertDialogCancel> <AlertDialogAction onClick={onConfirm}> Discard changes </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } const FormSheetDirtyContext = createContext<{ isDirty: boolean; setIsDirty: (dirty: boolean) => void; } | null>(null); export function useFormSheetDirty() { const context = useContext(FormSheetDirtyContext); if (!context) { throw new Error( "useFormSheetDirty must be used within FormSheetWithDirtyProtection", ); } return context; } export function FormSheetWithDirtyProtection({ children, open: controlledOpen, onOpenChange: controlledOnOpenChange, }: { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; }) { const [internalOpen, setInternalOpen] = useState(false); const [isDirty, setIsDirty] = useState(false); const [showAlert, setShowAlert] = useState(false); const shouldBypassAlert = useRef(false); const open = controlledOpen ?? internalOpen; const setOpen = controlledOnOpenChange ?? setInternalOpen; // Reset states when sheet closes useEffect(() => { if (!open) { setIsDirty(false); shouldBypassAlert.current = false; } }, [open]); const handleOpenChange = (newOpen: boolean) => { if (!newOpen && isDirty && !shouldBypassAlert.current) { // User is trying to close with unsaved changes setShowAlert(true); } else { setOpen(newOpen); } }; const handleDiscardChanges = () => { shouldBypassAlert.current = true; setShowAlert(false); setOpen(false); }; const handleInteractOutside = (e: Event) => { if (isDirty) { e.preventDefault(); setShowAlert(true); } }; const handleEscapeKeyDown = (e: KeyboardEvent) => { if (isDirty) { e.preventDefault(); setShowAlert(true); } }; return ( <FormSheetDirtyContext.Provider value={{ isDirty, setIsDirty }}> <Sheet open={open} onOpenChange={handleOpenChange}> {/* Clone children and inject event handlers if it's SheetContent */} {React.Children.map(children, (child) => { if ( React.isValidElement(child) && (child.type === FormSheetContent || child.type === SheetContent) ) { return React.cloneElement( child as React.ReactElement<{ onInteractOutside?: (e: Event) => void; onEscapeKeyDown?: (e: KeyboardEvent) => void; }>, { onInteractOutside: handleInteractOutside, onEscapeKeyDown: handleEscapeKeyDown, }, ); } return child; })} </Sheet> <FormSheetAlertDialog open={showAlert} onOpenChange={setShowAlert} onConfirm={handleDiscardChanges} /> </FormSheetDirtyContext.Provider> ); } export { SheetTitle as FormSheetTitle, SheetDescription as FormSheetDescription, Sheet as FormSheet, }; ================================================ FILE: apps/dashboard/src/components/forms/maintenance/form.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { ProcessMessage } from "@/components/content/process-message"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { addDays, format } from "date-fns"; import { CalendarIcon, ClockIcon } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z .object({ title: z.string().min(1, "Title is required"), message: z.string(), startDate: z.date(), endDate: z.date(), pageComponents: z.array(z.number()), notifySubscribers: z.boolean().optional(), }) .refine((data) => data.endDate > data.startDate, { error: "End date cannot be earlier than start date.", path: ["endDate"], }); export type FormValues = z.infer<typeof schema>; export function FormMaintenance({ defaultValues, onSubmit, className, pageComponents, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; pageComponents: { id: number; name: string }[]; onSubmit: (values: FormValues) => Promise<void>; }) { const trpc = useTRPC(); const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { title: "", message: "", startDate: new Date(), endDate: addDays(new Date(), 1), pageComponents: [], notifySubscribers: true, }, }); const watchEndDate = form.watch("endDate"); const watchMessage = form.watch("message"); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent> <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl> <Input placeholder="DB migration..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> {/* TODO: */} <FormField control={form.control} name="startDate" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>Start Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground", )} > {field.value ? ( format(field.value, "PPP 'at' h:mm a") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" side={mobile ? "bottom" : "left"} > <Calendar mode="single" selected={field.value} onSelect={(selectedDate) => { if (!selectedDate) return; const newDate = new Date(selectedDate); newDate.setHours( field.value.getHours(), field.value.getMinutes(), field.value.getSeconds(), field.value.getMilliseconds(), ); field.onChange(newDate); // NOTE: if end date is before start date, set it to the same day as the start date if (watchEndDate && newDate > watchEndDate) { form.setValue("endDate", newDate); } }} initialFocus /> <div className="border-t p-3"> <div className="flex items-center gap-3"> <Label htmlFor="time-start" className="text-xs"> Enter time </Label> <div className="relative grow"> <Input id="time-start" type="time" step="1" value={ field.value ? field.value.toTimeString().slice(0, 8) : new Date().toTimeString().slice(0, 8) } className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" onChange={(e) => { try { const timeValue = e.target.value; if (!timeValue || !field.value) return; const [hours, minutes, seconds] = timeValue .split(":") .map(Number); const newDate = new Date(field.value); newDate.setHours( hours, minutes, seconds || 0, 0, ); field.onChange(newDate); } catch (error) { console.error(error); } }} /> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <ClockIcon size={16} aria-hidden="true" /> </div> </div> </div> </div> </PopoverContent> </Popover> <FormDescription> When the maintenance starts. Shown in your timezone ( <code className="font-commit-mono text-foreground/70"> {timezone} </code> ) and saved as Unix time ( <code className="font-commit-mono text-foreground/70"> UTC </code> ). </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="endDate" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>End Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground", )} > {field.value ? ( format(field.value, "PPP 'at' h:mm a") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" side={mobile ? "bottom" : "left"} > <Calendar mode="single" selected={field.value} onSelect={(selectedDate) => { if (!selectedDate) return; const newDate = new Date(selectedDate); newDate.setHours( field.value.getHours(), field.value.getMinutes(), field.value.getSeconds(), field.value.getMilliseconds(), ); field.onChange(newDate); }} initialFocus /> <div className="border-t p-3"> <div className="flex items-center gap-3"> <Label htmlFor="time-end" className="text-xs"> Enter time </Label> <div className="relative grow"> <Input id="time-end" type="time" step="1" value={ field.value ? field.value.toTimeString().slice(0, 8) : new Date().toTimeString().slice(0, 8) } className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" onChange={(e) => { try { const timeValue = e.target.value; if (!timeValue || !field.value) return; const [hours, minutes, seconds] = timeValue .split(":") .map(Number); const newDate = new Date(field.value); newDate.setHours( hours, minutes, seconds || 0, 0, ); field.onChange(newDate); } catch (error) { console.error(error); } }} /> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <ClockIcon size={16} aria-hidden="true" /> </div> </div> </div> </div> </PopoverContent> </Popover> <FormDescription> When the maintenance ends. Shown in your timezone ( <code className="font-commit-mono text-foreground/70"> {timezone} </code> ) and saved as Unix time ( <code className="font-commit-mono text-foreground/70"> UTC </code> ). </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Tabs defaultValue="tab-1"> <TabsList> <TabsTrigger value="tab-1">Writing</TabsTrigger> <TabsTrigger value="tab-2">Preview</TabsTrigger> </TabsList> <TabsContent value="tab-1"> <FormField control={form.control} name="message" render={({ field }) => ( <FormItem> <FormLabel>Message</FormLabel> <FormControl> <Textarea rows={6} {...field} /> </FormControl> <FormMessage /> <FormDescription>Markdown support</FormDescription> </FormItem> )} /> </TabsContent> <TabsContent value="tab-2"> <div className="grid gap-2"> <Label>Preview</Label> <div className="prose dark:prose-invert prose-sm rounded-md border px-3 py-2 text-foreground text-sm"> <ProcessMessage value={watchMessage} /> </div> </div> </TabsContent> </Tabs> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="pageComponents" render={({ field }) => ( <FormItem> <FormLabel>Page Components</FormLabel> <FormDescription> Connected page components will be affected for the period of time. </FormDescription> {pageComponents.length ? ( <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={ field.value?.length === pageComponents.length } onCheckedChange={(checked) => { field.onChange( checked ? pageComponents.map((c) => c.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {pageComponents.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> ) : ( <EmptyStateContainer> <EmptyStateTitle>No page components found</EmptyStateTitle> </EmptyStateContainer> )} <FormMessage /> </FormItem> )} /> </FormCardContent> {!defaultValues && workspace?.limits["status-subscribers"] ? ( <> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="notifySubscribers" render={({ field }) => ( <FormItem> <FormLabel>Notify Subscribers</FormLabel> <FormControl> <div className="flex items-center gap-2"> <Checkbox id="notifySubscribers" checked={field.value} onCheckedChange={field.onChange} /> <Label htmlFor="notifySubscribers"> Send email notification to subscribers </Label> </div> </FormControl> <FormMessage /> <FormDescription> Subscribers will receive an email when creating a maintenance. </FormDescription> </FormItem> )} /> </FormCardContent> </> ) : null} </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/maintenance/sheet.tsx ================================================ "use client"; import { FormCard, FormCardGroup } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetFooterInfo, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { FormMaintenance, type FormValues, } from "@/components/forms/maintenance/form"; import { Button } from "@openstatus/ui/components/ui/button"; import { useState } from "react"; export function FormSheetMaintenance({ children, defaultValues, onSubmit, pageComponents, ...props }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: FormValues; pageComponents: { id: number; name: string }[]; onSubmit: (values: FormValues) => Promise<void>; }) { const [open, setOpen] = useState(false); return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger {...props} asChild> {children} </FormSheetTrigger> <FormSheetContent className="sm:max-w-lg"> <FormSheetHeader> <FormSheetTitle>Maintenance</FormSheetTitle> <FormSheetDescription> Configure and update the maintenance. </FormSheetDescription> </FormSheetHeader> <FormCardGroup className="overflow-y-auto"> <FormCard className="overflow-auto rounded-none border-none"> <FormMaintenance pageComponents={pageComponents} onSubmit={async (values) => { await onSubmit(values); setOpen(false); }} defaultValues={defaultValues} id="maintenance-form" className="my-4" /> </FormCard> </FormCardGroup> <FormSheetFooter> {defaultValues ? ( <FormSheetFooterInfo> Last Updated {/* TODO: use updatedAt */} <time>{defaultValues.startDate.toLocaleString()}</time> </FormSheetFooterInfo> ) : null} <Button type="submit" form="maintenance-form"> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-danger-zone.tsx ================================================ "use client"; import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; import { FormCard, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; export function FormDangerZone({ onSubmit, title, }: { onSubmit: () => Promise<void>; title: string; }) { return ( <FormCard variant="destructive"> <FormCardHeader> <FormCardTitle>Danger Zone</FormCardTitle> <FormCardDescription>This action cannot be undone.</FormCardDescription> </FormCardHeader> <FormCardFooter variant="destructive" className="justify-end"> <FormAlertDialog confirmationValue={title} submitAction={onSubmit} /> </FormCardFooter> </FormCard> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-follow-redirect.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Switch } from "@openstatus/ui/components/ui/switch"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; export const FOLLOW_REDIRECTS_DEFAULT = true; const schema = z.object({ followRedirects: z.boolean().prefault(true), }); type FormValues = z.input<typeof schema>; export function FormFollowRedirect({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { followRedirects: FOLLOW_REDIRECTS_DEFAULT, }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Follow Redirects</FormCardTitle> <FormCardDescription> Configure whether to follow redirects. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4"> <FormField control={form.control} name="followRedirects" render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel>Follow redirects</FormLabel> <FormDescription> When enabled, the monitor will automatically follow HTTP redirects (3xx status codes) to their final destination. This is useful when monitoring URLs that may redirect to other locations. </FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/monitoring/customization/follow-redirects/" rel="noreferrer" target="_blank" > follow redirects </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-general.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { dnsRecords, headerAssertion, jsonBodyAssertion, numberCompareDictionary, recordAssertion, recordCompareDictionary, statusAssertion, stringCompareDictionary, textBodyAssertion, } from "@openstatus/assertions"; import { monitorMethods } from "@openstatus/db/src/schema"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { RadioGroup, RadioGroupItem, } from "@openstatus/ui/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { Switch } from "@openstatus/ui/components/ui/switch"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { Globe, Network, Plus, Server, X } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const TYPES = ["http", "tcp", "dns"] as const; const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; const schema = z.object({ name: z.string().min(1, "Name is required"), type: z.enum(TYPES), method: z.enum(monitorMethods), url: z.string().min(1, "URL is required"), headers: z.array( z.object({ key: z.string(), value: z.string(), }), ), active: z.boolean().optional().prefault(true), assertions: z.array( z.discriminatedUnion("type", [ statusAssertion, headerAssertion, textBodyAssertion, jsonBodyAssertion, recordAssertion, ]), ), body: z.string().optional(), skipCheck: z.boolean().optional().prefault(false), saveCheck: z.boolean().optional().prefault(false), }); type FormValues = z.input<typeof schema>; export function FormGeneral({ defaultValues, disabled, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; disabled?: boolean; }) { const [error, setError] = useState<string | null>(null); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { active: true, name: "", type: undefined, method: "GET", url: "", headers: [], body: "", assertions: [], skipCheck: false, saveCheck: false, }, }); const [isPending, startTransition] = useTransition(); const watchType = form.watch("type"); const watchMethod = form.watch("method"); useEffect(() => { // NOTE: reset form when type changes if (watchType && !defaultValues) { form.setValue("assertions", []); form.setValue("body", ""); form.setValue("headers", []); form.setValue("method", "GET"); form.setValue("url", ""); } }, [watchType, defaultValues, form]); function submitAction(values: FormValues) { console.log("submitAction", values); if (isPending || disabled) return; // Validate assertions based on type for (let i = 0; i < values.assertions.length; i++) { const assertion = values.assertions[i]; if (assertion.type === "status") { if (typeof assertion.target !== "number" || assertion.target <= 0) { form.setError(`assertions.${i}.target`, { message: "Status target must be a positive number", }); return; } } else if (assertion.type === "header") { if (!assertion.key || assertion.key.trim() === "") { form.setError(`assertions.${i}.key`, { message: "Header key is required", }); return; } if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "Header target is required", }); return; } } else if (assertion.type === "textBody") { if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "Body target is required", }); return; } } else if (assertion.type === "dnsRecord") { if (!assertion.key || assertion.key.trim() === "") { form.setError(`assertions.${i}.key`, { message: "DNS record key is required", }); return; } if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "DNS record target is required", }); return; } } } startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { setError(error.message); return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Monitor Configuration</FormCardTitle> <FormCardDescription> Configure your monitor settings and endpoints. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="OpenStatus API" {...field} /> </FormControl> <FormMessage /> <FormDescription> Displayed on the status page. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="active" render={({ field }) => ( <FormItem className="flex flex-row items-center"> <FormLabel>Active</FormLabel> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="type" render={({ field }) => ( <FormItem> <FormLabel>Monitoring Type</FormLabel> <FormControl> <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="grid grid-cols-2 gap-4 sm:grid-cols-4" disabled={!!defaultValues?.type} > {[ { value: "http", icon: Globe, label: "HTTP" }, { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, ].map((type) => { return ( <Tooltip key={type.value}> <TooltipTrigger asChild> <FormItem className={cn( "relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-aria-[invalid=true]:border-destructive has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50", defaultValues && defaultValues.type !== type.value && "pointer-events-none opacity-50", )} > <FormControl> <RadioGroupItem value={type.value} className="sr-only" disabled={!!defaultValues?.type} /> </FormControl> <type.icon className="shrink-0 text-muted-foreground" size={16} aria-hidden="true" /> <FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0"> {type.label} </FormLabel> </FormItem> </TooltipTrigger> <TooltipContent> Monitor type cannot be changed after creation. </TooltipContent> </Tooltip> ); })} <div className={cn( "col-span-1 self-end text-muted-foreground text-xs sm:place-self-end", )} > Missing a type?{" "} <a href="mailto:ping@openstatus.dev">Contact us</a> </div> </RadioGroup> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> {watchType ? <FormCardSeparator /> : null} {watchType === "http" && ( <> <FormCardContent className="grid grid-cols-4 gap-4"> <div className="col-span-1"> <FormField control={form.control} name="method" render={({ field }) => ( <FormItem> <FormLabel>Method</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="Select a method" /> </SelectTrigger> </FormControl> <SelectContent> {monitorMethods.map((method) => ( <SelectItem key={method} value={method}> {method} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div> <div className="col-span-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem> <FormLabel>URL</FormLabel> <FormControl> <Input placeholder="https://openstatus.dev" type="url" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="headers" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Request Headers</FormLabel> {field.value.map((header, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-5"> <Input placeholder="Key" className="col-span-2" value={header.key} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], key: e.target.value, }; field.onChange(newHeaders); }} /> <Input placeholder="Value" className="col-span-2" value={header.value} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], value: e.target.value, }; field.onChange(newHeaders); }} /> <Button size="icon" variant="ghost" onClick={() => { const newHeaders = field.value.filter( (_, i) => i !== index, ); field.onChange(newHeaders); }} > <X /> </Button> </div> ))} <div> <Button size="sm" variant="outline" type="button" onClick={() => { field.onChange([ ...field.value, { key: "", value: "" }, ]); }} > <Plus /> Add Header </Button> </div> <FormMessage /> </FormItem> )} /> {["POST", "PUT", "PATCH", "DELETE"].includes(watchMethod) && ( <FormField control={form.control} name="body" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Body</FormLabel> <FormControl> <Textarea {...field} /> </FormControl> <FormDescription>Write your payload</FormDescription> <FormMessage /> </FormItem> )} /> )} </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="assertions" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Assertions</FormLabel> <FormDescription> Validate the response to ensure your service is working as expected. <br /> Add body, header, or status assertions. </FormDescription> {field.value.map((assertion, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-6"> <FormField control={form.control} name={`assertions.${index}.type`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} disabled={true} > <SelectTrigger aria-invalid={ !!form.formState.errors.assertions?.[ index ]?.type } className="w-full" > <SelectValue placeholder="Select type" /> </SelectTrigger> <SelectContent> {HTTP_ASSERTION_TYPES.map((type) => ( <SelectItem key={type} value={type}> {type} </SelectItem> ))} </SelectContent> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.compare`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} > <SelectTrigger className="w-full min-w-16"> <span className="truncate"> <SelectValue placeholder="Select compare" /> </span> </SelectTrigger> <SelectContent> {assertion.type === "status" ? Object.entries( numberCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> )) : Object.entries( stringCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {assertion.type === "header" && ( <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Input placeholder="Header key" className="w-full" {...field} value={field.value as string} /> <FormMessage /> </FormItem> )} /> )} <FormField control={form.control} name={`assertions.${index}.target`} render={({ field }) => ( <FormItem> <Input placeholder="Target value" className="w-full" type={ assertion.type === "status" ? "number" : "text" } {...field} value={field.value?.toString() || ""} onChange={(e) => { const value = assertion.type === "status" ? Number.parseInt(e.target.value) || 0 : e.target.value; field.onChange(value); }} /> <FormMessage /> </FormItem> )} /> <Button size="icon" variant="ghost" type="button" onClick={() => { const newAssertions = field.value.filter( (_, i) => i !== index, ); field.onChange(newAssertions); }} > <X /> </Button> </div> ))} <div className="flex flex-wrap gap-2"> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "status", version: "v1", compare: "eq", target: 200, }, ]); }} > <Plus /> Add Status Assertion </Button> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "header", version: "v1", compare: "eq", key: "", target: "", }, ]); }} > <Plus /> Add Header Assertion </Button> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "textBody", version: "v1", compare: "eq", target: "", }, ]); }} > <Plus /> Add Body Assertion </Button> </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </> )} {watchType === "tcp" && ( <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Host:Port</FormLabel> <FormControl> <Input placeholder="127.0.0.0.1:8080" {...field} /> </FormControl> <FormMessage /> <FormDescription> The input supports both IPv4 addresses and IPv6 addresses. </FormDescription> </FormItem> )} /> <div className="col-span-full text-muted-foreground text-sm"> Examples: <ul className="list-inside list-disc"> <li> Domain:{" "} <span className="font-mono text-foreground"> openstatus.dev:443 </span> </li> <li> IPv4:{" "} <span className="font-mono text-foreground"> 192.168.1.1:443 </span> </li> <li> IPv6:{" "} <span className="font-mono text-foreground"> [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 </span> </li> </ul> </div> </FormCardContent> )} {watchType === "dns" && ( <> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>URI</FormLabel> <FormControl> <Input placeholder="openstatus.dev" {...field} /> </FormControl> <FormMessage /> <FormDescription> The input supports both domain names and URIs. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="assertions" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Assertions</FormLabel> <FormDescription> Validate the response to ensure your service is working as expected. <br /> Add DNS record assertions. </FormDescription> {field.value.map((assertion, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-6"> <FormField control={form.control} name={`assertions.${index}.type`} defaultValue={"dnsRecord"} render={({ field }) => ( <FormItem className="hidden"> <Select value={field.value} onValueChange={field.onChange} disabled > <SelectTrigger className="w-full"> <SelectValue placeholder="Select type" /> </SelectTrigger> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Select value={field.value as string} onValueChange={field.onChange} > <SelectTrigger aria-invalid={ !!form.formState.errors.assertions?.[ index ]?.type } className="w-full" > <SelectValue placeholder="Select type" /> </SelectTrigger> <SelectContent> {DNS_ASSERTION_TYPES.map((type) => ( <SelectItem key={type} value={type}> {type} </SelectItem> ))} </SelectContent> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.compare`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} > <SelectTrigger className="w-full min-w-16"> <span className="truncate"> <SelectValue placeholder="Select compare" /> </span> </SelectTrigger> <SelectContent> {Object.entries( recordCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {assertion.type === "header" && ( <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Input placeholder="Header key" className="w-full" {...field} value={field.value as string} /> <FormMessage /> </FormItem> )} /> )} <FormField control={form.control} name={`assertions.${index}.target`} render={({ field }) => ( <FormItem> <Input placeholder="Target value" className="w-full" type={ assertion.type === "status" ? "number" : "text" } {...field} value={field.value?.toString() || ""} onChange={(e) => { const value = assertion.type === "status" ? Number.parseInt(e.target.value) || 0 : e.target.value; field.onChange(value); }} /> <FormMessage /> </FormItem> )} /> <Button size="icon" variant="ghost" type="button" onClick={() => { const newAssertions = field.value.filter( (_, i) => i !== index, ); field.onChange(newAssertions); }} > <X /> </Button> </div> ))} <div className="flex flex-wrap gap-2"> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "dnsRecord", version: "v1", compare: "eq", key: "A", target: "", }, ]); }} > <Plus /> Add DNS Record Assertion </Button> </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </> )} <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/tutorial/how-to-create-monitor/" rel="noreferrer" target="_blank" > Monitor Type </Link>{" "} and{" "} <Link href="https://docs.openstatus.dev/tutorial/how-to-create-monitor/" rel="noreferrer" target="_blank" > Assertions </Link> . We test your endpoint before saving the monitor. </FormCardFooterInfo> <Button type="submit" disabled={isPending || disabled}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> <AlertDialog open={!!error} onOpenChange={() => setError(null)}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Still save?</AlertDialogTitle> <AlertDialogDescription> It seems like the endpoint is not reachable or the assertions failed. Do you want to save the monitor anyway? </AlertDialogDescription> </AlertDialogHeader> <div className="max-h-48 overflow-auto whitespace-pre rounded-md border border-destructive/20 bg-destructive/10 p-2"> <p className="font-mono text-destructive text-sm">{error}</p> </div> <AlertDialogFooter> <AlertDialogCancel type="button">Cancel</AlertDialogCancel> <AlertDialogAction type="button" onClick={async (e) => { e.preventDefault(); form.setValue("skipCheck", true); form.handleSubmit(submitAction)(); form.setValue("skipCheck", false); setError(null); }} disabled={isPending} > Save </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-notifiers.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import type { NotificationProvider } from "@openstatus/db/src/schema"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ notifiers: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormNotifiers({ defaultValues, onSubmit, notifiers, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; notifiers: { id: number; name: string; provider: NotificationProvider }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { notifiers: [], }, }); const watchNotifiers = form.watch("notifiers"); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Notifications</FormCardTitle> <FormCardDescription> Get notified when your monitor is degraded or down. </FormCardDescription> </FormCardHeader> <FormCardContent> {notifiers.length > 0 ? ( <FormField control={form.control} name="notifiers" render={() => ( <FormItem> <div className="flex items-center justify-between"> <FormLabel className="text-base"> List of Notifications </FormLabel> <Button variant="ghost" size="sm" type="button" className={cn( watchNotifiers.length === notifiers.length && "text-muted-foreground", )} onClick={() => { const allSelected = notifiers.every((item) => watchNotifiers.includes(item.id), ); if (!allSelected) { form.setValue( "notifiers", notifiers.map((item) => item.id), ); } else { form.setValue("notifiers", []); } }} > Select all </Button> </div> {notifiers.map((item) => ( <FormField key={item.id} control={form.control} name="notifiers" render={({ field }) => { const Icon = config[item.provider].icon; const label = config[item.provider].label; return ( <FormItem key={item.id} className="flex items-center" > <FormControl> <Checkbox checked={ field.value?.includes(item.id) || false } onCheckedChange={(checked) => { return checked ? field.onChange([ ...field.value, item.id, ]) : field.onChange( field.value?.filter( (value) => value !== item.id, ), ); }} /> </FormControl> <FormLabel className="font-normal text-sm"> {item.name}{" "} <Badge variant="secondary" className="px-1.5 py-px font-mono text-[10px]" > <Icon className="size-2.5" /> {label} </Badge> </FormLabel> </FormItem> ); }} /> ))} <FormMessage /> </FormItem> )} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No notifications</EmptyStateTitle> </EmptyStateContainer> )} </FormCardContent> <FormCardFooter> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-otel.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Lock, Plus, X } from "lucide-react"; import NextLink from "next/link"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; // TODO: add headers const schema = z.object({ endpoint: z.url("Please enter a valid URL"), headers: z .array(z.object({ key: z.string(), value: z.string() })) .prefault([]), }); type FormValues = z.input<typeof schema>; export function FormOtel({ locked, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { locked?: boolean; defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { endpoint: "", headers: [] }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> {locked ? <FormCardUpgrade /> : null} <FormCardHeader> <FormCardTitle>OpenTelemetry</FormCardTitle> <FormCardDescription> Configure your OpenTelemetry Exporter. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid grid-cols-4 gap-4"> <FormField control={form.control} name="endpoint" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Endpoint</FormLabel> <FormControl> <Input placeholder="https://otel.openstatus.dev/api/v1/metrics" disabled={locked} {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="headers" disabled={locked} render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Request Headers</FormLabel> {field.value?.map((header, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-5"> <Input placeholder="Key" className="col-span-2" value={header.key} disabled={locked} onChange={(e) => { const newHeaders = [...(field.value ?? [])]; newHeaders[index] = { ...newHeaders[index], key: e.target.value, }; field.onChange(newHeaders); }} /> <Input placeholder="Value" className="col-span-2" value={header.value} disabled={locked} onChange={(e) => { const newHeaders = [...(field.value ?? [])]; newHeaders[index] = { ...newHeaders[index], value: e.target.value, }; field.onChange(newHeaders); }} /> <Button size="icon" variant="ghost" onClick={() => { const newHeaders = field.value?.filter( (_, i) => i !== index, ); field.onChange(newHeaders); }} > <X /> </Button> </div> ))} <div> <Button size="sm" variant="outline" type="button" disabled={locked} onClick={() => { field.onChange([ ...(field.value ?? []), { key: "", value: "" }, ]); }} > <Plus /> Add Header </Button> </div> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/http-monitor/#opentelemetry" rel="noreferrer" target="_blank" > OTel </Link> . </FormCardFooterInfo> {locked ? ( <Button asChild> <NextLink href="/settings/billing"> <Lock className="size-4" /> Upgrade </NextLink> </Button> ) : ( <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> )} </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-response-time.tsx ================================================ "use client"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const DEGRADED = 30_000; const TIMEOUT = 45_000; const schema = z.object({ degradedAfter: z.coerce.number<number>().optional(), timeout: z.coerce.number<number>(), }); type FormValues = z.input<typeof schema>; export function FormResponseTime({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { degradedAfter: DEGRADED, timeout: TIMEOUT, }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Response Time Thresholds</FormCardTitle> <FormCardDescription> Configure your degraded and timeout thresholds. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-2"> <FormField control={form.control} name="degradedAfter" render={({ field }) => ( <FormItem className="self-start"> <FormLabel>Degraded (in ms.)</FormLabel> <FormControl> <Input placeholder="30000" type="number" {...field} /> </FormControl> <FormDescription> Time after which the endpoint is considered degraded. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="timeout" render={({ field }) => ( <FormItem className="self-start"> <FormLabel>Timeout (in ms.)</FormLabel> <FormControl> <Input placeholder="45000" type="number" {...field} /> </FormControl> <FormDescription> Max. time allowed for request to complete. </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardFooter> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-retry.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const RETRY_MIN = 1; const RETRY_MAX = 10; export const RETRY_DEFAULT = 3; const schema = z.object({ retry: z.coerce .number<number>() .min(RETRY_MIN) .max(RETRY_MAX) .prefault(RETRY_DEFAULT), }); type FormValues = z.input<typeof schema>; export function FormRetry({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { retry: RETRY_DEFAULT, }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Retry Policy</FormCardTitle> <FormCardDescription> Configure the retry policy for your monitor. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-2"> <FormField control={form.control} name="retry" render={({ field }) => ( <FormItem> <FormLabel>Retry</FormLabel> <FormControl> <Input min={RETRY_MIN} max={RETRY_MAX} step={1} type="number" {...field} /> </FormControl> <FormMessage /> <FormDescription> The retry policy is exponential backoff. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/http-monitor/#retry" rel="noreferrer" target="_blank" > retries </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-scheduling-regions.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { type Region, monitorPeriodicity, } from "@openstatus/db/src/schema/constants"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Slider } from "@openstatus/ui/components/ui/slider"; import { cn } from "@openstatus/ui/lib/utils"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { IconCloudProviderTooltip } from "@/components/common/icon-cloud-provider"; import { Note, NoteButton } from "@/components/common/note"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { useTRPC } from "@/lib/trpc/client"; import { formatRegionCode, groupByContinent, regionDict, } from "@openstatus/regions"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { CircleX, Globe, Info } from "lucide-react"; const DEFAULT_PERIODICITY = "10m"; const DEFAULT_REGIONS = ["ams", "fra", "iad", "syd", "jnb", "gru"]; const PERIODICITY = monitorPeriodicity.filter((p) => p !== "other"); const DEFAULT_PRIVATE_LOCATIONS = [] satisfies { id: number; name: string }[]; const schema = z.object({ regions: z.array(z.string()), periodicity: z.enum(monitorPeriodicity), privateLocations: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormSchedulingRegions({ defaultValues, onSubmit, privateLocations, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; privateLocations: { id: number; name: string }[]; }) { const trpc = useTRPC(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { regions: DEFAULT_REGIONS, periodicity: DEFAULT_PERIODICITY, privateLocations: DEFAULT_PRIVATE_LOCATIONS, }, }); const [isPending, startTransition] = useTransition(); const watchPeriodicity = form.watch("periodicity"); const watchRegions = form.watch("regions"); const watchPrivateLocations = form.watch("privateLocations"); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } console.error(error); return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } if (!workspace) return null; const allowedRegions = workspace.limits.regions.filter( (r) => !regionDict[r as keyof typeof regionDict].deprecated, ); const maxRegions = workspace.limits["max-regions"]; const periodicity = workspace.limits.periodicity; const isMaxed = watchRegions.length >= maxRegions; return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Scheduling & Regions</FormCardTitle> <FormCardDescription> Configure the scheduling and regions for your monitor. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4"> <FormField control={form.control} name="periodicity" render={({ field }) => ( <FormItem> <FormLabel>Periodicity</FormLabel> <FormControl> <div> <Slider value={[monitorPeriodicity.indexOf(field.value)]} max={PERIODICITY.length - 1} aria-label="Slider with ticks" onValueChange={(value) => { field.onChange(PERIODICITY[value[0]]); }} className={cn( !periodicity.includes(watchPeriodicity) && "[&_[data-slot=slider-range]]:bg-destructive", )} /> <span className="mt-3 flex w-full items-center justify-between gap-1 px-2.5 font-medium text-muted-foreground text-xs" aria-hidden="true" > {PERIODICITY.map((period) => ( <span key={period} className="flex w-0 flex-col items-center justify-center gap-2" > <span className={cn("h-1 w-px bg-muted-foreground/70")} /> {period} </span> ))} </span> </div> </FormControl> <FormMessage /> </FormItem> )} /> {!periodicity.includes(watchPeriodicity) ? ( <Note color="error"> <CircleX /> The periodicity you are selecting is not allowed for your plan. <NoteButton type="button" onClick={() => setOpenDialog(true)}> Upgrade your plan </NoteButton> </Note> ) : null} </FormCardContent> <FormCardSeparator /> <FormCardContent className="grid gap-4"> <Note color="warning"> <Info /> To minimize false positives, we recommend monitoring your endpoint in at least 3 regions. </Note> <FormField control={form.control} name="regions" render={() => ( <FormItem> <FormControl> <div className="grid gap-4"> {Object.entries(groupByContinent).map( ([continent, r]) => { const selected = r .filter((r) => allowedRegions.includes(r.code)) .reduce((prev, curr) => { return ( prev + (watchRegions.includes(curr.code) ? 1 : 0) ); }, 0); const isAllSelected = selected === r.filter((r) => allowedRegions.includes(r.code)) .length; const disabled = r.length + watchRegions.length - selected > maxRegions; return ( <div key={continent} className="space-y-2"> <div className="flex items-center justify-between"> <FormLabel> {continent}{" "} <span className="align-baseline font-mono font-normal text-muted-foreground/70 text-xs tabular-nums"> ({selected}/{r.length}) </span> </FormLabel> <Button variant="ghost" size="sm" type="button" className={cn( isAllSelected && "text-muted-foreground", )} disabled={disabled} onClick={() => { if (!isAllSelected) { // Add all regions from this continent const newRegions = [...watchRegions]; r.filter((r) => allowedRegions.includes(r.code), ).forEach((region) => { if (!newRegions.includes(region.code)) { newRegions.push(region.code); } }); form.setValue("regions", newRegions); } else { // Remove all regions from this continent form.setValue( "regions", watchRegions?.filter( (region) => !r .map(({ code }) => code) .includes(region as Region), ), ); } }} > Select all </Button> </div> <div className="grid grid-cols-2 gap-2"> {r.map((region) => { return ( <FormField key={region.code} control={form.control} name="regions" render={({ field }) => { const checked = field.value?.includes( region.code, ); const disabled = checked ? false : !allowedRegions.includes( region.code, ) || isMaxed; const deprecated = region.deprecated; return ( <FormItem key={region.code} className="flex items-center" > <Checkbox id={region.code} checked={checked || false} disabled={ disabled || (deprecated && !checked) } onCheckedChange={(checked) => { if (checked) { field.onChange([ ...field.value, region.code, ]); } else { field.onChange( field.value?.filter( (r) => r !== region.code, ), ); } }} /> <FormLabel htmlFor={region.code} className="w-full truncate font-mono font-normal text-sm" > <span className="text-nowrap"> {formatRegionCode(region.code)}{" "} {region.flag} </span> <span className="truncate font-normal text-muted-foreground text-xs leading-[inherit]"> {region.location} </span> <IconCloudProviderTooltip provider={region.provider} className="size-3" /> </FormLabel> </FormItem> ); }} /> ); })} </div> </div> ); }, )} </div> </FormControl> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent className="grid gap-4"> {privateLocations.length === 0 ? ( <Note> <Globe /> Monitor your endpoints from private locations. <NoteButton variant="outline" asChild> <Link href="/private-locations">Learn more</Link> </NoteButton> </Note> ) : ( <FormField control={form.control} name="privateLocations" render={() => ( <FormItem> <div className="flex items-center justify-between"> <FormLabel> Private Locations{" "} <span className="align-baseline font-mono font-normal text-muted-foreground/70 text-xs tabular-nums"> ({watchPrivateLocations.length}/ {privateLocations.length}) </span> </FormLabel> <Button variant="ghost" size="sm" type="button" className={cn( watchPrivateLocations.length === privateLocations.length && "text-muted-foreground", )} onClick={() => { const allSelected = privateLocations.every((item) => watchPrivateLocations.includes(item.id), ); if (!allSelected) { form.setValue( "privateLocations", privateLocations.map((item) => item.id), ); } else { form.setValue("privateLocations", []); } }} > Select all </Button> </div> <div className="grid grid-cols-2 gap-2"> {privateLocations.map((item) => ( <FormField key={item.id} control={form.control} name="privateLocations" render={({ field }) => { return ( <FormItem key={item.id} className="flex items-center" > <FormControl> <Checkbox checked={ field.value?.includes(item.id) || false } onCheckedChange={(checked) => { return checked ? field.onChange([ ...field.value, item.id, ]) : field.onChange( field.value?.filter( (value) => value !== item.id, ), ); }} /> </FormControl> <FormLabel className="w-full truncate font-mono font-normal text-sm"> {item.name} <Globe className="size-3" /> </FormLabel> </FormItem> ); }} /> ))} </div> <FormMessage /> </FormItem> )} /> )} </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Your plan allows you to run{" "} <span className="font-medium text-foreground">{maxRegions}</span>{" "} out of{" "} <span className="font-medium text-foreground"> {allowedRegions.length} </span>{" "} regions. Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/http-monitor/#regions" rel="noreferrer" target="_blank" > Regions </Link>{" "} and{" "} <Link href="https://docs.openstatus.dev/reference/http-monitor/#frequency" rel="noreferrer" target="_blank" > Periodicity </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> <UpgradeDialog open={openDialog} onOpenChange={setOpenDialog} /> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-status-pages.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ description: z.string().optional(), externalName: z.string().optional(), statusPages: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormStatusPages({ defaultValues, onSubmit, statusPages, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; statusPages: { id: number; title: string; slug: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { description: "", externalName: "", statusPages: [], }, }); const [isPending, startTransition] = useTransition(); const watchStatusPages = form.watch("statusPages"); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Status Pages</FormCardTitle> <FormCardDescription> Add status pages to your monitor and configure the external name and description to be shown on the status page. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="externalName" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>External Name</FormLabel> <FormControl> <Input placeholder="OpenStatus API" {...field} /> </FormControl> <FormDescription> External name on the status page, the feed or subscribers notifications. If not provided, monitor's name will be used. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem className="sm:col-span-full"> <FormLabel>Description</FormLabel> <FormControl> <Input placeholder="My Status Page" {...field} /> </FormControl> <FormDescription> A tooltip with extra information about the monitor will be displayed. </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> {statusPages.length > 0 ? ( <FormField control={form.control} name="statusPages" render={() => ( <FormItem> <div className="flex items-center justify-between"> <FormLabel className="text-base"> List of Status Pages </FormLabel> <Button variant="ghost" size="sm" type="button" className={cn( watchStatusPages.length === statusPages.length && "text-muted-foreground", )} onClick={() => { const allSelected = statusPages.every((item) => watchStatusPages.includes(item.id), ); if (!allSelected) { form.setValue( "statusPages", statusPages.map((item) => item.id), ); } else { form.setValue("statusPages", []); } }} > Select all </Button> </div> {statusPages.map((item) => ( <FormField key={item.id} control={form.control} name="statusPages" render={({ field }) => { return ( <FormItem key={item.id} className="flex items-center" > <FormControl> <Checkbox checked={ field.value?.includes(item.id) || false } onCheckedChange={(checked) => { return checked ? field.onChange([ ...field.value, item.id, ]) : field.onChange( field.value?.filter( (value) => value !== item.id, ), ); }} /> </FormControl> <FormLabel className="font-normal text-sm"> {item.title}{" "} <Badge variant="secondary" className="px-1.5 py-px font-mono text-[10px]" > {item.slug} </Badge> </FormLabel> </FormItem> ); }} /> ))} <FormMessage /> </FormItem> )} /> ) : ( <EmptyStateContainer> <EmptyStateTitle>No status pages</EmptyStateTitle> </EmptyStateContainer> )} </FormCardContent> <FormCardFooter> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-tags.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown } from "lucide-react"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { FormSheetMonitorTag } from "../monitor-tag/sheet"; const schema = z.object({ tags: z.array( z.object({ id: z.number(), name: z.string(), color: z.string(), }), ), }); type FormValues = z.infer<typeof schema>; export function FormTags({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const trpc = useTRPC(); const { data: tags, refetch } = useQuery(trpc.monitorTag.list.queryOptions()); const syncTagsMutation = useMutation( trpc.monitorTag.syncTags.mutationOptions({ onSuccess: () => refetch(), }), ); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { tags: [], }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } useEffect(() => { // if tags name/color changed, update the form if (!tags) { form.setValue("tags", []); } else { const formTags = form.getValues("tags"); form.setValue( "tags", tags ?.filter((tag) => formTags.map((t) => t.id).includes(tag.id)) .map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, })) ?? [], ); } }, [tags, form]); if (!tags) return null; return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} id="monitor-tags-form" {...props} > <FormCard> <FormCardHeader> <FormCardTitle>Tags</FormCardTitle> <FormCardDescription> Add tags to categorize and organize your monitor. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 md:grid-cols-2"> <FormField control={form.control} name="tags" render={({ field }) => ( <FormItem className="flex flex-col md:col-span-1"> <FormLabel>Tags</FormLabel> <Popover> <PopoverTrigger asChild> <FormControl> <Button variant="outline" role="combobox" className={cn( "h-auto min-h-9 w-full justify-between", !field.value?.length && "text-muted-foreground", )} > <div className="group/badges -space-x-2 flex flex-wrap"> {field.value.length ? ( field.value.map((tag) => ( <Badge key={tag.id} variant="outline" className="relative flex translate-x-0 items-center gap-1.5 rounded-full bg-background transition-transform hover:z-10 hover:translate-x-1" > <div className={cn("size-2.5 rounded-full")} style={{ backgroundColor: tag.color }} /> {tag.name} </Badge> )) ) : ( <span className="text-muted-foreground"> No tags selected </span> )} </div> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0"> <Command> <CommandInput placeholder="Search tags..." /> <CommandList className="w-full"> <CommandEmpty>No tag found.</CommandEmpty> <CommandGroup> {tags?.map((tag) => ( <CommandItem value={tag.name} key={tag.id} onSelect={() => { if ( field.value .map((tag) => tag.id) ?.includes(tag.id) ) { form.setValue( "tags", field.value.filter( (value) => value.id !== tag.id, ), ); } else { form.setValue("tags", [ ...(field.value ?? []), { id: tag.id, name: tag.name, color: tag.color, }, ]); } }} > <div className={cn("mr-2 h-4 w-4 rounded-full")} style={{ backgroundColor: tag.color }} /> {tag.name} <Check className={cn( "ml-auto h-4 w-4", field.value ?.map((tag) => tag.id) ?.includes(tag.id) ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <FormMessage /> </FormItem> )} /> <div className="flex items-end"> <FormSheetMonitorTag onSubmit={async (values) => { await syncTagsMutation.mutateAsync(values.tags); }} defaultValues={{ tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, })), }} > <Button variant="outline" size="sm"> Edit Tags </Button> </FormSheetMonitorTag> </div> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://www.openstatus.dev/changelog/monitor-tags" rel="noreferrer" target="_blank" > tags </Link>{" "} and how to use them. </FormCardFooterInfo> <Button type="submit" form="monitor-tags-form" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/form-visibility.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Switch } from "@openstatus/ui/components/ui/switch"; import { Lock } from "lucide-react"; import NextLink from "next/link"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ visibility: z.boolean(), }); type FormValues = z.infer<typeof schema>; export function FormVisibility({ locked, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { locked?: boolean; defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { visibility: false, }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> {locked ? <FormCardUpgrade /> : null} <FormCardHeader> <FormCardTitle>Visibility</FormCardTitle> <FormCardDescription> Share your monitor stats with the public. </FormCardDescription> </FormCardHeader> <FormCardContent> <FormField control={form.control} name="visibility" disabled={locked} render={({ field }) => ( <FormItem className="flex flex-row items-center justify-between"> <div className="space-y-0.5"> <FormLabel>Allow public access</FormLabel> <FormDescription> Change monitor visibility. The monitor stats will be attached to the status page the monitor is connected to. </FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} disabled={locked} /> </FormControl> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/http-monitor/#public" rel="noreferrer" target="_blank" > monitor visibility </Link> . </FormCardFooterInfo> {locked ? ( <Button asChild> <NextLink href="/settings/billing"> <Lock className="size-4" /> Upgrade </NextLink> </Button> ) : ( <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> )} </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor/update.tsx ================================================ "use client"; import { FormCardGroup } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { deserialize } from "@openstatus/assertions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; import { FormDangerZone } from "./form-danger-zone"; import { FOLLOW_REDIRECTS_DEFAULT, FormFollowRedirect, } from "./form-follow-redirect"; import { FormGeneral } from "./form-general"; import { FormNotifiers } from "./form-notifiers"; import { FormOtel } from "./form-otel"; import { FormResponseTime } from "./form-response-time"; import { FormRetry, RETRY_DEFAULT } from "./form-retry"; import { FormSchedulingRegions } from "./form-scheduling-regions"; import { FormTags } from "./form-tags"; import { FormVisibility } from "./form-visibility"; export function FormMonitorUpdate() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const router = useRouter(); const queryClient = useQueryClient(); const { data: monitor, refetch } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); const { data: privateLocations } = useQuery( trpc.privateLocation.list.queryOptions(), ); const { data: notifications } = useQuery( trpc.notification.list.queryOptions(), ); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const updateRetryMutation = useMutation( trpc.monitor.updateRetry.mutationOptions({ onSuccess: () => refetch(), }), ); const updateOtelMutation = useMutation( trpc.monitor.updateOtel.mutationOptions({ onSuccess: () => refetch(), }), ); const updatePublicMutation = useMutation( trpc.monitor.updatePublic.mutationOptions({ onSuccess: () => refetch(), }), ); const updateSchedulingRegionsMutation = useMutation( trpc.monitor.updateSchedulingRegions.mutationOptions({ onSuccess: () => refetch(), }), ); const updateResponseTimeMutation = useMutation( trpc.monitor.updateResponseTime.mutationOptions({ onSuccess: () => refetch(), }), ); const updateTagsMutation = useMutation( trpc.monitor.updateTags.mutationOptions({ onSuccess: () => refetch(), }), ); const updateFollowRedirectsMutation = useMutation( trpc.monitor.updateFollowRedirects.mutationOptions({ onSuccess: () => refetch(), }), ); const updateGeneralMutation = useMutation( trpc.monitor.updateGeneral.mutationOptions({ onSuccess: () => { // NOTE: invalidate the list query to update the monitor in the list (especially the name) queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); refetch(); }, onError: (err) => { // TODO: open dialog console.error(err); }, }), ); const updateNotifiersMutation = useMutation( trpc.monitor.updateNotifiers.mutationOptions({ onSuccess: () => refetch(), }), ); const deleteMonitorMutation = useMutation( trpc.monitor.delete.mutationOptions({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: trpc.monitor.list.queryKey(), }); router.push("/monitors"); }, }), ); if ( !monitor || !statusPages || !notifications || !workspace || !privateLocations ) return null; return ( <FormCardGroup> <FormGeneral defaultValues={{ type: monitor.jobType as "http" | "tcp", url: monitor.url, name: monitor.name, method: monitor.method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE", headers: monitor.headers ?? [], body: monitor.body, active: monitor.active ?? true, // TODO: move to server after migration assertions: monitor?.assertions ? deserialize(monitor?.assertions).map((a) => a.schema) : [], skipCheck: false, saveCheck: false, }} onSubmit={async (values) => { await updateGeneralMutation.mutateAsync({ id: Number.parseInt(id), name: values.name, jobType: values.type, url: values.url, method: values.method, headers: values.headers, body: values.body, assertions: values.assertions, skipCheck: values.skipCheck, saveCheck: values.saveCheck, active: values.active, }); }} /> <FormResponseTime defaultValues={{ timeout: monitor.timeout, degradedAfter: monitor.degradedAfter ?? undefined, }} onSubmit={async (values) => { await updateResponseTimeMutation.mutateAsync({ id: Number.parseInt(id), timeout: values.timeout, degradedAfter: values.degradedAfter ?? undefined, }); }} /> <FormTags defaultValues={{ tags: monitor.tags, }} onSubmit={async (values) => { await updateTagsMutation.mutateAsync({ id: Number.parseInt(id), tags: values.tags.map((tag) => tag.id), }); }} /> <FormSchedulingRegions privateLocations={privateLocations} defaultValues={{ regions: monitor.regions, periodicity: monitor.periodicity, privateLocations: monitor.privateLocations.map(({ id }) => id), }} onSubmit={async (values) => { await updateSchedulingRegionsMutation.mutateAsync({ id: Number.parseInt(id), regions: values.regions, periodicity: values.periodicity, privateLocations: values.privateLocations, }); }} /> <FormNotifiers notifiers={notifications} defaultValues={{ notifiers: monitor.notifications.map(({ id }) => id), }} onSubmit={async (values) => { await updateNotifiersMutation.mutateAsync({ id: Number.parseInt(id), notifiers: values.notifiers, }); }} /> <FormRetry defaultValues={{ retry: monitor.retry ?? RETRY_DEFAULT, }} onSubmit={async (values) => await updateRetryMutation.mutateAsync({ id: Number.parseInt(id), retry: values.retry ?? RETRY_DEFAULT, }) } /> <FormFollowRedirect defaultValues={{ followRedirects: monitor.followRedirects ?? FOLLOW_REDIRECTS_DEFAULT, }} onSubmit={async (values) => await updateFollowRedirectsMutation.mutateAsync({ id: Number.parseInt(id), followRedirects: values.followRedirects ?? FOLLOW_REDIRECTS_DEFAULT, }) } /> <FormOtel locked={workspace.limits.otel === false} defaultValues={{ endpoint: monitor.otelEndpoint ?? "", headers: monitor.otelHeaders ?? [], }} onSubmit={async (values) => { await updateOtelMutation.mutateAsync({ id: Number.parseInt(id), otelEndpoint: values.endpoint, otelHeaders: values.headers, }); }} /> <FormVisibility defaultValues={{ visibility: monitor.public ?? false, }} onSubmit={async (values) => { await updatePublicMutation.mutateAsync({ id: Number.parseInt(id), public: values.visibility, }); }} /> <FormDangerZone title={monitor.name} onSubmit={async () => { await deleteMonitorMutation.mutateAsync({ id: Number.parseInt(id) }); }} /> </FormCardGroup> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor-tag/form-monitor-tag.tsx ================================================ "use client"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; import React, { useTransition } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const tagSchema = z.object({ id: z.number().optional(), name: z.string().min(1, "Name is required"), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format"), }); const schema = z.object({ tags: z.array(tagSchema), }); export type FormValues = z.infer<typeof schema>; // FIXME: rename, its not monitor specfic, its all the tags export function FormMonitorTag({ defaultValues, className, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const trpc = useTRPC(); const { data: tags } = useQuery(trpc.monitorTag.list.queryOptions()); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { tags: [], }, }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "tags", }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving tags...", success: "Tags saved successfully", error: "Failed to save tags", }); await promise; } catch (error) { console.error(error); } }); } if (!tags) return null; return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={(e) => { // NOTE: we use the form nested within another form, so we need to prevent the default behavior // and stop the propagation to avoid double submission e.preventDefault(); e.stopPropagation(); form.handleSubmit(submitAction)(e); }} {...props} > <div className="space-y-4"> <div className="flex items-center justify-between"> <FormLabel>Tags</FormLabel> <Button type="button" variant="ghost" size="sm" onClick={() => append({ name: "", color: "#00008B" })} > <Plus className="mr-2 h-4 w-4" /> Add Tag </Button> </div> {fields.map((field, index) => ( <div key={field.id} className="flex items-start gap-4"> <FormField control={form.control} name={`tags.${index}.color`} render={({ field }) => ( <FormItem className="p-1"> <FormControl> <Input type="color" className="size-7 overflow-hidden rounded-full p-0" style={{ backgroundColor: field.value }} {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`tags.${index}.name`} render={({ field }) => ( <FormItem className="flex-1"> <FormControl> <Input placeholder="Tag name" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} > <Trash2 className="h-4 w-4" /> </Button> </div> ))} </div> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/monitor-tag/sheet.tsx ================================================ "use client"; import { FormCard, FormCardContent, FormCardGroup, } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { FormMonitorTag, type FormValues, } from "@/components/forms/monitor-tag/form-monitor-tag"; import { Button } from "@openstatus/ui/components/ui/button"; import { useState } from "react"; export function FormSheetMonitorTag({ children, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const [open, setOpen] = useState(false); return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger {...props} asChild> {children} </FormSheetTrigger> <FormSheetContent> <FormSheetHeader> <FormSheetTitle>Monitor Tag</FormSheetTitle> <FormSheetDescription> Configure and update the monitor tag. </FormSheetDescription> </FormSheetHeader> <FormCardGroup className="flex-1 overflow-y-auto"> <FormCard className="flex-1 overflow-auto rounded-none border-none"> <FormCardContent> <FormMonitorTag onSubmit={onSubmit} defaultValues={defaultValues} id="tags-form" className="my-4" /> </FormCardContent> </FormCard> </FormCardGroup> <FormSheetFooter> <Button type="submit" form="tags-form"> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-discord.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Link } from "@/components/common/link"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("discord"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormDiscord({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "discord", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = config[provider].sendTest(data); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://example.com/webhook" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the webhook URL to your Discord channel.{" "} <Link href="https://docs.openstatus.dev/reference/notification/#discord" rel="noreferrer" target="_blank" > Read more </Link> . </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-email.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("email"), data: z.email(), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormEmail({ monitors, defaultValues, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "email", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input placeholder="max@openstatus.dev" type="email" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the email address to send notifications to. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-google-chat.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("google-chat"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormGoogleChat({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "google-chat", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const trpc = useTRPC(); const sendTestMutation = useMutation( trpc.notification.sendTest.mutationOptions(), ); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { "google-chat": data, }, }); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>Google Chat Webhook</FormLabel> <FormControl> <Input placeholder="https://..." {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the phone number to send notifications to. </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-grafana-oncall.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import React from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { useFormSheetDirty } from "../form-sheet"; const schema = z.object({ name: z.string(), provider: z.literal("grafana-oncall"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormGrafanaOncall({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "grafana-oncall", data: { webhookUrl: "", }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const trpc = useTRPC(); const sendTestMutation = useMutation( trpc.notification.sendTest.mutationOptions(), ); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { "grafana-oncall": data, }, }); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.webhookUrl" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://oncall-prod-us-central-0.grafana.net/oncall/integrations/v1/webhook/..." {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter your Grafana OnCall incoming webhook URL. You can find this in your Grafana OnCall integration settings. </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-ntfy.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("ntfy"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormNtfy({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "ntfy", data: { topic: "", serverUrl: "", token: "", }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = config[provider].sendTest( data as unknown as { topic: string; serverUrl?: string; token?: string; }, ); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.topic" render={({ field }) => ( <FormItem> <FormLabel>Topic</FormLabel> <FormControl> <Input placeholder="your-topic" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the topic for your ntfy notifications. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.serverUrl" render={({ field }) => ( <FormItem> <FormLabel>Server URL</FormLabel> <FormControl> <Input placeholder="https://ntfy.sh" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the ntfy server URL. Leave empty for default. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.token" render={({ field }) => ( <FormItem> <FormLabel>Bearer Token</FormLabel> <FormControl> <Input placeholder="tk_iloveopenstatus" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the bearer token for authentication. </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-opsgenie.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("opsgenie"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormOpsGenie({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "opsgenie", data: { apiKey: "", region: undefined, }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = config[provider].sendTest( data as unknown as { apiKey: string; region: "eu" | "us"; }, ); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.apiKey" render={({ field }) => ( <FormItem> <FormLabel>API Key</FormLabel> <FormControl> <Input placeholder="your-api-key" {...field} /> </FormControl> <FormMessage /> <FormDescription>Enter your OpsGenie API key.</FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.region" render={({ field }) => ( <FormItem> <FormLabel>Region</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger> <SelectValue placeholder="Select a region" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="us">US</SelectItem> <SelectItem value="eu">EU</SelectItem> </SelectContent> </Select> <FormMessage /> <FormDescription>Select your OpsGenie region.</FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-pagerduty.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { PagerDutySchema } from "@openstatus/notification-pagerduty"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { parseAsString, useQueryState } from "nuqs"; import React, { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("pagerduty"), data: z.string(), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormPagerDuty({ monitors, defaultValues, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const [searchConfig] = useQueryState("config", parseAsString); console.log(searchConfig); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "pagerduty", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); useEffect(() => { if (searchConfig) { const data = PagerDutySchema.safeParse(JSON.parse(searchConfig)); if (data.success) { form.setValue("data", JSON.stringify(data.data)); } else { toast.error("Invalid PagerDuty configuration"); } } }, [searchConfig, form]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); if (!data) { toast.error("No PagerDuty configuration found"); return; } const validation = PagerDutySchema.safeParse(JSON.parse(data)); if (!validation.success) { toast.error("Invalid PagerDuty configuration"); return; } const promise = config[provider].sendTest({ integrationKey: validation.data.integration_keys[0].integration_key, }); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>Config</FormLabel> <FormControl> <Input placeholder="..." disabled {...field} /> </FormControl> <FormMessage /> <FormDescription> The PagerDuty configuration that is being used. </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-slack.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Link } from "@/components/common/link"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("slack"), data: z.url("Please enter a valid URL"), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormSlack({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "slack", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = config[provider].sendTest(data); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://example.com/webhook" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the webhook URL to your Slack channel.{" "} <Link href="https://docs.openstatus.dev/reference/notification/#slack" rel="noreferrer" target="_blank" > Read more </Link> . </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-sms.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("sms"), data: z.string(), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormSms({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "sms", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => JSON.stringify(values), error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>SMS</FormLabel> <FormControl> <Input placeholder="+1234567890" type="tel" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the phone number to send notifications to. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-telegram.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { TelegramConnectionFlow } from "../components/telegram-connection-flow"; import { TelegramFormActions } from "../components/telegram-form-actions"; import { TelegramManualInput } from "../components/telegram-manual-input"; const schema = z.object({ name: z.string(), provider: z.literal("telegram"), data: z.object({ chatId: z.string(), }), monitors: z.array(z.number()), }); export type FormValues = z.infer<typeof schema>; export function FormTelegram({ monitors, defaultValues, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "telegram", data: { chatId: "", }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); // Check if we're editing an existing notification (has chatID) or creating a new one const isEditMode = React.useMemo(() => { return Boolean(defaultValues?.data?.chatId); }, [defaultValues]); const [mode, setMode] = React.useState<"qr" | "manual" | null>( isEditMode ? null : "qr", ); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; // Reset UI state after successful submission setMode(null); form.reset(); } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form id="notifier-form-telegram" className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <div className="flex flex-col gap-4"> {isEditMode ? ( // Edit mode: Show editable chatID input only <TelegramManualInput form={form} /> ) : ( // Create mode: Show QR/manual connection flow <TelegramConnectionFlow form={form} mode={mode} onModeChange={setMode} /> )} </div> <TelegramFormActions form={form} isPending={isPending} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-webhook.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Link } from "@/components/common/link"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("webhook"), data: z.record(z.string(), z.string()), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormWebhook({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "webhook", data: { endpoint: "", // headers: [] }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data.endpoint"); toast.promise(config[provider].sendTest({ url: data }), { loading: "Sending test...", success: "Test sent", error: "Failed to send test", }); } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.endpoint" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://example.com/webhook" {...field} /> </FormControl> <FormMessage /> <FormDescription> Send notifications to a custom webhook URL.{" "} <Link href="https://docs.openstatus.dev/reference/notification/#webhook" rel="noreferrer" target="_blank" > Read more </Link> . </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form-whatsapp.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.literal("whatsapp"), data: z.string(), monitors: z.array(z.number()), }); type FormValues = z.infer<typeof schema>; export function FormWhatsApp({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", provider: "whatsapp", data: "", monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const trpc = useTRPC(); const sendTestMutation = useMutation( trpc.notification.sendTest.mutationOptions(), ); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } function testAction() { if (isPending) return; startTransition(async () => { try { const provider = form.getValues("provider"); const data = form.getValues("data"); const promise = sendTestMutation.mutateAsync({ provider, data: { whatsapp: data, }, }); toast.promise(promise, { loading: "Sending test...", success: "Test sent", error: (error) => { if (error instanceof Error) { return error.message; } return "Failed to send test"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data" render={({ field }) => ( <FormItem> <FormLabel>WhatsApp</FormLabel> <FormControl> <Input placeholder="+1234567890" type="tel" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the phone number to send notifications to. </FormDescription> </FormItem> )} /> <div> <Button variant="outline" size="sm" type="button" onClick={testAction} > Send Test </Button> </div> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/form.tsx ================================================ "use client"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { cn } from "@openstatus/ui/lib/utils"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), provider: z.enum([ "slack", "discord", "email", "sms", "webhook", "opsgenie", "pagerduty", "ntfy", "telegram", "whatsapp", "google-chat", "grafana-oncall", ]), data: z.record(z.string(), z.string()).or(z.string()), monitors: z.array(z.number()), }); export type FormValues = z.infer<typeof schema>; export function NotifierForm({ defaultValues, className, onSubmit, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit?: (values: FormValues) => Promise<void> | void; monitors: { id: number; name: string }[]; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", data: { webhook: "", }, monitors: [], }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = new Promise((resolve) => setTimeout(resolve, 1000)); toast.promise(promise, { loading: "Saving...", success: () => JSON.stringify(values), error: "Failed to save", }); await promise; onSubmit?.(values); } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form id="notifier-form" className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Notifier" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your notifier. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="data.webhook" render={({ field }) => ( <FormItem> <FormLabel>Webhook URL</FormLabel> <FormControl> <Input placeholder="https://example.com/webhook" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Select the monitors you want to notify. </FormDescription> <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> <FormMessage /> </FormItem> )} /> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/notifications/sheet.tsx ================================================ "use client"; import { FormCard, FormCardGroup } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { config } from "@/data/notifications.client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useState } from "react"; import type { FormValues } from "./form"; export function FormSheetNotifier({ children, defaultValues, provider, onSubmit, monitors, defaultOpen, ...props }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: FormValues; provider: FormValues["provider"]; onSubmit?: (values: FormValues) => Promise<void>; monitors: { id: number; name: string }[]; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen ?? false); const Form = provider ? config[provider].form : undefined; return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger {...props} asChild> {children} </FormSheetTrigger> <FormSheetContent className="sm:max-w-lg"> <FormSheetHeader> <FormSheetTitle>Notifier</FormSheetTitle> <FormSheetDescription> Configure and update the notifier. </FormSheetDescription> </FormSheetHeader> <FormCardGroup className="overflow-y-auto"> <FormCard className="overflow-auto rounded-none border-none"> {Form && ( <Form id={`notifier-form-${provider}`} className="my-4" onSubmit={async (values) => { await onSubmit?.(values); setOpen(false); }} // @ts-expect-error - defaultValues is not defined in the form component defaultValues={ defaultValues ? { ...defaultValues, data: typeof defaultValues?.data === "string" ? defaultValues?.data : defaultValues?.data && typeof defaultValues.data === "object" && provider in defaultValues.data ? defaultValues.data[provider] : defaultValues?.data, } : undefined } monitors={monitors} /> )} </FormCard> </FormCardGroup> <FormSheetFooter> <Button type="submit" form={`notifier-form-${provider}`}> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/onboarding/create-monitor.tsx ================================================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ url: z.url(), }); export type FormValues = z.infer<typeof schema>; export function CreateMonitorForm({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { url: "", }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } console.error(error); return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem> <FormLabel>URL</FormLabel> <FormControl> <Input placeholder="https://api.openstatus.dev" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the URL of your API or website. </FormDescription> </FormItem> )} /> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/onboarding/create-page.tsx ================================================ "use client"; // FIXME: use input-group instead import { InputWithAddons } from "@/components/common/input-with-addons"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const SLUG_UNIQUE_ERROR_MESSAGE = "This slug is already taken. Please choose another one."; const schema = z.object({ slug: z.string().min(3), }); export type FormValues = z.infer<typeof schema>; export function CreatePageForm({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const trpc = useTRPC(); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { slug: "" }, }); const [isPending, startTransition] = useTransition(); const watchSlug = form.watch("slug"); const debouncedSlug = useDebounce(watchSlug, 500); const { data: isUnique } = useQuery( trpc.page.getSlugUniqueness.queryOptions( { slug: debouncedSlug }, { enabled: debouncedSlug.length > 0 }, ), ); useEffect(() => { if (isUnique === false) { form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); } else { form.clearErrors("slug"); } }, [isUnique, form]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { if (isUnique === false) { toast.error(SLUG_UNIQUE_ERROR_MESSAGE); form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); return; } const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } console.error(error); return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormField control={form.control} name="slug" render={({ field }) => ( <FormItem> <FormLabel>Slug</FormLabel> <FormControl> <InputWithAddons placeholder="status" trailing=".openstatus.dev" {...field} /> </FormControl> <FormMessage /> <FormDescription> Choose a unique subdomain for your status page (minimum 3 characters). </FormDescription> </FormItem> )} /> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/onboarding/learn-from.tsx ================================================ "use client"; import { Note } from "@/components/common/note"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { RadioGroup, RadioGroupItem, } from "@openstatus/ui/components/ui/radio-group"; import { cn } from "@openstatus/ui/lib/utils"; import { Check } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const wantFrom = [ { id: "statuspage", title: "Status Page", }, { id: "uptime-monitoring", title: "Uptime Monitoring", }, { id: "both", title: "Both", }, { id: "other", title: "Other", }, ] as const; const schema = z.object({ from: z.string(), other: z.string().optional(), }); export type FormValues = z.infer<typeof schema>; export function LearnFromForm({ onSubmit, defaultValues, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const [isPending, startTransition] = useTransition(); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { from: "", other: "", }, }); const watchFrom = form.watch("from"); function handleSubmit(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Submitting...", success: () => "Submitted", error: "Failed to submit", }); await promise; } catch (error) { console.error(error); } }); } if (!isPending && form.formState.isSubmitSuccessful) { return ( <Note color="success"> <Check /> Thank you for your feedback! </Note> ); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className={cn("space-y-3", className)} {...props} > <FormField control={form.control} name="from" render={({ field }) => ( <FormItem> <FormControl> <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="grid grid-cols-1 gap-4 sm:grid-cols-2" > {wantFrom.map((item) => ( <FormItem key={item.id} className="flex items-center gap-3"> <FormControl> <RadioGroupItem value={item.id} id={item.id} /> </FormControl> <FormLabel htmlFor={item.id} className="w-full font-normal" > {item.title} </FormLabel> </FormItem> ))} </RadioGroup> </FormControl> <FormMessage /> </FormItem> )} /> {watchFrom === "other" && ( <FormField control={form.control} name="other" render={({ field }) => ( <FormItem> <FormControl> <Input placeholder="Please specify" className="sm:w-1/2" {...field} /> </FormControl> </FormItem> )} /> )} <Button size="sm" type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/private-location/form.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { zodResolver } from "@hookform/resolvers/zod"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, } from "@openstatus/ui/components/ui/input-group"; import { Label } from "@openstatus/ui/components/ui/label"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { Check, Copy } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string().min(1, "Name is required"), token: z.string(), monitors: z.array(z.number()), }); export type FormValues = z.infer<typeof schema>; export function FormPrivateLocation({ defaultValues, onSubmit, className, monitors, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; monitors: { id: number; name: string; url: string }[]; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", token: crypto.randomUUID(), monitors: [], }, }); const [isPending, startTransition] = useTransition(); const { copy, isCopied } = useCopyToClipboard(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="My Raspberry Pi" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="token" render={({ field }) => ( <FormItem> <FormLabel>Token</FormLabel> <FormControl> <InputGroup> <InputGroupInput placeholder="Private Location Token" readOnly value={field.value} /> <InputGroupAddon align="inline-end"> <InputGroupButton aria-label="Copy" title="Copy" size="icon-xs" onClick={() => { copy(field.value, { successMessage: "Token copied to clipboard", }); }} > {isCopied ? <Check /> : <Copy />} </InputGroupButton> </InputGroupAddon> </InputGroup> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem> <FormLabel>Monitors</FormLabel> <FormDescription> Connected monitors will be automatically activated for the private location. </FormDescription> {monitors.length ? ( <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={field.value?.length === monitors.length} onCheckedChange={(checked) => { field.onChange( checked ? monitors.map((m) => m.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {monitors.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> ) : ( <EmptyStateContainer> <EmptyStateTitle>No monitors found</EmptyStateTitle> </EmptyStateContainer> )} <FormMessage /> </FormItem> )} /> </FormCardContent> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/private-location/sheet.tsx ================================================ "use client"; import { FormCard, FormCardGroup } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { FormPrivateLocation, type FormValues, } from "@/components/forms/private-location/form"; import { Button } from "@openstatus/ui/components/ui/button"; import { useState } from "react"; export function FormSheetPrivateLocation({ children, defaultValues, onSubmit, monitors, ...props }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: FormValues; monitors: { id: number; name: string; url: string }[]; onSubmit: (values: FormValues) => Promise<void>; }) { const [open, setOpen] = useState(false); return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger {...props} asChild> {children} </FormSheetTrigger> <FormSheetContent> <FormSheetHeader> <FormSheetTitle>Private Location</FormSheetTitle> <FormSheetDescription> Configure and update the private location. </FormSheetDescription> </FormSheetHeader> <FormCardGroup className="overflow-y-auto"> <FormCard className="overflow-auto rounded-none border-none"> <FormPrivateLocation monitors={monitors} onSubmit={async (values) => { await onSubmit(values); setOpen(false); }} defaultValues={defaultValues} id="private-location-form" className="my-4" /> </FormCard> </FormCardGroup> <FormSheetFooter> <Button type="submit" form="private-location-form"> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/settings/form-api-key.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { EmptyStateContainer } from "@/components/content/empty-state"; import { DataTable } from "@/components/data-table/settings/api-key/data-table"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@openstatus/ui/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; import { CalendarIcon, Check, Copy } from "lucide-react"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; // we should prefetch the api key on the server (layout) const schema = z.object({ name: z.string().min(1, "Name is required"), description: z.string().optional(), expiresAt: z.string().optional(), }); type FormValues = z.infer<typeof schema>; export function FormApiKey() { const trpc = useTRPC(); const [isPending, startTransition] = useTransition(); const { copy, isCopied } = useCopyToClipboard(); const [result, setResult] = useState<{ token: string; key: string; } | null>(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { name: "", description: "", expiresAt: "", }, }); const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); const { data: apiKeys = [], refetch } = useQuery( trpc.apiKeyRouter.getAll.queryOptions(), ); const createApiKeyMutation = useMutation( trpc.apiKeyRouter.create.mutationOptions({ onSuccess: (data) => { if (data) { refetch(); setResult({ token: data.token, key: data.key.name }); setCreateDialogOpen(false); form.reset(); } else { throw new Error("Failed to create API key"); } }, }), ); function createAction(values: FormValues) { if (isPending || !workspace) { return; } startTransition(async () => { try { const promise = createApiKeyMutation.mutateAsync({ name: values.name.trim(), description: values.description?.trim() || undefined, expiresAt: values.expiresAt ? new Date(values.expiresAt) : undefined, }); toast.promise(promise, { loading: "Creating...", success: () => "Created", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to create API key"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <FormCard> <FormCardHeader> <FormCardTitle>API Keys</FormCardTitle> <FormCardDescription> Create and manage your API keys. </FormCardDescription> </FormCardHeader> <FormCardContent> {apiKeys.length === 0 ? ( <EmptyStateContainer> <EmptyStateTitle>No API keys</EmptyStateTitle> <EmptyStateDescription> Access your data via API. </EmptyStateDescription> </EmptyStateContainer> ) : ( <DataTable apiKeys={apiKeys} refetch={refetch} /> )} </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Trigger monitors via CLI, CI/CD or create your own status page.{" "} <Link href="https://api.openstatus.dev/v1" rel="noreferrer" target="_blank" > Learn more </Link> . </FormCardFooterInfo> <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}> <DialogTrigger asChild> <Button size="sm">Create</Button> </DialogTrigger> <DialogContent> <Form {...form}> <form onSubmit={form.handleSubmit(createAction)}> <DialogHeader> <DialogTitle>Create API Key</DialogTitle> <DialogDescription> Create a new API key to access your workspace data. </DialogDescription> </DialogHeader> <div className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="Production API" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>Description</FormLabel> <FormControl> <Textarea placeholder="Used for production deployment" rows={3} {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="expiresAt" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>Expiration Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground", )} > {field.value ? ( format(new Date(field.value), "PPP") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" > <Calendar mode="single" selected={ field.value ? new Date(field.value) : undefined } onSelect={(date) => { if (!date) { field.onChange(""); return; } // Convert to ISO string and take only the date part (YYYY-MM-DD) const dateString = date .toISOString() .split("T")[0]; field.onChange(dateString); }} disabled={(date) => { const today = new Date(); today.setHours(0, 0, 0, 0); const compareDate = new Date(date); compareDate.setHours(0, 0, 0, 0); return compareDate < today; }} initialFocus /> </PopoverContent> </Popover> <FormMessage /> </FormItem> )} /> </div> <DialogFooter className="mt-4"> <Button variant="outline" type="button" onClick={() => setCreateDialogOpen(false)} > Cancel </Button> <Button type="submit" disabled={isPending}> Create </Button> </DialogFooter> </form> </Form> </DialogContent> </Dialog> </FormCardFooter> <AlertDialog open={!!result} onOpenChange={() => setResult(null)}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>API Key Created</AlertDialogTitle> <AlertDialogDescription> Ensure you copy your API key before closing this dialog. You will not see it again. </AlertDialogDescription> </AlertDialogHeader> <div> <Button variant="outline" size="sm" onClick={() => { copy(result?.token || "", { successMessage: "Copied API key to clipboard", }); }} > <code>{result?.token}</code> {isCopied ? ( <Check size={16} className="text-muted-foreground" /> ) : ( <Copy size={16} className="text-muted-foreground" /> )} </Button> </div> <AlertDialogFooter> <Button onClick={() => setResult(null)}>Done</Button> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </FormCard> ); } ================================================ FILE: apps/dashboard/src/components/forms/settings/form-members.tsx ================================================ "use client"; import { TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { FormCardContent, FormCardDescription, FormCardHeader, FormCardSeparator, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { Button } from "@openstatus/ui/components/ui/button"; import { FormCardFooter, FormCardFooterInfo } from "../form-card"; import { Link } from "@/components/common/link"; import { FormCard } from "@/components/forms/form-card"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Lock } from "lucide-react"; import { DataTable as InvitationsDataTable } from "@/components/data-table/settings/invitations/data-table"; import { DataTable as MembersDataTable } from "@/components/data-table/settings/members/data-table"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ email: z.email(), role: z.enum(["member"]), }); type FormValues = z.infer<typeof schema>; export function FormMembers({ locked, onCreate, }: { locked?: boolean; onCreate: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: "", role: "member", }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onCreate(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: "Failed to save", }); await promise; form.reset(); } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)}> <FormCard> {locked ? <FormCardUpgrade /> : null} <FormCardHeader> <FormCardTitle>Team</FormCardTitle> <FormCardDescription>Manage your team members.</FormCardDescription> </FormCardHeader> <FormCardContent> <Tabs defaultValue="members"> <TabsList> <TabsTrigger value="members">Members</TabsTrigger> <TabsTrigger value="pending">Pending</TabsTrigger> </TabsList> <TabsContent value="members"> <MembersDataTable /> </TabsContent> <TabsContent value="pending"> <InvitationsDataTable /> </TabsContent> </Tabs> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} disabled={locked} name="email" render={({ field }) => ( <FormItem> <FormLabel>Add member</FormLabel> <FormControl> <Input type="email" placeholder="Email" disabled={locked} {...field} /> </FormControl> <FormMessage /> <FormCardDescription> Send an invitation to join the team. </FormCardDescription> </FormItem> )} /> </FormCardContent> <FormCardFooter> {locked ? ( <> <FormCardFooterInfo> This feature is available on the{" "} <Link href="https://www.openstatus.dev/changelog/team-invites" rel="noreferrer" target="_blank" > Pro plan </Link> . </FormCardFooterInfo> <Button type="button" size="sm" asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </Button> </> ) : ( <Button type="submit" size="sm" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> )} </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/settings/form-slug.tsx ================================================ "use client"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { Button } from "@openstatus/ui/components/ui/button"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { Check, Copy } from "lucide-react"; import { z } from "zod"; const schema = z.object({ slug: z.string().min(1), }); type FormValues = z.infer<typeof schema>; export function FormSlug({ defaultValues }: { defaultValues?: FormValues }) { const { copy, isCopied } = useCopyToClipboard(); console.log({ defaultValues, schema }); return ( <FormCard> <FormCardHeader> <FormCardTitle>Slug</FormCardTitle> <FormCardDescription> The unique slug for your workspace. </FormCardDescription> </FormCardHeader> <FormCardContent> <Button variant="outline" size="sm" onClick={() => copy(defaultValues?.slug ?? "unknown slug", { successMessage: "Copied slug to clipboard", }) } > {defaultValues?.slug ?? "unknown slug"} {isCopied ? ( <Check size={16} className="text-muted-foreground" /> ) : ( <Copy size={16} className="text-muted-foreground" /> )} </Button> </FormCardContent> <FormCardFooter className="[&>:last-child]:ml-0"> <FormCardFooterInfo> Used when interacting with the API or for help on Discord.{" "} <FormDialogSupportContact> <Button variant="ghost" size="sm" className="px-0 py-0 text-accent-foreground hover:bg-transparent dark:hover:bg-transparent" > Let us know </Button> </FormDialogSupportContact>{" "} if you'd like to change it. </FormCardFooterInfo> </FormCardFooter> </FormCard> ); } ================================================ FILE: apps/dashboard/src/components/forms/settings/form-workspace.tsx ================================================ "use client"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ name: z.string(), }); type FormValues = z.infer<typeof schema>; export function FormWorkspace({ defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { name: "", }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: "Failed to save", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Workspace</FormCardTitle> <FormCardDescription> Manage your workspace name. </FormCardDescription> </FormCardHeader> <FormCardContent> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardFooter> <Button type="submit" disabled={isPending} size="sm"> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-appearance.tsx ================================================ import { useTransition } from "react"; import { z } from "zod"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { THEME_KEYS } from "@openstatus/theme-store"; import { THEMES } from "@openstatus/theme-store"; import type { ThemeKey } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { ArrowUpRight, Laptop, Moon, Sun } from "lucide-react"; import { Check, ChevronsUpDown } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; const schema = z.object({ forceTheme: z.enum(["light", "dark", "system"]), configuration: z.object({ theme: z.string(), }), }); type FormValues = z.infer<typeof schema>; export function FormAppearance({ defaultValues, onSubmit, }: { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const [isPending, startTransition] = useTransition(); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { forceTheme: "system", }, }); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)}> <FormCard> <FormCardHeader> <FormCardTitle>Appearance</FormCardTitle> <FormCardDescription> Forced theme will override the user's preference. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="forceTheme" render={({ field }) => ( <FormItem> <FormLabel>Mode</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="Select a theme" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="light"> <div className="flex items-center gap-2"> <Sun className="h-4 w-4" /> <span>Light</span> </div> </SelectItem> <SelectItem value="dark"> <div className="flex items-center gap-2"> <Moon className="h-4 w-4" /> <span>Dark</span> </div> </SelectItem> <SelectItem value="system"> <div className="flex items-center gap-2"> <Laptop className="h-4 w-4" /> <span>System</span> </div> </SelectItem> </SelectContent> </Select> <FormMessage /> <FormDescription> Override the user's preference. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="configuration.theme" render={({ field }) => ( <FormItem> <FormLabel>Style</FormLabel> <Popover> <PopoverTrigger asChild> <FormControl> <Button id="community-theme" variant="outline" role="combobox" className={cn( "w-full justify-between", !field.value && "text-muted-foreground", )} > <span className="truncate"> {THEMES[field.value as ThemeKey]?.name || "Select a theme"} </span> <ChevronsUpDown className="opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="p-0" align="start"> <Command> <CommandInput placeholder="Search themes..." className="h-9" /> <CommandList> <CommandEmpty>No themes found.</CommandEmpty> <CommandGroup> {THEME_KEYS.map((theme) => { const { name, author } = THEMES[theme]; return ( <CommandItem value={theme} key={theme} keywords={[theme, name, author.name]} onSelect={(v) => field.onChange(v)} > <span className="truncate">{name}</span> <span className="truncate font-commit-mono text-muted-foreground text-xs"> by {author.name} </span> <Check className={cn( "ml-auto", theme === field.value ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <FormMessage /> <FormDescription>Choose a theme to apply.</FormDescription> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Your user will still be able to change the mode via the theme toggle. </FormCardFooterInfo> <div className="flex items-center gap-2"> <Button type="button" variant="ghost" asChild> <Link href="https://themes.openstatus.dev" rel="noreferrer" target="_blank" > View Theme Explorer <ArrowUpRight className="h-4 w-4" /> </Link> </Button> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </div> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-configuration.tsx ================================================ import { useEffect, useState, useTransition } from "react"; import { z } from "zod"; import { Link } from "@/components/common/link"; import { Note } from "@/components/common/note"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@openstatus/ui/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { isTRPCClientError } from "@trpc/client"; import { ArrowUpRight } from "lucide-react"; import { parseAsStringLiteral, useQueryStates } from "nuqs"; import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; const schema = z.object({ configuration: z.record( z.string(), z.string().or(z.boolean().nullish()).optional(), ), }); const configurationSchema = z .object({ type: z.enum(["manual", "absolute"]), value: z.enum(["duration", "requests", "manual"]).nullish(), uptime: z.boolean().or(z.literal("true").or(z.literal("false"))), theme: z.enum(THEME_KEYS as [string, ...string[]]), }) .refine( (data) => { // If type is "manual", value must be "manual" if (data.type === "manual") return data.value === "manual"; return true; }, { error: "Value must be manual when type is manual", path: ["value"], }, ); type FormValues = z.infer<typeof schema>; export function FormConfiguration({ defaultValues, onSubmit, configLink, }: { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; configLink: string; }) { const [isPending, startTransition] = useTransition(); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { configuration: {}, }, }); const watchConfigurationType = form.watch("configuration.type") as | "manual" | "absolute"; const watchConfigurationValue = form.watch("configuration.value") as | "duration" | "requests"; const watchConfigurationUptime = form.watch("configuration.uptime") as | "true" | "false"; useEffect(() => { if (watchConfigurationType === "manual") { form.setValue("configuration.value", "manual"); } else { form.setValue("configuration.value", "duration"); form.setValue("configuration.type", "absolute"); if (!watchConfigurationUptime) { form.setValue("configuration.uptime", "true"); } } }, [watchConfigurationType, watchConfigurationUptime, form]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <> <Form {...form}> <form id="redesign" onSubmit={form.handleSubmit(submitAction)}> <FormCard> <FormCardHeader> <FormCardTitle>Components Configuration</FormCardTitle> <FormCardDescription> Configure which data should be shown for your components. </FormCardDescription> </FormCardHeader> <FormCardSeparator /> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="configuration.type" render={({ field }) => ( <FormItem> <FormLabel>Bar Type*</FormLabel> <Select onValueChange={field.onChange} defaultValue={String(field.value) ?? "absolute"} > <FormControl> <SelectTrigger className="w-full capitalize"> <SelectValue placeholder="Select a type" /> </SelectTrigger> </FormControl> <SelectContent> {["absolute", "manual"].map((type) => ( <SelectItem key={type} value={type} className="capitalize" > {type} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="configuration.value" render={({ field }) => ( <FormItem> <FormLabel>Card Value*</FormLabel> <Select onValueChange={field.onChange} defaultValue={String(field.value) ?? "duration"} disabled={watchConfigurationType === "manual"} > <FormControl> <SelectTrigger className="w-full capitalize"> <SelectValue placeholder="Select a type" /> </SelectTrigger> </FormControl> <SelectContent> {["duration", "requests"].map((type) => ( <SelectItem key={type} value={type} className="capitalize" > {type} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="configuration.uptime" render={({ field }) => ( <FormItem> <FormLabel>Show Uptime</FormLabel> <Select onValueChange={field.onChange} defaultValue={String(field.value) ?? "true"} > <FormControl> <SelectTrigger className="w-full capitalize"> <SelectValue placeholder="Select a type" /> </SelectTrigger> </FormControl> <SelectContent> {["true", "false"].map((type) => ( <SelectItem key={type} value={type} className="capitalize" > {type} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <p className="col-span-full text-foreground/70 text-sm"> *Configuration settings only apply to monitor components. </p> <Note className="col-span-full"> <ul className="list-inside list-disc"> <li> <span>Bar Type </span> <span className="font-medium"> {watchConfigurationType} </span> : <span>{message.type[watchConfigurationType]}</span> </li> <li> <span>Card Value </span> <span className="font-medium"> {watchConfigurationValue} </span> :{" "} <span> {message.value[watchConfigurationValue] ?? message.value.default} </span> </li> <li> <span>Show Uptime </span> <span className="font-medium capitalize"> {String(watchConfigurationUptime)} </span> : <span>{message.uptime[watchConfigurationUptime]}</span> </li> </ul> </Note> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page" rel="noreferrer" target="_blank" > Configuration </Link> . </FormCardFooterInfo> <div className="flex items-center gap-2"> <Button type="button" variant="ghost" asChild> <Link href={configLink} rel="noreferrer" target="_blank" className="inline-flex items-center gap-1" > View and configure status page{" "} <ArrowUpRight className="h-4 w-4" /> </Link> </Button> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </div> </FormCardFooter> </FormCard> </form> </Form> <FormConfigurationDialog defaultValues={defaultValues} form={form} onSubmit={async (e) => { await onSubmit(e); // NOTE: make sure to sync the form with the new values form.reset(e); }} /> </> ); } // TODO: const message = { type: { manual: "only shares the duration of reports and maintenaces you are setting up - nothing else.", absolute: "shares the status of your endpoint for the duration of the different statuses.", }, value: { duration: "shares the duration of the different statuses.", requests: "shares the number of requests received (success, degraded, error).", default: "shares only the worse status of the day", }, uptime: { true: "shares the uptime percentage and current status of your endpoint.", false: "shares only the current status.", }, } as const; // ?type=manual&value=manual&uptime=true&theme=default const searchParams = { type: parseAsStringLiteral(["manual", "absolute"]), value: parseAsStringLiteral(["duration", "requests", "manual"]), uptime: parseAsStringLiteral(["true", "false"]), theme: parseAsStringLiteral(Object.keys(THEMES)), }; function FormConfigurationDialog({ defaultValues, onSubmit, }: { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; form: UseFormReturn<FormValues>; }) { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); const [{ type, value, uptime, theme }, setSearchParams] = useQueryStates(searchParams); useEffect(() => { if (type) setOpen(true); }, [type]); function submitAction(values: FormValues) { if (isPending) return; const data = configurationSchema.safeParse(values.configuration); if (!data.success) { toast.error(data.error.message); return; } startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; await setSearchParams({ type: null, value: null, uptime: null, theme: null, }); setOpen(false); } catch (error) { console.error(error); } finally { if (typeof window !== "undefined") { window.location.reload(); } } }); } return ( <Dialog open={open} onOpenChange={setOpen}> <DialogContent> <DialogHeader> <DialogTitle>Status Page Configuration</DialogTitle> <DialogDescription> Do you want to update the status page based on the configured settings? You can always change the settings later. </DialogDescription> </DialogHeader> <div className="flex flex-col gap-2"> <pre className="rounded-md border bg-muted/50 px-3 py-2 font-commit-mono text-sm"> {JSON.stringify({ type, value, uptime, theme }, null, 2)} </pre> </div> <DialogFooter> <DialogClose asChild> <Button variant="outline">Cancel</Button> </DialogClose> <Button type="button" onClick={() => submitAction({ ...defaultValues, configuration: { type: type ?? undefined, value: value ?? undefined, uptime: uptime ?? undefined, theme: theme ?? undefined, }, }) } disabled={isPending} > {isPending ? "Saving..." : "Save"} </Button> </DialogFooter> </DialogContent> </Dialog> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-custom-domain.tsx ================================================ "use client"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, FormCardUpgrade, } from "@/components/forms/form-card"; import { Label } from "@openstatus/ui/components/ui/label"; // FIXME: use input-group instead import { InputWithAddons } from "@/components/common/input-with-addons"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Lock } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Link } from "@/components/common/link"; import DomainConfiguration from "@/components/domains/domain-configuration"; import { useDomainStatus } from "@/components/domains/use-domain-status"; import { Form, FormField, FormItem, FormMessage, } from "@openstatus/ui/components/ui/form"; import { isTRPCClientError } from "@trpc/client"; import type React from "react"; import { useEffect, useTransition } from "react"; import { toast } from "sonner"; const schema = z.object({ domain: z.string(), }); type FormValues = z.infer<typeof schema>; export function FormCustomDomain({ locked, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { locked?: boolean; defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { domain: undefined, }, }); const [isPending, startTransition] = useTransition(); const { refresh, isLoading } = useDomainStatus(defaultValues?.domain); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } // NOTE: poll every 30 seconds to check for the status useEffect(() => { const interval = setInterval(() => refresh(), 30_000); return () => clearInterval(interval); }, [refresh]); return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> {locked ? <FormCardUpgrade /> : null} <FormCardHeader> <FormCardTitle>Custom Domain</FormCardTitle> <FormCardDescription> Use your own domain for your status page. </FormCardDescription> </FormCardHeader> <FormCardContent> <FormField control={form.control} name="domain" render={({ field }) => ( <FormItem> <Label>Domain</Label> <InputWithAddons placeholder="status.openstatus.dev" leading="https://" disabled={locked} {...field} /> <FormMessage /> </FormItem> )} /> </FormCardContent> {defaultValues?.domain ? ( <> <FormCardSeparator /> <FormCardContent> <DomainConfiguration domain={defaultValues?.domain} /> </FormCardContent> </> ) : null} <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/status-page/#custom-domain" rel="noreferrer" target="_blank" > Custom Domain </Link> . </FormCardFooterInfo> {locked ? ( <Button type="button" asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </Button> ) : ( <div className="flex items-center gap-2"> <Button type="button" variant="ghost" disabled={isPending || isLoading} onClick={refresh} className="hidden sm:block" > {isLoading ? "Refreshing..." : "Refresh Configuration"} </Button> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </div> )} </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-danger-zone.tsx ================================================ "use client"; import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; import { FormCard, FormCardDescription, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; export function FormDangerZone({ onSubmit, title, }: { onSubmit: () => Promise<void>; title: string; }) { return ( <FormCard variant="destructive"> <FormCardHeader> <FormCardTitle>Danger Zone</FormCardTitle> <FormCardDescription>This action cannot be undone.</FormCardDescription> </FormCardHeader> <FormCardFooter variant="destructive" className="justify-end"> <FormAlertDialog confirmationValue={title} submitAction={onSubmit} /> </FormCardFooter> </FormCard> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-general.tsx ================================================ "use client"; // FIXME: use input-group instead import { InputWithAddons } from "@/components/common/input-with-addons"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import Image from "next/image"; import { useEffect, useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const SLUG_UNIQUE_ERROR_MESSAGE = "This slug is already taken. Please choose another one."; function formatSlug(title: string) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } const schema = z.object({ title: z.string().min(1, "Title is required"), slug: z.string().min(3, "Slug is required"), icon: z.string().optional(), description: z.string().optional(), }); type FormValues = z.infer<typeof schema>; /** Convert a File to a base64 string without the data: prefix */ async function fileToBase64(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; // result is like "data:image/png;base64,XXXX" – we only need the part after the comma resolve(result.split(",")[1] || ""); }; reader.onerror = reject; reader.readAsDataURL(file); }); } export function FormGeneral({ disabled, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; disabled?: boolean; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { title: "", slug: "", icon: undefined, description: "", }, }); const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const uploadMutation = useMutation(trpc.blob.upload.mutationOptions()); const watchSlug = form.watch("slug"); const watchTitle = form.watch("title"); const watchIcon = form.watch("icon"); const debouncedSlug = useDebounce(watchSlug, 500); const { data: isUnique } = useQuery( trpc.page.getSlugUniqueness.queryOptions( { slug: debouncedSlug }, { enabled: debouncedSlug.length > 0 }, ), ); useEffect(() => { if (!defaultValues?.title) { const formattedSlug = formatSlug(watchTitle); form.setValue("slug", formattedSlug); } }, [form, defaultValues?.title, watchTitle]); useEffect(() => { if (isUnique === undefined) return; if (defaultValues?.slug === debouncedSlug) return; if (!isUnique) { form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); } else { form.clearErrors("slug"); } }, [isUnique, form, debouncedSlug, defaultValues?.slug]); function submitAction(values: FormValues) { if (isPending || disabled) return; startTransition(async () => { try { if (isUnique === false && defaultValues?.slug !== values.slug) { toast.error(SLUG_UNIQUE_ERROR_MESSAGE); form.setError("slug", { message: SLUG_UNIQUE_ERROR_MESSAGE }); return; } const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>General</FormCardTitle> <FormCardDescription> Configure the essential details for your status page. </FormCardDescription> </FormCardHeader> <FormCardSeparator /> <FormCardContent className="grid gap-4"> <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl> <Input placeholder="My Status Page" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter a descriptive name for your status page. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="slug" render={({ field }) => ( <FormItem> <FormLabel>Slug</FormLabel> <FormControl> <InputWithAddons placeholder="status" trailing=".openstatus.dev" {...field} /> </FormControl> <FormMessage /> <FormDescription> Choose a unique subdomain for your status page (minimum 3 characters). </FormDescription> </FormItem> )} /> <FormField control={form.control} name="icon" render={() => ( <FormItem> <FormLabel>Icon</FormLabel> <FormControl> <div className="flex items-center space-x-2"> {watchIcon ? ( <> <div className="size-[36px] overflow-hidden rounded-md border bg-muted"> <Image src={watchIcon} width={36} height={36} alt="Icon preview" /> </div> <Button variant="ghost" size="sm" type="button" onClick={() => form.setValue("icon", undefined)} > Remove </Button> </> ) : ( <Input type="file" accept="image/png,image/x-icon" onChange={async (e) => { const file = e.target.files?.[0]; if (!file) return; const base64String = await fileToBase64(file); try { const blob = await uploadMutation.mutateAsync({ filename: file.name, file: base64String, }); if (blob?.url) { form.setValue("icon", blob.url as string); } } catch (err) { console.error(err); toast.error("Upload failed"); } }} /> )} </div> </FormControl> <FormMessage /> <FormDescription> Select an icon for your status page. Ideally sized 512x512px. Will be used as favicon. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>Description</FormLabel> <FormControl> <Textarea {...field} /> </FormControl> <FormMessage /> <FormDescription> Provide a brief overview of your status page purpose. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardFooter> <Button type="submit" disabled={isPending || disabled}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-links.tsx ================================================ import { useTransition } from "react"; import { z } from "zod"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { isTRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; const schema = z.object({ homepageUrl: z.string().optional(), contactUrl: z.string().optional(), }); type FormValues = z.infer<typeof schema>; export function FormLinks({ defaultValues, onSubmit, }: { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const [isPending, startTransition] = useTransition(); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { homepageUrl: "", contactUrl: "", }, }); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)}> <FormCard> <FormCardHeader> <FormCardTitle>Links</FormCardTitle> <FormCardDescription> Configure the links for the status page. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="homepageUrl" render={({ field }) => ( <FormItem className="sm:col-span-full"> <FormLabel>Homepage URL</FormLabel> <FormControl> <Input placeholder="https://acme.com" {...field} /> </FormControl> <FormMessage /> <FormDescription> What URL should the logo link to? Leave empty to hide. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="contactUrl" render={({ field }) => ( <FormItem className="sm:col-span-full"> <FormLabel>Contact URL</FormLabel> <FormControl> <Input placeholder="https://acme.com/contact" {...field} /> </FormControl> <FormMessage /> <FormDescription> Enter the URL for your contact page. Or start with{" "} <code className="rounded-md bg-muted px-1 py-0.5"> mailto: </code>{" "} to open the email client. Leave empty to hide. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page/#3-links" rel="noreferrer" target="_blank" > links </Link> . </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-monitors.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay, } from "@/components/ui/sortable"; import type { UniqueIdentifier } from "@dnd-kit/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { PopoverContent } from "@openstatus/ui/components/ui/popover"; import { Popover, PopoverTrigger } from "@openstatus/ui/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { Check, ChevronsUpDown, GripVertical, Plus, Trash2, } from "lucide-react"; import { useCallback, useEffect, useState, useTransition } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; type Monitor = { id: number; name: string; externalName: string | null; url: string; active: boolean | null; }; type MonitorGroup = { id: number; name: string; monitors: Monitor[]; }; const monitorSchema = z.object({ id: z.number(), order: z.number(), active: z.boolean().nullable(), }); const schema = z.object({ monitors: z.array(monitorSchema), groups: z.array( z.object({ id: z.number(), order: z.number(), name: z.string(), monitors: z.array(monitorSchema).min(1, { error: "At least one monitor is required", }), }), ), }); const getSortedMonitors = ( monitors: Monitor[], monitorData: { id: number; order: number }[], ) => { const orderMap = new Map(monitorData?.map((m) => [m.id, m.order]) ?? []); return monitors .filter((monitor) => orderMap.has(monitor.id)) .sort((a, b) => { const aOrder = orderMap.get(a.id) ?? 0; const bOrder = orderMap.get(b.id) ?? 0; return aOrder - bOrder; }); }; const getSortedItems = ( monitors: Monitor[], monitorData: { id: number; order: number }[], groups: Array<{ id: number; order: number; name: string; monitors: Array<{ id: number; order: number; active: boolean | null }>; }>, ): (Monitor | MonitorGroup)[] => { // Create map of monitor orders const monitorOrderMap = new Map(monitorData.map((m) => [m.id, m.order])); // Create array of monitors with their orders const monitorsWithOrder = monitors .filter((monitor) => monitorOrderMap.has(monitor.id)) .map((monitor) => ({ item: monitor, order: monitorOrderMap.get(monitor.id) ?? 0, })); // Create array of groups with their orders const groupsWithOrder = groups.map((group) => ({ item: { id: group.id, name: group.name, monitors: getSortedMonitors(monitors, group.monitors), } as MonitorGroup, order: group.order, })); // Combine and sort by order return [...monitorsWithOrder, ...groupsWithOrder] .sort((a, b) => a.order - b.order) .map((entry) => entry.item); }; type FormValues = z.infer<typeof schema>; export function FormMonitors({ defaultValues, onSubmit, monitors, legacy, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; monitors: Monitor[]; /** * Whether the status page is legacy or new */ legacy: boolean; onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? {}, }); const [isPending, startTransition] = useTransition(); const watchMonitors = form.watch("monitors"); const watchGroups = form.watch("groups"); const [data, setData] = useState<(Monitor | MonitorGroup)[]>( getSortedItems( monitors, defaultValues?.monitors ?? [], defaultValues?.groups ?? [], ), ); // Get all monitor IDs that are already used in groups const monitorsInGroups = new Set( (watchGroups ?? []).flatMap((g) => g.monitors.map((m) => m.id)), ); useEffect(() => { const sortedItems = getSortedItems( monitors, watchMonitors, watchGroups ?? [], ); setData(sortedItems); }, [watchMonitors, watchGroups, monitors]); const onValueChange = useCallback( (newItems: (Monitor | MonitorGroup)[]) => { setData(newItems); // Update monitors with their position in the overall list const monitors = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: Monitor; index: number } => "url" in entry.item, ) .map(({ item, index }) => ({ id: item.id, order: index, active: item.active, })); form.setValue("monitors", monitors); // Update groups with their position in the overall list const existingGroups = form.getValues("groups") ?? []; const groups = newItems .map((item, index) => ({ item, index })) .filter( (entry): entry is { item: MonitorGroup; index: number } => "monitors" in entry.item && !("url" in entry.item), ) .map(({ item, index }) => { const existingGroup = existingGroups.find((g) => g.id === item.id); return existingGroup ? { ...existingGroup, order: index, } : { id: item.id, order: index, name: item.name, monitors: [], }; }); form.setValue("groups", groups); }, [form], ); const getItemValue = useCallback( (item: Monitor | MonitorGroup) => item.id, [], ); const handleAddGroup = useCallback(() => { const newGroupId = Date.now(); const existingGroups = form.getValues("groups") ?? []; const existingMonitors = form.getValues("monitors") ?? []; const order = existingGroups.length + existingMonitors.length; const newGroups = [ ...existingGroups, { id: newGroupId, order, name: "", monitors: [] }, ]; form.setValue("groups", newGroups); setData((prev) => [ ...prev, { id: newGroupId, order, name: "", monitors: [] }, ]); }, [form]); const handleDeleteGroup = useCallback( (groupId: number) => { const existingGroups = form.getValues("groups") ?? []; form.setValue( "groups", existingGroups.filter((g) => g.id !== groupId), ); setData((prev) => prev.filter((item) => item.id !== groupId)); }, [form], ); const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const monitor = data.find((item) => item.id === value); if (!monitor) return null; if ("url" in monitor) { return ( <MonitorRow monitor={monitor} form={form} className="border-transparent border-x px-2" /> ); } const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex((g) => g.id === monitor.id); return ( <MonitorGroup group={monitor} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} monitors={monitors} /> ); }, [data, handleDeleteGroup, form, monitors], ); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Monitors</FormCardTitle> <FormCardDescription> Connect your monitors to your status page. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <FormField control={form.control} name="monitors" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel className="sr-only">Monitors</FormLabel> <Popover> <PopoverTrigger asChild> <FormControl> <Button variant="outline" role="combobox" className={cn( "w-full justify-between", !field.value && "text-muted-foreground", )} > {field.value.length > 0 ? `${field.value.length} monitors selected` : "Select monitors"} <ChevronsUpDown className="opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty>No monitors found.</CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const isInGroup = monitorsInGroups.has( monitor.id, ); const isSelected = field.value.some( (m) => m.id === monitor.id, ); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isInGroup} onSelect={() => { if (isSelected) { form.setValue( "monitors", field.value.filter( (m) => m.id !== monitor.id, ), ); } else { form.setValue("monitors", [ ...field.value, { id: monitor.id, order: watchMonitors.length, active: monitor.active, }, ]); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <FormDescription>Choose monitors to display.</FormDescription> <FormMessage /> </FormItem> )} /> {legacy ? ( <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <span className="w-full"> <Button variant="outline" type="button" className="w-full" disabled={legacy} > <Plus /> Add Group </Button> </span> </TooltipTrigger> <TooltipContent> <p> Enable the new redesign to add groups to your status page. </p> </TooltipContent> </Tooltip> </TooltipProvider> ) : ( <Button variant="outline" type="button" className="w-full" onClick={handleAddGroup} > <Plus /> Add Group </Button> )} </FormCardContent> <FormCardSeparator /> <FormCardContent> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item) => { if ("url" in item) { return ( <MonitorRow key={`${item.id}-monitor`} className="border-transparent border-x px-2" monitor={item} form={form} /> ); } const groups = form.getValues("groups") ?? []; const groupIndex = groups.findIndex( (g) => g.id === item.id, ); return ( <MonitorGroup key={`${item.id}-group`} group={item} groupIndex={groupIndex} onDeleteGroup={handleDeleteGroup} form={form} monitors={monitors} /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No monitors selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> Learn more about monitor <Link href="#">display options</Link>. </FormCardFooterInfo> <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> </form> </Form> ); } interface MonitorRowProps extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { monitor: Monitor; form: UseFormReturn<FormValues>; } function MonitorRow({ monitor, className, ...props }: MonitorRowProps) { return ( <SortableItem value={monitor.id} asChild className={cn("rounded-md", className)} {...props} > <div className="grid h-9 grid-cols-3 gap-2"> <div className="flex flex-row items-center gap-4 self-center"> <SortableItemHandle> <GripVertical size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> <span className="truncate text-sm"> {monitor.name}{" "} <span className="text-muted-foreground"> {monitor.externalName ? `(${monitor.externalName})` : ""} </span> </span> </div> <div className="self-center truncate text-muted-foreground text-sm"> {monitor.url} </div> <div className="self-center truncate text-muted-foreground text-sm"> {monitor.active ? "Active" : "Inactive"} </div> </div> </SortableItem> ); } interface MonitorGroupProps extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { group: MonitorGroup; groupIndex: number; onDeleteGroup: (groupId: number) => void; form: UseFormReturn<FormValues>; monitors: Monitor[]; } function MonitorGroup({ group, groupIndex, onDeleteGroup, form, monitors, }: MonitorGroupProps) { const watchGroup = form.watch(`groups.${groupIndex}`); const watchMonitors = form.watch("monitors"); const watchGroups = form.watch("groups"); const [data, setData] = useState<Monitor[]>(group.monitors); // Calculate taken monitors (in main list or other groups) const takenMonitorIds = new Set([ ...watchMonitors.map((m) => m.id), ...watchGroups .filter((g) => g.id !== group.id) .flatMap((g) => g.monitors.map((m) => m.id)), ]); const onValueChange = useCallback( (newMonitors: Monitor[]) => { setData(newMonitors); // Update the form with the new monitor order form.setValue( `groups.${groupIndex}.monitors`, newMonitors.map((m, index) => ({ id: m.id, order: index, active: m.active, })), ); }, [form, groupIndex], ); useEffect(() => { setData(getSortedMonitors(monitors, watchGroup.monitors)); }, [watchGroup.monitors, monitors]); const getItemValue = useCallback((item: Monitor) => item.id, []); const renderOverlay = useCallback( ({ value }: { value: UniqueIdentifier }) => { const monitor = data.find((item) => item.id === value); if (!monitor) return null; return <MonitorRow monitor={monitor} form={form} />; }, [data, form], ); return ( <SortableItem value={group.id} className="rounded-md border bg-muted"> <div className="grid grid-cols-3 gap-2 px-2 pt-2"> <div className="flex flex-row items-center gap-1 self-center"> <SortableItemHandle> <GripVertical size={16} aria-hidden="true" className="text-muted-foreground" /> </SortableItemHandle> <FormField key={`${group.id}-name-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.name` as const} render={({ field }) => ( <FormItem className="w-full"> <FormLabel className="sr-only">Group name</FormLabel> <FormControl> <Input placeholder="Group Name" className="w-full bg-background" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField key={`${group.id}-monitors-${groupIndex}`} control={form.control} name={`groups.${groupIndex}.monitors` as const} render={({ field }) => ( <FormItem className="flex w-full flex-col"> <FormLabel className="sr-only">Monitors</FormLabel> <Popover> <PopoverTrigger asChild> <FormControl> <Button variant="outline" role="combobox" className={cn( "w-full justify-between", !field.value && "text-muted-foreground", )} > {Array.isArray(field.value) && field.value.length > 0 ? `${field.value.length} monitors selected` : "Select monitors"} <ChevronsUpDown className="opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="p-0"> <Command> <CommandInput placeholder="Search monitors..." className="h-9" /> <CommandList> <CommandEmpty>No monitors found.</CommandEmpty> <CommandGroup> {monitors.map((monitor) => { const current = field.value ?? []; const isSelected = current.some( (m) => m.id === monitor.id, ); const isTaken = takenMonitorIds.has(monitor.id); return ( <CommandItem value={monitor.name} key={monitor.id} disabled={isTaken} onSelect={() => { if (isSelected) { form.setValue( `groups.${groupIndex}.monitors`, current.filter((m) => m.id !== monitor.id), ); } else { form.setValue( `groups.${groupIndex}.monitors`, [ ...current, { id: monitor.id, order: 0, active: monitor.active, }, ], ); } }} > {monitor.name} <Check className={cn( "ml-auto", isSelected ? "opacity-100" : "opacity-0", )} /> </CommandItem> ); })} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <FormMessage /> </FormItem> )} /> <div className="flex justify-end"> <AlertDialog> <AlertDialogTrigger asChild> <Button type="button" variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:size-4 [&_svg]:text-destructive" // NOTE: delete directly if no monitors are in the group {...(data.length === 0 ? { onClick: () => onDeleteGroup(group.id) } : {})} > <Trash2 /> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogDescription> You are about to delete this group and all its monitors. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" onClick={() => onDeleteGroup(group.id)} > Delete </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> </div> <div className="mt-2 border-t px-2 pt-2 pb-2"> <Sortable value={data} onValueChange={onValueChange} getItemValue={getItemValue} orientation="vertical" > {data.length ? ( <SortableContent className="grid gap-2"> {data.map((item) => { return ( <MonitorRow key={`${item.id}-monitor`} monitor={item} form={form} /> ); })} <SortableOverlay>{renderOverlay}</SortableOverlay> </SortableContent> ) : ( <EmptyStateContainer> <EmptyStateTitle>No monitors selected</EmptyStateTitle> </EmptyStateContainer> )} </Sortable> </div> </SortableItem> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/form-page-access.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { FormCard, FormCardContent, FormCardContentUpgrade, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle, } from "@/components/forms/form-card"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { RadioGroup, RadioGroupItem, } from "@openstatus/ui/components/ui/radio-group"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { Key, Lock, LockOpen, ShieldUser } from "lucide-react"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const accessTypeSchema = z.enum(["public", "password", "email-domain"]); const schema = z.object({ accessType: accessTypeSchema, password: z.string().optional(), authEmailDomains: z .preprocess( (val: string[] | undefined) => val ? String(val) .split(",") .map((domain) => domain.trim()) .filter((domain) => domain.length > 0) : [], z.array(z.string()).optional(), ) .optional(), }); type FormValues = z.infer<typeof schema>; export function FormPageAccess({ lockedMap, defaultValues, onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { lockedMap?: Map<z.infer<typeof accessTypeSchema>, boolean>; defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; }) { const [isPending, startTransition] = useTransition(); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { accessType: "public", password: "", authEmailDomains: [], }, }); const watchAccessType = form.watch("accessType"); const locked = lockedMap?.get(watchAccessType); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { console.log(values); const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Page Access</FormCardTitle> <FormCardDescription> Enable protection for your status page. Choose between simple password or email domain authentication via magic link. </FormCardDescription> </FormCardHeader> <FormCardContent> <FormField control={form.control} name="accessType" render={({ field }) => ( <FormItem> <FormLabel>Protection Type</FormLabel> <FormControl> <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="grid grid-cols-2 gap-4 sm:grid-cols-4" > {[ { value: "public", icon: LockOpen, label: "Public" }, { value: "password", icon: Key, label: "Password" }, { value: "email-domain", icon: ShieldUser, label: "Magic Link (Auth)", }, ].map((type) => { return ( <FormItem key={type.value} className={cn( "relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-aria-[invalid=true]:border-destructive has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50", )} > <FormControl> <RadioGroupItem value={type.value} className="sr-only" /> </FormControl> <type.icon className="shrink-0 text-muted-foreground" size={16} aria-hidden="true" /> <FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0"> {type.label} </FormLabel> </FormItem> ); })} </RadioGroup> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> {watchAccessType && watchAccessType !== "public" ? ( <FormCardSeparator /> ) : null} {watchAccessType === "password" ? ( <FormCardContent className="grid gap-4"> {locked ? <FormCardContentUpgrade /> : null} <FormField control={form.control} name="password" disabled={locked} render={({ field }) => ( <FormItem> <FormLabel>Password</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> <FormDescription> Set a password to your status page to have a very basic protection. </FormDescription> </FormItem> )} /> </FormCardContent> ) : null} {watchAccessType === "email-domain" ? ( <FormCardContent className="grid gap-4"> {locked ? <FormCardContentUpgrade /> : null} <FormField control={form.control} name="authEmailDomains" disabled={locked} render={({ field }) => ( <FormItem> <FormLabel>Email Domains</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> <FormDescription> Comma-separated list of email domains. Only emails from these domains will be authenticated to access the status page. </FormDescription> </FormItem> )} /> </FormCardContent> ) : null} <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href="https://docs.openstatus.dev/reference/status-page/#password" rel="noreferrer" target="_blank" > Protection </Link> . </FormCardFooterInfo> {locked ? ( <Button type="button" asChild> <Link href="/settings/billing"> <Lock /> Upgrade </Link> </Button> ) : ( <Button type="submit" disabled={isPending}> {isPending ? "Submitting..." : "Submit"} </Button> )} </FormCardFooter> </FormCard> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-page/update.tsx ================================================ import { Link } from "@/components/common/link"; import { Note, NoteButton } from "@/components/common/note"; import { FormCardGroup } from "@/components/forms/form-card"; import { useTRPC } from "@/lib/trpc/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Info } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { FormAppearance } from "./form-appearance"; import { FormCustomDomain } from "./form-custom-domain"; import { FormDangerZone } from "./form-danger-zone"; import { FormGeneral } from "./form-general"; import { FormLinks } from "./form-links"; import { FormPageAccess } from "./form-page-access"; export function FormStatusPageUpdate() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const trpc = useTRPC(); const { data: statusPage, refetch } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); const queryClient = useQueryClient(); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const updateStatusPageMutation = useMutation( trpc.page.updateGeneral.mutationOptions({ onSuccess: () => { refetch(); // NOTE: invalidate status page list to update name queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); const updatePasswordProtectionMutation = useMutation( trpc.page.updatePasswordProtection.mutationOptions({ onSuccess: () => refetch(), }), ); const updateCustomDomainMutation = useMutation( trpc.page.updateCustomDomain.mutationOptions({ onSuccess: () => refetch(), }), ); const updatePageAppearanceMutation = useMutation( trpc.page.updateAppearance.mutationOptions({ onSuccess: () => refetch(), }), ); const deleteStatusPageMutation = useMutation( trpc.page.delete.mutationOptions({ onSuccess: () => { router.push("/status-pages"); // NOTE: invalidate workspace to update the usage queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); // NOTE: invalidate status page list to update the usage queryClient.invalidateQueries({ queryKey: trpc.page.list.queryKey(), }); }, }), ); const updateLinksMutation = useMutation( trpc.page.updateLinks.mutationOptions({ onSuccess: () => refetch(), }), ); if (!statusPage || !monitors || !workspace) return null; return ( <FormCardGroup> <Note color="warning"> <Info /> <p className="text-sm"> Looking to connect monitors to your status page? The setup now has a separate page{" "} <Link href={`/status-pages/${id}/components`}>components</Link>. </p> <NoteButton variant="default" asChild> <Link href="https://openstatus.dev/blog/status-page-components"> Learn more </Link> </NoteButton> </Note> <FormGeneral defaultValues={{ title: statusPage.title, slug: statusPage.slug, description: statusPage.description, icon: statusPage.icon ?? undefined, }} onSubmit={async (values) => { await updateStatusPageMutation.mutateAsync({ id: Number.parseInt(id), title: values.title, slug: values.slug, description: values.description ?? "", icon: values.icon ?? "", }); }} /> <FormCustomDomain locked={workspace.limits["custom-domain"] === false} defaultValues={{ domain: statusPage.customDomain ?? undefined, }} onSubmit={async (values) => { await updateCustomDomainMutation.mutateAsync({ id: Number.parseInt(id), customDomain: values.domain, }); }} /> <FormLinks defaultValues={{ homepageUrl: statusPage.homepageUrl ?? "", contactUrl: statusPage.contactUrl ?? "", }} onSubmit={async (values) => { await updateLinksMutation.mutateAsync({ id: Number.parseInt(id), homepageUrl: values.homepageUrl ?? undefined, contactUrl: values.contactUrl ?? undefined, }); }} /> <FormAppearance defaultValues={{ forceTheme: statusPage.forceTheme ?? "system", configuration: { theme: statusPage.configuration?.theme ?? "default", }, }} onSubmit={async (values) => { await updatePageAppearanceMutation.mutateAsync({ id: Number.parseInt(id), forceTheme: values.forceTheme, configuration: values.configuration, }); }} /> <FormPageAccess lockedMap={ new Map([ ["public", false], ["password", workspace.limits["password-protection"] === false], [ "email-domain", workspace.limits["email-domain-protection"] === false, ], ]) } defaultValues={{ accessType: statusPage.accessType, password: statusPage.password ?? undefined, authEmailDomains: statusPage.authEmailDomains ?? [], }} onSubmit={async (values) => { await updatePasswordProtectionMutation.mutateAsync({ id: Number.parseInt(id), accessType: values.accessType, password: values.password, authEmailDomains: values.authEmailDomains, }); }} /> <FormDangerZone title={statusPage.title} onSubmit={async () => { await deleteStatusPageMutation.mutateAsync({ id: Number.parseInt(id), }); }} /> </FormCardGroup> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-report/form.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { ProcessMessage } from "@/components/content/process-message"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { colors } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { type PageComponent, statusReportStatus, } from "@openstatus/db/src/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; import { CalendarIcon, ClockIcon } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ status: z.enum(statusReportStatus), title: z.string(), message: z.string(), date: z.date(), pageComponents: z.array(z.number()), notifySubscribers: z.boolean().optional(), }); const updateSchema = schema.omit({ message: true, date: true, notifySubscribers: true, }); export type FormValues = z.infer<typeof schema> | z.infer<typeof updateSchema>; export function FormStatusReport({ defaultValues, onSubmit, className, pageComponents, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; pageComponents: Pick<PageComponent, "id" | "name" | "type">[]; }) { const trpc = useTRPC(); const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm<FormValues>({ resolver: zodResolver(defaultValues ? updateSchema : schema), defaultValues: defaultValues ?? { status: "investigating", title: "", message: "", date: new Date(), pageComponents: [], notifySubscribers: true, }, }); const watchMessage = form.watch("message"); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent> <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="status" render={({ field }) => ( <FormItem> <FormLabel>Status</FormLabel> <FormControl> <Select defaultValue={field.value} onValueChange={field.onChange} > <SelectTrigger className={cn( colors[field.value], "font-mono capitalize", )} > <SelectValue placeholder="Select a status" /> </SelectTrigger> <SelectContent> {statusReportStatus.map((status) => ( <SelectItem key={status} value={status} className={cn("font-mono capitalize", colors[status])} > {status} </SelectItem> ))} </SelectContent> </Select> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> {!defaultValues ? ( <> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="date" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground", )} > {field.value ? ( format(field.value, "PPP 'at' h:mm a") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" side={mobile ? "bottom" : "left"} > <Calendar mode="single" selected={field.value} onSelect={(selectedDate) => { if (!selectedDate) return; const newDate = new Date(selectedDate); newDate.setHours( field.value.getHours(), field.value.getMinutes(), field.value.getSeconds(), field.value.getMilliseconds(), ); field.onChange(newDate); }} disabled={(date) => date > new Date() || date < new Date("1900-01-01") } initialFocus /> <div className="border-t p-3"> <div className="flex items-center gap-3"> <Label htmlFor="time" className="text-xs"> Enter time </Label> <div className="relative grow"> <Input id="time" type="time" step="1" defaultValue={new Date() .toTimeString() .slice(0, 8)} className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" onChange={(e) => { try { const timeValue = e.target.value; if (!timeValue || !field.value) return; const [hours, minutes, seconds] = timeValue .split(":") .map(Number); const newDate = new Date(field.value); newDate.setHours( hours, minutes, seconds || 0, 0, ); field.onChange(newDate); } catch (error) { console.error(error); } }} /> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <ClockIcon size={16} aria-hidden="true" /> </div> </div> </div> </div> </PopoverContent> </Popover> <FormDescription> When the status report was created. Shown in your timezone ( <code className="font-commit-mono text-foreground/70"> {timezone} </code> ) and saved as Unix time ( <code className="font-commit-mono text-foreground/70"> UTC </code> ). </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Tabs defaultValue="tab-1"> <TabsList> <TabsTrigger value="tab-1">Writing</TabsTrigger> <TabsTrigger value="tab-2">Preview</TabsTrigger> </TabsList> <TabsContent value="tab-1"> <FormField control={form.control} name="message" render={({ field }) => ( <FormItem> <FormLabel>Message</FormLabel> <FormControl> <Textarea rows={6} {...field} /> </FormControl> <FormMessage /> <FormDescription>Markdown support</FormDescription> </FormItem> )} /> </TabsContent> <TabsContent value="tab-2"> <div className="grid gap-2"> <Label>Preview</Label> <div className="prose dark:prose-invert prose-sm rounded-md border px-3 py-2 text-foreground text-sm"> <ProcessMessage value={watchMessage} /> </div> </div> </TabsContent> </Tabs> </FormCardContent> </> ) : null} <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="pageComponents" render={({ field }) => ( <FormItem> <FormLabel>Page Components</FormLabel> <FormDescription> Select the page components you want to notify. </FormDescription> {pageComponents.length ? ( <div className="grid gap-3"> <div className="flex items-center gap-2"> <FormControl> <Checkbox id="all" checked={ field.value?.length === pageComponents.length } onCheckedChange={(checked) => { field.onChange( checked ? pageComponents.map((c) => c.id) : [], ); }} /> </FormControl> <Label htmlFor="all">Select all</Label> </div> {pageComponents.map((item) => ( <div key={item.id} className="flex items-center gap-2"> <FormControl> <Checkbox id={String(item.id)} checked={field.value?.includes(item.id)} onCheckedChange={(checked) => { const newValue = checked ? [...(field.value || []), item.id] : field.value?.filter((id) => id !== item.id); field.onChange(newValue); }} /> </FormControl> <Label htmlFor={String(item.id)}>{item.name}</Label> </div> ))} </div> ) : ( <EmptyStateContainer> <EmptyStateTitle>No page components found</EmptyStateTitle> </EmptyStateContainer> )} <FormMessage /> </FormItem> )} /> </FormCardContent> {!defaultValues && workspace?.limits["status-subscribers"] ? ( <> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="notifySubscribers" render={({ field }) => ( <FormItem> <FormLabel>Notify Subscribers</FormLabel> <FormControl> <div className="flex items-center gap-2"> <Checkbox id="notifySubscribers" checked={field.value} onCheckedChange={field.onChange} /> <Label htmlFor="notifySubscribers"> Send email notification to subscribers </Label> </div> </FormControl> <FormMessage /> <FormDescription> Subscribers will receive an email when creating a status report. </FormDescription> </FormItem> )} /> </FormCardContent> </> ) : null} </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-report/sheet.tsx ================================================ "use client"; import { FormCard, FormCardGroup } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { FormStatusReport, type FormValues, } from "@/components/forms/status-report/form"; import type { PageComponent } from "@openstatus/db/src/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useState } from "react"; export function FormSheetStatusReport({ children, defaultValues, onSubmit, pageComponents, warning, }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; pageComponents: Pick<PageComponent, "id" | "name" | "type">[]; warning?: React.ReactNode; }) { const [open, setOpen] = useState(false); return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger asChild>{children}</FormSheetTrigger> <FormSheetContent className="sm:max-w-lg"> <FormSheetHeader> <FormSheetTitle>Status Report</FormSheetTitle> <FormSheetDescription> Configure and update the status of your report. </FormSheetDescription> </FormSheetHeader> {warning ? ( <> <p className="px-4 py-4 text-sm text-warning">{warning}</p> <Separator /> </> ) : null} <FormCardGroup className="overflow-y-scroll"> <FormCard className="overflow-auto rounded-none border-none"> <FormStatusReport id="status-report-form" className="my-4" onSubmit={async (values) => { await onSubmit(values); setOpen(false); }} defaultValues={defaultValues} pageComponents={pageComponents} /> </FormCard> </FormCardGroup> <FormSheetFooter> <Button type="submit" form="status-report-form"> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx ================================================ "use client"; import { ProcessMessage } from "@/components/content/process-message"; import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { FormCard, FormCardFooter, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { colors } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { type StatusReportUpdate, statusReportStatus, } from "@openstatus/db/src/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; import { CalendarIcon, ClockIcon } from "lucide-react"; import { useParams } from "next/navigation"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ status: z.enum(statusReportStatus), message: z.string(), date: z.date(), notifySubscribers: z.boolean().optional(), }); export type FormValues = z.infer<typeof schema>; export function FormStatusReportUpdateCard({ defaultValues, onSubmit, className, index, update, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; index: number; update: StatusReportUpdate; }) { const { reportId } = useParams<{ id: string; reportId: string }>(); const trpc = useTRPC(); const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { status: "identified", message: "", date: new Date(), notifySubscribers: true, }, }); const watchMessage = form.watch("message"); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); const { data: statusReport, refetch } = useQuery( trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), ); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } const deleteStatusReportUpdateMutation = useMutation( trpc.statusReport.deleteUpdate.mutationOptions({ onSuccess: () => { refetch(); }, }), ); const updates = [...(statusReport?.updates ?? [])].sort( (a, b) => b.date.getTime() - a.date.getTime(), ); return ( <FormCard> <FormCardHeader> <FormCardTitle> Status Report Update #{updates.length - index} </FormCardTitle> </FormCardHeader> <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="status" render={({ field }) => ( <FormItem> <FormLabel>Status</FormLabel> <FormControl> <Select defaultValue={field.value} onValueChange={field.onChange} > <SelectTrigger className={cn( colors[field.value], "w-full font-mono capitalize", )} > <SelectValue placeholder="Select a status" /> </SelectTrigger> <SelectContent> {statusReportStatus.map((status) => ( <SelectItem key={status} value={status} className={cn( colors[status], "font-mono capitalize", )} > {status} </SelectItem> ))} </SelectContent> </Select> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="date" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "h-9 w-full pl-3 text-left font-normal sm:w-[240px]", !field.value && "text-muted-foreground", )} > {field.value ? ( format(field.value, "PPP 'at' h:mm a") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" side={mobile ? "bottom" : "left"} > <Calendar mode="single" selected={field.value} onSelect={(selectedDate) => { if (!selectedDate) return; const newDate = new Date(selectedDate); newDate.setHours( field.value.getHours(), field.value.getMinutes(), field.value.getSeconds(), field.value.getMilliseconds(), ); field.onChange(newDate); }} disabled={(date) => date > new Date() || date < new Date("1900-01-01") } initialFocus /> <div className="border-t p-3"> <div className="flex items-center gap-3"> <Label htmlFor="time" className="text-xs"> Enter time </Label> <div className="relative grow"> <Input id="time" type="time" step="1" defaultValue={new Date() .toTimeString() .slice(0, 8)} className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" onChange={(e) => { try { const timeValue = e.target.value; if (!timeValue || !field.value) return; const [hours, minutes, seconds] = timeValue .split(":") .map(Number); const newDate = new Date(field.value); newDate.setHours( hours, minutes, seconds || 0, 0, ); field.onChange(newDate); } catch (error) { console.error(error); } }} /> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <ClockIcon size={16} aria-hidden="true" /> </div> </div> </div> </div> </PopoverContent> </Popover> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardContent> <FormDescription> When the status report was created. Shown in your timezone ( <code className="font-commit-mono text-foreground/70"> {timezone} </code> ) and saved as Unix time ( <code className="font-commit-mono text-foreground/70">UTC</code> ). </FormDescription> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Tabs defaultValue="tab-1"> <TabsList> <TabsTrigger value="tab-1">Writing</TabsTrigger> <TabsTrigger value="tab-2">Preview</TabsTrigger> </TabsList> <TabsContent value="tab-1"> <FormField control={form.control} name="message" render={({ field }) => ( <FormItem> <FormLabel>Message</FormLabel> <FormControl> <Textarea rows={6} {...field} /> </FormControl> <FormMessage /> <FormDescription>Markdown support</FormDescription> </FormItem> )} /> </TabsContent> <TabsContent value="tab-2"> <div className="grid gap-2"> <Label>Preview</Label> <div className="prose prose-sm dark:prose-invert rounded-md border px-3 py-2 text-foreground text-sm"> <ProcessMessage value={watchMessage} /> </div> </div> </TabsContent> </Tabs> </FormCardContent> {!defaultValues && workspace?.limits["status-subscribers"] ? ( <> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="notifySubscribers" render={({ field }) => ( <FormItem> <FormLabel>Notify Subscribers</FormLabel> <FormControl> <div className="flex items-center gap-2"> <Checkbox id="notifySubscribers" checked={field.value} onCheckedChange={field.onChange} /> <Label htmlFor="notifySubscribers"> Send email notification to subscribers </Label> </div> </FormControl> <FormMessage /> <FormDescription> Subscribers will receive an email when creating a status report. </FormDescription> </FormItem> )} /> </FormCardContent> </> ) : null} </form> </Form> <FormCardFooter className="flex items-center justify-end gap-2 [&>:last-child]:ml-0"> <FormAlertDialog confirmationValue={update.status} submitAction={async () => { await deleteStatusReportUpdateMutation.mutateAsync({ id: update.id, }); }} > <Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" > Delete </Button> </FormAlertDialog> <Button type="submit" form={`update-form-${update.id}`}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-report-update/form.tsx ================================================ "use client"; import { ProcessMessage } from "@/components/content/process-message"; import { FormCardContent, FormCardSeparator, } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { colors } from "@/data/status-report-updates.client"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { statusReportStatus } from "@openstatus/db/src/schema"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Popover } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { TabsContent } from "@openstatus/ui/components/ui/tabs"; import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tabs } from "@openstatus/ui/components/ui/tabs"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { format } from "date-fns"; import { CalendarIcon, ClockIcon } from "lucide-react"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ status: z.enum(statusReportStatus), message: z.string(), date: z.date(), notifySubscribers: z.boolean().optional(), }); export type FormValues = z.infer<typeof schema>; export function FormStatusReportUpdate({ defaultValues, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: Partial<FormValues>; onSubmit: (values: FormValues) => Promise<void>; }) { const trpc = useTRPC(); const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); const mobile = useIsMobile(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { status: defaultValues?.status ?? "identified", message: defaultValues?.message ?? "", date: defaultValues?.date ?? new Date(), notifySubscribers: defaultValues?.notifySubscribers ?? true, }, }); const watchMessage = form.watch("message"); const [isPending, startTransition] = useTransition(); const { setIsDirty } = useFormSheetDirty(); const formIsDirty = form.formState.isDirty; React.useEffect(() => { setIsDirty(formIsDirty); }, [formIsDirty, setIsDirty]); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: () => "Saved", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form className={cn("grid gap-4", className)} onSubmit={form.handleSubmit(submitAction)} {...props} > <FormCardContent> <FormField control={form.control} name="status" render={({ field }) => ( <FormItem> <FormLabel>Status</FormLabel> <FormControl> <Select defaultValue={field.value} onValueChange={field.onChange} > <SelectTrigger className={cn( colors[field.value], "font-mono capitalize", )} > <SelectValue placeholder="Select a status" /> </SelectTrigger> <SelectContent> {statusReportStatus.map((status) => ( <SelectItem key={status} value={status} className={cn(colors[status], "font-mono capitalize")} > {status} </SelectItem> ))} </SelectContent> </Select> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="date" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>Date</FormLabel> <Popover modal> <FormControl> <PopoverTrigger asChild> <Button type="button" variant="outline" size="sm" className={cn( "w-[240px] pl-3 text-left font-normal", !field.value && "text-muted-foreground", )} > {field.value ? ( format(field.value, "PPP 'at' h:mm a") ) : ( <span>Pick a date</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> </PopoverTrigger> </FormControl> <PopoverContent className="pointer-events-auto w-auto p-0" align="start" side={mobile ? "bottom" : "left"} > <Calendar mode="single" selected={field.value} onSelect={(selectedDate) => { if (!selectedDate) return; const newDate = new Date(selectedDate); newDate.setHours( field.value.getHours(), field.value.getMinutes(), field.value.getSeconds(), field.value.getMilliseconds(), ); field.onChange(newDate); }} disabled={(date) => date > new Date() || date < new Date("1900-01-01") } initialFocus /> <div className="border-t p-3"> <div className="flex items-center gap-3"> <Label htmlFor="time" className="text-xs"> Enter time </Label> <div className="relative grow"> <Input id="time" type="time" step="1" defaultValue={new Date().toTimeString().slice(0, 8)} className="peer appearance-none ps-9 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" onChange={(e) => { try { const timeValue = e.target.value; if (!timeValue || !field.value) return; const [hours, minutes, seconds] = timeValue .split(":") .map(Number); const newDate = new Date(field.value); newDate.setHours( hours, minutes, seconds || 0, 0, ); field.onChange(newDate); } catch (error) { console.error(error); } }} /> <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <ClockIcon size={16} aria-hidden="true" /> </div> </div> </div> </div> </PopoverContent> </Popover> <FormDescription> When the status report was created. Shown in your timezone ( <code className="font-commit-mono text-foreground/70"> {timezone} </code> ) and saved as Unix time ( <code className="font-commit-mono text-foreground/70"> UTC </code> ). </FormDescription> <FormMessage /> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <Tabs defaultValue="tab-1"> <TabsList> <TabsTrigger value="tab-1">Writing</TabsTrigger> <TabsTrigger value="tab-2">Preview</TabsTrigger> </TabsList> <TabsContent value="tab-1"> <FormField control={form.control} name="message" render={({ field }) => ( <FormItem> <FormLabel>Message</FormLabel> <FormControl> <Textarea rows={6} {...field} /> </FormControl> <FormMessage /> <FormDescription>Markdown support</FormDescription> </FormItem> )} /> </TabsContent> <TabsContent value="tab-2"> <div className="grid gap-2"> <Label>Preview</Label> <div className="prose prose-sm dark:prose-invert rounded-md border px-3 py-2 text-foreground text-sm"> <ProcessMessage value={watchMessage} /> </div> </div> </TabsContent> </Tabs> </FormCardContent> {!defaultValues?.date && workspace?.limits["status-subscribers"] ? ( <> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="notifySubscribers" render={({ field }) => ( <FormItem> <FormLabel>Notify Subscribers</FormLabel> <FormControl> <div className="flex items-center gap-2"> <Checkbox id="notifySubscribers" checked={field.value} onCheckedChange={field.onChange} /> <Label htmlFor="notifySubscribers"> Send email notification to subscribers </Label> </div> </FormControl> <FormMessage /> <FormDescription> Subscribers will receive an email when creating a status report. </FormDescription> </FormItem> )} /> </FormCardContent> </> ) : null} </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/forms/status-report-update/sheet.tsx ================================================ "use client"; import { FormCard, FormCardGroup } from "@/components/forms/form-card"; import { FormSheetContent, FormSheetDescription, FormSheetFooter, FormSheetHeader, FormSheetTitle, FormSheetTrigger, FormSheetWithDirtyProtection, } from "@/components/forms/form-sheet"; import { FormStatusReportUpdate, type FormValues, } from "@/components/forms/status-report-update/form"; import { Button } from "@openstatus/ui/components/ui/button"; import { useState } from "react"; export function FormSheetStatusReportUpdate({ children, defaultValues, onSubmit, }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { defaultValues?: Partial<FormValues>; onSubmit: (values: FormValues) => Promise<void>; }) { const [open, setOpen] = useState(false); return ( <FormSheetWithDirtyProtection open={open} onOpenChange={setOpen}> <FormSheetTrigger asChild>{children}</FormSheetTrigger> <FormSheetContent className="sm:max-w-lg"> <FormSheetHeader> <FormSheetTitle>Status Report Update</FormSheetTitle> <FormSheetDescription> Configure and update the status of your report. </FormSheetDescription> </FormSheetHeader> <FormCardGroup className="overflow-y-scroll"> <FormCard className="overflow-auto rounded-none border-none"> <FormStatusReportUpdate id="status-report-update-form" className="my-4" onSubmit={async (values) => { await onSubmit(values); setOpen(false); }} defaultValues={defaultValues} /> </FormCard> </FormCardGroup> <FormSheetFooter> <Button type="submit" form="status-report-update-form"> Submit </Button> </FormSheetFooter> </FormSheetContent> </FormSheetWithDirtyProtection> ); } ================================================ FILE: apps/dashboard/src/components/forms/support-contact/dialog.tsx ================================================ import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@openstatus/ui/components/ui/dialog"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { ContactForm, type FormValues } from "./form"; export function FormDialogSupportContact({ children, defaultValues, ...props }: React.ComponentProps<typeof DialogTrigger> & { defaultValues?: FormValues; }) { const [open, setOpen] = useState(false); const isMobile = useIsMobile(); const trpc = useTRPC(); const { data: user } = useQuery(trpc.user.get.queryOptions()); const feedbackMutation = useMutation(trpc.feedback.submit.mutationOptions()); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger {...props} asChild> {children} </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Support</DialogTitle> <DialogDescription> Please fill out the form below to get in touch with us. Or send us an email to{" "} <Link href="mailto:ping@openstatus.dev">ping@openstatus.dev</Link>. </DialogDescription> </DialogHeader> <ContactForm defaultValues={{ name: defaultValues?.name ?? user?.name ?? undefined, email: defaultValues?.email ?? user?.email ?? undefined, type: defaultValues?.type, message: defaultValues?.message, blocker: defaultValues?.blocker, }} onSubmit={async (data) => { await feedbackMutation.mutateAsync({ name: data.name, email: data.email, type: data.type, message: data.message, blocker: data.blocker, path: window.location.pathname, isMobile, }); setOpen(false); }} /> </DialogContent> </Dialog> ); } ================================================ FILE: apps/dashboard/src/components/forms/support-contact/form.tsx ================================================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@openstatus/ui/components/ui/form"; import { Button } from "@openstatus/ui/components/ui/button"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Input } from "@openstatus/ui/components/ui/input"; import { SelectItem } from "@openstatus/ui/components/ui/select"; import { SelectContent, SelectValue, } from "@openstatus/ui/components/ui/select"; import { SelectTrigger } from "@openstatus/ui/components/ui/select"; import { Select } from "@openstatus/ui/components/ui/select"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { cn } from "@openstatus/ui/lib/utils"; import { toast } from "sonner"; export const types = [ { label: "Report a bug", value: "bug" as const, }, { label: "Book a demo", value: "demo" as const, }, { label: "Suggest a feature", value: "feature" as const, }, { label: "Report a security issue", value: "security" as const, }, { label: "Something else", value: "question" as const, }, ]; export const schema = z.object({ name: z.string().min(1, { error: "Name is required", }), type: z.enum(["bug", "demo", "feature", "security", "question"]), email: z.email({ error: "Invalid email address", }), message: z.string().min(1, { error: "Message is required", }), blocker: z.boolean(), }); export type FormValues = z.infer<typeof schema>; interface ContactFormProps { defaultValues?: Partial<FormValues>; onSubmit: (data: FormValues) => Promise<void>; className?: string; } export function ContactForm({ defaultValues, onSubmit, className, }: ContactFormProps) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { name: defaultValues?.name ?? "", email: defaultValues?.email ?? "", type: defaultValues?.type ?? undefined, message: defaultValues?.message ?? "", blocker: defaultValues?.blocker ?? false, }, }); const [isPending, startTransition] = useTransition(); const watchType = form.watch("type"); async function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Sending message...", success: "Message sent. We'll get back to you soon.", error: "Failed to send message. Please try again.", }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} className={cn("grid gap-4 sm:grid-cols-2", className)} > <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="Max" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input placeholder="max@openstatus.dev" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="type" render={({ field }) => ( <FormItem className="sm:col-span-full"> <FormLabel>Type</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="What you need help with" /> </SelectTrigger> </FormControl> <SelectContent> {types.map((type) => ( <SelectItem key={type.value} value={type.value}> {type.label} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {watchType ? ( <FormField control={form.control} name="message" render={({ field }) => ( <FormItem className="sm:col-span-full"> <FormLabel>Message</FormLabel> <FormControl> <Textarea placeholder="Tell us about it..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> ) : null} {watchType === "bug" ? ( <FormField control={form.control} name="blocker" render={({ field }) => ( <FormItem className="flex flex-row items-start sm:col-span-full"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel className="font-normal leading-none"> This bug prevents me from using the product. </FormLabel> </FormItem> )} /> ) : null} <Button type="submit" className="w-full sm:col-span-full" disabled={isPending} > {isPending ? "Submitting..." : "Submit"} </Button> </form> </Form> ); } ================================================ FILE: apps/dashboard/src/components/layout/auth-layout.tsx ================================================ import Image from "next/image"; export function AuthLayout({ children }: { children: React.ReactNode }) { return ( <div className="grid min-h-screen grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5"> <aside className="col-span-1 flex w-full flex-col gap-4 border border-border bg-sidebar p-4 backdrop-blur-[2px] md:p-8 xl:col-span-2"> <a href="https://openstatus.dev" className="relative h-8 w-8"> <Image src="https://openstatus.dev/icon.png" alt="OpenStatus" height={32} width={32} className="rounded-full border border-border" /> </a> <div className="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center gap-8 text-center md:text-left"> <div className="mx-auto grid gap-3"> <h1 className="font-cal text-3xl text-foreground"> Open Source Monitoring Service </h1> <p className="text-muted-foreground text-sm"> Monitor your website or API and create your own status page within a couple of minutes. Want to know how it works? <br /> <br /> Check out{" "} <a href="https://github.com/openstatushq/openstatus" target="_blank" rel="noreferrer" className="text-foreground underline underline-offset-4 hover:no-underline" > GitHub </a>{" "} and let us know your use case! </p> </div> </div> <div className="md:h-8" /> </aside> <main className="container col-span-1 mx-auto flex items-center justify-center md:col-span-1 xl:col-span-3"> {children} </main> </div> ); } ================================================ FILE: apps/dashboard/src/components/metric/global-uptime/section.tsx ================================================ "use client"; import { MetricCard, MetricCardBadge, MetricCardGroup, MetricCardHeader, MetricCardSkeleton, MetricCardTitle, MetricCardValue, } from "@/components/metric/metric-card"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { mapMetrics, metricsCards } from "@/data/metrics.client"; import { formatMilliseconds, formatNumber, formatPercentage, } from "@/lib/formatter"; import { formatDistanceToNow } from "date-fns"; type Metric = { label: string; value: string; trend?: number | null; variant: React.ComponentProps<typeof MetricCard>["variant"]; }; // TODO: move the fetch to the parent component // TODO: missing dynamic degraded export function GlobalUptimeSection({ monitorId, jobType, period = "7d", regions, }: { monitorId: string; jobType: "http" | "tcp"; period: "1d" | "7d" | "14d"; regions: string[] | undefined; }) { const trpc = useTRPC(); const { data: metrics, isLoading } = useQuery( trpc.tinybird.metrics.queryOptions({ monitorId, period, type: jobType, regions, }), ); // Helper to transform the data the same way it used to be in the page function defineMetrics() { if (!metrics) return null; const _metrics = mapMetrics(metrics); if (_metrics.length !== 2) return null; return _metrics.reverse().reduce( (acc, metric) => { Object.entries(metric).forEach(([key, value]) => { const k = key as keyof typeof acc; const v = (() => { if (k === "lastTimestamp") { if (!value) return "N/A"; return formatDistanceToNow(new Date(value ?? 0), { addSuffix: true, }); } if (k === "uptime") { return formatPercentage(value ?? 0); } if (k.startsWith("p")) { return formatMilliseconds(value ?? 0); } return formatNumber(value ?? 0); })(); if (k in acc) { const trend = acc[k]?.raw ? k === "uptime" ? acc[k]?.raw / (value ?? 0) : (value ?? 0) / acc[k]?.raw : 1; const hasTrend = !Number.isNaN(trend) && trend !== Number.POSITIVE_INFINITY && k !== "total" && k !== "lastTimestamp"; acc[k] = { label: metricsCards[k].label, variant: metricsCards[k].variant, value: v ?? "0", trend: hasTrend ? trend : null, raw: value ?? 0, } as (typeof acc)[typeof k & keyof typeof acc]; } else { acc[k] = { label: metricsCards[k].label, variant: metricsCards[k].variant, value: v ?? "0", trend: 1, raw: value ?? 0, } as (typeof acc)[typeof k & keyof typeof acc]; } }); return acc; }, {} as Record< keyof ReturnType<typeof mapMetrics>[number], Metric & { raw: number } >, ); } const refinedMetrics: (Metric | null)[] = ( [ "uptime", "degraded", "error", "total", "lastTimestamp", "p50", "p75", "p90", "p95", "p99", ] as const ).map((key) => { if (!key) return null; const metric = defineMetrics()?.[key as keyof ReturnType<typeof mapMetrics>[number]]; return { label: metricsCards[key].label, value: metric?.value ?? "0", trend: metric?.trend ?? null, variant: metricsCards[key].variant, } as Metric; }); // TODO: rework, might be removed and simply add a condition on the other return if (isLoading) { return ( <MetricCardGroup> {refinedMetrics.map((metric) => { if (metric === null) return <div key={metric} className="hidden lg:block" />; return ( <MetricCard key={metric.label} variant={metric.variant}> <MetricCardHeader> <MetricCardTitle className="truncate"> {metric.label} </MetricCardTitle> </MetricCardHeader> <MetricCardSkeleton className="h-6 w-12" /> </MetricCard> ); })} </MetricCardGroup> ); } return ( <MetricCardGroup> {refinedMetrics.map((metric) => { if (metric === null) return <div key={metric} className="hidden lg:block" />; return ( <MetricCard key={metric.label} variant={metric.variant}> <MetricCardHeader> <MetricCardTitle className="truncate"> {metric.label} </MetricCardTitle> </MetricCardHeader> <div className="flex flex-row flex-wrap items-center gap-1.5"> <MetricCardValue>{metric.value}</MetricCardValue> {metric.trend ? <MetricCardBadge value={metric.trend} /> : null} </div> </MetricCard> ); })} </MetricCardGroup> ); } ================================================ FILE: apps/dashboard/src/components/metric/metric-card.tsx ================================================ import type { VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import type React from "react"; const metricCardVariants = cva( "flex flex-col gap-1 border rounded-lg px-3 py-2 text-card-foreground", { variants: { variant: { default: "border-input bg-card", ghost: "border-transparent", destructive: "border-destructive/80 bg-destructive/10", success: "border-success/80 bg-success/10", warning: "border-warning/80 bg-warning/10", }, }, defaultVariants: { variant: "default", }, }, ); export function MetricCard({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof metricCardVariants>) { return ( <div data-variant={variant} className={cn(metricCardVariants({ variant, className }), "group")} {...props} > {children} </div> ); } export function MetricCardTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn( "font-commit-mono font-medium text-sm tracking-tight ", className, )} {...props} > {children} </p> ); } export function MetricCardHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "text-muted-foreground", "group-data-[variant=destructive]:text-destructive", "group-data-[variant=success]:text-success", "group-data-[variant=warning]:text-warning", className, )} {...props} > {children} </div> ); } export function MetricCardValue({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-medium text-foreground", className)} {...props}> {children} </p> ); } export function MetricCardGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5", className, )} {...props} > {children} </div> ); } const badgeVariants = cva("px-1.5 font-mono text-[10px]", { variants: { variant: { default: "border-border", increase: "border-destructive/20 bg-destructive/10 hover:bg-destructive/10 text-destructive", decrease: "border-success/20 bg-success/10 hover:bg-success/10 text-success", }, }, defaultVariants: { variant: "default", }, }); export function MetricCardBadge({ value, decimal = 1, className, ...props }: React.ComponentProps<typeof Badge> & { value: number; decimal?: number; }) { const round = 10 ** decimal; // 10^1 = 10 (1 decimal), 10^2 = 100 (2 decimals), etc. const percentage = Math.round((value - 1) * 100 * round) / round; const variant: VariantProps<typeof badgeVariants>["variant"] = percentage > 0 ? "increase" : percentage < 0 ? "decrease" : "default"; return ( <Badge variant="secondary" className={badgeVariants({ variant, className })} {...props} > {percentage !== 0 ? ( <span> {percentage > 0 ? <ChevronUp className="mr-px size-2.5" /> : null} {percentage < 0 ? <ChevronDown className="mr-px size-2.5" /> : null} </span> ) : null} {Math.abs(percentage)}% </Badge> ); } const metricCardButtonVariants = cva( "group w-full text-left transition-all rounded-md outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 cursor-pointer", // TODO: discuss if we want rings ); export function MetricCardButton({ children, className, variant, ...props }: React.ComponentProps<"button"> & VariantProps<typeof metricCardVariants>) { return ( <button type="button" data-variant={variant} className={cn( metricCardVariants({ variant, className }), metricCardButtonVariants(), )} {...props} > {children} </button> ); } export function MetricCardSkeleton({ className, ...props }: React.ComponentProps<typeof Skeleton>) { return ( <Skeleton className={cn( "group-data-[variant=destructive]:bg-destructive/50", "group-data-[variant=success]:bg-success/50", "group-data-[variant=warning]:bg-warning/50", className, )} {...props} /> ); } ================================================ FILE: apps/dashboard/src/components/nav/app-header.tsx ================================================ import { cn } from "@/lib/utils"; export function AppHeader({ children, className, ...props }: React.ComponentProps<"header">) { return ( <header className={cn( "sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-2", className, )} {...props} > {children} </header> ); } export function AppHeaderContent({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("flex flex-1 items-center gap-2 px-3", className)} {...props} > {children} </div> ); } export function AppHeaderActions({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("ml-auto px-3", className)} {...props}> {children} </div> ); } ================================================ FILE: apps/dashboard/src/components/nav/app-sidebar.tsx ================================================ "use client"; import { Activity, Bell, Bot, Cog, Globe, LayoutGrid, PanelTop, Terminal, } from "lucide-react"; import * as React from "react"; import { Kbd } from "@/components/common/kbd"; import { NavMonitors } from "@/components/nav/nav-monitors"; import { NavOverview } from "@/components/nav/nav-overview"; import { NavStatusPages } from "@/components/nav/nav-status-pages"; import { NavUser } from "@/components/nav/nav-user"; import { WorkspaceSwitcher } from "@/components/nav/workspace-switcher"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, SidebarTrigger, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { NavBanner } from "./nav-banner"; import { NavHelp } from "./nav-help"; const SIDEBAR_KEYBOARD_SHORTCUT = "["; // This is sample data. const data = { user: { name: "mxkaske", email: "max@openstatus.dev", avatar: "/avatars/shadcn.jpg", }, overview: [ { name: "Overview", url: "/overview", icon: LayoutGrid, }, { name: "Monitors", url: "/monitors", icon: Activity, }, { name: "Status Pages", url: "/status-pages", icon: PanelTop, }, { name: "Notifications", url: "/notifications", icon: Bell, }, { name: "Settings", url: "/settings/general", icon: Cog, }, { name: "Private Locations", url: "/private-locations", icon: Globe, }, { name: "Agents", url: "/agents", icon: Bot, }, { name: "CLI", url: "/cli", icon: Terminal, }, ], }; export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { return ( <Sidebar collapsible="icon" {...props}> <SidebarHeader className="flex h-14 justify-center gap-0 border-b p-0"> <WorkspaceSwitcher /> </SidebarHeader> <SidebarContent> <NavOverview items={data.overview} /> <NavStatusPages /> <NavMonitors /> <div className="mt-auto px-2"> <NavBanner /> </div> <NavHelp /> </SidebarContent> <SidebarFooter className="flex h-14 flex-col justify-center gap-0 border-t p-0"> <NavUser /> </SidebarFooter> <SidebarRail /> </Sidebar> ); } export function AppSidebarTrigger() { const { toggleSidebar } = useSidebar(); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [toggleSidebar]); return ( <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <SidebarTrigger /> </TooltipTrigger> <TooltipContent side="right"> <p className="mr-px inline-flex items-center"> Toggle Sidebar{" "} <Kbd className="border-muted-foreground bg-primary font-mono text-background"> ⌘ </Kbd> <Kbd className="border-muted-foreground bg-primary font-mono text-background"> {SIDEBAR_KEYBOARD_SHORTCUT} </Kbd> </p> </TooltipContent> </Tooltip> </TooltipProvider> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-banner-checklist.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuItem, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; import { CircleCheck, CircleDashed, X } from "lucide-react"; export function NavBannerChecklist({ handleClose, }: { handleClose: () => void; }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); if (!workspace) return null; const hasMonitors = (workspace.usage?.monitors ?? 0) > 0; const hasStatusPages = (workspace.usage?.pages ?? 0) > 0; const hastNotifications = (workspace.usage?.notifications ?? 0) > 0; if (hasMonitors && hasStatusPages && hastNotifications) return null; const items = [ { title: "Create Monitor", checked: hasMonitors, href: "/monitors/create", }, { title: "Create Status Page", checked: hasStatusPages, href: "/status-pages/create", }, { title: "Create Notification", checked: hastNotifications, href: "/notifications", }, ]; return ( <SidebarGroup className="rounded-lg border bg-background group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel className="flex items-center justify-between pr-1"> <span>Getting Started</span> <SidebarMenuAction className="relative top-0 right-0" onClick={handleClose} > <X className="text-muted-foreground" size={16} /> </SidebarMenuAction> </SidebarGroupLabel> <SidebarMenu> {items.map((item) => ( <SidebarMenuItem key={item.title} className="flex items-center gap-2 text-sm" > {item.checked ? ( <> <CircleCheck className="shrink-0 text-success" size={12} /> <span>{item.title}</span> </> ) : ( <> <CircleDashed className="shrink-0 text-muted-foreground/50" size={12} /> <Link href={item.href}>{item.title}</Link> </> )} </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-banner-upgrade.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; import { Rocket, X } from "lucide-react"; import { useState } from "react"; import { UpgradeDialog } from "../dialogs/upgrade"; export function NavBannerUpgrade({ handleClose }: { handleClose: () => void }) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const [open, setOpen] = useState(false); if (!workspace) return null; return ( <SidebarGroup className="rounded-lg border bg-background group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel className="flex items-center justify-between pr-1"> <span>OpenStatus Pro</span> <SidebarMenuAction onClick={handleClose} className="relative top-0 right-0" > <X className="text-muted-foreground" size={16} /> </SidebarMenuAction> </SidebarGroupLabel> <SidebarMenu> <SidebarMenuItem className="flex items-center gap-2 text-sm"> <Rocket className="shrink-0 text-info" size={12} /> <span> Unlock custom domains, teams, 1 min. checks, subscriptions and more. </span> </SidebarMenuItem> <SidebarMenuItem> <SidebarMenuButton className="justify-center border" data-active="true" onClick={() => setOpen(true)} > Upgrade </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> <UpgradeDialog open={open} onOpenChange={setOpen} /> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-banner.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useQuery } from "@tanstack/react-query"; import { NavBannerChecklist } from "./nav-banner-checklist"; import { NavBannerUpgrade } from "./nav-banner-upgrade"; const EXPIRES_IN = 7 * 24 * 60 * 60 * 1000; // in 7 days export function NavBanner() { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const [openChecklist, setOpenChecklist] = useCookieState<"true" | "false">( "sidebar_banner_checklist", "true", { expires: EXPIRES_IN }, ); const [openUpgrade, setOpenUpgrade] = useCookieState<"true" | "false">( "sidebar_banner_upgrade", "true", { expires: EXPIRES_IN }, ); if (!workspace) return null; if (openChecklist === "true") { return <NavBannerChecklist handleClose={() => setOpenChecklist("false")} />; } if (openUpgrade === "true" && workspace.plan === "free") { return <NavBannerUpgrade handleClose={() => setOpenUpgrade("false")} />; } return null; } ================================================ FILE: apps/dashboard/src/components/nav/nav-breadcrumb.tsx ================================================ "use client"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "@openstatus/ui/components/ui/breadcrumb"; import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { Fragment } from "react"; interface NavBreadcrumbProps { items: ( | { type: "link"; label: string; href: string; icon?: LucideIcon; } | { type: "page"; label: string; icon?: LucideIcon; } )[]; } export function NavBreadcrumb({ items }: NavBreadcrumbProps) { return ( <Breadcrumb> <BreadcrumbList> <BreadcrumbSeparator className="hidden md:block" /> {items.map((item, i) => ( <Fragment key={item.type === "link" ? item.href : item.label}> <BreadcrumbItem> {item.type === "link" ? ( <BreadcrumbLink className="hidden flex-nowrap items-center gap-1.5 md:flex" asChild > <Link href={item.href} className="font-commit-mono tracking-tight" > {item.icon && ( <item.icon size={16} aria-hidden="true" className="shrink-0" /> )} {item.label} </Link> </BreadcrumbLink> ) : null} {item.type === "page" ? ( <BreadcrumbPage className=" hidden max-w-[120px] truncate font-commit-mono tracking-tight md:block lg:max-w-[200px] "> <span className="flex items-center gap-1.5"> {item.icon && ( <item.icon size={16} aria-hidden="true" className="shrink-0" /> )} {item.label} </span> </BreadcrumbPage> ) : null} </BreadcrumbItem> {i < items.length - 1 && ( <BreadcrumbSeparator className="hidden md:block" /> )} </Fragment> ))} </BreadcrumbList> </Breadcrumb> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-feedback.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@openstatus/ui/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Kbd } from "@openstatus/ui/components/ui/kbd"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { useMutation } from "@tanstack/react-query"; import { AudioLines, Inbox, LoaderCircle, Mic } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ message: z.string().min(1), }); export function NavFeedback() { const [open, setOpen] = useState(false); const isMobile = useIsMobile(); const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema), defaultValues: { message: "", }, }); const trpc = useTRPC(); const feedbackMutation = useMutation(trpc.feedback.submit.mutationOptions()); const [isListening, setIsListening] = useState(false); const recognitionRef = useRef<SpeechRecognition | null>(null); useEffect(() => { if (typeof window === "undefined") return; if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) { console.log("speech recognition API supported"); } else { console.log("speech recognition API not supported"); } const SpeechRecognitionCtor = // biome-ignore lint/suspicious/noExplicitAny: <explanation> (window as any).webkitSpeechRecognition || // biome-ignore lint/suspicious/noExplicitAny: <explanation> (window as any).SpeechRecognition; // Browser not supported if (!SpeechRecognitionCtor) return; const recognition: SpeechRecognition = new SpeechRecognitionCtor(); recognition.lang = "en-US"; recognition.continuous = false; recognition.interimResults = false; recognition.onresult = (event: SpeechRecognitionEvent) => { const transcript = Array.from(event.results) .map((r) => r[0].transcript) .join(" "); form.setValue( "message", `${form.getValues("message") ?? ""}${transcript} `, ); }; recognition.onend = () => { setIsListening(false); }; recognitionRef.current = recognition; }, [form]); const toggleListening = () => { const recognition = recognitionRef.current; if (!recognition) return; if (isListening) { recognition.stop(); } else { try { recognition.start(); setIsListening(true); } catch { // recognition already started, ignore } } }; const onSubmit = useCallback( async (values: z.infer<typeof schema>) => { const promise = feedbackMutation.mutateAsync({ ...values, path: window.location.pathname, isMobile, }); toast.promise(promise, { loading: "Sending feedback...", success: "Feedback sent", error: "Failed to send feedback", }); await promise; }, [feedbackMutation, isMobile], ); useEffect(() => { if (!open && feedbackMutation.isSuccess) { // NOTE: the popover takes 300ms to close, so we need to wait for that setTimeout(() => feedbackMutation.reset(), 300); } }, [open, feedbackMutation]); useEffect(() => { const down = async (e: KeyboardEvent) => { if (open && (e.metaKey || e.ctrlKey) && e.key === "Enter") { await form.handleSubmit(onSubmit)(); } const target = e.target as HTMLElement; const isTyping = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable; if (isTyping) return; if (!open) { if (e.key === "f") { e.preventDefault(); setOpen(true); } return; } }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, [open, form, onSubmit]); useEffect(() => { if (!open) { form.reset(); } }, [open, form]); if (isMobile) { return null; } return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="ghost" size="sm" className="group gap-0 px-2 text-muted-foreground text-sm hover:bg-transparent hover:text-foreground data-[state=open]:text-foreground" > Feedback{" "} <Kbd className="ml-1 font-mono group-hover:text-foreground group-data-[state=open]:text-foreground"> F </Kbd> </Button> </PopoverTrigger> <PopoverContent align="end" className="relative border-none p-0"> {feedbackMutation.isSuccess ? ( <div className="flex h-[110px] flex-col items-center justify-center gap-1 rounded-md border border-input p-3 text-base shadow-xs"> <Inbox className="size-4 shrink-0" /> <p className="text-center font-medium">Thanks for sharing!</p> <p className="text-center text-muted-foreground text-sm"> We'll get in touch if there's a follow-up. </p> </div> ) : ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}> <FormField control={form.control} name="message" render={({ field }) => ( <FormItem> <FormLabel className="sr-only">Feedback</FormLabel> <FormControl> <Textarea placeholder="Ideas, bugs, or anything else..." className="field-sizing-fixed h-[110px] resize-none p-3" rows={4} {...field} /> </FormControl> </FormItem> )} /> {recognitionRef.current && ( <Button type="button" size="sm" variant="ghost" className="group absolute bottom-1.5 left-1.5 gap-0" onClick={toggleListening} > {isListening ? ( <AudioLines className="size-4 animate-pulse" /> ) : ( <Mic className="size-4" /> )} </Button> )} <Button size="sm" variant="ghost" className="group absolute right-1.5 bottom-1.5 gap-0" type="submit" disabled={feedbackMutation.isPending} > {feedbackMutation.isPending ? ( <LoaderCircle className="size-4 animate-spin" /> ) : ( <> Send <Kbd className="ml-1 font-mono group-hover:text-foreground"> ⌘ </Kbd> <Kbd className="ml-1 font-mono group-hover:text-foreground"> ↵ </Kbd> </> )} </Button> </form> </Form> )} </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-help.tsx ================================================ "use client"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; import { DiscordIcon } from "@openstatus/icons"; import { GitHubIcon } from "@openstatus/icons"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Book, Braces, CalendarClock, HelpCircle, LifeBuoy, } from "lucide-react"; import Link from "next/link"; export function NavHelp() { const { isMobile } = useSidebar(); return ( <SidebarGroup> <SidebarGroupContent> <SidebarMenu> <SidebarMenuItem> <DropdownMenu> <DropdownMenuTrigger asChild> <SidebarMenuButton className="font-commit-mono tracking-tight" tooltip="Get Help" > <HelpCircle /> <span>Get Help</span> </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" side={isMobile ? "bottom" : "right"} align="end" sideOffset={4} > <DropdownMenuLabel className="text-muted-foreground text-xs"> Get Help </DropdownMenuLabel> <FormDialogSupportContact> <DropdownMenuItem onSelect={(e) => e.preventDefault()}> <LifeBuoy /> Support </DropdownMenuItem> </FormDialogSupportContact> <DropdownMenuItem asChild> <Link href="https://docs.openstatus.dev" target="_blank" rel="noreferrer" > <Book /> Docs </Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="https://api.openstatus.dev/openapi" target="_blank" rel="noreferrer" > <Braces /> API Reference </Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="https://openstatus.dev/cal" target="_blank" rel="noreferrer" > <CalendarClock /> Book a Call </Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="https://openstatus.dev/discord" target="_blank" rel="noreferrer" > <DiscordIcon /> Community </Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link href="https://openstatus.dev/github" target="_blank" rel="noreferrer" > <GitHubIcon /> GitHub </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> </SidebarMenu> </SidebarGroupContent> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-main.tsx ================================================ "use client"; import { ChevronRight, type LucideIcon } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import Link from "next/link"; export function NavMain({ items, }: { items: { title: string; url: string; icon?: LucideIcon; isActive?: boolean; items?: { title: string; url: string; }[]; }[]; }) { const { setOpenMobile } = useSidebar(); return ( <SidebarGroup> <SidebarGroupLabel>Workspace Data</SidebarGroupLabel> <SidebarMenu> {items.map((item) => ( <Collapsible key={item.title} asChild defaultOpen={item.isActive} className="group/collapsible" > <SidebarMenuItem> <CollapsibleTrigger asChild> <SidebarMenuButton tooltip={item.title}> {item.icon && <item.icon />} <span>{item.title}</span> <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> </SidebarMenuButton> </CollapsibleTrigger> <CollapsibleContent> <SidebarMenuSub> {item.items?.map((subItem) => ( <SidebarMenuSubItem key={subItem.title}> <SidebarMenuSubButton asChild> <Link href={subItem.url} onClick={() => setOpenMobile(false)} > <span>{subItem.title}</span> </Link> </SidebarMenuSubButton> </SidebarMenuSubItem> ))} </SidebarMenuSub> </CollapsibleContent> </SidebarMenuItem> </Collapsible> ))} </SidebarMenu> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-monitors.tsx ================================================ "use client"; import { useState } from "react"; import { MoreHorizontal, Plus } from "lucide-react"; import { ExportCodeDialog } from "@/components/dialogs/export-code"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/monitors.client"; import { useTRPC } from "@/lib/trpc/client"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { toast } from "sonner"; export const STATUS = { degraded: "bg-warning border border-warning", error: "bg-destructive border border-destructive", inactive: "bg-muted-foreground/70 border border-muted-foreground/70", active: "bg-success border border-success", }; export function NavMonitors() { const [openDialog, setOpenDialog] = useState(false); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const { isMobile, setOpenMobile } = useSidebar(); const trpc = useTRPC(); const router = useRouter(); const pathname = usePathname(); const { data: monitors, isLoading, refetch, } = useQuery(trpc.monitor.list.queryOptions()); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const queryClient = useQueryClient(); const deleteMonitorMutation = useMutation( trpc.monitor.delete.mutationOptions({ onSuccess: () => { refetch(); queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); const cloneMonitorMutation = useMutation( trpc.monitor.clone.mutationOptions({ onSuccess: (newMonitor) => { refetch(); queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); router.push(`/monitors/${newMonitor.id}`); }, }), ); if (!workspace || !monitors) return null; const limitReached = monitors.length >= workspace.limits.monitors; return ( <SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel className="flex items-center justify-between pr-1" style={{ paddingRight: 4 }} > <div className="flex items-center gap-1"> <span>Monitors</span> {isLoading ? ( <Skeleton className="h-4 w-5 shrink-0" /> ) : ( <code className="text-muted-foreground">({monitors.length})</code> )} </div> <div className="flex items-center gap-2"> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <SidebarMenuAction data-limited={limitReached} className="relative top-0 right-0 border data-[limited=true]:opacity-80" onClick={() => { if (limitReached) { setOpenUpgradeDialog(true); return; } router.push("/monitors/create"); setOpenMobile(false); }} > <Plus className="text-muted-foreground" /> <span className="sr-only">Create Monitor</span> </SidebarMenuAction> </TooltipTrigger> <TooltipContent side="right" align="center"> {limitReached ? "Upgrade" : "Create Monitor"} </TooltipContent> </Tooltip> </TooltipProvider> </div> </SidebarGroupLabel> <SidebarMenu> {isLoading ? ( <SidebarMenuItem> <SidebarMenuSkeleton /> </SidebarMenuItem> ) : monitors && monitors.length > 0 ? ( monitors.map((item) => { const isActive = pathname.startsWith(`/monitors/${item.id}/`); const actions = getActions({ edit: () => router.push(`/monitors/${item.id}/edit`), "copy-id": () => { navigator.clipboard.writeText(item.id.toString()); toast.success("Monitor ID copied to clipboard"); }, clone: () => { const promise = cloneMonitorMutation.mutateAsync({ id: item.id, }); toast.promise(promise, { loading: "Cloning monitor...", success: "Monitor cloned", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to clone monitor"; }, }); }, // export: () => setOpenDialog(true), }); return ( <SidebarMenuItem key={item.id}> <SidebarMenuButton className="group-has-data-[sidebar=menu-dot]/menu-item:pr-11" isActive={isActive} asChild > <Link href={`/monitors/${item.id}/overview`} onClick={() => setOpenMobile(false)} className="font-commit-mono tracking-tight" > <span>{item.name}</span> </Link> </SidebarMenuButton> <div data-sidebar="menu-dot" className={cn( "absolute top-1.5 right-1 flex h-2.5 items-center justify-center p-2.5 transition-all duration-200 group-focus-within/menu-item:right-6 group-hover/menu-action:right-6 group-hover/menu-item:right-6 group-data-[state=open]/menu-action:right-6 [&:has(+[data-sidebar=menu-action][data-state=open])]:right-6", isMobile && "right-6", )} > <div className="relative flex items-center justify-center"> <div className={cn( "-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 h-2 w-2 rounded-full", STATUS[item.active ? item.status : "inactive"], )} > <span className="sr-only">{item.status}</span> </div> </div> </div> <QuickActions actions={actions} deleteAction={{ confirmationValue: item.name ?? "monitor", submitAction: async () => { await deleteMonitorMutation.mutateAsync({ id: item.id, }); if (pathname.startsWith(`/monitors/${item.id}`)) { router.push("/monitors"); } }, }} side={isMobile ? "bottom" : "right"} align={isMobile ? "end" : "start"} > <SidebarMenuAction showOnHover> <MoreHorizontal /> <span className="sr-only">More</span> </SidebarMenuAction> </QuickActions> </SidebarMenuItem> ); }) ) : ( <SidebarMenuItem> <SidebarMenuButton disabled> <span>No monitors found</span> </SidebarMenuButton> </SidebarMenuItem> )} </SidebarMenu> <ExportCodeDialog open={openDialog} onOpenChange={setOpenDialog} /> <UpgradeDialog open={openUpgradeDialog} onOpenChange={setOpenUpgradeDialog} /> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-overview.tsx ================================================ "use client"; import type { LucideIcon } from "lucide-react"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import Link from "next/link"; import { usePathname } from "next/navigation"; export function NavOverview({ items, }: { items: { name: string; url: string; icon: LucideIcon; }[]; }) { const pathname = usePathname(); const { setOpenMobile } = useSidebar(); return ( <SidebarGroup> <SidebarGroupLabel>Workspace</SidebarGroupLabel> <SidebarMenu> {items.map((item) => ( <SidebarMenuItem key={item.name}> <SidebarMenuButton // FIXME: check with settings as exception (as it includes subpages) isActive={pathname === item.url} asChild tooltip={item.name} > <Link href={item.url} onClick={() => setOpenMobile(false)} className="font-commit-mono tracking-tight" > <item.icon /> <span>{item.name}</span> </Link> </SidebarMenuButton> </SidebarMenuItem> ))} </SidebarMenu> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-status-pages.tsx ================================================ "use client"; import { MoreHorizontal, Plus } from "lucide-react"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { getActions } from "@/data/status-pages.client"; import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { toast } from "sonner"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { useTRPC } from "@/lib/trpc/client"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; const STATUS = { operational: "bg-success border border-success", degraded: "bg-warning border border-warning", outage: "bg-destructive border border-destructive", }; export function NavStatusPages() { const { isMobile, setOpenMobile } = useSidebar(); const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); const pathname = usePathname(); const trpc = useTRPC(); const router = useRouter(); const { data: statusPages, refetch, isLoading, } = useQuery(trpc.page.list.queryOptions()); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const queryClient = useQueryClient(); const deleteStatusPage = useMutation( trpc.page.delete.mutationOptions({ onSuccess: () => { refetch(); queryClient.invalidateQueries({ queryKey: trpc.workspace.get.queryKey(), }); }, }), ); if (!workspace || !statusPages) return null; const limitReached = statusPages.length >= workspace.limits["status-pages"]; return ( <SidebarGroup className="group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel className="flex items-center justify-between pr-1"> <div className="flex items-center gap-1"> <span>Status Pages</span> {isLoading ? ( <Skeleton className="h-4 w-5 shrink-0" /> ) : ( <code className="text-muted-foreground"> ({statusPages?.length}) </code> )} </div> <div className="flex items-center gap-2"> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <SidebarMenuAction data-limited={limitReached} className="relative top-0 right-0 border data-[limited=true]:opacity-80" onClick={() => { if (limitReached) { setOpenUpgradeDialog(true); return; } router.push("/status-pages/create"); setOpenMobile(false); }} > <Plus className="text-muted-foreground" /> <span className="sr-only">Create Status Page</span> </SidebarMenuAction> </TooltipTrigger> <TooltipContent side="right" align="center"> {limitReached ? "Upgrade" : "Create Status Page"} </TooltipContent> </Tooltip> </TooltipProvider> </div> </SidebarGroupLabel> <SidebarMenu> {isLoading ? ( <SidebarMenuItem> <SidebarMenuSkeleton /> </SidebarMenuItem> ) : statusPages && statusPages.length > 0 ? ( statusPages.map((item) => { const isActive = pathname.startsWith(`/status-pages/${item.id}/`); const actions = getActions({ edit: () => router.push(`/status-pages/${item.id}/edit`), "copy-id": async () => { await navigator.clipboard.writeText(item.id.toString()); toast.success("Status Page ID copied to clipboard"); }, }); const hasActiveStatusReport = item.statusReports.some( (report) => report.status !== "resolved", ); return ( <SidebarMenuItem key={item.id}> <SidebarMenuButton className="group-has-data-[sidebar=menu-dot]/menu-item:pr-11" isActive={isActive} asChild > <Link href={`/status-pages/${item.id}/status-reports`} onClick={() => setOpenMobile(false)} className="font-commit-mono tracking-tight" > <span>{item.title}</span> </Link> </SidebarMenuButton> <div data-sidebar="menu-dot" className={cn( "absolute top-1.5 right-1 flex h-2.5 items-center justify-center p-2.5 transition-all duration-200 group-focus-within/menu-item:right-6 group-hover/menu-action:right-6 group-hover/menu-item:right-6 group-data-[state=open]/menu-action:right-6 [&:has(+[data-sidebar=menu-action][data-state=open])]:right-6", isMobile && "right-6", )} > <div className="relative flex items-center justify-center"> <div className={cn( "-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 h-2 w-2 rounded-full", STATUS[ hasActiveStatusReport ? "degraded" : "operational" ], )} /> </div> </div> <QuickActions actions={actions} deleteAction={{ confirmationValue: item.title ?? "status page", submitAction: async () => { await deleteStatusPage.mutateAsync({ id: item.id }); if (pathname.includes(`/status-pages/${item.id}`)) { router.push("/status-pages"); } }, }} side={isMobile ? "bottom" : "right"} align={isMobile ? "end" : "start"} > <SidebarMenuAction showOnHover> <MoreHorizontal /> <span className="sr-only">More</span> </SidebarMenuAction> </QuickActions> </SidebarMenuItem> ); }) ) : ( <SidebarMenuItem> <SidebarMenuButton disabled> <span>No status pages found</span> </SidebarMenuButton> </SidebarMenuItem> )} </SidebarMenu> <UpgradeDialog open={openUpgradeDialog} onOpenChange={setOpenUpgradeDialog} limit="status-pages" /> </SidebarGroup> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-tabs.tsx ================================================ "use client"; import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; interface NavTabsProps { items: { value: string; label: string; icon: LucideIcon; href: string; }[]; } export function NavTabs({ items }: NavTabsProps) { const pathname = usePathname(); const normalizedPath = pathname.replace(/\/+$/, "") || "/"; return ( <nav className="sticky top-14 z-10 h-[41px] w-full overflow-x-auto border-b bg-background px-2"> <ul className="inline-flex h-full items-center gap-1 px-3 text-sm"> {items.map((item) => { const normalizedHref = item.href.replace(/\/+$/, "") || "/"; const isActive = normalizedPath === normalizedHref || normalizedPath.startsWith(`${normalizedHref}/`); return ( <li key={item.value} className={cn( "relative flex h-full items-center", isActive && "after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-foreground", )} > <Link href={item.href} aria-current={isActive ? "page" : undefined} className={cn( "relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-2 py-1 font-commit-mono tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > <item.icon size={16} aria-hidden="true" className="shrink-0" /> {item.label} </Link> </li> ); })} </ul> </nav> ); } ================================================ FILE: apps/dashboard/src/components/nav/nav-user.tsx ================================================ "use client"; import { ChevronsUpDown, CreditCard, Laptop, LogOut, Moon, Sparkles, Sun, User, } from "lucide-react"; import { useTRPC } from "@/lib/trpc/client"; import { Avatar, AvatarFallback, AvatarImage, } from "@openstatus/ui/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; import Link from "next/link"; export function NavUser() { const { isMobile, setOpenMobile } = useSidebar(); const { theme, setTheme } = useTheme(); const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: user } = useQuery(trpc.user.get.queryOptions()); if (!user || !workspace) return null; const userName = user?.name ?? `${user?.firstName} ${user?.lastName}`.trim(); return ( <SidebarMenu> <SidebarMenuItem> <DropdownMenu> <DropdownMenuTrigger asChild> <SidebarMenuButton size="lg" className="h-14 rounded-none px-4 ring-inset data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-2! group-data-[collapsible=icon]:rounded-lg! group-data-[collapsible=icon]:px-0!" > <Avatar className="h-8 w-8 rounded-lg"> <AvatarImage src={user?.photoUrl ?? undefined} alt={userName} /> <AvatarFallback className="rounded-lg uppercase"> {userName.slice(0, 2)} </AvatarFallback> {/* <img src={`https://api.dicebear.com/9.x/glass/svg?seed=${workspace.slug}`} alt="avatar" /> */} </Avatar> <div className="grid flex-1 text-left text-sm leading-tight"> <span className="truncate font-medium">{userName}</span> <span className="truncate font-commit-mono text-xs tracking-tight"> {user?.email} </span> </div> <ChevronsUpDown className="ml-auto size-4" /> </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" side={isMobile ? "bottom" : "right"} align="end" sideOffset={4} > <DropdownMenuLabel className="p-0 font-normal"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <Avatar className="h-8 w-8 rounded-lg"> <AvatarImage src={user?.photoUrl ?? undefined} alt={userName} /> <AvatarFallback className="rounded-lg"> {userName.slice(0, 2)} </AvatarFallback> </Avatar> <div className="grid flex-1 text-left text-sm leading-tight"> <span className="truncate font-medium">{userName}</span> <span className="truncate font-commit-mono text-xs tracking-tight"> {user?.email} </span> </div> </div> </DropdownMenuLabel> <DropdownMenuSeparator /> {workspace.plan === "free" ? ( <> <DropdownMenuItem asChild> <Link href="/settings/billing" onClick={() => setOpenMobile(false)} className="font-commit-mono tracking-tight" > <Sparkles /> Upgrade Workspace </Link> </DropdownMenuItem> <DropdownMenuSeparator /> </> ) : null} <DropdownMenuGroup className="font-commit-mono tracking-tight"> <DropdownMenuItem asChild> <Link href="/settings/account" onClick={() => setOpenMobile(false)} > <User /> Account </Link> </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> {theme === "dark" ? ( <Moon /> ) : theme === "light" ? ( <Sun /> ) : ( <Laptop /> )} Theme </DropdownMenuSubTrigger> <DropdownMenuPortal> <DropdownMenuSubContent className="font-commit-mono tracking-tight"> <DropdownMenuItem onClick={() => setTheme("light")}> <Sun /> Light </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme("dark")}> <Moon /> Dark </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme("system")}> <Laptop /> System </DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuPortal> </DropdownMenuSub> <DropdownMenuItem asChild> <Link href="/settings/billing" onClick={() => setOpenMobile(false)} > <CreditCard /> Billing </Link> </DropdownMenuItem> </DropdownMenuGroup> <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => signOut()} className="font-commit-mono tracking-tight" > <LogOut /> Log out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> </SidebarMenu> ); } ================================================ FILE: apps/dashboard/src/components/nav/sidebar-metadata.tsx ================================================ import { ChevronRight } from "lucide-react"; import * as React from "react"; import { EmptyStateContainer, EmptyStateDescription, } from "@/components/content/empty-state"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, } from "@openstatus/ui/components/ui/sidebar"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { TooltipProvider } from "@radix-ui/react-tooltip"; export type SidebarMetadataProps = { label: string; items?: { label: string; value: React.ReactNode; isNested?: boolean; }[]; }; export function SidebarMetadata({ label, items }: SidebarMetadataProps) { return ( <SidebarGroup className="p-0"> <Collapsible defaultOpen className="group/collapsible border-b"> <SidebarGroupLabel asChild className="group/label h-9 w-full rounded-none text-sidebar-foreground text-sm hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" > <CollapsibleTrigger> {label}{" "} <ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" /> </CollapsibleTrigger> </SidebarGroupLabel> <CollapsibleContent> <SidebarGroupContent className="border-t"> {items && items.length > 0 ? ( <SidebarMetadataTable items={items} /> ) : ( <EmptyStateContainer className="m-2"> <EmptyStateDescription>No {label}</EmptyStateDescription> </EmptyStateContainer> )} </SidebarGroupContent> </CollapsibleContent> </Collapsible> </SidebarGroup> ); } function SidebarMetadataTable({ items, }: { items: { label: string; value: React.ReactNode; isNested?: boolean; tooltip?: string; }[]; }) { return ( <Table> <TableHeader className="sr-only"> <TableRow> <TableHead className="w-26">Label</TableHead> <TableHead>Value</TableHead> </TableRow> </TableHeader> <TableBody> {items.map((item, index) => ( <TableRow key={`${item.label}-${index}`}> <TableCell className="w-26 border-r text-muted-foreground"> <div className="min-w-[90px] max-w-[90px] truncate"> {item.isNested ? "└ " : ""} {item.label} </div> </TableCell> <SidebarMetadataTableCell className="max-w-0 truncate font-mono"> {item.value} </SidebarMetadataTableCell> </TableRow> ))} </TableBody> </Table> ); } function SidebarMetadataTableCell({ className, ...props }: React.ComponentProps<typeof TableCell>) { const ref = React.useRef<HTMLTableCellElement>(null); const [isTruncated, setIsTruncated] = React.useState(false); const { copy, isCopied } = useCopyToClipboard(); const [open, setOpen] = React.useState(false); // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> React.useEffect(() => { if (ref.current) { setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth); } }, [ref]); const handleClick = () => { if (typeof props.children === "string") { copy(props.children, { withToast: false, timeout: 1000 }); } }; React.useEffect(() => { if (isCopied) setOpen(true); }, [isCopied]); return ( <TableCell {...props} ref={ref} className={cn( typeof props.children === "string" && "cursor-pointer", className, )} onClick={handleClick} > <TooltipProvider> {isTruncated || isCopied ? ( <Tooltip open={open} onOpenChange={setOpen}> <TooltipTrigger // NOTE: all the prevent default events avoid the tooltip to hide and show again onClick={(event) => event.preventDefault()} onPointerDown={(event) => event.preventDefault()} asChild > <span className="block truncate">{props.children}</span> </TooltipTrigger> <TooltipContent onPointerDownOutside={(event) => event.preventDefault()} side="left" > {isCopied ? "Copied" : props.children} </TooltipContent> </Tooltip> ) : ( props.children )} </TooltipProvider> </TableCell> ); } ================================================ FILE: apps/dashboard/src/components/nav/sidebar-right.tsx ================================================ "use client"; import * as React from "react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarSeparator, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; import { cn } from "@openstatus/ui/lib/utils"; import { PanelRight } from "lucide-react"; import { Kbd } from "../common/kbd"; import { SidebarMetadata, type SidebarMetadataProps } from "./sidebar-metadata"; const SIDEBAR_KEYBOARD_SHORTCUT = "]"; const SIDEBAR_WIDTH = "18rem"; const SIDEBAR_WIDTH_2XL = "24rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; type SidebarRightProps = React.ComponentProps<typeof Sidebar> & { header: string; metadata: SidebarMetadataProps[]; footerButton?: React.ComponentProps<typeof SidebarMenuButton>; }; export function SidebarRight({ header, metadata, footerButton, ...props }: SidebarRightProps) { const isMobile = useIsMobile(); const is2XL = useMediaQuery("(min-width: 1536px)"); return ( <SidebarProvider style={ { "--sidebar-width": isMobile ? SIDEBAR_WIDTH_MOBILE : is2XL ? SIDEBAR_WIDTH_2XL : SIDEBAR_WIDTH, } as React.CSSProperties } defaultOpen={false} cookieName="sidebar_state_right" > <Sidebar collapsible="offcanvas" side="right" className="top-14 flex h-[calc(100svh_-_56px)]" {...props} > <SidebarHeader className="relative border-sidebar-border border-b"> {header} <div className="-left-9 absolute inset-y-0 z-10 flex items-center justify-center"> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <SidebarTrigger /> </TooltipTrigger> <TooltipContent side="left"> <p className="mr-px inline-flex items-center"> Toggle Sidebar{" "} <Kbd className="border-muted-foreground bg-primary font-mono text-background"> ⌘ </Kbd> <Kbd className="border-muted-foreground bg-primary font-mono text-background"> {SIDEBAR_KEYBOARD_SHORTCUT} </Kbd> </p> </TooltipContent> </Tooltip> </TooltipProvider> </div> </SidebarHeader> <SidebarContent className="flex flex-col gap-0"> {metadata.map((item) => ( <SidebarMetadata key={item.label} {...item} /> ))} </SidebarContent> <SidebarSeparator className="mx-0" /> {footerButton ? ( <SidebarFooter> <SidebarMenu> <SidebarMenuItem> <SidebarMenuButton {...footerButton} /> </SidebarMenuItem> </SidebarMenu> </SidebarFooter> ) : null} </Sidebar> </SidebarProvider> ); } export function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { const { toggleSidebar } = useSidebar(); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [toggleSidebar]); return ( <Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon" className={cn("size-7", className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > <PanelRight /> <span className="sr-only">Toggle Sidebar</span> </Button> ); } ================================================ FILE: apps/dashboard/src/components/nav/workspace-switcher.tsx ================================================ "use client"; import { ChevronsUpDown, Plus } from "lucide-react"; import { useTRPC } from "@/lib/trpc/client"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { useQuery } from "@tanstack/react-query"; import { Link } from "../common/link"; export function WorkspaceSwitcher() { const { isMobile, setOpenMobile } = useSidebar(); const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: workspaces } = useQuery(trpc.workspace.list.queryOptions()); if (!workspace) return null; function handleClick(slug: string) { document.cookie = `workspace-slug=${slug}; path=/;`; window.location.href = "/overview"; } return ( <SidebarMenu> <SidebarMenuItem> <DropdownMenu> <DropdownMenuTrigger asChild> <SidebarMenuButton size="lg" className="h-14 rounded-none px-4 ring-inset data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:mx-2! group-data-[collapsible=icon]:rounded-lg! group-data-[collapsible=icon]:px-0!" > <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary"> <div className="size-8 overflow-hidden rounded-lg"> <img src={`https://api.dicebear.com/9.x/glass/svg?seed=${workspace.slug}`} alt="avatar" /> </div> </div> <div className="grid flex-1 text-left text-sm leading-tight"> <div className="truncate font-medium"> {workspace.name || "Untitled Workspace"} </div> <div className="truncate text-xs"> <span className="font-commit-mono tracking-tight"> {workspace.slug} </span>{" "} <span className="text-muted-foreground"> {workspace.plan === "team" ? "pro" : workspace.plan} </span> </div> </div> <ChevronsUpDown className="ml-auto" /> </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" align="start" side={isMobile ? "bottom" : "right"} sideOffset={4} > <DropdownMenuLabel className="text-muted-foreground text-xs"> Workspaces </DropdownMenuLabel> {workspaces?.map((workspace) => ( <DropdownMenuItem key={workspace.id} onClick={() => { handleClick(workspace.slug); setOpenMobile(false); }} className="gap-2 p-2" > <span className="truncate"> {workspace.name || "Untitled Workspace"} </span> <span className="truncate font-mono text-muted-foreground text-xs"> {workspace.slug} </span> </DropdownMenuItem> ))} <DropdownMenuSeparator /> <DropdownMenuItem className="gap-2 p-2" asChild> <Link href="/settings/general"> <Plus /> <div className="font-commit-mono text-muted-foreground tracking-tight"> Add team member </div> </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> </SidebarMenu> ); } ================================================ FILE: apps/dashboard/src/components/popovers/popover-quantile.tsx ================================================ import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; export function PopoverQuantile({ children, className, ...props }: React.ComponentProps<typeof PopoverTrigger>) { return ( <Popover> <PopoverTrigger className={cn( "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", className, )} {...props} > {children} </PopoverTrigger> <PopoverContent side="top" className="p-0 text-sm"> <p className="px-3 py-2 font-medium"> A quantile represents a specific percentile in your dataset. </p> <Separator /> <p className="px-3 py-2 text-muted-foreground"> For example, p50 is the 50th percentile - the point below which 50% of data falls. Higher percentiles include more data and highlight the upper range. </p> </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/popovers/popover-resolution.tsx ================================================ import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; export function PopoverResolution({ children, className, ...props }: React.ComponentProps<typeof PopoverTrigger>) { return ( <Popover> <PopoverTrigger className={cn( "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", className, )} {...props} > {children} </PopoverTrigger> <PopoverContent side="top" className="p-0 text-sm"> <p className="px-3 py-2 font-medium"> Run data aggregation on fixed time boundaries. </p> <Separator /> <p className="px-3 py-2 text-muted-foreground"> A 30-minute resolution aligns to the top or bottom of the hour (e.g., 00:00, 00:30) so all intervals are consistent for analysis. </p> </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/tailwind-indicator.tsx ================================================ export function TailwindIndicator() { if (process.env.NODE_ENV === "production") return null; return ( <div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-foreground p-3 font-mono text-background text-xs"> <div className="block sm:hidden">xs</div> <div className="hidden sm:block md:hidden">sm</div> <div className="hidden md:block lg:hidden">md</div> <div className="hidden lg:block xl:hidden">lg</div> <div className="hidden xl:block 2xl:hidden">xl</div> <div className="hidden 2xl:block">2xl</div> </div> ); } ================================================ FILE: apps/dashboard/src/components/theme-provider.tsx ================================================ "use client"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import type * as React from "react"; export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) { return <NextThemesProvider {...props}>{children}</NextThemesProvider>; } ================================================ FILE: apps/dashboard/src/components/theme-toggle.tsx ================================================ "use client"; import { useTheme } from "next-themes"; import type * as React from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; import { Laptop, Moon, Sun } from "lucide-react"; import { useState } from "react"; import { useEffect } from "react"; export function ThemeToggle({ className, ...props }: React.ComponentProps<typeof SelectTrigger>) { const { setTheme, theme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); // NOTE: hydration error if we don't do this if (!mounted) { return ( <Select> <SelectTrigger className={cn("w-[180px]", className)} {...props}> <SelectValue placeholder="Select theme" /> </SelectTrigger> </Select> ); } return ( <Select value={theme} onValueChange={setTheme}> <SelectTrigger className={cn("w-[180px]", className)} {...props}> <SelectValue defaultValue={theme} placeholder="Select theme" /> </SelectTrigger> <SelectContent> <SelectItem value="light"> <div className="flex items-center gap-2"> <Sun className="h-4 w-4" /> <span>Light</span> </div> </SelectItem> <SelectItem value="dark"> <div className="flex items-center gap-2"> <Moon className="h-4 w-4" /> <span>Dark</span> </div> </SelectItem> <SelectItem value="system"> <div className="flex items-center gap-2"> <Laptop className="h-4 w-4" /> <span>System</span> </div> </SelectItem> </SelectContent> </Select> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-action-bar.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import type { Table } from "@tanstack/react-table"; import { Loader, X } from "lucide-react"; import * as React from "react"; import * as ReactDOM from "react-dom"; export interface DataTableActionBarProps<TData> extends React.ComponentProps<"div"> { table: Table<TData>; visible?: boolean; container?: Element | DocumentFragment | null; } function DataTableActionBar<TData>({ table, visible: visibleProp, container: containerProp, children, className, ...props }: DataTableActionBarProps<TData>) { const [mounted, setMounted] = React.useState(false); React.useLayoutEffect(() => { setMounted(true); }, []); React.useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { table.toggleAllRowsSelected(false); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [table]); const container = containerProp ?? (mounted ? globalThis.document?.body : null); if (!container) return null; const visible = visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0; return ReactDOM.createPortal( <div> {visible && ( <div role="toolbar" aria-orientation="horizontal" className={cn( "fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm", className, )} {...props} > {children} </div> )} </div>, container, ); } interface DataTableActionBarActionProps extends React.ComponentProps<typeof Button> { tooltip?: string; isPending?: boolean; } function DataTableActionBarAction({ size = "sm", tooltip, isPending, disabled, className, children, ...props }: DataTableActionBarActionProps) { const trigger = ( <Button variant="secondary" size={size} className={cn( "gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5", size === "icon" ? "size-7" : "h-7", className, )} disabled={disabled || isPending} {...props} > {isPending ? <Loader className="animate-spin" /> : children} </Button> ); if (!tooltip) return trigger; return ( <Tooltip> <TooltipTrigger asChild>{trigger}</TooltipTrigger> <TooltipContent sideOffset={6} className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden" > <p>{tooltip}</p> </TooltipContent> </Tooltip> ); } interface DataTableActionBarSelectionProps<TData> { table: Table<TData>; } function DataTableActionBarSelection<TData>({ table, }: DataTableActionBarSelectionProps<TData>) { const onClearSelection = React.useCallback(() => { table.toggleAllRowsSelected(false); }, [table]); return ( <div className="flex h-7 items-center rounded-md border pr-1 pl-2.5"> <span className="whitespace-nowrap text-xs"> {table.getFilteredSelectedRowModel().rows.length} selected </span> <Separator orientation="vertical" className="mr-1 ml-2 data-[orientation=vertical]:h-4" /> <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon" className="size-5" onClick={onClearSelection} > <X className="size-3.5" /> </Button> </TooltipTrigger> <TooltipContent sideOffset={10} className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden" > <p>Clear selection</p> <kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs"> <abbr title="Escape" className="no-underline"> Esc </abbr> </kbd> </TooltipContent> </Tooltip> </div> ); } export { DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection, }; ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-column-header.tsx ================================================ import type { Column } from "@tanstack/react-table"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; interface DataTableColumnHeaderProps<TData, TValue> extends React.ComponentProps<"button"> { column: Column<TData, TValue>; title: string; } export function DataTableColumnHeader<TData, TValue>({ column, title, className, ...props }: DataTableColumnHeaderProps<TData, TValue>) { if (!column.getCanSort()) { return <div className={cn(className)}>{title}</div>; } return ( <Button variant="ghost" size="sm" onClick={() => { column.toggleSorting(undefined); }} className={cn( "flex h-7 w-full items-center justify-between gap-2 px-0 py-0 hover:bg-transparent dark:hover:bg-transparent", className, )} {...props} > <span>{title}</span> <span className="flex flex-col"> <ChevronUp className={cn( "-mb-0.5 size-3", column.getIsSorted() === "asc" ? "text-accent-foreground" : "text-muted-foreground", )} /> <ChevronDown className={cn( "-mt-0.5 size-3", column.getIsSorted() === "desc" ? "text-accent-foreground" : "text-muted-foreground", )} /> </span> </Button> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-faceted-filter.tsx ================================================ import type { Column } from "@tanstack/react-table"; import { Check, PlusCircle } from "lucide-react"; import type * as React from "react"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@openstatus/ui/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; interface DataTableFacetedFilterProps<TData, TValue> { column?: Column<TData, TValue>; title?: string; options: { label: string; value: string | number; icon?: React.ComponentType<{ className?: string }>; }[]; } export function DataTableFacetedFilter<TData, TValue>({ column, title, options, }: DataTableFacetedFilterProps<TData, TValue>) { const facets = column?.getFacetedUniqueValues(); const selectedValues = new Set( column?.getFilterValue() as (string | number)[], ); return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-8 border-dashed"> <PlusCircle /> {title} {selectedValues?.size > 0 && ( <> <Separator orientation="vertical" className="mx-2 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden" > {selectedValues.size} </Badge> <div className="hidden space-x-1 lg:flex"> {selectedValues.size > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal" > {selectedValues.size} selected </Badge> ) : ( options .filter((option) => selectedValues.has(option.value)) .map((option) => ( <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal" > {option.label} </Badge> )) )} </div> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0" align="start"> <Command> <CommandInput placeholder={title} /> <CommandList> <CommandEmpty>No results found.</CommandEmpty> <CommandGroup> {options.map((option) => { const isSelected = selectedValues.has(option.value); return ( <CommandItem key={option.value} onSelect={() => { if (isSelected) { selectedValues.delete(option.value); } else { selectedValues.add(option.value); } const filterValues = Array.from(selectedValues); column?.setFilterValue( filterValues.length ? filterValues : undefined, ); }} > <div className={cn( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible", )} > <Check /> </div> {option.icon && ( <option.icon className="mr-2 h-4 w-4 text-muted-foreground" /> )} <span>{option.label}</span> {facets?.get(option.value) && ( <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> {facets.get(option.value)} </span> )} </CommandItem> ); })} </CommandGroup> {selectedValues.size > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem onSelect={() => column?.setFilterValue(undefined)} className="justify-center text-center" > Clear filters </CommandItem> </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-pagination.tsx ================================================ import type { Table } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; export interface DataTablePaginationProps<TData> { table: Table<TData>; } export function DataTablePagination<TData>({ table, }: DataTablePaginationProps<TData>) { return ( <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex-1 text-muted-foreground text-sm"> {table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} row(s) selected. </div> <div className="flex items-center space-x-6 lg:space-x-8"> <div className="flex items-center space-x-2"> <p className="font-medium text-sm">Rows per page</p> <Select value={`${table.getState().pagination.pageSize}`} onValueChange={(value) => { table.setPageSize(Number(value)); }} > <SelectTrigger className="h-8 w-[70px]"> <SelectValue placeholder={table.getState().pagination.pageSize} /> </SelectTrigger> <SelectContent side="top"> {[10, 20, 30, 40, 50].map((pageSize) => ( <SelectItem key={pageSize} value={`${pageSize}`}> {pageSize} </SelectItem> ))} </SelectContent> </Select> </div> <div className="flex items-center justify-center font-medium text-sm"> Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()} </div> <div className="flex items-center space-x-2"> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to first page</span> <ChevronsLeft /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to previous page</span> <ChevronLeft /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to next page</span> <ChevronRight /> </Button> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to last page</span> <ChevronsRight /> </Button> </div> </div> </div> ); } export function DataTablePaginationSimple<TData>({ table, }: DataTablePaginationProps<TData>) { return ( <div className="flex items-center justify-between"> <div className="flex-1 text-muted-foreground text-sm"> {table.getFilteredRowModel().rows.length} of{" "} {table.getPreFilteredRowModel().rows.length} row(s) filtered. </div> <div className="flex items-center space-x-2"> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to first page</span> <ChevronsLeft /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > <span className="sr-only">Go to previous page</span> <ChevronLeft /> </Button> <Button variant="outline" className="h-8 w-8 p-0" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to next page</span> <ChevronRight /> </Button> <Button variant="outline" className="hidden h-8 w-8 p-0 lg:flex" onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} > <span className="sr-only">Go to last page</span> <ChevronsRight /> </Button> </div> </div> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-skeleton.tsx ================================================ import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; interface DataTableSkeletonProps { /** * Number of rows to render * @default 10 */ rows?: number; } // TODO: add checkbox skeleton (for MonitorTable e.g.) export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { return ( <Table> <TableHeader className="bg-muted/50"> <TableRow className="hover:bg-transparent"> <TableHead> <Skeleton className="my-1.5 h-4 w-24" /> </TableHead> <TableHead className="hidden sm:table-cell"> <Skeleton className="my-1.5 h-4 w-32" /> </TableHead> <TableHead className="hidden md:table-cell"> <Skeleton className="my-1.5 h-4 w-16" /> </TableHead> <TableHead> <Skeleton className="my-1.5 h-4 w-20" /> </TableHead> <TableHead className="flex items-center justify-end" /> </TableRow> </TableHeader> <TableBody> {[rows].fill(0).map((_, i) => ( <TableRow key={i} className="hover:bg-transparent"> <TableCell> <Skeleton className="my-1.5 h-4 w-full max-w-40" /> </TableCell> <TableCell className="hidden sm:table-cell"> <Skeleton className="my-1.5 h-4 w-full max-w-52" /> </TableCell> <TableCell className="hidden md:table-cell"> <Skeleton className="my-1.5 h-4 w-24" /> </TableCell> <TableCell> <Skeleton className="my-1.5 h-4 w-full max-w-40" /> </TableCell> <TableCell className="flex justify-end"> <Skeleton className="my-1.5 h-5 w-5" /> </TableCell> </TableRow> ))} </TableBody> </Table> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-toobar.tsx ================================================ "use client"; import type { Table } from "@tanstack/react-table"; import { X } from "lucide-react"; import { DataTableViewOptions } from "@/components/ui/data-table/data-table-view-options"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; export interface DataTableToolbarProps<TData> { table: Table<TData>; } export function DataTableToolbar<TData>({ table, }: DataTableToolbarProps<TData>) { const isFiltered = table.getState().columnFilters.length > 0; return ( <div className="flex items-center justify-between"> <div className="flex flex-1 items-center space-x-2"> <Input placeholder="Filter entries..." value={(table.getColumn("title")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("title")?.setFilterValue(event.target.value) } className="h-8 w-[150px] lg:w-[250px]" /> {table.getColumn("status") && ( <DataTableFacetedFilter column={table.getColumn("status")} title="Status" options={[]} /> )} {table.getColumn("tags") && ( <DataTableFacetedFilter column={table.getColumn("tags")} title="Tags" options={[]} /> )} {isFiltered && ( <Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3" > Reset <X /> </Button> )} </div> <DataTableViewOptions table={table} /> </div> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table-view-options.tsx ================================================ "use client"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import type { Table } from "@tanstack/react-table"; import { Settings2 } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, } from "@openstatus/ui/components/ui/dropdown-menu"; interface DataTableViewOptionsProps<TData> { table: Table<TData>; } export function DataTableViewOptions<TData>({ table, }: DataTableViewOptionsProps<TData>) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex" > <Settings2 /> View </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-[150px]"> <DropdownMenuLabel>Toggle columns</DropdownMenuLabel> <DropdownMenuSeparator /> {table .getAllColumns() .filter( (column) => typeof column.accessorFn !== "undefined" && column.getCanHide(), ) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> ); })} </DropdownMenuContent> </DropdownMenu> ); } ================================================ FILE: apps/dashboard/src/components/ui/data-table/data-table.tsx ================================================ "use client"; import { type ColumnDef, type ColumnFiltersState, type PaginationState, type Row, type SortingState, type VisibilityState, flexRender, getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import * as React from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { Fragment } from "react"; import type { DataTableActionBarProps } from "./data-table-action-bar"; import type { DataTablePaginationProps } from "./data-table-pagination"; import type { DataTableToolbarProps } from "./data-table-toobar"; export interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; rowComponent?: React.ComponentType<{ row: Row<TData> }>; toolbarComponent?: React.ComponentType<DataTableToolbarProps<TData>>; actionBar?: React.ComponentType<DataTableActionBarProps<TData>>; paginationComponent?: React.ComponentType<DataTablePaginationProps<TData>>; onRowClick?: (row: Row<TData>) => void; defaultSorting?: SortingState; defaultColumnVisibility?: VisibilityState; defaultColumnFilters?: ColumnFiltersState; defaultPagination?: PaginationState; autoResetPageIndex?: boolean; /** access the state from the parent component */ columnFilters?: ColumnFiltersState; setColumnFilters?: React.Dispatch<React.SetStateAction<ColumnFiltersState>>; sorting?: SortingState; setSorting?: React.Dispatch<React.SetStateAction<SortingState>>; pagination?: PaginationState; setPagination?: React.Dispatch<React.SetStateAction<PaginationState>>; } export function DataTable<TData, TValue>({ columns, data, rowComponent, toolbarComponent, actionBar, paginationComponent, onRowClick, defaultSorting = [], defaultColumnVisibility = {}, defaultColumnFilters = [], defaultPagination = { pageIndex: 0, pageSize: 20 }, autoResetPageIndex = true, columnFilters, setColumnFilters, sorting, setSorting, pagination, setPagination, }: DataTableProps<TData, TValue>) { // biome-ignore lint/suspicious/noExplicitAny: <explanation> const [globalFilter, setGlobalFilter] = React.useState<any>(); const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(defaultColumnVisibility); const [internalPagination, setInternalPagination] = React.useState<PaginationState>(defaultPagination); const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>(defaultColumnFilters); const [internalSorting, setInternalSorting] = React.useState<SortingState>(defaultSorting); // Use controlled or uncontrolled column filters const columnFiltersState = columnFilters ?? internalColumnFilters; const setColumnFiltersState = setColumnFilters ?? setInternalColumnFilters; const sortingState = sorting ?? internalSorting; const setSortingState = setSorting ?? setInternalSorting; const paginationState = pagination ?? internalPagination; const setPaginationState = setPagination ?? setInternalPagination; const table = useReactTable({ data, columns, state: { sorting: sortingState, columnVisibility, rowSelection, pagination: paginationState, columnFilters: columnFiltersState, globalFilter, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onSortingChange: setSortingState, onColumnFiltersChange: setColumnFiltersState, onColumnVisibilityChange: setColumnVisibility, onPaginationChange: setPaginationState, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getExpandedRowModel: getExpandedRowModel(), autoResetPageIndex, // @ts-expect-error as we have an id in the data getRowCanExpand: (row) => Boolean(row.original.id), }); return ( <div className="grid gap-2"> {toolbarComponent ? React.createElement(toolbarComponent, { table }) : null} <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id} colSpan={header.colSpan} className={header.column.columnDef.meta?.headerClassName} > {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} </TableHead> ); })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <Fragment key={row.id}> <TableRow data-state={ (row.getIsSelected() || row.getIsExpanded()) && "selected" } onClick={() => onRowClick?.(row)} className="data-[state=selected]:bg-muted/50" > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id} className={cell.column.columnDef.meta?.cellClassName} > {flexRender( cell.column.columnDef.cell, cell.getContext(), )} </TableCell> ))} </TableRow> {row.getIsExpanded() && ( <TableRow className="hover:bg-background"> <TableCell className="p-0" colSpan={row.getVisibleCells().length} > {rowComponent ? React.createElement(rowComponent, { row }) : null} </TableCell> </TableRow> )} </Fragment> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> No results. </TableCell> </TableRow> )} </TableBody> {actionBar ? React.createElement(actionBar, { table }) : null} </Table> {paginationComponent ? React.createElement(paginationComponent, { table }) : null} </div> ); } ================================================ FILE: apps/dashboard/src/components/ui/sortable.tsx ================================================ "use client"; import { type Announcements, DndContext, type DndContextProps, type DragEndEvent, DragOverlay, type DraggableSyntheticListeners, type DropAnimation, KeyboardSensor, MouseSensor, type ScreenReaderInstructions, TouchSensor, type UniqueIdentifier, closestCenter, closestCorners, defaultDropAnimationSideEffects, useSensor, useSensors, } from "@dnd-kit/core"; import { restrictToHorizontalAxis, restrictToParentElement, restrictToVerticalAxis, } from "@dnd-kit/modifiers"; import { SortableContext, type SortableContextProps, arrayMove, horizontalListSortingStrategy, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Slot } from "@radix-ui/react-slot"; import * as React from "react"; import * as ReactDOM from "react-dom"; import { composeEventHandlers, useComposedRefs } from "@/lib/composition"; import { cn } from "@/lib/utils"; const orientationConfig = { vertical: { modifiers: [restrictToVerticalAxis, restrictToParentElement], strategy: verticalListSortingStrategy, collisionDetection: closestCenter, }, horizontal: { modifiers: [restrictToHorizontalAxis, restrictToParentElement], strategy: horizontalListSortingStrategy, collisionDetection: closestCenter, }, mixed: { modifiers: [restrictToParentElement], strategy: undefined, collisionDetection: closestCorners, }, }; const ROOT_NAME = "Sortable"; const CONTENT_NAME = "SortableContent"; const ITEM_NAME = "SortableItem"; const ITEM_HANDLE_NAME = "SortableItemHandle"; const OVERLAY_NAME = "SortableOverlay"; const SORTABLE_ERRORS = { [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``, [CONTENT_NAME]: `\`${CONTENT_NAME}\` must be within \`${ROOT_NAME}\``, [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${CONTENT_NAME}\``, [ITEM_HANDLE_NAME]: `\`${ITEM_HANDLE_NAME}\` must be within \`${ITEM_NAME}\``, [OVERLAY_NAME]: `\`${OVERLAY_NAME}\` must be within \`${ROOT_NAME}\``, } as const; interface SortableRootContextValue<T> { id: string; items: UniqueIdentifier[]; modifiers: DndContextProps["modifiers"]; strategy: SortableContextProps["strategy"]; activeId: UniqueIdentifier | null; setActiveId: (id: UniqueIdentifier | null) => void; getItemValue: (item: T) => UniqueIdentifier; flatCursor: boolean; } const SortableRootContext = React.createContext<SortableRootContextValue<unknown> | null>(null); SortableRootContext.displayName = ROOT_NAME; function useSortableContext(name: keyof typeof SORTABLE_ERRORS) { const context = React.useContext(SortableRootContext); if (!context) { throw new Error(SORTABLE_ERRORS[name]); } return context; } interface GetItemValue<T> { /** * Callback that returns a unique identifier for each sortable item. Required for array of objects. * @example getItemValue={(item) => item.id} */ getItemValue: (item: T) => UniqueIdentifier; } type SortableProps<T> = DndContextProps & { value: T[]; onValueChange?: (items: T[]) => void; onMove?: ( event: DragEndEvent & { activeIndex: number; overIndex: number }, ) => void; strategy?: SortableContextProps["strategy"]; orientation?: "vertical" | "horizontal" | "mixed"; flatCursor?: boolean; } & (T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>); function Sortable<T>(props: SortableProps<T>) { const { value, onValueChange, collisionDetection, modifiers, strategy, onMove, orientation = "vertical", flatCursor = false, getItemValue: getItemValueProp, accessibility, ...sortableProps } = props; const id = React.useId(); const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null); const sensors = useSensors( useSensor(MouseSensor), useSensor(TouchSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const config = React.useMemo( () => orientationConfig[orientation], [orientation], ); const getItemValue = React.useCallback( (item: T): UniqueIdentifier => { if (typeof item === "object" && !getItemValueProp) { throw new Error( "getItemValue is required when using array of objects.", ); } return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier); }, [getItemValueProp], ); const items = React.useMemo(() => { return value.map((item) => getItemValue(item)); }, [value, getItemValue]); const onDragEnd = React.useCallback( (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over?.id) { const activeIndex = value.findIndex( (item) => getItemValue(item) === active.id, ); const overIndex = value.findIndex( (item) => getItemValue(item) === over.id, ); if (onMove) { onMove({ ...event, activeIndex, overIndex }); } else { onValueChange?.(arrayMove(value, activeIndex, overIndex)); } } setActiveId(null); }, [value, onValueChange, onMove, getItemValue], ); const announcements: Announcements = React.useMemo( () => ({ onDragStart({ active }) { const activeValue = active.id.toString(); return `Grabbed sortable item "${activeValue}". Current position is ${ active.data.current?.sortable.index + 1 } of ${value.length}. Use arrow keys to move, space to drop.`; }, onDragOver({ active, over }) { if (over) { const overIndex = over.data.current?.sortable.index ?? 0; const activeIndex = active.data.current?.sortable.index ?? 0; const moveDirection = overIndex > activeIndex ? "down" : "up"; const activeValue = active.id.toString(); return `Sortable item "${activeValue}" moved ${moveDirection} to position ${ overIndex + 1 } of ${value.length}.`; } return "Sortable item is no longer over a droppable area. Press escape to cancel."; }, onDragEnd({ active, over }) { const activeValue = active.id.toString(); if (over) { const overIndex = over.data.current?.sortable.index ?? 0; return `Sortable item "${activeValue}" dropped at position ${ overIndex + 1 } of ${value.length}.`; } return `Sortable item "${activeValue}" dropped. No changes were made.`; }, onDragCancel({ active }) { const activeIndex = active.data.current?.sortable.index ?? 0; const activeValue = active.id.toString(); return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${ activeIndex + 1 } of ${value.length}.`; }, onDragMove({ active, over }) { if (over) { const overIndex = over.data.current?.sortable.index ?? 0; const activeIndex = active.data.current?.sortable.index ?? 0; const moveDirection = overIndex > activeIndex ? "down" : "up"; const activeValue = active.id.toString(); return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${ overIndex + 1 } of ${value.length}.`; } return "Sortable item is no longer over a droppable area. Press escape to cancel."; }, }), [value], ); const screenReaderInstructions: ScreenReaderInstructions = React.useMemo( () => ({ draggable: ` To pick up a sortable item, press space or enter. While dragging, use the ${ orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow" } keys to move the item. Press space or enter again to drop the item in its new position, or press escape to cancel. `, }), [orientation], ); const contextValue = React.useMemo( () => ({ id, items, modifiers: modifiers ?? config.modifiers, strategy: strategy ?? config.strategy, activeId, setActiveId, getItemValue, flatCursor, }), [ id, items, modifiers, strategy, config.modifiers, config.strategy, activeId, getItemValue, flatCursor, ], ); return ( <SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>} > <DndContext collisionDetection={collisionDetection ?? config.collisionDetection} modifiers={modifiers ?? config.modifiers} sensors={sensors} {...sortableProps} id={id} onDragStart={composeEventHandlers( sortableProps.onDragStart, ({ active }) => setActiveId(active.id), )} onDragEnd={composeEventHandlers(sortableProps.onDragEnd, onDragEnd)} onDragCancel={composeEventHandlers(sortableProps.onDragCancel, () => setActiveId(null), )} accessibility={{ announcements, screenReaderInstructions, ...accessibility, }} /> </SortableRootContext.Provider> ); } const SortableContentContext = React.createContext<boolean>(false); SortableContentContext.displayName = CONTENT_NAME; interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> { strategy?: SortableContextProps["strategy"]; children: React.ReactNode; asChild?: boolean; withoutSlot?: boolean; } const SortableContent = React.forwardRef<HTMLDivElement, SortableContentProps>( (props, forwardedRef) => { const { strategy: strategyProp, asChild, withoutSlot, children, ...contentProps } = props; const context = useSortableContext(CONTENT_NAME); const ContentPrimitive = asChild ? Slot : "div"; return ( <SortableContentContext.Provider value={true}> <SortableContext items={context.items} strategy={strategyProp ?? context.strategy} > {withoutSlot ? ( children ) : ( <ContentPrimitive {...contentProps} ref={forwardedRef}> {children} </ContentPrimitive> )} </SortableContext> </SortableContentContext.Provider> ); }, ); SortableContent.displayName = CONTENT_NAME; interface SortableItemContextValue { id: string; attributes: React.HTMLAttributes<HTMLElement>; listeners: DraggableSyntheticListeners | undefined; setActivatorNodeRef: (node: HTMLElement | null) => void; isDragging?: boolean; disabled?: boolean; } const SortableItemContext = React.createContext<SortableItemContextValue | null>(null); SortableItemContext.displayName = ITEM_NAME; interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> { value: UniqueIdentifier; asHandle?: boolean; asChild?: boolean; disabled?: boolean; } const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>( (props, forwardedRef) => { const { value, style, asHandle, asChild, disabled, className, ...itemProps } = props; const inSortableContent = React.useContext(SortableContentContext); const inSortableOverlay = React.useContext(SortableOverlayContext); if (!inSortableContent && !inSortableOverlay) { throw new Error(SORTABLE_ERRORS[ITEM_NAME]); } if (value === "") { throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`); } const context = useSortableContext(ITEM_NAME); const id = React.useId(); const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id: value, disabled }); const composedRef = useComposedRefs(forwardedRef, (node) => { if (disabled) return; setNodeRef(node); if (asHandle) setActivatorNodeRef(node); }); const composedStyle = React.useMemo<React.CSSProperties>(() => { return { transform: CSS.Translate.toString(transform), transition, ...style, }; }, [transform, transition, style]); const itemContext = React.useMemo<SortableItemContextValue>( () => ({ id, attributes, listeners, setActivatorNodeRef, isDragging, disabled, }), [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled], ); const ItemPrimitive = asChild ? Slot : "div"; return ( <SortableItemContext.Provider value={itemContext}> <ItemPrimitive id={id} data-dragging={isDragging ? "" : undefined} {...itemProps} {...(asHandle ? attributes : {})} {...(asHandle ? listeners : {})} tabIndex={disabled ? undefined : 0} ref={composedRef} style={composedStyle} className={cn( "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1", { "touch-none select-none": asHandle, "cursor-default": context.flatCursor, "data-dragging:cursor-grabbing": !context.flatCursor, "cursor-grab": !isDragging && asHandle && !context.flatCursor, "opacity-50": isDragging, "pointer-events-none opacity-50": disabled, }, className, )} /> </SortableItemContext.Provider> ); }, ); SortableItem.displayName = ITEM_NAME; interface SortableItemHandleProps extends React.ComponentPropsWithoutRef<"button"> { asChild?: boolean; } const SortableItemHandle = React.forwardRef< HTMLButtonElement, SortableItemHandleProps >((props, forwardedRef) => { const { asChild, disabled, className, ...itemHandleProps } = props; const itemContext = React.useContext(SortableItemContext); if (!itemContext) { throw new Error(SORTABLE_ERRORS[ITEM_HANDLE_NAME]); } const context = useSortableContext(ITEM_HANDLE_NAME); const isDisabled = disabled ?? itemContext.disabled; const composedRef = useComposedRefs(forwardedRef, (node) => { if (!isDisabled) return; itemContext.setActivatorNodeRef(node); }); const HandlePrimitive = asChild ? Slot : "button"; return ( <HandlePrimitive type="button" aria-controls={itemContext.id} data-dragging={itemContext.isDragging ? "" : undefined} {...itemHandleProps} {...itemContext.attributes} {...itemContext.listeners} ref={composedRef} className={cn( "select-none disabled:pointer-events-none disabled:opacity-50", context.flatCursor ? "cursor-default" : "cursor-grab data-dragging:cursor-grabbing", className, )} disabled={isDisabled} /> ); }); SortableItemHandle.displayName = ITEM_HANDLE_NAME; const SortableOverlayContext = React.createContext(false); SortableOverlayContext.displayName = OVERLAY_NAME; const dropAnimation: DropAnimation = { sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4", }, }, }), }; interface SortableOverlayProps extends Omit<React.ComponentPropsWithoutRef<typeof DragOverlay>, "children"> { container?: Element | DocumentFragment | null; children?: | ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode; } function SortableOverlay(props: SortableOverlayProps) { const { container: containerProp, children, ...overlayProps } = props; const context = useSortableContext(OVERLAY_NAME); const [mounted, setMounted] = React.useState(false); React.useLayoutEffect(() => setMounted(true), []); const container = containerProp ?? (mounted ? globalThis.document?.body : null); if (!container) return null; return ReactDOM.createPortal( <DragOverlay dropAnimation={dropAnimation} modifiers={context.modifiers} className={cn(!context.flatCursor && "cursor-grabbing")} {...overlayProps} > <SortableOverlayContext.Provider value={true}> {context.activeId ? typeof children === "function" ? children({ value: context.activeId }) : children : null} </SortableOverlayContext.Provider> </DragOverlay>, container, ); } const Root = Sortable; const Content = SortableContent; const Item = SortableItem; const ItemHandle = SortableItemHandle; const Overlay = SortableOverlay; export { Root, Content, Item, ItemHandle, Overlay, // Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay, }; ================================================ FILE: apps/dashboard/src/data/audit-logs.client.ts ================================================ import { formatMilliseconds } from "@/lib/formatter"; import type { PrivateLocation } from "@openstatus/db/src/schema"; import { getRegionInfo } from "@openstatus/regions"; import { CircleAlert, CircleCheck, CircleMinus, Send, Siren, } from "lucide-react"; export const config = { "incident.created": { icon: Siren, color: "text-destructive", title: "Incident Created", }, "incident.resolved": { icon: CircleCheck, color: "text-success", title: "Incident Resolved", }, "monitor.failed": { icon: CircleMinus, color: "text-destructive", title: "Monitor Failed", }, "notification.sent": { icon: Send, color: "text-info", title: "Notification Sent", }, "monitor.recovered": { icon: CircleCheck, color: "text-success", title: "Monitor Recovered", }, "monitor.degraded": { icon: CircleAlert, color: "text-warning", title: "Monitor Degraded", }, } as const; export const getMetadata = (privateLocations?: PrivateLocation[]) => { return { region: { label: "Region", key: "region", unit: undefined, visible: () => true, format: (value) => { const regionInfo = getRegionInfo(`${value}`, { location: privateLocations?.find( (location) => String(location.id) === String(value), )?.name, }); return `${regionInfo.location} (${regionInfo.provider})`; }, }, cronTimestamp: { label: "Timestamp", key: "timestamp", unit: undefined, visible: () => false, format: (value) => String(value), }, statusCode: { label: "Status Code", key: "status", unit: undefined, visible: (_value) => typeof _value === "number" && _value !== -1, format: (value) => String(value), }, latency: { label: "Latency", key: "latency", unit: "ms", visible: () => true, format: (value) => formatMilliseconds(Number(value)), }, provider: { label: "Provider", key: "provider", unit: undefined, visible: () => true, format: (value) => String(value), }, } as const satisfies Record< string, { label: string; key: string; unit?: string | undefined; visible: (value: string | number) => boolean; format: (value: string | number) => string; } >; }; ================================================ FILE: apps/dashboard/src/data/audit-logs.ts ================================================ export const auditLogs = [ { id: 3, timestamp: new Date("2025-05-05 12:00:00"), action: "incident.created" as const, }, { id: 2, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.failed" as const, metadata: { region: "ams", status: 500, latency: 1400, } as const, }, { id: 1, timestamp: new Date("2025-05-05 12:00:00"), action: "notification.sent" as const, metadata: { provider: "slack", } as const, }, { id: 0, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.recovered" as const, metadata: { region: "ams", latency: 140, } as const, }, { id: -1, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.degraded" as const, metadata: { region: "ams", latency: 30_000, } as const, }, { id: -2, timestamp: new Date("2025-05-05 12:00:00"), action: "incident.resolved" as const, }, { id: -3, timestamp: new Date("2025-05-05 12:00:00"), action: "incident.created" as const, }, { id: -4, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.degraded" as const, metadata: { region: "ams", latency: 30_000, } as const, }, { id: -5, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.degraded" as const, metadata: { region: "ams", latency: 32_000, } as const, }, { id: -6, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.degraded" as const, metadata: { region: "ams", latency: 33_000, } as const, }, { id: -7, timestamp: new Date("2025-05-05 12:00:00"), action: "monitor.degraded" as const, metadata: { region: "ams", latency: 34_000, } as const, }, ]; export type AuditLog = (typeof auditLogs)[number]; ================================================ FILE: apps/dashboard/src/data/icons.ts ================================================ "use client"; import { Activity, AlertCircle, Search, SearchCheck } from "lucide-react"; export const status = { resolved: SearchCheck, investigating: AlertCircle, identified: Search, monitoring: Activity, } as const; export const icons = { status, }; ================================================ FILE: apps/dashboard/src/data/incidents.client.ts ================================================ import { Bookmark, Check, Trash2 } from "lucide-react"; export const actions = [ { id: "acknowledge", label: "Acknowledge", icon: Bookmark, variant: "default" as const, }, { id: "resolve", label: "Resolve", icon: Check, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type IncidentAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<IncidentAction["id"], () => Promise<void> | void>>, ): (IncidentAction & { onClick?: () => Promise<void> | void; })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/incidents.ts ================================================ export const incidents = [ { id: 1, startedAt: new Date("2025-05-05 12:00:00"), acknowledged: null, resolvedAt: new Date("2025-05-05 14:00:00"), monitor: "OpenStatus API", }, ]; export type Incident = (typeof incidents)[number]; ================================================ FILE: apps/dashboard/src/data/invitations.ts ================================================ export const invitations = [ { id: 1, email: "thibault@openstatus.dev", role: "member", createdAt: "2021-01-01", expiresAt: "2021-01-07", acceptedAt: "2021-01-02", }, ]; export type Invitation = (typeof invitations)[number]; ================================================ FILE: apps/dashboard/src/data/maintenances.client.ts ================================================ import { Cog, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type MaintenanceAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<MaintenanceAction["id"], () => Promise<void> | void>>, ): (MaintenanceAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/maintenances.ts ================================================ export const maintenances = [ { id: 1, title: "DB Migration", message: "We are currently performing a db migration on our system and will be down for a few hours.", startDate: new Date("2025-04-01"), endDate: new Date("2025-04-02"), affected: ["OpenStatus API"], }, ]; export type Maintenance = (typeof maintenances)[number]; ================================================ FILE: apps/dashboard/src/data/members.ts ================================================ export const members = [ { id: 1, name: "Maximilian Kaske", email: "max@openstatus.dev", role: "admin", createdAt: "2021-01-01", }, ]; export type Member = (typeof members)[number]; ================================================ FILE: apps/dashboard/src/data/metrics.client.ts ================================================ "use client"; import type { MetricCard } from "@/components/metric/metric-card"; import { formatDateTime, formatMilliseconds } from "@/lib/formatter"; import type { RouterOutputs } from "@openstatus/api"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; import { startOfDay, subDays } from "date-fns"; import type { RegionMetric } from "./region-metrics"; export const STATUS = ["success", "error", "degraded"] as const; export const PERIODS = ["1d", "7d", "14d"] as const; export const REGIONS = monitorRegions as unknown as (typeof monitorRegions)[number][]; export const PERCENTILES = ["p50", "p75", "p90", "p95", "p99"] as const; export const INTERVALS = [5, 15, 30, 60, 120, 240, 480, 1440] as const; export const TRIGGER = ["api", "cron"] as const; const PERCENTILE_MAP = { p50: "p50Latency", p75: "p75Latency", p90: "p90Latency", p95: "p95Latency", p99: "p99Latency", } as const; // FIXME: rename pipe return values export function mapMetrics(metrics: RouterOutputs["tinybird"]["metrics"]) { return metrics.data?.map((metric) => { return { p50: metric.p50Latency, p75: metric.p75Latency, p90: metric.p90Latency, p95: metric.p95Latency, p99: metric.p99Latency, total: metric.count, uptime: (metric.success + metric.degraded) / metric.count, degraded: metric.degraded, error: metric.error, lastTimestamp: metric.lastTimestamp, }; }); } export const metricsCards = { uptime: { label: "UPTIME", variant: "success", }, degraded: { label: "DEGRADED", variant: "warning", }, error: { label: "FAILING", variant: "destructive", }, total: { label: "REQUESTS", variant: "default", }, lastTimestamp: { label: "LAST CHECKED", variant: "ghost", }, p50: { label: "P50", variant: "default", }, p75: { label: "P75", variant: "default", }, p90: { label: "P90", variant: "default", }, p95: { label: "P95", variant: "default", }, p99: { label: "P99", variant: "default", }, } as const satisfies Record< keyof ReturnType<typeof mapMetrics>[number], { label: string; variant: React.ComponentProps<typeof MetricCard>["variant"]; } >; export function mapUptime(status: RouterOutputs["tinybird"]["uptime"]) { return status.data .map((status) => { return { ...status, ok: status.success, interval: formatDateTime(status.interval), total: status.success + status.error + status.degraded, }; }) .reverse(); } /** * Transform Tinybird `metricsRegions` response into RegionMetric[] for UI. */ export function mapRegionMetrics( timeline: RouterOutputs["tinybird"]["metricsRegions"] | undefined, regions: string[], percentile: (typeof PERCENTILES)[number], ): RegionMetric[] { if (!timeline) return (regions .sort((a, b) => a.localeCompare(b)) .map((region) => ({ region, p50: 0, p90: 0, p99: 0, trend: [] as { latency: number; timestamp: number; [key: string]: number; }[], })) ?? []) satisfies RegionMetric[]; type TimelineRow = (typeof timeline.data)[number]; const map = new Map< string, { region: string; p50: number; p90: number; p99: number; trend: { latency: number; timestamp: number; [key: string]: number; }[]; } >(); (timeline.data as TimelineRow[]) .filter((row) => regions.includes(row.region)) .sort((a, b) => a.region.localeCompare(b.region)) .forEach((row) => { const region = row.region; const entry = map.get(region) ?? { region, p50: 0, p90: 0, p99: 0, trend: [], }; entry.trend.push({ latency: row[PERCENTILE_MAP[percentile]] ?? 0, timestamp: row.timestamp, [region]: row[PERCENTILE_MAP[percentile]] ?? 0, }); entry.p50 += row.p50Latency ?? 0; entry.p90 += row.p90Latency ?? 0; entry.p99 += row.p99Latency ?? 0; map.set(region, entry); }); map.forEach((entry) => { const count = entry.trend.length || 1; entry.trend.reverse(); entry.p50 = Math.round(entry.p50 / count); entry.p90 = Math.round(entry.p90 / count); entry.p99 = Math.round(entry.p99 / count); }); return Array.from(map.values()) as RegionMetric[]; } export function mapGlobalMetrics( metrics: RouterOutputs["tinybird"]["globalMetrics"], ) { return metrics.data?.map((metric) => { return { p50: metric.p50Latency, p75: metric.p75Latency, p90: metric.p90Latency, p95: metric.p95Latency, p99: metric.p99Latency, total: metric.count, monitorId: metric.monitorId, }; }); } export type MonitorListMetric = { title: string; key: "degraded" | "error" | "active" | "inactive" | "p95"; value: number | string | undefined; variant: React.ComponentProps<typeof MetricCard>["variant"]; }; export const globalCards = [ "active", "degraded", "error", "inactive", "p95", ] as const; export const metricsGlobalCards: Record< (typeof globalCards)[number], { title: string; key: (typeof globalCards)[number]; } > = { active: { title: "Normal", key: "active" as const, }, degraded: { title: "Degraded", key: "degraded" as const, }, error: { title: "Failing", key: "error" as const, }, inactive: { title: "Inactive", key: "inactive" as const, }, p95: { title: "Slowest P95", key: "p95" as const, }, }; /** * Build the metric cards data that is shown on the monitors list page. */ export function getMonitorListMetrics( monitors: RouterOutputs["monitor"]["list"] = [], data: { p95Latency: number; monitorId: string; }[] = [], ): readonly MonitorListMetric[] { const variantMap: Record< (typeof globalCards)[number], React.ComponentProps<typeof MetricCard>["variant"] > = { active: "success", degraded: "warning", error: "destructive", inactive: "default", p95: "ghost", } as const; return globalCards.map((key) => { let value: number | string | undefined; switch (key) { case "active": value = monitors.filter( (m) => m.status === "active" && m.active, ).length; break; case "degraded": value = monitors.filter( (m) => m.status === "degraded" && m.active, ).length; break; case "error": value = monitors.filter((m) => m.status === "error" && m.active).length; break; case "inactive": value = monitors.filter((m) => m.active === false).length; break; case "p95": const p95 = data.sort((a, b) => b.p95Latency - a.p95Latency)[0] ?.p95Latency; value = p95 ? formatMilliseconds(p95) : "N/A"; break; } return { title: metricsGlobalCards[key].title, key, value, variant: variantMap[key], } as const; }) as readonly MonitorListMetric[]; } export function mapLatency( latency: RouterOutputs["tinybird"]["metricsLatency"], percentile: (typeof PERCENTILES)[number], ) { return latency.data?.map((metric) => { return { timestamp: formatDateTime(new Date(metric.timestamp)), latency: metric[PERCENTILE_MAP[percentile]], }; }); } export function mapTimingPhases( timingPhases: RouterOutputs["tinybird"]["metricsTimingPhases"], percentile: (typeof PERCENTILES)[number], ) { return timingPhases.data?.map((metric) => { return { timestamp: formatDateTime(new Date(metric.timestamp)), dns: metric[`${percentile}Dns`], ttfb: metric[`${percentile}Ttfb`], transfer: metric[`${percentile}Transfer`], connect: metric[`${percentile}Connect`], tls: metric[`${percentile}Tls`], }; }); } export const periodToInterval = { "1d": 60, "7d": 240, "14d": 480, } satisfies Record<(typeof PERIODS)[number], number>; export const periodToFromDate = { "1d": startOfDay(subDays(new Date(), 1)), "7d": startOfDay(subDays(new Date(), 7)), "14d": startOfDay(subDays(new Date(), 14)), } satisfies Record<(typeof PERIODS)[number], Date>; ================================================ FILE: apps/dashboard/src/data/monitor-tags.ts ================================================ export const monitorTags = [ { value: "production", label: "Production", color: "bg-green-500", }, { value: "development", label: "Development", color: "bg-blue-500", }, { value: "staging", label: "Staging", color: "bg-yellow-500", }, { value: "testing", label: "Testing", color: "bg-purple-500", }, { value: "api", label: "API", color: "bg-red-500", }, { value: "database", label: "Database", color: "bg-orange-500", }, ]; export type MonitorTag = (typeof monitorTags)[number]; ================================================ FILE: apps/dashboard/src/data/monitors.client.ts ================================================ import { Cog, Copy, CopyPlus, Globe, Network, Server, Trash2, } from "lucide-react"; export const monitorTypes = [ { id: "http", label: "HTTP", icon: Globe, }, { id: "tcp", label: "TCP", icon: Network, }, { id: "dns", label: "DNS", icon: Server, }, ] as const; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "copy-id", label: "Copy ID", icon: Copy, variant: "default" as const, }, { id: "clone", label: "Clone", icon: CopyPlus, variant: "default" as const, }, // { // id: "export", // label: "Export Code", // icon: Code, // variant: "default" as const, // }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type MonitorAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<MonitorAction["id"], () => Promise<void> | void>>, ): (MonitorAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/monitors.ts ================================================ export const monitors = [ { id: 1, name: "OpenStatus Marketing", description: "Marketing website for OpenStatus", public: false, active: true, status: "Normal" as const, url: "https://openstatus.dev", tags: ["Production"], lastIncident: undefined, p50: 110, p90: 200, p99: 250, }, { id: 2, name: "OpenStatus API", description: "API for OpenStatus", public: false, active: true, status: "Normal" as const, url: "https://api.openstatus.dev/v1/ping", tags: ["Production", "API"], lastIncident: undefined, p50: 34, p90: 201, p99: 530, }, { id: 3, name: "OpenStatus App", description: "Dashboard for OpenStatus", public: false, active: true, status: "Failing" as const, url: "https://openstatus.dev/app", tags: ["Production"], lastIncident: "10 minutes ago", p50: 130, p90: 200, p99: 250, }, { id: 4, name: "Lightweight OS", description: "Lightweight Operations System", public: false, active: false, status: "Inactive" as const, url: "https://data-table.openstatus.dev/light", tags: ["Development"], lastIncident: undefined, p50: undefined, p90: undefined, p99: undefined, }, { id: 5, name: "Astro Status Page", description: "Status page for Astro", public: false, active: true, status: "Degraded" as const, url: "https://status.openstat.us", tags: ["Development"], lastIncident: undefined, p50: 130, p90: 201, p99: 250, }, { id: 6, name: "Vercel Edge Ping", description: "Ping for Vercel Edge", public: false, active: true, status: "Normal" as const, url: "https://light.openstatus.dev", tags: ["Staging"], lastIncident: "15 days ago", p50: 30, p90: 240, p99: 400, }, ]; export type Monitor = (typeof monitors)[number]; ================================================ FILE: apps/dashboard/src/data/notifications.client.ts ================================================ import { FormDiscord } from "@/components/forms/notifications/form-discord"; import { FormEmail } from "@/components/forms/notifications/form-email"; import { FormGoogleChat } from "@/components/forms/notifications/form-google-chat"; import { FormGrafanaOncall } from "@/components/forms/notifications/form-grafana-oncall"; import { FormNtfy } from "@/components/forms/notifications/form-ntfy"; import { FormOpsGenie } from "@/components/forms/notifications/form-opsgenie"; import { FormPagerDuty } from "@/components/forms/notifications/form-pagerduty"; import { FormSlack } from "@/components/forms/notifications/form-slack"; import { FormSms } from "@/components/forms/notifications/form-sms"; import { FormTelegram } from "@/components/forms/notifications/form-telegram"; import { FormWebhook } from "@/components/forms/notifications/form-webhook"; import { FormWhatsApp } from "@/components/forms/notifications/form-whatsapp"; import { DiscordIcon, GoogleIcon, GrafanaIcon, TelegramIcon, WhatsappIcon, } from "@openstatus/icons"; import { OpsGenieIcon } from "@openstatus/icons"; import { PagerDutyIcon } from "@openstatus/icons"; import { SlackIcon } from "@openstatus/icons"; import { sendTestDiscordMessage as sendTestDiscord } from "@openstatus/notification-discord"; import { sendTest as sendTestGrafanaOncall } from "@openstatus/notification-grafana-oncall"; import { sendTest as sendTestNtfy } from "@openstatus/notification-ntfy"; import { sendTest as sendTestOpsGenie } from "@openstatus/notification-opsgenie"; import { sendTest as sendTestPagerDuty } from "@openstatus/notification-pagerduty"; import { sendTestSlackMessage as sendTestSlack } from "@openstatus/notification-slack"; import { sendTest as sendTestTelegram } from "@openstatus/notification-telegram"; import { sendTest as sendWhatsAppTest } from "@openstatus/notification-twillio-whatsapp"; import { sendTest as sendTestWebhook } from "@openstatus/notification-webhook"; import { BellIcon, Cog, Mail, MessageCircle, Trash2, Webhook, } from "lucide-react"; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type NotifierAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<NotifierAction["id"], () => Promise<void> | void>>, ): (NotifierAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; // List of the notifiers export const config = { slack: { icon: SlackIcon, label: "Slack", form: FormSlack, sendTest: sendTestSlack, }, discord: { icon: DiscordIcon, label: "Discord", form: FormDiscord, sendTest: sendTestDiscord, }, email: { icon: Mail, label: "Email", form: FormEmail, // TODO: add sendTest sendTest: undefined, }, sms: { icon: MessageCircle, label: "SMS", form: FormSms, // TODO: add sendTest sendTest: undefined, }, webhook: { icon: Webhook, label: "Webhook", form: FormWebhook, sendTest: sendTestWebhook, }, opsgenie: { icon: OpsGenieIcon, label: "OpsGenie", form: FormOpsGenie, sendTest: sendTestOpsGenie, }, "google-chat": { icon: GoogleIcon, label: "Google Chat", form: FormGoogleChat, sendTest: sendTestWebhook, }, "grafana-oncall": { icon: GrafanaIcon, label: "Grafana OnCall", form: FormGrafanaOncall, sendTest: sendTestGrafanaOncall, }, pagerduty: { icon: PagerDutyIcon, label: "PagerDuty", form: FormPagerDuty, sendTest: sendTestPagerDuty, }, ntfy: { icon: BellIcon, // TODO: add svg icon label: "Ntfy", form: FormNtfy, sendTest: sendTestNtfy, }, telegram: { icon: TelegramIcon, label: "Telegram", form: FormTelegram, sendTest: sendTestTelegram, }, whatsapp: { icon: WhatsappIcon, label: "WhatsApp", form: FormWhatsApp, sendTest: sendWhatsAppTest, }, }; export type NotifierProvider = keyof typeof config; ================================================ FILE: apps/dashboard/src/data/notifications.ts ================================================ export const notifications = [ { id: 1, name: "Email", provider: "email", value: "max@openstatus.dev", }, ]; export type Notification = (typeof notifications)[number]; ================================================ FILE: apps/dashboard/src/data/page-components.client.ts ================================================ import { Trash2 } from "lucide-react"; export const actions = [ { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type PageComponentAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<PageComponentAction["id"], () => Promise<void> | void>>, ): (PageComponentAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/plans.ts ================================================ import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type React from "react"; export const plans = allPlans; export const config: Record< string, { label: string; features: { value: keyof Limits; label: string; description?: React.ReactNode; // tooltip informations badge?: string; monthly?: boolean; }[]; } > = { "status-pages": { label: "Status Pages", features: [ { value: "status-pages", label: "Number of status pages", }, { value: "page-components", label: "Number of components", }, { value: "maintenance", label: "Maintenance status", }, { value: "slack-agent", label: "Slack Agent", }, { value: "monitor-values-visibility", label: "Toggle numbers visibility", }, { value: "status-subscribers", label: "Subscribers", }, { value: "custom-domain", label: "Custom domain", }, { value: "white-label", label: "White Label", }, ], }, "status-page-audience": { label: "Status Page Audience", features: [ { value: "password-protection", label: "Password Protection (Basic)", }, { value: "email-domain-protection", label: "Magic Link (Auth)", }, ], }, monitors: { label: "Monitors", features: [ { value: "periodicity", label: "Frequency", }, { value: "monitors", label: "Number of monitors", }, { value: "multi-region", label: "Multi-region monitoring", }, { value: "regions", label: "Total regions" }, { value: "max-regions", label: "Regions per monitor" }, { value: "data-retention", label: "Data retention" }, { value: "response-logs", label: "Response Logs" }, { value: "otel", label: "OTel Exporter" }, { value: "synthetic-checks", label: "Synthetic API Checks", monthly: true, }, ], }, notifications: { label: "Notifications", features: [ { value: "notifications", label: "Slack, Discord, Email, Webhook, ntfy.sh", }, { value: "sms", label: "SMS", }, { value: "pagerduty", label: "PagerDuty", }, { value: "opsgenie", label: "OpsGenie", }, { value: "grafana-oncall", label: "Grafana OnCall", }, { value: "whatsapp", label: "WhatsApp", }, { value: "notification-channels", label: "Number of notification channels", }, ], }, collaboration: { label: "Collaboration", features: [ { value: "members", label: "Team members", }, { value: "audit-log", label: "Audit log", badge: "Planned", }, ], }, }; ================================================ FILE: apps/dashboard/src/data/region-metrics.client.ts ================================================ import { Filter } from "lucide-react"; export const actions = [ { id: "filter", label: "Filter", icon: Filter, variant: "default" as const, }, // { // id: "trigger", // label: "Trigger", // icon: Zap, // variant: "default" as const, // }, ] as const; export type RegionMetricAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<RegionMetricAction["id"], () => Promise<void> | void>>, ): (RegionMetricAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/region-metrics.ts ================================================ export const regionMetrics = [ { region: "ams", p50: 100, p90: 150, p99: 200, trend: [{ ams: 100, timestamp: 1716729600, latency: 100 }] as { [key: string]: number; timestamp: number; latency: number; }[], }, { region: "fra", p50: 110, p90: 155, p99: 220, trend: [{ fra: 100, timestamp: 1716729600, latency: 100 }] as { [key: string]: number; timestamp: number; latency: number; }[], }, { region: "gru", p50: 120, p90: 160, p99: 230, trend: [{ gru: 100, timestamp: 1716729600, latency: 100 }] as { [key: string]: number; timestamp: number; latency: number; }[], }, ]; export type RegionMetric = (typeof regionMetrics)[number]; ================================================ FILE: apps/dashboard/src/data/regions.ts ================================================ export const regions = [ { code: "ams", location: "Amsterdam, Netherlands", flag: "🇳🇱", continent: "Europe", provider: "Fly", }, { code: "arn", location: "Stockholm, Sweden", flag: "🇸🇪", continent: "Europe", provider: "Fly", }, { code: "atl", location: "Atlanta, Georgia, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "bog", location: "Bogotá, Colombia", flag: "🇨🇴", continent: "South America", provider: "Fly", }, { code: "bom", location: "Mumbai, India", flag: "🇮🇳", continent: "Asia", provider: "Fly", }, { code: "bos", location: "Boston, Massachusetts, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "cdg", location: "Paris, France", flag: "🇫🇷", continent: "Europe", provider: "Fly", }, { code: "den", location: "Denver, Colorado, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "dfw", location: "Dallas, Texas, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "ewr", location: "Secaucus, New Jersey, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "eze", location: "Ezeiza, Argentina", flag: "🇦🇷", continent: "South America", provider: "Fly", }, { code: "fra", location: "Frankfurt, Germany", flag: "🇩🇪", continent: "Europe", provider: "Fly", }, { code: "gdl", location: "Guadalajara, Mexico", flag: "🇲🇽", continent: "North America", provider: "Fly", }, { code: "gig", location: "Rio de Janeiro, Brazil", flag: "🇧🇷", continent: "South America", provider: "Fly", }, { code: "gru", location: "Sao Paulo, Brazil", flag: "🇧🇷", continent: "South America", provider: "Fly", }, { code: "hkg", location: "Hong Kong, Hong Kong", flag: "🇭🇰", continent: "Asia", provider: "Fly", }, { code: "iad", location: "Ashburn, Virginia, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "jnb", location: "Johannesburg, South Africa", flag: "🇿🇦", continent: "Africa", }, { code: "lax", location: "Los Angeles, California, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "lhr", location: "London, United Kingdom", flag: "🇬🇧", continent: "Europe", provider: "Fly", }, { code: "mad", location: "Madrid, Spain", flag: "🇪🇸", continent: "Europe", }, { code: "mia", location: "Miami, Florida, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "nrt", location: "Tokyo, Japan", flag: "🇯🇵", continent: "Asia", provider: "Fly", }, { code: "ord", location: "Chicago, Illinois, USA", flag: "🇺🇸", continent: "North America", }, { code: "otp", location: "Bucharest, Romania", flag: "🇷🇴", continent: "Europe", provider: "Fly", }, { code: "phx", location: "Phoenix, Arizona, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "qro", location: "Querétaro, Mexico", flag: "🇲🇽", continent: "North America", provider: "Fly", }, { code: "scl", location: "Santiago, Chile", flag: "🇨🇱", continent: "South America", provider: "Fly", }, { code: "sjc", location: "San Jose, California, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "sea", location: "Seattle, Washington, USA", flag: "🇺🇸", continent: "North America", provider: "Fly", }, { code: "sin", location: "Singapore, Singapore", flag: "🇸🇬", continent: "Asia", provider: "Fly", }, { code: "syd", location: "Sydney, Australia", flag: "🇦🇺", continent: "Oceania", provider: "Fly", }, { code: "waw", location: "Warsaw, Poland", flag: "🇵🇱", continent: "Europe", provider: "Fly", }, { code: "yul", location: "Montreal, Canada", flag: "🇨🇦", continent: "North America", provider: "Fly", }, { code: "yyz", location: "Toronto, Canada", flag: "🇨🇦", continent: "North America", provider: "Fly", }, { code: "koyeb_fra", location: "Frankfurt, Germany", flag: "🇩🇪", continent: "Europe", provider: "koyeb", }, { code: "koyeb_par", location: "Paris, France", flag: "🇫🇷", continent: "Europe", provider: "koyeb", }, { code: "koyeb_sfo", location: "San Francisco, USA", flag: "🇺🇸", continent: "North America", provider: "koyeb", }, { code: "koyeb_sin", location: "Singapore, Singapore", flag: "🇸🇬", continent: "Asia", provider: "koyeb", }, { code: "koyeb_tyo", location: "Tokyo, Japan", flag: "🇯🇵", continent: "Asia", provider: "koyeb", }, { code: "koyeb_was", location: "Washington, USA", flag: "🇺🇸", continent: "North America", provider: "koyeb", }, { code: "railway_us-west2", location: "California, USA", flag: "🇺🇸", continent: "North America", provider: "railway", }, { code: "railway_us-east4-eqdc4a", location: "Virginia, USA", flag: "🇺🇸", continent: "North America", provider: "railway", }, { code: "railway_europe-west4-drams3a", location: "Amsterdam, Netherlands", flag: "🇳🇱", continent: "Europe", provider: "railway", }, { code: "railway_asia-southeast1-eqsg3a", location: "Singapore, Singapore", flag: "🇸🇬", continent: "Asia", provider: "railway", }, ] as const; export type Region = (typeof regions)[number]["code"]; export const groupedRegions = regions.reduce( (acc, region) => { const continent = region.continent; if (!acc[continent]) { acc[continent] = []; } acc[continent].push(region.code); return acc; }, {} as Record<string, Region[]>, ); export const regionColors = { ams: "hsl(217.2 91.2% 59.8%)", arn: "hsl(238.7 83.5% 66.7%)", atl: "hsl(258.3 89.5% 66.3%)", bog: "hsl(270.7 91% 65.1%)", bom: "hsl(292.2 84.1% 60.6%)", bos: "hsl(330.4 81.2% 60.4%)", cdg: "hsl(349.7 89.2% 60.2%)", den: "hsl(215.4 16.3% 46.9%)", dfw: "hsl(220 8.9% 46.1%)", ewr: "hsl(240 3.8% 46.1%)", eze: "hsl(0 0% 45.1%)", fra: "hsl(25 5.3% 44.7%)", gdl: "hsl(0 84.2% 60.2%)", gig: "hsl(24.6 95% 53.1%)", gru: "hsl(37.7 92.1% 50.2%)", hkg: "hsl(45.4 93.4% 47.5%)", iad: "hsl(83.7 80.5% 44.3%)", jnb: "hsl(142.1 70.6% 45.3%)", lax: "hsl(160.1 84.1% 39.4%)", lhr: "hsl(173.4 80.4% 40%)", mad: "hsl(188.7 94.5% 42.7%)", mia: "hsl(198.6 88.7% 48.4%)", nrt: "hsl(217.2 91.2% 59.8%)", ord: "hsl(238.7 83.5% 66.7%)", otp: "hsl(258.3 89.5% 66.3%)", phx: "hsl(270.7 91% 65.1%)", qro: "hsl(292.2 84.1% 60.6%)", scl: "hsl(330.4 81.2% 60.4%)", sjc: "hsl(349.7 89.2% 60.2%)", sea: "hsl(215.4 16.3% 46.9%)", sin: "hsl(220 8.9% 46.1%)", syd: "hsl(240 3.8% 46.1%)", waw: "hsl(0 0% 45.1%)", yul: "hsl(25 5.3% 44.7%)", yyz: "hsl(0 84.2% 60.2%)", koyeb_fra: "hsl(25 5.3% 44.7%)", koyeb_par: "hsl(25 5.3% 44.7%)", koyeb_sin: "hsl(25 5.3% 44.7%)", koyeb_sfo: "hsl(0 0% 45.1%)", koyeb_tyo: "hsl(0 0% 45.1%)", koyeb_was: "hsl(0 0% 45.1%)", "railway_asia-southeast1-eqsg3a": "hsl(0 0% 45.1%)", "railway_europe-west4-drams3a": "hsl(0 0% 45.1%)", "railway_us-east4-eqdc4a": "hsl(0 0% 45.1%)", "railway_us-west2": "hsl(0 0% 45.1%)", } satisfies Record<Region, string>; export function getRegionColor(region: string) { if (region in regionColors) { return regionColors[region as keyof typeof regionColors]; } return "hsl(0 0% 45.1%)"; } ================================================ FILE: apps/dashboard/src/data/response-logs.ts ================================================ import type { RouterOutputs } from "@openstatus/api"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; import { startOfDay } from "date-fns"; type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; const today = startOfDay(new Date()); export const exampleLogs: ResponseLog[] = Array.from({ length: 10 }).map( (_, i) => ({ id: i.toString(), type: "http", url: "https://api.openstatus.dev", method: "GET", statusCode: 200, requestStatus: "success" as const, latency: 150, timing: { dns: 10, connect: 20, tls: 30, ttfb: 40, transfer: 50, }, assertions: [], region: monitorRegions[i], error: false, timestamp: today.getTime() + i * 1000 * 60, headers: { "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", "Content-Type": "text/html; charset=utf-8", Date: "Sun, 28 Jan 2024 08:50:13 GMT", Server: "Vercel", }, workspaceId: "1", monitorId: "1", cronTimestamp: today.getTime() + i * 1000 * 60, trigger: "cron" as const satisfies "cron" | "api", }), ); ================================================ FILE: apps/dashboard/src/data/status-codes.ts ================================================ export const statusCodes = [ { code: 200 as const, bg: "bg-success", text: "text-success", name: "OK", }, { code: 500 as const, bg: "bg-destructive", text: "text-destructive", name: "Internal Server Error", }, ]; export type StatusCode = (typeof statusCodes)[number]["code"]; export const getStatusCodeVariant = (code?: number | null) => { if (!code) return "muted"; if (code.toString().startsWith("2")) return "success"; if (code.toString().startsWith("3")) return "info"; if (code.toString().startsWith("4")) return "warning"; if (code.toString().startsWith("5")) return "destructive"; return "muted"; }; export const bgColors = { success: "bg-success", info: "bg-info", warning: "bg-warning", destructive: "bg-destructive", muted: "bg-muted", }; export const textColors = { success: "text-success", info: "text-info", warning: "text-warning", destructive: "text-destructive", muted: "text-muted-foreground", }; ================================================ FILE: apps/dashboard/src/data/status-pages.client.ts ================================================ import { Cog, Copy, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "copy-id", label: "Copy ID", icon: Copy, variant: "default" as const, }, // { // id: "create-badge", // label: "Create Badge", // icon: Tag, // variant: "default" as const, // }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusPageAction = (typeof actions)[number]; export const getActions = ( props: Partial<Record<StatusPageAction["id"], () => Promise<void> | void>>, ): (StatusPageAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/status-pages.ts ================================================ export const statusPages = [ { id: 1, name: "OpenStatus Status", description: "See our uptime history and status reports.", slug: "status", favicon: "https://openstatus.dev/favicon.ico", domain: "status.openstatus.dev", protected: true, showValues: false, // NOTE: the worst status of a report status: "degraded" as const, monitors: [], }, ]; export type StatusPage = (typeof statusPages)[number]; ================================================ FILE: apps/dashboard/src/data/status-report-updates.client.ts ================================================ import type { StatusReportStatus } from "@openstatus/db/src/schema"; import { Cog, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusReportUpdateAction = (typeof actions)[number]; export const getActions = ( props: Partial< Record<StatusReportUpdateAction["id"], () => Promise<void> | void> >, ): (StatusReportUpdateAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; export const colors = { resolved: "text-success/80 data-[state=selected]:bg-success/10 data-[state=selected]:text-success", investigating: "text-destructive/80 data-[state=selected]:bg-destructive/10 data-[state=selected]:text-destructive", monitoring: "text-info/80 data-[state=selected]:bg-info/10 data-[state=selected]:text-info", identified: "text-warning/80 data-[state=selected]:bg-warning/10 data-[state=selected]:text-warning", } as const satisfies Record<StatusReportStatus, string>; /** * Get the next status in the progression: * investigating → identified → monitoring → resolved * * @param currentStatus - The current status * @returns The next status in the progression, or 'resolved' if already at the end, or 'investigating' for invalid statuses */ export function getNextStatus(currentStatus: string): StatusReportStatus { const statusProgression: Record<StatusReportStatus, StatusReportStatus> = { investigating: "identified", identified: "monitoring", monitoring: "resolved", resolved: "resolved", }; return ( statusProgression[currentStatus as StatusReportStatus] ?? "investigating" ); } ================================================ FILE: apps/dashboard/src/data/status-reports.client.ts ================================================ import { Cog, Eye, Plus, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Settings", icon: Cog, variant: "default" as const, }, { id: "create-update", label: "Create Update", icon: Plus, variant: "default" as const, }, { id: "view-report", label: "View Report", icon: Eye, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusReportUpdateAction = (typeof actions)[number]; export const getActions = ( props: Partial< Record<StatusReportUpdateAction["id"], () => Promise<void> | void> >, ): (StatusReportUpdateAction & { onClick?: () => Promise<void> | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/dashboard/src/data/status-reports.ts ================================================ export const statusReports = [ { id: 1, name: "Downtime API", startedAt: new Date("2025-06-07 12:00:00"), updatedAt: new Date("2025-06-07 12:30:00"), status: "operational", updates: [ { id: 2, status: "operational" as const, message: "Everything is under control, we continue to monitor the situation.", date: new Date("2025-06-07 12:30:00"), updatedAt: new Date("2025-06-07 12:30:00"), monitors: [1], }, { id: 1, status: "investigating" as const, message: "Our hosting provider is having an increase of 400 errors. We are aware of the dependency and will be working on a solution to reduce the risk.", date: new Date("2025-06-07 12:00:00"), updatedAt: new Date("2025-06-07 12:00:00"), monitors: [1], }, ], affected: ["OpenStatus API"], }, { id: 2, name: "Downtime API", startedAt: new Date("2025-06-04 12:10:00"), updatedAt: new Date("2025-06-04 12:30:00"), status: "operational", updates: [ { id: 2, status: "operational" as const, message: "Everything is under control, we continue to monitor the situation.", date: new Date("2025-06-04 12:30:00"), updatedAt: new Date("2025-06-04 12:30:00"), monitors: [1], }, { id: 1, status: "investigating" as const, message: "Our hosting provider is having an increase of 400 errors. We are working on a solution to reduce the risk.", date: new Date("2025-06-04 12:00:00"), updatedAt: new Date("2025-06-04 12:00:00"), monitors: [1], }, ], affected: ["OpenStatus API"], }, ]; export type StatusReport = (typeof statusReports)[number]; ================================================ FILE: apps/dashboard/src/data/subscribers.ts ================================================ export const subscribers = [ { id: "1", email: "max@openstatus.dev", createdAt: "2025-05-20", validatedAt: "2025-05-20", }, { id: "2", email: "thibault@openstatus.dev", createdAt: "2025-05-20", validatedAt: "2025-05-20", }, ]; export type Subscriber = (typeof subscribers)[number]; ================================================ FILE: apps/dashboard/src/hooks/use-feature.ts ================================================ import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; /** * Record<feature, [workspaceId, ...]> */ const features = { "slack-agent": [1, 6850], }; export function useFeature(feature: keyof typeof features) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); if (!workspace) return false; return features[feature]?.includes(workspace.id) ?? false; } ================================================ FILE: apps/dashboard/src/hooks/use-telegram-connection.ts ================================================ "use client"; import type { FormValues } from "@/components/forms/notifications/form-telegram"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import React, { useReducer, useTransition } from "react"; import type { UseFormReturn } from "react-hook-form"; import { toast } from "sonner"; interface UseTelegramConnectionProps { form: UseFormReturn<FormValues>; mode: "qr" | "manual" | null; } interface TelegramConnectionState { flowStep: "private" | "group"; privateChatId: string | null; userName: string | null; groupTitle: string | null; sessionStartTime: number | null; } type TelegramConnectionAction = | { type: "SET_SESSION_START_TIME"; payload: number | null } | { type: "RESET_STATE" } | { type: "RESET_GROUP_CONNECTION" } | { type: "SET_PRIVATE_CONNECTION_DATA"; payload: { privateChatId: string; userName: string; }; } | { type: "SET_GROUP_CONNECTION_DATA"; payload: { groupTitle: string; chatId: string; }; }; const initialState: TelegramConnectionState = { flowStep: "private", privateChatId: null, userName: null, groupTitle: null, sessionStartTime: null, }; function telegramConnectionReducer( state: TelegramConnectionState, action: TelegramConnectionAction, ): TelegramConnectionState { switch (action.type) { case "SET_SESSION_START_TIME": return { ...state, sessionStartTime: action.payload }; case "RESET_STATE": return initialState; case "RESET_GROUP_CONNECTION": return { ...state, groupTitle: null, sessionStartTime: Math.floor(Date.now() / 1000), flowStep: state.privateChatId ? "group" : "private", }; case "SET_PRIVATE_CONNECTION_DATA": return { ...state, privateChatId: action.payload.privateChatId, userName: action.payload.userName, flowStep: "group", }; case "SET_GROUP_CONNECTION_DATA": return { ...state, groupTitle: action.payload.groupTitle, }; default: return state; } } export function useTelegramConnection({ form, mode, }: UseTelegramConnectionProps) { const [isPending, startTransition] = useTransition(); const trpc = useTRPC(); const [state, dispatch] = useReducer(telegramConnectionReducer, initialState); // Create Telegram Token const { data: tokenData, isLoading: isTokenLoading } = useQuery({ ...trpc.notification.createTelegramToken.queryOptions(), refetchOnWindowFocus: false, }); // Set session start time when entering QR mode React.useEffect(() => { if (mode === "qr") { dispatch({ type: "SET_SESSION_START_TIME", payload: Math.floor(Date.now() / 1000), }); } else if (mode === null) { dispatch({ type: "SET_SESSION_START_TIME", payload: null }); } }, [mode]); // Cleanup: Reset UI state when component unmounts (e.g., on discard) React.useEffect(() => { return () => { // This runs when component unmounts dispatch({ type: "RESET_STATE" }); }; }, []); // Start polling for updates const { data: updates } = useQuery({ ...trpc.notification.getTelegramUpdates.queryOptions({ privateChatId: state.flowStep === "group" ? state.privateChatId ?? undefined : undefined, since: state.sessionStartTime ?? undefined, }), enabled: !!tokenData?.token && !form.getValues("data.chatId") && mode === "qr", refetchInterval: 5000, }); React.useEffect(() => { if (updates && updates.length > 0) { const lastUpdate = updates[updates.length - 1]; // Phase 1: Private chat ID received if (lastUpdate.chatType === "private" && state.flowStep === "private") { dispatch({ type: "SET_PRIVATE_CONNECTION_DATA", payload: { privateChatId: lastUpdate.chatId, userName: lastUpdate.user?.first_name || "Unknown", }, }); toast.success( `Connected to ${lastUpdate.user?.first_name || "Unknown"}'s account. Now add the bot to your group.`, ); } // Phase 2: Group chat ID received else if (lastUpdate.chatType === "group" && state.flowStep === "group") { dispatch({ type: "SET_GROUP_CONNECTION_DATA", payload: { groupTitle: lastUpdate.chatTitle || "Unknown", chatId: lastUpdate.chatId, }, }); startTransition(() => { form.setValue("data.chatId", lastUpdate.chatId, { shouldDirty: true, }); toast.success( `Connected to group "${lastUpdate.chatTitle || "Unknown"}"`, ); }); } } }, [updates, form, state.flowStep]); const resetConnection = React.useCallback(() => { form.setValue("data.chatId", "", { shouldDirty: true }); dispatch({ type: "RESET_GROUP_CONNECTION" }); }, [form]); const confirmPrivateChat = React.useCallback(() => { if (state.privateChatId) { startTransition(() => { form.setValue("data.chatId", state.privateChatId ?? "", { shouldDirty: true, }); toast.success( `Connected to ${state.userName || "Unknown"}'s private chat`, ); }); } }, [form, state.privateChatId, state.userName]); return { tokenData, isTokenLoading, flowStep: state.flowStep, privateChatId: state.privateChatId, userName: state.userName, groupTitle: state.groupTitle, isPolling: !!tokenData?.token && !form.watch("data.chatId") && mode === "qr", resetConnection, confirmPrivateChat, isPending, }; } ================================================ FILE: apps/dashboard/src/instrumentation.ts ================================================ import * as Sentry from "@sentry/nextjs"; export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { await import("../sentry.server.config"); } if (process.env.NEXT_RUNTIME === "edge") { await import("../sentry.edge.config"); } } export const onRequestError = Sentry.captureRequestError; ================================================ FILE: apps/dashboard/src/lib/auth/adapter.ts ================================================ import { DrizzleAdapter } from "@auth/drizzle-adapter"; import type { Adapter } from "next-auth/adapters"; import { db } from "@openstatus/db"; import { account, session, user, verificationToken, } from "@openstatus/db/src/schema"; import { createUser, getUser } from "./helpers"; export const adapter: Adapter = { ...DrizzleAdapter(db, { // @ts-expect-error: problem with type usersTable: user, // @ts-expect-error: problem with type accountsTable: account, // @ts-expect-error: problem with type sessionsTable: session, verificationTokensTable: verificationToken, }), createUser: async (data) => { const user = await createUser(data); return { ...user, id: user.id.toString(), email: user.email || "", }; }, getUser: async (id) => { const user = await getUser(id); if (!user) return null; return { ...user, id: user.id.toString(), email: user.email || "", }; }, }; ================================================ FILE: apps/dashboard/src/lib/auth/helpers.ts ================================================ import type { AdapterUser } from "next-auth/adapters"; import * as randomWordSlugs from "random-word-slugs"; import { db, eq } from "@openstatus/db"; import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; export async function createUser(data: AdapterUser) { const newUser = await db .insert(user) .values({ email: data.email, photoUrl: data.image, name: data.name, firstName: data.firstName, lastName: data.lastName, }) .returning() .get(); let slug: string | undefined = undefined; while (!slug) { slug = randomWordSlugs.generateSlug(2); const slugAlreadyExists = await db .select() .from(workspace) .where(eq(workspace.slug, slug)) .get(); if (slugAlreadyExists) { console.warn(`slug already exists: '${slug} - recreating new one'`); slug = undefined; } } const newWorkspace = await db .insert(workspace) .values({ slug, name: "" }) .returning({ id: workspace.id }) .get(); await db .insert(usersToWorkspaces) .values({ userId: newUser.id, workspaceId: newWorkspace.id, role: "owner", }) .returning() .get(); return newUser; } export async function getUser(id: string) { const _user = await db .select() .from(user) .where(eq(user.id, Number(id))) .get(); return _user || null; } ================================================ FILE: apps/dashboard/src/lib/auth/index.ts ================================================ import type { DefaultSession } from "next-auth"; import NextAuth from "next-auth"; import { Events, setupAnalytics } from "@openstatus/analytics"; import { db, eq } from "@openstatus/db"; import { user } from "@openstatus/db/src/schema"; import { WelcomeEmail, sendEmail } from "@openstatus/emails"; import { headers } from "next/headers"; import { adapter } from "./adapter"; import { GitHubProvider, GoogleProvider, ResendProvider } from "./providers"; export type { DefaultSession }; export const { handlers, signIn, signOut, auth } = NextAuth({ // debug: true, adapter, providers: process.env.NODE_ENV === "development" || process.env.SELF_HOST === "true" ? [GitHubProvider, GoogleProvider, ResendProvider] : [GitHubProvider, GoogleProvider], callbacks: { async signIn(params) { // We keep updating the user info when we loggin in if (params.account?.provider === "google") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; await db .update(user) .set({ firstName: params.profile.given_name, lastName: params.profile.family_name || "", photoUrl: params.profile.picture, // keep the name in sync name: `${params.profile.given_name} ${ params.profile.family_name || "" }`.trim(), updatedAt: new Date(), }) .where(eq(user.id, Number(params.user.id))) .run(); } if (params.account?.provider === "github") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; await db .update(user) .set({ name: params.profile.name, photoUrl: String(params.profile.avatar_url), updatedAt: new Date(), }) .where(eq(user.id, Number(params.user.id))) .run(); } // REMINDER: only used in dev mode if (params.account?.provider === "resend") { if (Number.isNaN(Number(params.user.id))) return true; await db .update(user) .set({ updatedAt: new Date() }) .where(eq(user.id, Number(params.user.id))) .run(); } return true; }, async session(params) { return params.session; }, }, events: { // That should probably done in the callback method instead async createUser(params) { if (!params.user.id || !params.user.email) { throw new Error("User id & email is required"); } // this means the user has already been created with clerk if (params.user.tenantId) return; await sendEmail({ from: "Thibault from OpenStatus <thibault@openstatus.dev>", subject: "Welcome to OpenStatus.", to: [params.user.email], react: WelcomeEmail(), }); const analytics = await setupAnalytics({ userId: `usr_${params.user.id}`, email: params.user.email, location: (await headers()).get("x-forwarded-for") ?? undefined, userAgent: (await headers()).get("user-agent") ?? undefined, }); await analytics.track(Events.CreateUser); }, async signIn(params) { if (params.isNewUser) return; if (!params.user.id || !params.user.email) return; const analytics = await setupAnalytics({ userId: `usr_${params.user.id}`, email: params.user.email, location: (await headers()).get("x-forwarded-for") ?? undefined, userAgent: (await headers()).get("user-agent") ?? undefined, }); await analytics.track(Events.SignInUser); }, }, pages: { signIn: "/login", newUser: "/onboarding", }, // basePath: "/api/auth", // default is `/api/auth` // secret: process.env.AUTH_SECRET, // default is `AUTH_SECRET` debug: process.env.NODE_ENV === "development", }); ================================================ FILE: apps/dashboard/src/lib/auth/providers.ts ================================================ import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; import Resend from "next-auth/providers/resend"; export const GitHubProvider = GitHub({ allowDangerousEmailAccountLinking: true, }); export const GoogleProvider = Google({ allowDangerousEmailAccountLinking: true, authorization: { params: { // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest prompt: "select_account", // scope: // "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", }, }, }); export const ResendProvider = Resend({ apiKey: undefined, // REMINDER: keep undefined to avoid sending emails async sendVerificationRequest(params) { console.log(""); console.log(`>>> Magic Link: ${params.url}`); console.log(""); }, }); ================================================ FILE: apps/dashboard/src/lib/composition.ts ================================================ import * as React from "react"; /** * A utility to compose multiple event handlers into a single event handler. * Run originalEventHandler first, then ourEventHandler unless prevented. */ function composeEventHandlers<E>( originalEventHandler?: (event: E) => void, ourEventHandler?: (event: E) => void, { checkForDefaultPrevented = true } = {}, ) { return function handleEvent(event: E) { originalEventHandler?.(event); if ( checkForDefaultPrevented === false || !(event as unknown as Event).defaultPrevented ) { return ourEventHandler?.(event); } }; } /** * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx */ type PossibleRef<T> = React.Ref<T> | undefined; /** * Set a given ref to a given value. * This utility takes care of different types of refs: callback refs and RefObject(s). */ function setRef<T>(ref: PossibleRef<T>, value: T) { if (typeof ref === "function") { return ref(value); } if (ref !== null && ref !== undefined) { ref.current = value; } } /** * A utility to compose multiple refs together. * Accepts callback refs and RefObject(s). */ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> { return (node) => { let hasCleanup = false; const cleanups = refs.map((ref) => { const cleanup = setRef(ref, node); if (!hasCleanup && typeof cleanup === "function") { hasCleanup = true; } return cleanup; }); // React <19 will log an error to the console if a callback ref returns a // value. We don't use ref cleanups internally so this will only happen if a // user's ref callback returns a value, which we only expect if they are // using the cleanup functionality added in React 19. if (hasCleanup) { return () => { for (let i = 0; i < cleanups.length; i++) { const cleanup = cleanups[i]; if (typeof cleanup === "function") { cleanup(); } else { setRef(refs[i], null); } } }; } }; } /** * A custom hook that composes multiple refs. * Accepts callback refs and RefObject(s). */ function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> { // eslint-disable-next-line react-hooks/exhaustive-deps return React.useCallback(composeRefs(...refs), refs); } export { composeEventHandlers, composeRefs, useComposedRefs }; ================================================ FILE: apps/dashboard/src/lib/domains.ts ================================================ export const getSubdomain = (name: string, apexName: string) => { if (name === apexName) return null; return name.slice(0, name.length - apexName.length - 1); }; export const getApexDomain = (url: string) => { let domain: string; try { domain = new URL(url).hostname; } catch (e) { console.error(e); return ""; } const parts = domain.split("."); if (parts.length > 2) { // if it's a subdomain (e.g. dub.vercel.app), return the last 2 parts return parts.slice(-2).join("."); } // if it's a normal domain (e.g. dub.sh), we return the domain return domain; }; export function extractDomain(url: string) { // Use URL constructor to parse try { if (url.trim() === "") return ""; const hostname = new URL(url).hostname; // e.g. "craft.mxkaske.dev" const parts = hostname.split("."); // ["craft", "mxkaske", "dev"] if (parts.length === 2) { // no subdomain return parts[0]; // "mxkaske" } if (parts.length > 2) { // has subdomain(s) return `${parts.slice(0, -2).join("-")}-${parts[parts.length - 2]}`; // "craft-mxkaske" } return ""; } catch (e) { console.error(e); return ""; } } ================================================ FILE: apps/dashboard/src/lib/formatter.ts ================================================ import { endOfDay, isSameDay, startOfDay } from "date-fns"; export function formatMilliseconds(ms: number) { if (ms > 1000) { return `${Intl.NumberFormat("en-US", { style: "unit", unit: "second", maximumFractionDigits: 2, }).format(ms / 1000)}`; } return `${Intl.NumberFormat("en-US", { style: "unit", unit: "millisecond", }).format(ms)}`; } export function formatPercentage(value: number) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value)}`; } export function formatNumber(value: number) { return `${Intl.NumberFormat("en-US").format(value)}`; } // TODO: think of supporting custom formats export function formatDate(date: Date) { return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); } export function formatDateTime(date: Date) { return date.toLocaleDateString("en-US", { month: "long", day: "numeric", hour: "numeric", minute: "numeric", }); } export function formatTime(date: Date) { return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric", }); } export function formatDateRange(from?: Date, to?: Date) { const sameDay = from && to && isSameDay(from, to); const isFromStartDay = from && startOfDay(from).getTime() === from.getTime(); const isToEndDay = to && endOfDay(to).getTime() === to.getTime(); if (sameDay) { if (from && to) { return `${formatDateTime(from)} - ${formatTime(to)}`; } } if (from && to) { if (isFromStartDay && isToEndDay) { return `${formatDate(from)} - ${formatDate(to)}`; } return `${formatDateTime(from)} - ${formatDateTime(to)}`; } if (to) { return `Until ${formatDateTime(to)}`; } if (from) { return `Since ${formatDateTime(from)}`; } return "All time"; } export function formatDateForInput(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; } ================================================ FILE: apps/dashboard/src/lib/middleware/with-invitation.ts ================================================ ================================================ FILE: apps/dashboard/src/lib/stripe.ts ================================================ import type { Stripe as StripeProps } from "@stripe/stripe-js"; import { loadStripe } from "@stripe/stripe-js"; let stripePromise: Promise<StripeProps | null>; export const getStripe = () => { if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { throw new Error("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set"); } if (!stripePromise) { stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); } return stripePromise; }; ================================================ FILE: apps/dashboard/src/lib/trpc/client.tsx ================================================ "use client"; import { endingLink } from "@/lib/trpc/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createTRPCClient, loggerLink } from "@trpc/client"; import { createTRPCContext } from "@trpc/tanstack-react-query"; import { useState } from "react"; import type { AppRouter } from "@openstatus/api"; export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>(); function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }); } let browserQueryClient: QueryClient | undefined = undefined; function getQueryClient() { if (typeof window === "undefined") { // Server: always make a new query client return makeQueryClient(); } // Browser: make a new query client if we don't already have one // This is very important, so we don't re-make a new client if React // suspends during the initial render. This may not be needed if we // have a suspense boundary BELOW the creation of the query client if (!browserQueryClient) browserQueryClient = makeQueryClient(); return browserQueryClient; } export function TRPCReactProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient(); const [trpcClient] = useState(() => createTRPCClient<AppRouter>({ links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), endingLink({ headers: { "x-trpc-source": "client", }, }), ], }), ); return ( <QueryClientProvider client={queryClient}> <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}> {children} </TRPCProvider> </QueryClientProvider> ); } ================================================ FILE: apps/dashboard/src/lib/trpc/query-client.ts ================================================ import { QueryClient, defaultShouldDehydrateQuery, } from "@tanstack/react-query"; export function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", }, hydrate: {}, }, }); } ================================================ FILE: apps/dashboard/src/lib/trpc/server.tsx ================================================ import "server-only"; import type { AppRouter } from "@openstatus/api"; import { HydrationBoundary } from "@tanstack/react-query"; import { dehydrate } from "@tanstack/react-query"; import { createTRPCClient, loggerLink } from "@trpc/client"; import { type TRPCQueryOptions, createTRPCOptionsProxy, } from "@trpc/tanstack-react-query"; import { cookies } from "next/headers"; import { cache } from "react"; import { makeQueryClient } from "./query-client"; import { endingLink } from "./shared"; // IMPORTANT: Create a stable getter for the query client that // will return the same client during the same request. export const getQueryClient = cache(makeQueryClient); export const trpc = createTRPCOptionsProxy<AppRouter>({ queryClient: getQueryClient, client: createTRPCClient({ links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), endingLink({ headers: { "x-trpc-source": "server", }, fetch: async (url, options) => { const cookieStore = await cookies(); return fetch(url, { ...options, credentials: "include", headers: { ...options?.headers, cookie: cookieStore.toString(), }, }); }, }), ], }), }); export function HydrateClient(props: { children: React.ReactNode }) { const queryClient = getQueryClient(); return ( <HydrationBoundary state={dehydrate(queryClient)}> {props.children} </HydrationBoundary> ); } // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>( queryOptions: T, ) { const queryClient = getQueryClient(); if (queryOptions.queryKey[1]?.type === "infinite") { // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any void queryClient.prefetchInfiniteQuery(queryOptions as any); } else { void queryClient.prefetchQuery(queryOptions); } } // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<any>>>( queryOptionsArray: T[], ) { const queryClient = getQueryClient(); for (const queryOptions of queryOptionsArray) { if (queryOptions.queryKey[1]?.type === "infinite") { // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any void queryClient.prefetchInfiniteQuery(queryOptions as any); } else { void queryClient.prefetchQuery(queryOptions); } } } ================================================ FILE: apps/dashboard/src/lib/trpc/shared.ts ================================================ import type { HTTPBatchLinkOptions, HTTPHeaders, TRPCLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@openstatus/api"; import superjson from "superjson"; const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // Note: dashboard has its own tRPC API routes if (process.env.VERCEL_URL) return "https://app.openstatus.dev"; // Vercel return "http://localhost:3000"; // Local dev and Docker (internal calls) }; const lambdas = [ "stripeRouter", "emailRouter", "apiKeyRouter", "integrationRouter", ]; export const endingLink = (opts?: { fetch?: typeof fetch; headers?: HTTPHeaders | (() => HTTPHeaders | Promise<HTTPHeaders>); }) => ((runtime) => { const sharedOpts = { headers: opts?.headers, fetch: opts?.fetch, transformer: superjson, // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any } satisfies Partial<HTTPBatchLinkOptions<any>>; const edgeLink = httpBatchLink({ ...sharedOpts, url: `${getBaseUrl()}/api/trpc/edge`, })(runtime); const lambdaLink = httpBatchLink({ ...sharedOpts, url: `${getBaseUrl()}/api/trpc/lambda`, })(runtime); return (ctx) => { const path = ctx.op.path.split(".") as [string, ...string[]]; const endpoint = lambdas.includes(path[0]) ? "lambda" : "edge"; const newCtx = { ...ctx, op: { ...ctx.op, path: path.join(".") }, }; return endpoint === "edge" ? edgeLink(newCtx) : lambdaLink(newCtx); }; }) satisfies TRPCLink<AppRouter>; ================================================ FILE: apps/dashboard/src/lib/utils.ts ================================================ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================================================ FILE: apps/dashboard/src/next-auth.d.ts ================================================ import type { User as DefaultUserSchema } from "@openstatus/db/src/schema"; declare module "next-auth" { // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface User extends DefaultUserSchema {} } ================================================ FILE: apps/dashboard/src/proxy.ts ================================================ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db, eq } from "@openstatus/db"; import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; export default auth(async (req) => { const url = req.nextUrl.clone(); const response = NextResponse.next(); const continent = req.headers.get("x-vercel-ip-continent") || "NA"; const country = req.headers.get("x-vercel-ip-country") || "US"; const currency = getCurrency({ continent, country }); // NOTE: used in the pricing table to display the currency based on user's location response.cookies.set("x-currency", currency); if (url.pathname.includes("api/trpc")) { return response; } if (!req.auth && url.pathname !== "/login") { console.log("User not authenticated, redirecting to login"); const newURL = new URL("/login", req.url); const encodedSearchParams = `${url.pathname}${url.search}`; if (encodedSearchParams) { newURL.searchParams.append("redirectTo", encodedSearchParams); } return NextResponse.redirect(newURL); } if (req.auth && url.pathname === "/login") { const redirectTo = url.searchParams.get("redirectTo"); console.log("User authenticated, redirecting to", redirectTo); if (redirectTo) { const redirectToUrl = new URL(redirectTo, req.url); return NextResponse.redirect(redirectToUrl); } } const hasWorkspaceSlug = req.cookies.has("workspace-slug"); if (req.auth?.user?.id && !hasWorkspaceSlug) { const [query] = await db .select() .from(usersToWorkspaces) .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) .where(eq(user.id, Number.parseInt(req.auth.user.id))) .all(); if (!query) { console.error(">> Should not happen, no workspace found for user"); } response.cookies.set("workspace-slug", query.workspace.slug); } if (!req.auth && hasWorkspaceSlug) { response.cookies.delete("workspace-slug"); } return response; }); export const config = { matcher: [ "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", ], }; ================================================ FILE: apps/dashboard/src/scripts/README.md ================================================ # Export Blog Post Metrics Script This script exports monitor metrics data from OpenStatus for use in blog posts and documentation. ## Overview The script fetches monitor data directly from the database and Tinybird analytics, then exports it to a JSON file that can be used for visualizations in blog posts. **Features:** - Fetches metrics from both regular regions and private locations - Automatically combines public regions with private location data - Supports both HTTP and TCP monitors ## Configuration Edit the constants at the top of `export-blog-post-metrics.ts`: ```typescript const MONITOR_ID = "1"; // The ID of the monitor to export const PERIOD = "7d"; // Time period: "1d", "7d", or "14d" const INTERVAL = 60; // Interval in minutes for data points const TYPE = "http"; // Fallback monitor type: "http" or "tcp" (auto-detected from monitor) const OUTPUT_FILE = "blog-post-metrics.json"; // Output filename ``` **Note:** The script automatically detects the monitor type from the database, but you can set a fallback with the `TYPE` constant. ## Prerequisites 1. Make sure you have the `TINY_BIRD_API_KEY` environment variable set in your `.env` file 2. The database should be accessible (local or remote) 3. Install dependencies: `pnpm install` ## Usage > [!IMPORTANT] > Go to the `/tinybird/src/client.ts` file and make sure tb is **not using the NoopClient**. From the `apps/dashboard` directory: ```bash # Using the npm script pnpm export-metrics # Or directly with bun bun src/scripts/export-blog-post-metrics.ts ``` ## Output Format The script generates a JSON file with the following structure: ```json { "regions": ["ams", "fra", "lhr", ...], "data": { "regions": ["ams", "fra", "lhr", ...], "data": [ { "timestamp": "2025-08-18T16:00:00.000Z", "ams": 207, "fra": 142, "lhr": 327, ... } ] }, "metricsByRegions": [ { "region": "ams", "count": 1000, "ok": 995, "p50Latency": 150, "p75Latency": 200, "p90Latency": 250, "p95Latency": 300, "p99Latency": 400 } ] } ``` ## Data Fields - **regions**: Array of region codes and private location names for the monitor - **data.data**: Timeline data with latency values per region/location at each timestamp - **metricsByRegions**: Summary statistics per region/location including: - `count`: Total number of checks - `ok`: Number of successful checks - `p50Latency`, `p75Latency`, `p90Latency`, `p95Latency`, `p99Latency`: Latency percentiles in milliseconds **Note:** The script automatically includes both public Fly.io regions and any private locations connected to the monitor. ## Example: Moving to Web Assets To use the exported data in the web app (like the existing `hono-cold.json`): ```bash # After running the script cp blog-post-metrics.json ../web/public/assets/posts/your-blog-post/data.json ``` ## Troubleshooting **Error: "TINY_BIRD_API_KEY environment variable is required"** - Make sure you have the `TINY_BIRD_API_KEY` set in your `.env` file **Error: "Monitor with ID X not found"** - Verify the monitor ID exists in your database - Check that you're connected to the correct database **No data returned** - Ensure the monitor has been running and collecting data for the specified period - Try a different time period (e.g., "7d" instead of "1d") ================================================ FILE: apps/dashboard/src/scripts/export-blog-post-metrics.ts ================================================ import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { db, eq } from "@openstatus/db"; import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { OSTinybird } from "@openstatus/tinybird"; // WARNING: make sure to enable the Tinybird client in the env you are running this script in // Configuration const MONITOR_ID = "7002"; const PERIOD = "7d" as const; const INTERVAL = 60; const TYPE = "http" as const; const OUTPUT_FILE = "blog-post-metrics.json"; const PERCENTILE = "p50"; // p50, p75, p90, p95, p99 async function main() { // Get Tinybird API key from environment const tinybirdApiKey = process.env.TINY_BIRD_API_KEY; if (!tinybirdApiKey) { throw new Error("TINY_BIRD_API_KEY environment variable is required"); } const tb = new OSTinybird(tinybirdApiKey); console.log(`Fetching data for monitor ID: ${MONITOR_ID}`); // 1. Fetch monitor from database with private locations const monitorDataRaw = await db.query.monitor.findFirst({ where: eq(monitor.id, Number.parseInt(MONITOR_ID)), with: { privateLocationToMonitors: { with: { privateLocation: true, }, }, }, }); if (!monitorDataRaw) { throw new Error(`Monitor with ID ${MONITOR_ID} not found`); } // Parse the monitor data using the schema to convert regions string to array const monitorData = selectMonitorSchema.parse(monitorDataRaw); // Get private location names const privateLocationNames = monitorDataRaw.privateLocationToMonitors ?.map((pl) => pl.privateLocation?.name) .filter((name): name is string => Boolean(name)) || []; // Combine regular regions with private locations const allRegions = [...monitorData.regions, ...privateLocationNames]; console.log(`\nMonitor Details:`); console.log(` ID: ${MONITOR_ID}`); console.log(` Name: ${monitorData.name || "Unnamed"}`); console.log(` Type: ${monitorData.jobType}`); console.log(` Active: ${monitorData.active}`); console.log(` Created: ${monitorData.createdAt}`); console.log(` Regular regions: ${monitorData.regions.join(", ")}`); console.log( ` Private locations: ${privateLocationNames.join(", ") || "None"}` ); console.log(` Total regions: ${allRegions.length}`); console.log(`\nQuery Parameters:`); console.log(` Period: ${PERIOD}`); console.log(` Interval: ${INTERVAL} minutes`); // Use the monitor's actual type, or fall back to the configured TYPE const monitorType = (monitorData.jobType || TYPE) as "http" | "tcp"; // 2. Fetch metricsRegions (timeline data with region, timestamp, and quantiles) const metricsRegionsResult = monitorType === "http" ? PERIOD === "7d" ? await tb.httpMetricsRegionsWeekly({ monitorId: MONITOR_ID, interval: INTERVAL, }) : await tb.httpMetricsRegionsDaily({ monitorId: MONITOR_ID, interval: INTERVAL, }) : PERIOD === "7d" ? await tb.tcpMetricsByIntervalWeekly({ monitorId: MONITOR_ID, interval: INTERVAL, }) : await tb.tcpMetricsByIntervalDaily({ monitorId: MONITOR_ID, interval: INTERVAL, }); console.log( `\nFetched ${metricsRegionsResult.data.length} metrics regions data points` ); if (metricsRegionsResult.data.length > 0) { console.log( ` First data point:`, JSON.stringify(metricsRegionsResult.data[0], null, 2) ); console.log( ` Last data point:`, JSON.stringify( metricsRegionsResult.data[metricsRegionsResult.data.length - 1], null, 2 ) ); } else { console.log(` ⚠️ No data returned. This could mean:`); console.log(` - The monitor hasn't collected any data yet`); console.log(` - The monitor is inactive or was just created`); console.log( ` - There's no data in the selected time period (${PERIOD})` ); console.log( `\n 💡 Tip: Try querying without the interval parameter or using PERIOD="1d"` ); // Try without interval to see if that helps console.log(`\n Trying without interval parameter...`); const retryResult = monitorType === "http" ? PERIOD === "7d" ? await tb.httpMetricsRegionsWeekly({ monitorId: MONITOR_ID, }) : await tb.httpMetricsRegionsDaily({ monitorId: MONITOR_ID, }) : PERIOD === "7d" ? await tb.tcpMetricsByIntervalWeekly({ monitorId: MONITOR_ID, }) : await tb.tcpMetricsByIntervalDaily({ monitorId: MONITOR_ID, }); console.log(` Retry returned ${retryResult.data.length} data points`); if (retryResult.data.length > 0) { console.log(` ✅ Success! The interval parameter might be the issue.`); console.log( ` First data point:`, JSON.stringify(retryResult.data[0], null, 2) ); } } // 3. Fetch metricsByRegion (summary data by region) const metricsByRegionProcedure = monitorType === "http" ? PERIOD === "7d" ? tb.httpMetricsByRegionWeekly : tb.httpMetricsByRegionDaily : PERIOD === "7d" ? tb.tcpMetricsByRegionWeekly : tb.tcpMetricsByRegionDaily; const metricsByRegionsResult = await metricsByRegionProcedure({ monitorId: MONITOR_ID, }); console.log( `\nFetched ${metricsByRegionsResult.data.length} metrics by region data points` ); if (metricsByRegionsResult.data.length > 0) { console.log( ` Sample:`, JSON.stringify(metricsByRegionsResult.data.slice(0, 3), null, 2) ); } // 4. Transform metricsRegions data to match expected format // Group by timestamp and pivot regions as columns const timelineMap = new Map<number, Record<string, number | string>>(); for (const row of metricsRegionsResult.data) { const timestamp = row.timestamp; const region = row.region; const latency = row[`${PERCENTILE}Latency`] ?? 0; if (!timelineMap.has(timestamp)) { timelineMap.set(timestamp, { timestamp: new Date(timestamp).toISOString(), }); } const entry = timelineMap.get(timestamp)!; entry[region] = latency; } // Convert map to sorted array const timelineData = Array.from(timelineMap.values()).sort((a, b) => { const timeA = new Date(a.timestamp as string).getTime(); const timeB = new Date(b.timestamp as string).getTime(); return timeA - timeB; }); // 5. Build final output structure const output = { regions: allRegions, data: { regions: allRegions, data: timelineData, }, metricsByRegions: metricsByRegionsResult.data, }; // 6. Write to file const outputPath = resolve(process.cwd(), OUTPUT_FILE); writeFileSync(outputPath, JSON.stringify(output, null, 2)); console.log(`\n✅ Data exported successfully to: ${outputPath}`); console.log(`Total timeline entries: ${timelineData.length}`); console.log( `Total regions (including private locations): ${allRegions.length}` ); } // Run the script main().catch((error) => { console.error("Error:", error); process.exit(1); }); ================================================ FILE: apps/dashboard/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "strictNullChecks": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": ["node_modules", "env.ts"] } ================================================ FILE: apps/docs/.gitignore ================================================ # build output dist/ # generated types .astro/ # dependencies node_modules/ # logs npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # environment variables .env .env.production # macOS-specific files .DS_Store ================================================ FILE: apps/docs/README.md ================================================ # Starlight Starter Kit: Basics [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) ``` npm create astro@latest -- --template starlight ``` [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! ## 🚀 Project Structure Inside of your Astro + Starlight project, you'll see the following folders and files: ``` . ├── public/ ├── src/ │ ├── assets/ │ ├── content/ │ │ ├── docs/ │ │ └── config.ts │ └── env.d.ts ├── astro.config.mjs ├── package.json └── tsconfig.json ``` Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. Images can be added to `src/assets/` and embedded in Markdown with a relative link. Static assets, like favicons, can be placed in the `public/` directory. ## 🧞 Commands All commands are run from the root of the project, from a terminal: | Command | Action | | :------------------------ | :----------------------------------------------- | | `npm install` | Installs dependencies | | `npm run dev` | Starts local dev server at `localhost:4321` | | `npm run build` | Build your production site to `./dist/` | | `npm run preview` | Preview your build locally, before deploying | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | | `npm run astro -- --help` | Get help using the Astro CLI | ## 👀 Want to learn more? Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). ================================================ FILE: apps/docs/astro.config.mjs ================================================ import sitemap from "@astrojs/sitemap"; import starlight from "@astrojs/starlight"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, envField } from "astro/config"; import starlightImageZoom from "starlight-image-zoom"; import starlightLinksValidator from "starlight-links-validator"; import starlightLlmsTxt from "starlight-llms-txt"; import Icons from "unplugin-icons/vite"; // https://astro.build/config export default defineConfig({ site: "https://docs.openstatus.dev", vite: { plugins: [Icons({ compiler: "astro" }), tailwindcss()], ssr: { noExternal: ["zod"], }, }, env: { schema: { NEXT_PUBLIC_OPENPANEL_CLIENT_ID: envField.string({ access: "public", context: "client", }), }, }, integrations: [ sitemap(), starlight({ title: "openstatus docs", favicon: "/favicon.ico", social: [ { icon: "github", label: "GitHub", href: "https://github.com/openstatusHQ/openstatus", }, { icon: "discord", label: "Discord", href: "https://www.openstatus.dev/discord", }, { icon: "blueSky", label: "BlueSky", href: "https://bsky.app/profile/openstatus.dev", }, ], components: { SiteTitle: "./src/components/SiteTitle.astro", Head: "./src/components/Head.astro", Hero: "./src/components/Hero.astro", Footer: "./src/components/Footer.astro", }, editLink: { baseUrl: "https://github.com/openstatusHQ/openstatus/app/docs", }, customCss: [ // Path to your Tailwind base styles: "./src/global.css", "./src/custom.css", "@fontsource-variable/inter", ], sidebar: [ { label: "Concepts", items: [ { label: "About Uptime monitoring", slug: "concept/uptime-monitoring", }, { label: "Best Practices for Status Pages", slug: "concept/best-practices-status-page", }, { label: "Uptime Calculation and Values", slug: "concept/uptime-calculation-and-values", }, { label: "Uptime Monitoring as Code", slug: "concept/uptime-monitoring-as-code", }, { label: "Latency vs Response Time", slug: "concept/latency-vs-response-time", }, ], }, { label: "Tutorials", items: [ { label: "How to create a monitor", slug: "tutorial/how-to-create-monitor", }, { label: "How to create a status page", slug: "tutorial/how-to-create-status-page", }, { label: "How to configure a status page", slug: "tutorial/how-to-configure-status-page", }, { label: "How to create a private location (beta)", slug: "tutorial/how-to-create-private-location", }, { label: "Get Started with OpenStatus CLI", slug: "tutorial/get-started-with-openstatus-cli", }, { label: "How to set up the Slack Agent", slug: "tutorial/how-to-setup-slack-agent", }, ], }, { label: "Guides", items: [ { label: "Monitor your MCP Server", slug: "guides/how-to-monitor-mcp-server", }, { label: "Run check in GitHub Actions", slug: "guides/how-to-run-synthetic-test-github-action", }, { label: "Export Metrics to your OTLP Endpoint", slug: "guides/how-to-export-metrics-to-otlp-endpoint", }, { label: "How to Add an SVG Status Badge to your GitHub README", slug: "guides/how-to-add-svg-status-badge", }, { label: "How to use React Status Widget", slug: "guides/how-to-use-react-widget", }, { label: "How to deploy probes on Cloudflare Containers ", slug: "guides/how-to-deploy-probes-cloudflare-containers", }, { label: "How to self-host openstatus", slug: "guides/self-hosting-openstatus", }, { label: "Self host Status Page only", slug: "guides/self-host-status-page-only", }, ], }, { label: "SDK", items: [ { label: "Node SDK", autogenerate: { directory: "sdk/nodejs" }, collapsed: true, }, ], }, { label: "Reference", items: [ { label: "CLI Reference", slug: "reference/cli-reference", }, { label: "API Reference V1 - Deprecated", link: "https://api.openstatus.dev/v1", // badge: { text: 'External' }, attrs: { target: "_blank", }, }, { label: "API Reference V2", link: "https://api.openstatus.dev/openapi", // badge: { text: 'External' }, attrs: { target: "_blank", }, }, { label: "DNS Monitor", slug: "reference/dns-monitor", }, { label: "HTTP Monitor", slug: "reference/http-monitor", }, { label: "Incident", slug: "reference/incident", }, { label: "TCP Monitor", slug: "reference/tcp-monitor", }, { label: "Notification", slug: "reference/notification", }, { label: "Location", slug: "reference/location", }, { label: "Private location", slug: "reference/private-location", }, { label: "Status Page", slug: "reference/status-page", }, { label: "Page Components", slug: "reference/page-components", }, { label: "Status Report", slug: "reference/status-report", }, { label: "Subscriber", slug: "reference/subscriber", }, { label: "Terraform Provider", slug: "reference/terraform", }, ], }, ], plugins: [ starlightLinksValidator({ errorOnLocalLinks: false, }), starlightLlmsTxt({ projectName: "openstatus docs", description: "openstatus is an open-source status page platform with global monitoring (HTTP, TCP, DNS).", }), starlightImageZoom(), ], }), ], }); ================================================ FILE: apps/docs/package.json ================================================ { "name": "@openstatus/docs", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro dev", "start": "astro dev", "build": "astro check && astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "@astrojs/check": "0.9.6", "@astrojs/react": "4.4.2", "@astrojs/sitemap": "3.6.0", "@astrojs/starlight": "0.37.1", "@astrojs/starlight-tailwind": "4.0.2", "@fontsource-variable/inter": "5.2.8", "@openpanel/astro": "1.0.1", "@tailwindcss/vite": "4.1.8", "astro": "5.16.6", "shiki": "3.23.0", "sharp": "0.33.5", "starlight-image-zoom": "0.13.2", "starlight-links-validator": "0.19.2", "starlight-llms-txt": "0.7.0", "starlight-showcases": "0.3.1", "starlight-sidebar-topics": "0.6.2", "tailwindcss": "4.1.11", "unplugin-icons": "22.1.0" }, "devDependencies": { "@iconify-json/lucide": "1.2.26", "typescript": "5.9.3" } } ================================================ FILE: apps/docs/public/robots.txt ================================================ # Allow all crawlers User-agent: * Allow: / # Sitemap location Sitemap: https://docs.openstatus.dev/sitemap-index.xml ================================================ FILE: apps/docs/src/components/Footer.astro ================================================ --- import EditLink from "@astrojs/starlight/components/EditLink.astro"; import LastUpdated from "@astrojs/starlight/components/LastUpdated.astro"; import Pagination from "@astrojs/starlight/components/Pagination.astro"; --- <footer class="sl-flex"> <div class="meta sl-flex"> <EditLink {...Astro.props} /> <LastUpdated {...Astro.props} /> </div> <Pagination {...Astro.props} /> <div class="github font-mono"> <span>Show your support! Star us on GitHub ⭐️</span> <a class="github-button" href="https://github.com/openstatusHQ/openstatus" data-color-scheme="no-preference: light; light: light; dark: light;" data-icon="octicon-star" data-size="large" data-show-count="true" aria-label="Star openstatusHQ/openstatus on GitHub">Star</a> </div> <div class="flex items-center justify-center"> <a href="https://status.openstatus.dev" target="_blank" > <img src='https://status.openstatus.dev/badge/v2?variant=outline'> </a> </div> </footer> <style> footer { flex-direction: column; gap: 1.5rem; } .meta { gap: 0.75rem 3rem; justify-content: space-between; flex-wrap: wrap; margin-top: 3rem; font-size: var(--sl-text-sm); color: var(--sl-color-gray-3); } .meta > :global(p:only-child) { margin-inline-start: auto; } .github { align-items: center; justify-content: center; gap: 0.5em; margin: 2rem auto; font-size: var(--sl-text-sm); text-decoration: none; display: flex; flex-direction: column; } .github span { font-size: var(--sl-text-sm); cursor: default; } </style> ================================================ FILE: apps/docs/src/components/Head.astro ================================================ --- import { NEXT_PUBLIC_OPENPANEL_CLIENT_ID } from "astro:env/client"; import Default from "@astrojs/starlight/components/Head.astro"; import { OpenPanelComponent } from "@openpanel/astro"; const title = Astro.locals.starlightRoute.entry.data.title; const { siteTitle } = Astro.locals.starlightRoute; const url = `https://openstatus.dev/api/og?title=${encodeURIComponent(siteTitle)}&description=${encodeURIComponent(title)}`; --- <Default><slot /></Default> <meta property="og:image" content={url} /> <meta name="twitter:image" content={url} /> <script is:inline defer data-domain="docs.openstatus.dev" src="https://plausible.io/js/script.js" /> <!-- REMINDER: prevent unexpected font flashes for our 'OpenStatus' logo on each page load --> <link rel="preload" href="/fonts/CalSans-SemiBold.ttf" as="font" type="font/ttf" crossorigin> <link rel="preload" href="/fonts/CommitMono-400-Regular.otf" as="font" type="font/otf" crossorigin> <link rel="preload" href="/fonts/CommitMono-700-Regular.otf" as="font" type="font/otf" crossorigin> <script is:inline defer async src="https://buttons.github.io/buttons.js" /> <OpenPanelComponent clientId={NEXT_PUBLIC_OPENPANEL_CLIENT_ID!} trackScreenViews trackOutgoingLinks trackAttributes /> ================================================ FILE: apps/docs/src/components/Hero.astro ================================================ --- const { data } = Astro.locals.starlightRoute.entry; const { title = data.title, tagline, actions = [] } = data.hero || {}; import { LinkButton } from "@astrojs/starlight/components"; --- <div class="flex w-full flex-col justify-center gap-1 px-3 py-4 text-center md:p-6" > <div class="flex flex-col gap-6"> <h1 class="font-bold text-4xl md:text-6xl" data-page-title set:html={title} /> { tagline && ( <h2 class="mx-auto font-normal max-w-md text-lg text-muted-foreground md:max-w-xl md:text-xl" set:html={tagline} /> ) } </div> <div class="my-4 grid gap-2 sm:grid-cols-2"> <div class="text-center sm:block sm:text-left"> { actions.length > 0 && ( <div class="sl-flex actions"> {actions.map( ({ attrs: { class: className, ...attrs } = {}, icon, link: href, text, variant }) => ( <LinkButton {href} {variant} icon={icon?.name} class:list={[className]} {...attrs}> {text} {icon?.html && <Fragment set:html={icon.html} />} </LinkButton> ) )} </div> ) } </div> </div> </div> ================================================ FILE: apps/docs/src/components/SiteTitle.astro ================================================ --- import { Image } from "astro:assets"; import logo from "../assets/icon.png"; // Image is 1600x900 --- <a href="/" class="flex items-center gap-2 font-bold no-underline text-black dark:text-white text-lg font-cal"> <Image src={logo} alt="OpenStatus" height={30} width={30} class="rounded-full border border-border bg-transparent " /> openstatus </a> ================================================ FILE: apps/docs/src/components/Status.astro ================================================ --- import { getStatus, statusDictionary } from "./utils"; const { status } = await getStatus("openstatus"); const { label, color } = statusDictionary[status]; --- <a class="inline-flex max-w-fit items-center gap-2 rounded-md border border-gray-200 px-3 py-1 text-gray-700 text-sm hover:bg-gray-100 hover:text-black dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white no-underline" href="https://status.openstatus.dev" target="_blank" rel="noreferrer" > {label} <span class="relative flex h-2 w-2"> {status === "operational" ? ( <span class={`absolute inline-flex h-full w-full animate-ping rounded-full ${color} opacity-75 duration-1000`} /> ) : null} <span class={`relative inline-flex h-2 w-2 rounded-full ${color}`} /> </span> </a> ================================================ FILE: apps/docs/src/components/utils.ts ================================================ export type Status = | "operational" | "degraded_performance" | "partial_outage" | "major_outage" | "under_maintenance" | "unknown" | "incident"; type StatusResponse = { status: Status }; export const statusDictionary: Record< Status, { label: string; color: string } > = { operational: { label: "Operational", color: "bg-green-500", }, degraded_performance: { label: "Degraded Performance", color: "bg-yellow-500", }, partial_outage: { label: "Partial Outage", color: "bg-yellow-500", }, major_outage: { label: "Major Outage", color: "bg-red-500", }, unknown: { label: "Unknown", color: "bg-gray-500", }, incident: { label: "Incident", color: "bg-yellow-500", }, under_maintenance: { label: "Under Maintenance", color: "bg-blue-500", }, } as const; export async function getStatus(slug: string): Promise<StatusResponse> { const res = await fetch(`https://api.openstatus.dev/public/status/${slug}`); if (res.ok) { const data = (await res.json()) as StatusResponse; return data; } return { status: "unknown" }; } ================================================ FILE: apps/docs/src/content/config.ts ================================================ import { defineCollection } from "astro:content"; import { docsLoader } from "@astrojs/starlight/loaders"; import { docsSchema } from "@astrojs/starlight/schema"; // import { glob } from "astro/loaders"; export const collections = { docs: defineCollection({ loader: docsLoader(), // loader: glob({ pattern: "**/*.mdx", base: "./src/content/docs" }), schema: docsSchema(), }), }; ================================================ FILE: apps/docs/src/content/docs/404.md ================================================ --- title: Not Found template: splash editUrl: false hero: tagline: This page could not be found. actions: [] pagefind: false sidebar: hidden: true draft: false --- ================================================ FILE: apps/docs/src/content/docs/concept/best-practices-status-page.mdx ================================================ --- title: Building Trust with Status Pages description: "Understanding how to communicate effectively during incidents and build user trust through transparency." --- ``` +------------------------------------------------+ | openstatus Status Page | +------------------------------------------------+ | Service Name | Status | Uptime | +-------------------+--------+-------------------+ | Web Server | ✅ OK | 99.9% | | Database | ✅ OK | 99.8% | | API Gateway | ⚠️ Degraded | 99.5% | | Monitoring | ✅ OK | 100% | | Payment Processing| ✅ OK | 99.7% | +-------------------+--------+-------------------+ | Incidents: | | | | - Degraded performance on API Gateway due | | to high traffic. Our team is investigating.| +------------------------------------------------+ ``` ## The purpose of a status page A status page is more than just a dashboard of green lights. It's a critical tool for communication and a cornerstone of building trust with your users. Its primary purpose is to provide a single, authoritative source of truth about your service's health and any ongoing incidents. When done right, a status page: - **Reduces support burden**: Users can self-serve information about outages instead of contacting your team. - **Builds trust**: Proactive transparency, even when things go wrong, demonstrates accountability. - **Improves communication**: It provides a central and consistent channel for incident updates. - **Demonstrates professionalism**: It shows that you take reliability and user experience seriously. This article explores the principles that make a status page an effective tool for building trust. ## Principles of Effective Status Pages ### Maintain Transparency and Honesty A status page's effectiveness hinges on being a reliable source of truth. Be upfront about issues, even minor ones. Hiding problems erodes user trust and can lead to frustration and a higher support load. - **Communicate Clearly:** Use simple, non-technical language. Your users shouldn't need a technical dictionary to understand the impact of an issue. - **Be Timely:** Update the page as soon as an incident is confirmed. Provide regular, predictable updates throughout the resolution process, even if the only update is "we're still working on it." ### Automate Where Possible Manual updates during a high-stress outage are prone to error and can be slow. Automation ensures that your status page reflects reality quickly and accurately. - **Integrate Monitoring Tools:** Your status page should be directly connected to your internal monitoring and alerting systems. When a metric crosses a threshold (e.g., a high error rate), the status page can be updated automatically to reflect a degraded state. - **Use an API:** We provide APIs that allow you to programmatically update component statuses and post new incidents, integrating your status page into your incident response workflows. ### Provide Context-Rich Incident Communication When an incident occurs, a structured narrative helps users understand the situation. - **Start with the Impact:** Clearly and concisely state what the problem is from the user's perspective. For example, "Users are currently unable to log in." - **Explain the Cause (When Known):** Briefly explain the root cause if you've identified it. Transparency here is key. - **Outline Next Steps and ETA:** Explain what is being done to resolve the issue and provide an estimated time to resolution if possible. It's better to give a conservative estimate or no estimate than to give one you can't meet. A typical incident communication lifecycle looks like this: - **Investigating:** "We're currently investigating an issue affecting user logins." - **Identified:** "We've identified the root cause as a database connection issue and are working on a fix." - **Monitoring:** "A fix has been deployed, and we're monitoring the system to ensure stability." - **Resolved:** "The issue has been resolved. We will publish a post-mortem within 48 hours." ### Ensure Easy Accessibility Your status page is useless if no one can find it. - **Prominent Link:** Link to your status page from your application's footer, your main website, and your support documentation. - **Custom Domain:** Use a simple, memorable URL like `status.yourcompany.com`. ## Advanced Considerations for Deeper Trust ### Scheduled Maintenance Communicating planned downtime is just as important as communicating unexpected incidents. - Announce maintenance well in advance (e.g., at least 72 hours). - Display upcoming maintenance windows clearly on the status page. - Send reminders to subscribers before maintenance begins. ### Historical Data and Post-Mortems Demonstrate your commitment to reliability by being open about your track record. - Display historical uptime percentages (e.g., over the last 30/60/90 days). - Link to past incidents and their post-mortems. Being honest about past failures and what you've learned from them is a powerful trust-builder. ### Subscriber Notifications Allow users to opt-in to the level of communication they want. - Email notifications for new incidents and resolutions. - SMS for critical alerts (if applicable). - RSS/Atom feeds for users who want to integrate your status into their own monitoring. ## Common Pitfalls to Avoid 1. **Claiming unrealistic uptime**: Don't claim 100% uptime unless you can back it up. Honesty is better than perfection. 2. **Hiding or downplaying incidents**: Users will find out anyway. It's better they hear it from you. 3. **Using technical jargon**: Write for a broad audience, not just other engineers. 4. **Leaving users in the dark**: During an incident, regular updates are crucial, even if there's no new information. A simple "still investigating" is better than silence. 5. **Hosting your status page on the same infrastructure**: Your status page must be available even when your main service is down. ## Implementing with openstatus openstatus is designed to make implementing these principles straightforward: - **[Create a status page](/tutorial/how-to-create-status-page)** - Get set up in minutes. - **[Configure your page](/tutorial/how-to-configure-status-page)** - Customize its appearance to match your brand. - **[Understand uptime calculations](/concept/uptime-calculation-and-values)** - Be transparent about how you measure uptime. ## Next steps - **[Understanding uptime monitoring](/concept/uptime-monitoring)** - Learn more about monitoring what you communicate. - **[Status page reference](/reference/status-page)** - Dive into technical configuration options. ================================================ FILE: apps/docs/src/content/docs/concept/getting-started.mdx ================================================ --- title: Foundational Concepts description: "Understand uptime monitoring, latency vs response time, SLA calculations, and the design decisions behind OpenStatus." sidebar: label: Concepts Overview order: 1 --- ## Building a solid foundation This section of our documentation is dedicated to explanation. It's not about quick fixes or step-by-step instructions. Instead, it's here to help you build a deep understanding of uptime monitoring and the principles behind OpenStatus. A solid mental model will empower you to use our tools more effectively, make better decisions for your own systems, and communicate with your team and stakeholders with clarity and confidence. We'll explore the "why" behind the "what," covering core concepts, best practices, and the design philosophy that guides our development. ### Core Concepts Start here to grasp the fundamental building blocks of uptime monitoring. - **[Uptime Monitoring](/concept/uptime-monitoring)**: What is it, why does it matter, and how does it work? - **[Uptime Calculation and Values](/concept/uptime-calculation-and-values)**: A look under the hood at how uptime percentages are calculated and what they truly represent. - **[Latency vs Response Time](/concept/latency-vs-response-time)**: Untangle the difference between these two critical performance metrics. ### Best Practices & Philosophy Learn from experience and understand our approach to modern monitoring. - **[Building Trust with Status Pages](/concept/best-practices-status-page)**: How to communicate effectively during incidents and maintain user trust. - **[Uptime Monitoring as Code](/concept/uptime-monitoring-as-code)**: The why and how of managing your monitoring configuration in a GitOps workflow. Have questions or want to discuss these concepts? [Join our community](/help/support/) and share your thoughts! ================================================ FILE: apps/docs/src/content/docs/concept/latency-vs-response-time.mdx ================================================ --- title: Understanding Latency vs Response Time description: "Deep dive into the difference between latency and response time, and why both matter for monitoring" --- ## The confusion Latency and response time are often used interchangeably, but they measure different things. Understanding the distinction is crucial for effective monitoring and performance optimization. **The key difference:** - **Latency** measures network travel time - **Response time** measures total time including server processing Both metrics matter, but for different reasons. ## What is latency? Latency and response time are two different metrics used in uptime monitoring. Latency measures the time it takes for a request to travel from the probes to the server and back. Response time is the time it takes for the server to process the request and send back a response, plus the latency. ``` openstatus Network Server (Website) | | | |------- Request ---------->| | | (Timestamp A: Send) | | | |------- Process --------->| | | (Server processing time) | | |<------- Response --------| | | (Timestamp B: Receive) | | | | Latency = Timestamp B - Timestamp A ``` Latency is the time it takes for data to travel from its source to its destination. Think of it as the round-trip time (RTT) for a network packet. This delay is influenced by several factors: - **Distance:** The physical distance between the client and the server. Data traveling across continents will have higher latency than data traveling within the same city. - **Network Congestion:** When too much data is on the network, it can slow down transmission, similar to a traffic jam on a highway. To measure latency, you can monitor endpoints like `/ping` or `/healthcheck` with minimum server processing time. ## What Is Response Time? ``` openstatus Network Server | | | (Start) |------- Request -------->| | (T1) | | | | |--- Processing ----->| | | (Server's work) | | |<-- Response Data ---| | | | (End) |<--- (Received) ---------| | (T2) | | | Response Time = T2 - T1 ``` Response time is the total time from the moment a user's request is sent until the moment the first byte of the server's response is received. It includes both the network latency and the server's processing time. Response time = Network Latency + Server Processing Time The server processing time is the duration the server spends on tasks like: - Executing database queries. - Running application logic. - Generating the HTML or JSON response. A high response time often indicates a problem with the server-side application itself. For example, slow database queries or inefficient can dramatically increase the response time, even if the network latency is low. ## Why the distinction matters for uptime monitoring Understanding the difference between these two metrics is crucial for diagnosing performance issues. - If your monitoring shows a **high response time but low latency**, the problem is likely with your server's performance. You should investigate your application's code, database queries, and server resources. - If both your **latency and response time** are high, the issue is likely network-related. This could be due to a poor connection between the monitoring location and your server, or a broader network issue. - **Response time is the ultimate measure of user experience** because it reflects the full journey of a request. Users don't just care how fast a packet can get to the server; they care how long it takes to see the results. By monitoring both metrics, you can quickly pinpoint whether a performance slowdown is caused by your application or by the network. ## Practical implications ### For monitoring strategy - **Monitor both metrics**: Don't rely on just one - **Set appropriate thresholds**: Latency thresholds should be lower than response time thresholds - **Consider geographic factors**: Latency varies by monitoring location - **Track trends**: Sudden changes in either metric indicate issues ### For optimization - **Reduce latency**: Use CDNs, optimize routing, choose closer hosting - **Improve response time**: Optimize code, database queries, caching - **User location matters**: Users far from your server will always see higher latency ### Common scenarios **Scenario 1: Consistent latency, variable response time** - Indicates server-side performance issues - Look at: Database queries, API calls, resource utilization **Scenario 2: High latency from specific regions** - Indicates geographic network issues - Solution: Add regional monitoring points or CDN **Scenario 3: Both metrics degrading** - Could be network saturation or DDoS attack - Check: Network bandwidth, traffic patterns, security ## What openstatus tracks openstatus monitors and displays: - **Total response time**: The complete user experience - **Detailed timing breakdown**: DNS, TCP, TLS, request, response - **Regional differences**: Compare performance across locations - **Historical trends**: Identify patterns over time ## Next steps - **[Create your first monitor](/tutorial/how-to-create-monitor)** - Start tracking these metrics - **[Understanding uptime monitoring](/concept/uptime-monitoring)** - Broader monitoring concepts - **[HTTP monitor reference](/reference/http-monitor)** - Technical specifications ================================================ FILE: apps/docs/src/content/docs/concept/uptime-calculation-and-values.mdx ================================================ --- title: Uptime Calculation and Shared Values --- Let’s face it - uptime values can be a complete lie if they’re not properly connected to monitoring. We want to make uptime transparent and configurable. You decide how your uptime is calculated and which values you want to share on your status page. When monitoring an endpoint, a check can end up in one of three states: - ✅ Success – everything’s fine - ⚠️ Degraded – slow or partially failing - ❌ Down – no response or full failure We now offer multiple types of uptime calculation: - **Absolute** (default): derived directly from your monitoring data - **Duration**: aggregated from the incidents duration - **Requests** (default): aggregated from the request values - **Manual**: for teams that prefer full control over what’s shown **TL;DR** | Type | Source of Truth | What Users See | Best For | |-------------|----------------------|-------------------------------------|----------------------------------| | **Duration** (Absolute) | Incident duration | Time based uptime, proportional colors | Accurate long-term view | | **Request** (Absolute) | Every ping result | Request-based uptime % | Real-time reflection | | **Manual** | Manually set status | Controlled, narrative updates | Transparency without monitoring | <br /> Let’s break them down! ## Absolute Type The absolute type calculates uptime based on actual monitoring results. It’s the most accurate reflection of what’s really happening, and comes in two variants: _Duration_ and _Request_. Both of these share real data with your users - incidents, degraded states, and historical uptime - but they differ in how they aggregate and display that data. --- ### Duration The duration value is calculated from the **total monitoring time and the duration of incidents**. In simple terms: `uptime = (total time - incident duration) / total time` This means uptime is based on how long something was down, not how many checks failed. Only incident durations are included in the calculation. Temporary single-region ping failures (e.g., one location failing once, sometimes this just happens) are not propagated to users - because these often don’t represent a real outage. That’s also why we recommend at least three locations per monitor for redundancy. The proportional colors in the status bar are drawn from these duration values. Hovering over a day shows both incidents and status reports, so users can explore what happened. --- ### Request The request value is more straightforward - it looks at each ping result individually. **Every check we run contributes to your uptime score**. In simple terms: `uptime = (success + degraded - error) / total requests` This is the current default mode for most openstatus users. It’s simple, data-driven, and updates immediately as new results come in. Like with duration, hover cards display incidents and status reports, giving your users a quick overview of recent events. --- ## Manual Type The manual type is for teams who want to **fully control what’s shown** on your status page, without relying on automatic checks. By default, your monitor is marked operational. You can then manually create status reports whenever you want to reflect changes - independent of any monitoring data. This is ideal if: - you don’t have synthetic monitoring set up yet, - or you’re sharing updates that aren’t tied to uptime (e.g., service degradation due to external dependencies). In this mode, all displayed uptime values and statuses come from your shared report data, not from active pings. In simple terms: `uptime = (total duration - status report duration) / total duration` > **Note**: the values you are defining are attached to a status page. You cannot change them per monitor (for now). ================================================ FILE: apps/docs/src/content/docs/concept/uptime-monitoring-as-code.mdx ================================================ --- title: Understanding Monitoring as Code description: "Why and how to manage monitoring configuration as code for GitOps workflows" --- import { Code } from '@astrojs/starlight/components'; ## The traditional approach (and its problems) Traditionally, monitoring is configured through web dashboards: 1. Log into a web interface 2. Click through forms to create monitors 3. Manually replicate configuration across environments 4. No audit trail of who changed what 5. Difficult to review changes before they go live This works for small teams with few monitors, but doesn't scale. ## What is monitoring as code? **Monitoring as Code** treats your monitoring configuration the same way you treat your application code: as text files that can be versioned, reviewed, and deployed through automated pipelines. Instead of clicking buttons, you define monitors in YAML: export const code = ` # yaml-language-server: $schema=https://www.openstatus.dev/schema.json uptime-monitor: name: "Uptime Monitor" description: "Uptime monitoring example" frequency: "10m" active: true regions: - iad - ams - syd - jnb - gru retry: 3 kind: http request: url: https://openstat.us method: GET headers: User-Agent: openstatus assertions: - kind: statusCode compare: eq target: 200 graphql-monitor: name: "Graphql" description: "GitHub GraphQL API" frequency: "10m" active: true regions: - iad - ams - syd - jnb - gru retry: 3 kind: http request: url: https://api.github.com/graphql method: POST headers: User-Agent: openstatus Authorization: Bearer YOUR_TOKEN_HERE body: | { "query": "query { viewer { login }}" } ` Uptime monitoring is a vital part of any robust system, ensuring your services are online and available to users. Historically, this has involved manually configuring monitors through a web interface, which can be tedious and prone to human error. Uptime Monitoring as Code changes this by treating your monitoring configurations like any other part of your application-as code. ## Why Use Uptime Monitoring as Code? This approach offers significant advantages: - **Version Control:** By defining your monitors in a YAML file, you can track every change, rollback to previous versions, and see who made which modifications using tools like Git. This is crucial for auditing and troubleshooting. - **Automation and Consistency:** Your monitoring setup can be part of your automated deployment pipeline. When you deploy a new service, its monitors are created automatically, ensuring consistency across your entire infrastructure. This eliminates the risk of forgetting to set up monitoring for a new service. - **Collaboration:** A code-based approach simplifies collaboration among teams. A developer can create a new monitor definition in the YAML file and submit it for peer review, just as they would with any other code change. This promotes a shared understanding of your system's health. - **Scalability:** Manually setting up hundreds of monitors is a nightmare. With a code-based approach, you can programmatically generate configurations for a large number of services, making it easy to scale your monitoring as your infrastructure grows. - **Simplified Auditing:** Since the entire configuration is in a file, it's easy to see the current state of your monitors at a glance. You don't have to navigate through multiple screens in a web UI. ## How It Works with openstatus We offer the use ofa simple, human-readable YAML file to define all uptime monitors. This file serves as the single source of truth for your monitoring setup. You define each monitor with its URL, expected status code, and other parameters. Here’s an example of what your `openstatus.yaml` file might look like: <Code code={code} lang="yaml" title='openstatus.yaml' /> ### Making Changes with the CLI Once your `openstatus.yaml` file is ready, you use our [command-line interface (CLI)](/tutorial/get-started-with-openstatus-cli) to apply the changes. The CLI compares your local configuration with the current state of your monitors and applies only the necessary changes creating new monitors, updating existing ones, or deleting those no longer defined. **Common CLI Commands:** - `openstatus monitors apply`: Applies the changes defined in your `openstatus.yaml` file. - `openstatus monitors import`: Import the monitors from your dashboard to a new `openstatus.yaml` file. By integrating this **Uptime Monitoring as Code** workflow into your development lifecycle, you can achieve a more reliable, consistent, and scalable system. It's about moving from manual clicks to automated, version-controlled operations. ## Best practices 1. **Start simple**: Begin with a few monitors, expand as you learn 2. **Use templates**: Create reusable patterns for common monitor types 3. **Environment variables**: Use secrets management for tokens and sensitive data 4. **Review changes**: Always review diffs before applying 5. **Document decisions**: Use commit messages to explain "why" ## Next steps Ready to implement monitoring as code? - **[Get Started with CLI](/tutorial/get-started-with-openstatus-cli)** - Install and configure the CLI - **[Monitor Your MCP Server](/guides/how-to-monitor-mcp-server/)** - Real-world example - **[CLI Reference](/reference/cli-reference)** - Complete command documentation - **[YAML Examples](https://github.com/openstatusHQ/cli-template)** - Sample configurations ================================================ FILE: apps/docs/src/content/docs/concept/uptime-monitoring.mdx ================================================ --- title: Understanding Uptime Monitoring description: A deep dive into uptime monitoring concepts, architecture, and best practices --- ## What is uptime monitoring? Uptime monitoring is the practice of continuously asking a simple question: "Is our service working correctly for our users?" It's a systematic, automated process for checking the availability, performance, and correctness of a website, server, or application. Instead of waiting for users to report a problem, uptime monitoring acts as a proactive defense, alerting you the moment an issue arises. This helps you minimize downtime and protect your reputation. At its core, uptime monitoring answers three critical questions: 1. **Is my service available?** Can users reach it? 2. **Is it performing well?** Are response times fast enough? 3. **Is it functioning correctly?** Is it returning the expected data and behaving as intended? ``` +----------------+ | Service to be | | Monitored | +----------------+ ▲ | | (Network Latency) | +-----+-------+ +-----+-------+ +-----+-------+ | Monitoring | | Monitoring | | Monitoring | | Node (USA) | | Node (EU) | | Node (Asia) | +-----+-------+ +-----+-------+ +-----+-------+ | | | |---------------|-----------------| ▼ ▼ ▼ +------------------------------------------------+ | Global Uptime Monitoring Service | | | | - Sends automated requests (e.g., pings or | | HTTP checks) from all nodes at set intervals | | - Records response time and success/failure | | - Compares results from different nodes | | - If a failure or a slow response is detected, | | it triggers an alert. | +------------------------------------------------+ | | (Alerts: Email, SMS, Slack, etc.) 🔔 | +-----+-----+ | Your Team | +-----------+ ``` ## Key Concepts - **Downtime**: The period when a service is unavailable or not functioning as expected. It can be caused by server failures, network issues, software bugs, or even cyberattacks. - **Uptime**: The percentage of time a service is available and operational. A high uptime percentage (e.g., 99.9% or "three nines") indicates reliability. - **Alerting**: The system of notifying a team or individual when downtime is detected. Alerts can be sent via email, SMS, Slack, or other communication channels. ## Why Uptime Monitoring is Crucial - **Business Continuity**: Downtime can lead to significant financial losses, damage to reputation, and loss of customer trust. Uptime monitoring helps you address issues quickly, ensuring your services are always available to your users. - **Performance Insight**: Monitoring tools often provide data on latency and response times, giving you insights into your service's performance beyond just availability. This can help you optimize your infrastructure and user experience. - **Proactive Problem Solving**: Instead of waiting for a customer to report an issue, uptime monitoring allows you to be the first to know about it. This enables you to troubleshoot and resolve problems before they escalate. ## How it Works Uptime monitoring typically involves a monitoring agent that periodically sends a request (like an HTTP GET request) to your service. - If the service responds with a successful status code (e.g., 200 OK), it's considered up. - If the service returns an error code, a timeout, or no response, the agent will perform a re-check from a different location to confirm the outage. This helps prevent false alarms caused by temporary network glitches. - Upon confirmation, the system triggers an alert, notifying the relevant team members. The monitoring system will continue to check the service until it's back online, at which point a recovery alert is often sent. Common types of checks include: - **HTTP/HTTPS checks:** Verify a website is accessible and returns a valid response. - **TCP checks:** Confirm a server is reachable on the network. ## Planning an Uptime Monitoring System When planning your own uptime monitoring system, consider the following: 1. Define What to Monitor: Identify all critical services, websites, APIs, and servers that need to be monitored. Prioritize based on business impact. 2. Select a Monitoring Tool: Choose a tool that fits your needs. Options range from simple free services to complex enterprise-level platforms. Look for features like: - **Multiple locations:** Checks from various geographic regions to ensure global availability. - **Customizable alerting:** Set up different alert thresholds and notification methods. - **Reporting and dashboards:** Visualize uptime history, performance metrics, and incident reports. - **Integrations:** Connect with your existing tools like Slack, PagerDuty, or email. 3. **Establish Alerting Rules:** Determine who should be notified and when. Set up an escalation policy, for example, if a primary on-call engineer doesn't respond within 15 minutes, the alert is sent to a manager. 4. **Regularly Review and Optimize:** Monitor your monitoring system itself. Review historical data to identify recurring issues, fine-tune alert thresholds, and update your list of monitored services as your infrastructure evolves. ## The human factor While uptime monitoring is largely automated, it's important to remember the human aspects: - **Alert fatigue**: Too many false positives can lead teams to ignore alerts. Fine-tune your monitoring to reduce noise. - **On-call burden**: Distribute monitoring responsibilities fairly and ensure adequate coverage. - **Communication**: During incidents, clear communication with users is as important as technical fixes. - **Post-mortems**: Learn from downtime by conducting blameless post-mortems. ## Monitoring philosophy Different approaches to monitoring reflect different philosophies: - **Optimistic monitoring**: Assume everything is working unless proven otherwise. Alert on failures. - **Pessimistic monitoring**: Assume nothing works unless actively verified. Alert on missing data. - **SLI/SLO based**: Monitor Service Level Indicators against defined Service Level Objectives. openstatus supports all these approaches, letting you choose what works best for your team. ## Beyond basic availability Modern uptime monitoring goes beyond simple "up or down" checks: - **Performance monitoring**: Track response times and identify degradation before outages. - **Geographic monitoring**: Verify availability from multiple regions to catch regional issues. - **Synthetic monitoring**: Simulate user journeys to catch functional issues. - **Real user monitoring (RUM)**: Complement synthetic checks with actual user experience data. ## Next steps Now that you understand uptime monitoring concepts: - **[Get started with a monitor](/tutorial/how-to-create-monitor)** - Apply these concepts in practice - **[Learn about uptime calculations](/concept/uptime-calculation-and-values)** - Understand how uptime percentages work - **[Building Trust with Status Pages](/concept/best-practices-status-page)** - Communicate effectively during incidents - **[Monitoring as Code](/concept/uptime-monitoring-as-code)** - Manage monitoring configuration programmatically ================================================ FILE: apps/docs/src/content/docs/guides/getting-started.mdx ================================================ --- title: How-to Guides description: "Practical guides for integrating OpenStatus with GitHub Actions, OTLP endpoints, Cloudflare, and more." sidebar: label: Guides Overview order: 1 --- ## 🛠️ How-to Guides ### What you'll find here Our how-to guides are designed to help you: - Solve specific problems with step-by-step instructions - Implement advanced features and integrations - Customize and extend your openstatus setup ### Monitoring & Integration Extend your monitoring capabilities: - **[Monitor Your MCP Server](/guides/how-to-monitor-mcp-server/)** - Set up monitoring for Model Context Protocol servers - **[Export Metrics to OTLP Endpoint](/guides/how-to-export-metrics-to-otlp-endpoint)** - Send monitoring data to your observability platform - **[Run Synthetic Tests in GitHub Actions](/guides/how-to-run-synthetic-test-github-action/)** - Automate testing in your CI/CD pipeline ### Status Pages & Widgets Share your status with users: - **[Add SVG Status Badge to GitHub README](/guides/how-to-add-svg-status-badge)** - Display real-time status in your repository - **[Use React Status Widget](/guides/how-to-use-react-widget)** - Embed live status updates in your React application - **[Deploy Status Page on Cloudflare Pages](/guides/how-deploy-status-page-cf-pages)** - Host your status page on Cloudflare's edge network ### Infrastructure & Deployment Self-host and customize your setup: - **[Deploy Private Locations on Cloudflare Containers](/guides/how-to-deploy-probes-cloudflare-containers)** - Run monitoring agents in your own infrastructure - **[Self-Host openstatus](/guides/self-hosting-openstatus)** - Deploy openstatus on your own servers - **[Self-Host Status Page Only](/guides/self-host-status-page-only)** - Deploy only the status page without monitoring ### Related sections - **[Tutorials](/tutorial/getting-started/)** - If you need step-by-step learning instead - **[Explanations](/concept/getting-started/)** - To understand the concepts behind these guides - **[Reference](/reference)** - For detailed technical specifications ================================================ FILE: apps/docs/src/content/docs/guides/how-deploy-status-page-cf-pages.mdx ================================================ --- title: How to Deploy a Status Page to Cloudflare Pages description: Learn how to use openstatus monitoring data to deploy a status page on Cloudflare Pages. sidebar: label: Host your status page on Cloudflare Pages --- import { Image } from 'astro:assets'; import statusPage from '../../../assets/guides/how-deploy-status-page-cf-pages/status-page.png'; ## Problem You need a fast, reliable, and automated status page, but you don't want to manage the hosting infrastructure. Manually updating a status page during an incident is inefficient and error-prone. ## Solution Deploy a custom status page to Cloudflare's global edge network using openstatus as the data source. This guide shows you how to use our Astro-based template to create a status page that automatically reflects your monitoring data. The code for the template is available on [GitHub](https://github.com/openstatusHQ/astro-status-page). <Image src={statusPage} alt="Astro Status Page" /> ## Prerequisites - A Cloudflare account - An [openstatus account](https://www.openstatus.dev) with at least one monitor configured. ## Step-by-step guide ### 1. Get your openstatus API key First, you need an API key to fetch your monitoring data. 1. Navigate to your openstatus dashboard. 2. Go to **Settings** → **API Token**. 3. Click **Create API Key** and copy the key. ### 2. Set up the Astro project Clone our status page template and install the dependencies. ```bash git clone https://github.com/openstatusHQ/astro-status-page.git cd astro-status-page npm install ``` ### 3. Customize the status page You need to specify which monitors to display on your page. 1. Open the `src/pages/index.astro` file. 2. Find the following line of code: ```javascript const monitorIds = [1] ``` 3. Replace the `1` with the ID of the monitor you want to display. You can find the monitor ID in the URL when you view a monitor in the openstatus dashboard (`/monitors/[ID]`). You can also add multiple IDs: `[1, 2, 5]`. ### 4. Configure your Cloudflare environment variable Before deploying, you must provide your openstatus API key to Cloudflare. 1. Go to your Cloudflare dashboard and click on **Workers & Pages**. 2. Select your site and go to the **Settings** tab. 3. Navigate to **Environment variables** and add a new variable: - **Variable name**: `API_KEY` - **Value**: Paste your openstatus API key here. ### 5. Deploy to Cloudflare Pages Now you can deploy your status page. ```bash npm run pages:deploy ``` After the command completes, your status page will be live on Cloudflare Pages. 🎉 ================================================ FILE: apps/docs/src/content/docs/guides/how-to-add-svg-status-badge.mdx ================================================ --- title: How to Add a Status Badge to a GitHub README description: A step-by-step guide to adding a real-time SVG or PNG status badge to your GitHub repository. --- ## Problem You want to display the real-time status of your service directly in your GitHub repository's README file. This provides immediate visibility to your users and team members about the health of your application. ## Solution openstatus provides embeddable status badges that you can add to any Markdown file, including your `README.md`. You can choose between a modern SVG badge or a legacy PNG badge. ## Prerequisites - An openstatus account with a configured status page. - The "slug" of your status page (the unique name in its URL, e.g., `https://[slug].openstatus.dev`). ## Step-by-step guide ### Step 1: Choose your badge type We recommend using the modern SVG badge (v2) as it offers more customization options. - **SVG Badge (v2)**: More flexible, better styling, and uses a monospaced font. - **PNG Badge (Legacy)**: Simpler, but with fewer customization options. ### Step 2: Add the badge to your README Copy the Markdown snippet for your chosen badge type and paste it into your `README.md` file. **Remember to replace `[slug]` with your status page slug.** --- #### Option A: Modern SVG Badge (Recommended) This is the recommended badge for most use cases. ##### Base URL ``` https://[slug].openstatus.dev/badge/v2 ``` ##### Markdown Snippet ``` ![Status](https://[slug].openstatus.dev/badge/v2) ``` **Example:** ![Status](https://status.openstatus.dev/badge/v2) ##### Customization You can customize the badge by adding query parameters to the URL. - **Theme**: `?theme=dark` (default is `light`) - **Size**: `?size=md` (options: `sm`, `md`, `lg`, `xl`; default is `sm`) - **Variant**: `?variant=outline` (adds a border; default has no border) **Example with all options:** ``` ![Status](https://[slug].openstatus.dev/badge/v2?theme=dark&size=lg&variant=outline) ``` --- #### Option B: Legacy PNG Badge Use this badge if you prefer the older style. ##### Base URL ``` https://[slug].openstatus.dev/badge ``` ##### Markdown Snippet ```markdown ![Status](https://[slug].openstatus.dev/badge) ``` **Example:** ![Status](https://status.openstatus.dev/badge) ##### Customization - **Theme**: `?theme=dark` (default is `light`) - **Size**: `?size=lg` (options: `sm`, `md`, `lg`, `xl`; default is `sm`) **Example with all options:** ``` ![Status](https://[slug].openstatus.dev/badge?theme=dark&size=lg) ``` ### Step 3: Commit your changes Save your `README.md` file and commit it to your repository. The status badge will now be visible to anyone visiting your repository. It will automatically update to reflect the current status of your services. ================================================ FILE: apps/docs/src/content/docs/guides/how-to-deploy-probes-cloudflare-containers.mdx ================================================ --- title: How to Deploy a Private Probe on Cloudflare Containers description: A step-by-step guide to deploying an openstatus private monitoring probe on Cloudflare Containers. sidebar: label: Deploy private probes on Cloudflare Containers --- import { Image } from 'astro:assets'; import log from '../../../assets/guides/how-to-deploy-probes-cf-containters/cloudflare-log.png'; import os from '../../../assets/guides/how-to-deploy-probes-cf-containters/private-location.jpg'; ## Problem You need to monitor internal services that are not accessible from the public internet, or you want to run checks from your own infrastructure for compliance or performance reasons. You need a lightweight, serverless way to run these monitoring probes without managing virtual machines. ## Solution Deploy the openstatus private location probe as a serverless container using Cloudflare Containers. This allows you to run monitoring checks from within your own network infrastructure, managed by Cloudflare. This guide will walk you through the entire process, from creating the private location in openstatus to deploying the container. The code for the Cloudflare Worker template is available on [GitHub](https://github.com/openstatusHQ/private-location-cloudflare-container). ## Prerequisites - A Cloudflare account - An [openstatus account](https://www.openstatus.dev) - `pnpm` and `docker` installed on your local machine ## Step-by-step guide ### 1. Create a private location in openstatus First, you need to create a private location in your openstatus workspace to get an access key. 1. Go to the openstatus dashboard. 2. Click on **Private locations** in the sidebar. 3. Click **Create Private Location**. 4. Give it a human-readable name (e.g., "Cloudflare-EU"). 5. Copy the generated token and save it somewhere secure. 6. Click **Submit** to save the new private location. ### 2. Set up the Cloudflare project Next, create a new Cloudflare project using the containers template. ```bash pnpm create cloudflare@latest --template=cloudflare/templates/containers-template ``` ### 3. Pull and tag the probe Docker image Pull the official openstatus private location image from Docker Hub. You must specify the `linux/amd64` platform, as this is what Cloudflare Containers supports. ```bash # Pull the image docker pull --platform linux/amd64 ghcr.io/openstatushq/private-location:latest # Tag the image for Cloudflare (you cannot use the 'latest' tag) docker tag ghcr.io/openstatushq/private-location:latest openstatus-private-location:v1 ``` ### 4. Push the image to Cloudflare Container Registry Push the tagged image to your Cloudflare account's container registry. ```bash pnpm wrangler containers push openstatus-private-location:v1 ``` ### 5. Configure `wrangler.toml` Now, configure your Cloudflare project to use the container and run it on a schedule. 1. Open the `wrangler.toml` file. 2. Add a `[containers]` section to link the image you pushed. Replace `GENERATED_ID` with the actual ID from the previous step's output. ```toml [containers] image = "registry.cloudflare.com/GENERATED_ID/openstatus-private-location:v1" ``` 3. Add a `triggers` section to run the worker on a cron schedule. This keeps the container alive, as Cloudflare Containers automatically scales to zero. ```toml triggers = { cron = ["*/2 * * * *"] } # Runs every 2 minutes ``` ### 6. Configure the worker Update the worker script (`index.ts`) to start the container with the correct environment variables. 1. Set the `sleepAfter` value to control how long the container runs after being invoked. ```typescript sleepAfter = "150s"; ``` 2. Update the `scheduled` function to pass your openstatus key to the container. ```typescript async scheduled(_controller: any, env: Env) { try { const container = getContainer(env.MY_CONTAINER); await container.start({ envVars: { OPENSTATUS_KEY: env.OPENSTATUS_KEY, }, }); } catch (e) { console.error("Error in scheduled task:", e); } return new Response("ok"); }, ``` ### 7. Add your openstatus key as a secret Securely provide your private location token to the Cloudflare worker. ```bash # Paste the token you saved in Step 1 when prompted pnpm wrangler secret put OPENSTATUS_KEY ``` ### 8. Deploy the application Finally, deploy the worker and container to Cloudflare. ```bash pnpm wrangler deploy ``` Your private location probe is now running on Cloudflare Containers and will start picking up monitoring jobs from your openstatus workspace. ## Verify the deployment You can check the logs of your Cloudflare Worker to see the probe in action. In your openstatus dashboard, the private location should now show as connected. <Image src={log} alt="Cloudflare Workers Logs showing openstatus Private Location running" caption="Cloudflare Workers logs showing the private probe running." /> <Image src={os} alt="openstatus Private Location connected" caption="The private location showing as 'Connected' in the openstatus dashboard." /> ================================================ FILE: apps/docs/src/content/docs/guides/how-to-export-metrics-to-otlp-endpoint.mdx ================================================ --- title: How to Export Metrics to an OTLP Endpoint description: A step-by-step guide to sending openstatus metrics to your observability platform via OTLP. --- import { Image } from 'astro:assets'; import grafana from '../../../assets/guides/how-to-export-metrics-via-otel/grafana.png'; import newrelic from '../../../assets/guides/how-to-export-metrics-via-otel/newrelic.jpg'; import signoz from '../../../assets/guides/how-to-export-metrics-via-otel/signoz.jpg'; import honeycomb from '../../../assets/guides/how-to-export-metrics-via-otel/honeycomb.jpg'; ## Problem You want to analyze your openstatus monitoring data alongside other telemetry data in your existing observability platform (like Grafana, New Relic, or Honeycomb). You need a standardized way to export these metrics without building a custom integration. ## Solution openstatus can export monitoring metrics to any OTLP (OpenTelemetry Protocol) compatible endpoint. By adding a simple configuration to your `openstatus.yaml` file, you can have metrics from every check sent directly to your monitoring stack. ## Prerequisites - An observability platform that supports OTLP metric ingestion over HTTP. - An `openstatus.yaml` file to configure your monitors. - The [openstatus CLI](/tutorial/get-started-with-openstatus-cli) to apply your configuration. ## Step-by-step guide ### 1. Locate your OTLP endpoint URL and headers First, you need to find the specific URL and any required authentication headers from your observability platform. This is usually found in the documentation under "OTLP," "OpenTelemetry," or "Metrics Export." - **Endpoint URL**: Look for an HTTP endpoint for OTLP metrics. It will typically end in `/v1/metrics`. For example: `https://otlp.your-provider.com/v1/metrics`. - **Headers**: You will likely need an authentication header, such as `Authorization: Bearer YOUR_API_KEY` or `X-API-Key: YOUR_API_KEY`. ### 2. Configure your `openstatus.yaml` file Open your `openstatus.yaml` file and add the `openTelemetry` block at the top level. ```yaml # yaml-language-server: $schema=https://www.openstatus.dev/schema.json openTelemetry: endpoint: <YOUR_OTLP_ENDPOINT_URL> headers: Authorization: Bearer <YOUR_TOKEN> # Add any other required headers here # Your monitors are defined below my-first-monitor: # ... ``` Replace `<YOUR_OTLP_ENDPOINT_URL>` and `<YOUR_TOKEN>` with the values you found in step 1. **Note**: Currently, we only support OTLP over HTTP. ### 3. Apply the configuration Use the openstatus CLI to apply the changes to your account. ```bash openstatus apply ``` After applying the configuration, openstatus will send metrics to your specified endpoint after every check is completed. ### 4. Verify in your observability platform Go to your observability platform and look for the new metrics coming from openstatus. You should be able to build dashboards and alerts based on this data. Here are some examples of what it can look like: #### Grafana <Image src={grafana} alt="openstatus metrics in grafana" /> #### Honeycomb <Image src={honeycomb} alt="openstatus metrics in honeycomb" /> #### New Relic <Image src={newrelic} alt="openstatus metrics in new-relic" /> #### SigNoz <Image src={signoz} alt="openstatus metrics in signoz" /> ================================================ FILE: apps/docs/src/content/docs/guides/how-to-monitor-mcp-server.mdx ================================================ --- title: How to Monitor Your Model Context Provider (MCP) Server description: Learn how to monitor your MCP server with openstatus using JSON-RPC ping checks sidebar: label: Monitor your MCP Server --- ## Problem: Ensuring Your MCP Server is Always Responsive Running a Model Context Provider (MCP) server is critical for your AI applications, but traditional HTTP monitoring often falls short. MCP servers communicate using the JSON-RPC 2.0 protocol, requiring specific request/response patterns that standard health checks don't cover. How can you confidently ensure your MCP server is healthy and responsive at all times, without custom scripts or complex setups? ## Solution: JSON-RPC Ping Monitoring with openstatus openstatus offers a robust solution for monitoring your MCP servers. By sending precise JSON-RPC `ping` requests to your endpoint from multiple global locations, openstatus verifies not only network reachability but also the correct functioning of your server's JSON-RPC interface. This guide will walk you through setting up comprehensive monitoring for any MCP server using the openstatus CLI. ## Prerequisites Before you begin, ensure you have: - An [openstatus account](https://www.openstatus.dev/app/login). - The [openstatus CLI installed](/tutorial/get-started-with-openstatus-cli). (If you haven't installed it yet, follow this guide first). - An MCP server with a publicly accessible endpoint. - A basic understanding of the [JSON-RPC 2.0 protocol](https://www.jsonrpc.org/specification). ## Background: Understanding MCP for Monitoring A Model Context Provider (MCP) is a crucial component that extends AI models with external context, data, or capabilities via the **Model Context Protocol (MCP)**. Essentially, it acts as a bridge, allowing AI models to interact with resources like databases, APIs, or file systems that aren't part of their core training. ### The MCP Server and JSON-RPC 2.0 ## Step-by-step guide ### 1. Create your `openstatus.yaml` file openstatus allows you to define and manage your monitors using a YAML configuration file, which is ideal for GitOps workflows. This approach ensures your monitoring setup is version-controlled, auditable, and easily deployable. Create a file named `openstatus.yaml` and add the following configuration, adapting it for your own MCP endpoint. This example targets a Hugging Face MCP server. ```yaml # yaml-language-server: $schema=https://www.openstatus.dev/schema.json mcp-server: name: "HF MCP Server" description: "Hugging Face MCP server monitoring" frequency: "1m" active: true regions: ["iad", "ams", "lax"] retry: 3 kind: http request: url: https://hf.co/mcp method: POST body: > { "jsonrpc": "2.0", "id": "openstatus", "method": "ping" } headers: User-Agent: openstatus Accept: application/json, text/event-stream Content-Type: application/json assertions: - kind: statusCode compare: eq target: 200 - kind: textBody compare: eq target: '{"result":{},"jsonrpc":"2.0","id":"openstatus"}' ``` ### 2. Understand the configuration Let's break down the key fields in this YAML configuration: - `name` & `description`: A human-readable name and explanation for your monitor. - `frequency`: How often openstatus will run the check (e.g., `1m`, `5m`, `10m`). - `regions`: An array of geographic regions from which to perform checks (e.g., `["iad", "ams", "lax"]`). Monitoring from multiple regions helps detect localized issues. - `retry`: The number of times to retry a failed check before marking it as down. - `kind`: Must be `http` for MCP servers. - `request`: - `url`: The full URL of your MCP server's JSON-RPC endpoint. - `method`: Must be `POST` for JSON-RPC requests. - `body`: The JSON-RPC `ping` request payload. - `headers`: Standard HTTP headers for JSON-RPC communication. - `assertions`: Rules to validate the server's response. - `statusCode`: Ensures the HTTP response is `200 OK`. - `textBody`: Verifies that the response payload exactly matches the expected JSON-RPC `ping` result. ### 3. Test your MCP server (Optional) Before deploying your monitor, you can manually test your MCP server's `ping` endpoint with `curl` to confirm it responds as expected. This helps verify the `target` value for your `textBody` assertion. ```bash curl -X POST \\ -H "Content-Type: application/json" \\ -d '{"jsonrpc": "2.0", "id": "openstatus", "method": "ping"}' \\ https://hf.co/mcp # Replace with your MCP server URL ``` A healthy server should return a JSON response like `{"result":{},"jsonrpc":"2.0","id":"openstatus"}`. ### 4. Deploy your monitor Once your `openstatus.yaml` file is ready, use the openstatus CLI to create the monitor: ```bash openstatus create openstatus.yaml ``` This command uploads your configuration, and monitoring will begin immediately. ## Conclusion: Comprehensive MCP Server Monitoring Achieved This guide has equipped you with the knowledge to effectively monitor your MCP server using openstatus. By leveraging YAML configuration and the openstatus CLI, you can ensure your critical AI infrastructure remains healthy and responsive. ## What You've Accomplished - ✅ Successfully configured a JSON-RPC based monitor for your MCP server. - ✅ Implemented precise assertions to validate `ping` responses. - ✅ Set up global monitoring to detect localized or widespread issues. - ✅ Automated monitor deployment using a version-controlled YAML configuration. ## Next Steps Now that your MCP server is under robust monitoring, consider further enhancing your setup: - **[Export Metrics to OTLP](/guides/how-to-export-metrics-to-otlp-endpoint)**: Integrate your MCP monitoring data with your existing observability platform for centralized analytics. - **[Run Synthetic Tests in GitHub Actions](/guides/how-to-run-synthetic-test-github-action/)**: Incorporate these synthetic checks into your CI/CD pipeline for pre-deployment validation. ## Related Resources - **[JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification)**: Deep dive into the JSON-RPC protocol. - **[MCP Official Documentation](https://modelcontextprotocol.io/docs/concepts/architecture#debugging-and-monitoring)**: Official insights into MCP health checks. - **[HTTP Monitor Reference](/reference/http-monitor)**: Comprehensive API reference for HTTP monitors. - **[CLI Reference](/reference/cli-reference)**: Full documentation for the openstatus CLI. ================================================ FILE: apps/docs/src/content/docs/guides/how-to-run-synthetic-test-github-action.mdx ================================================ --- title: How to Run Synthetic Tests in GitHub Actions description: Integrate openstatus synthetic monitoring into your CI/CD pipeline sidebar: label: GitHub Action for Synthetics --- ## Problem You want to validate that your application's critical endpoints are working before deploying to production. Running synthetic tests in your CI/CD pipeline catches issues early and prevents broken deployments. ## Solution openstatus provides a GitHub Action that runs your configured monitors as part of your CI/CD workflow. This guide shows you how to set it up. ## Prerequisites - An [openstatus](https://www.openstatus.dev) account - A GitHub repository - At least one monitor configured in openstatus - Admin access to your GitHub repository (for secrets) ## Step-by-step guide ### 1. Create a configuration file Create a file named `openstatus.config.yaml` in your repository root: ```yaml tests: ids: - 1 - 2 ``` **Finding monitor IDs:** 1. Go to your openstatus dashboard 2. Click on a monitor 3. The ID is in the URL: `https://www.openstatus.dev/app/[workspace]/monitors/[ID]` **Tip:** Start with your most critical monitors and expand from there. ### 2. Get your openstatus API key 1. Go to your openstatus workspace settings 2. Navigate to the API section 3. Create a new API key or copy an existing one 4. Store it securely - you'll need it for the next step ### 3. Add your API key to GitHub Secrets Secure your API key as a GitHub secret: 1. Go to your GitHub repository 2. Click **Settings** → **Secrets and variables** → **Actions** 3. Click **New repository secret** 4. Name: `OPENSTATUS_API_KEY` 5. Value: Your openstatus API key 6. Click **Add secret** ### 4. Create the GitHub Action workflow Create `.github/workflows/openstatus.yml`: ```yaml name: Run openstatus Synthetics CI on: workflow_dispatch: # Manual trigger push: branches: [ main ] # Trigger on push to main pull_request: # Run on PRs (optional) jobs: synthetic_ci: runs-on: ubuntu-latest name: Run openstatus Synthetics CI steps: - name: Checkout uses: actions/checkout@v4 - name: Run openstatus Synthetics CI uses: openstatushq/openstatus-github-action@v1 with: api_key: ${{ secrets.OPENSTATUS_API_KEY }} ``` ### 5. Commit and push ```bash git add openstatus.config.yaml .github/workflows/openstatus.yml git commit -m "Add openstatus synthetic tests to CI" git push origin main ``` The GitHub Action will run automatically on the next push to `main`. ## What you've accomplished Great work! You've successfully: - ✅ Integrated openstatus into your CI/CD pipeline - ✅ Automated synthetic testing on every deployment - ✅ Added a safety check before production releases - ✅ Set up continuous validation of critical endpoints ## Customization options ### Run on different branches ```yaml on: push: branches: [ main, staging, develop ] ``` ### Run on pull requests ```yaml on: pull_request: types: [opened, synchronize, reopened] ``` ### Run on a schedule ```yaml on: schedule: - cron: '0 */4 * * *' # Every 4 hours ``` ### Multiple configuration files ```yaml - name: Run openstatus Synthetics CI uses: openstatushq/openstatus-github-action@v1 with: api_key: ${{ secrets.OPENSTATUS_API_KEY }} config_file: .openstatus/production.yaml ``` ## Best practices 1. **Start small**: Begin with 2-3 critical monitors 2. **Fail fast**: Run synthetic tests early in your pipeline 3. **Monitor the monitors**: Track your synthetic test success rate 4. **Environment-specific**: Use different monitors for staging vs production 5. **Document failures**: Investigate and document any CI failures ## Troubleshooting **Action fails with authentication error:** - Verify `OPENSTATUS_API_KEY` secret is set correctly - Check that your API key hasn't expired **Monitors not found:** - Confirm monitor IDs are correct in `openstatus.config.yaml` - Ensure monitors are active in your openstatus dashboard **Tests timing out:** - Check that your endpoints are accessible from GitHub's runners - Consider increasing timeouts in monitor configuration ## Next steps - **[Monitor Your MCP Server](/guides/how-to-monitor-mcp-server/)** - Advanced monitoring examples - **[Monitoring as Code](/concept/uptime-monitoring-as-code)** - Manage monitors with YAML - **[CLI Reference](/reference/cli-reference)** - Automate monitor management - **[Export Metrics](/guides/how-to-export-metrics-to-otlp-endpoint)** - Send data to your observability platform ## Related resources - **[GitHub Action on Marketplace](https://github.com/marketplace/actions/openstatus-synthetics-ci)** - Official action - **[Example Repository](https://github.com/openstatusHQ/github-action-example)** - Working examples - **[Join Discord](https://www.openstatus.dev/discord)** - Get help from the community ================================================ FILE: apps/docs/src/content/docs/guides/how-to-use-react-widget.mdx ================================================ --- title: How to Use openstatus React Widget --- Install the [npm](https://www.npmjs.com/package/@openstatus/react) package: ```bash npm install @openstatus/react ``` ## React Server Component ```tsx import { StatusWidget } from "@openstatus/react"; export function Page() { return <StatusWidget slug="status" />; } ``` It will automatically attach the slug to the href to allow the user to open a new tab on click to `https://slug.openstatus.dev`. If you want to redirect him to a specific page, use the `href` property, like so: ```tsx <StatusWidget slug="documenso" href="https://status.documenso.com" /> ``` > `StatusWidget` is an **async function** and will only work with RSC. Using it > within a dead simple React App will not work. ### Styling #### With tailwindcss ```ts // tailwind.config.js module.exports = { content: [ "./app/**/*.{tsx,ts,mdx,md}", "./node_modules/@openstatus/react/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }; ``` #### Without tailwindcss ```tsx // app/layout.tsx import "@openstatus/react/dist/styles.css"; ``` ## Typed fetch function ```tsx import { getStatus } from "@openstatus/react"; // React Server Component async function CustomStatusWidget() { const res = await getStatus("slug"); // ^StatusResponse = { status: Status } const { status } = res; // ^Status = "unknown" | "operational" | "degraded_performance" | "partial_outage" | "major_outage" | "under_maintenance" | "incident" return <div>{/* customize */}</div>; } ``` ================================================ FILE: apps/docs/src/content/docs/guides/self-host-status-page-only.mdx ================================================ --- title: Self-Host the OpenStatus Status Page (Lightweight) description: Deploy only the openstatus status page and dashboard on your own infrastructure, without monitoring, analytics, or background services. --- ## Problem You want a status page to communicate incidents and maintenance to your users, but you don't need automated monitoring, analytics, or alerting. You may already have your own monitoring tools, or you simply want a lightweight way to manage your public-facing status page. ## Solution OpenStatus provides a lightweight Docker Compose setup that runs only 4 services: a database, a one-shot migration runner, the dashboard, and the status page. This is ideal for teams who only want to self-host the status page without monitoring, or for teams that manage incidents manually using external monitoring tools. ## Lightweight vs Full The lightweight stack strips away all monitoring infrastructure. Here's what each version includes: | Feature | Full | Lightweight | |---------|------|-------------| | Status page | Yes | Yes | | Dashboard | Yes | Yes | | Database (libSQL) | Yes | Yes | | Automated monitoring | Yes | **No** | | Analytics & charts (Tinybird) | Yes | **No** | | API server | Yes | **No** | | Private location probes | Yes | **No** | If you need automated monitoring, follow the [full self-hosting guide](/guides/self-hosting-openstatus) instead. ## Prerequisites - Docker and Docker Compose installed - Git installed - Command line experience ## Step-by-step guide ### Part 1: Initial Setup and Service Launch 1. **Clone the Repository** Get the latest version of openstatus: ```bash git clone https://github.com/openstatushq/openstatus cd openstatus ``` 2. **Configure Your Environment** Copy the example environment file. This is a simplified version of the full configuration, with only the variables relevant to the status page and dashboard. ```bash cp .env.docker-lightweight.example .env.docker ``` Open `.env.docker` in a text editor. You **must** set values for the following variables: - `AUTH_SECRET` — required for authentication. Generate a value with: ```bash openssl rand -base64 32 ``` - `RESEND_API_KEY` — required for magic link login emails. Get a key from [resend.com](https://resend.com). Optionally, you can configure GitHub or Google OAuth providers by filling in the `AUTH_GITHUB_*` or `AUTH_GOOGLE_*` variables in the same file. 3. **Build and Start Services** Use Docker Compose to build and run all services in the background: ```bash docker compose -f docker-compose-lightweight.yaml up -d ``` The first build takes several minutes as it compiles the Next.js applications. Subsequent starts are much faster. Check the status of the services: ```bash docker compose -f docker-compose-lightweight.yaml ps ``` Wait until all services show as `healthy` before proceeding. The `db-migrate` service will show as exited — this is expected, as it runs once and stops. ### Part 2: Application Configuration 4. **Access the Applications** - **Dashboard:** `http://localhost:3000` - **Status Page:** `http://localhost:3001` Log in to the dashboard using email authentication (magic link). This will create your account and workspace. 5. **Set Workspace Limits** Because this is a self-hosted instance, you need to manually set the feature limits for your workspace directly in the database. The following command updates the limits for the workspace with `id = 1`: ```bash curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["UPDATE workspace SET limits = '\''{\\"monitors\\":100,\\"periodicity\\":[\\"30s\\",\\"1m\\",\\"5m\\",\\"10m\\",\\"30m\\",\\"1h\\"],\\"multi-region\\":true,\\"data-retention\\":\\"24 months\\",\\"status-pages\\":20,\\"maintenance\\":true,\\"status-subscribers\\":true,\\"custom-domain\\":true,\\"password-protection\\":true,\\"white-label\\":true,\\"notifications\\":true,\\"sms\\":true,\\"pagerduty\\":true,\\"notification-channels\\":50,\\"members\\":\\"Unlimited\\",\\"audit-log\\":true,\\"private-locations\\":true}'\'' WHERE id = 1"]}' ``` You can find your workspace ID by querying the database: ```bash curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["SELECT id, name FROM workspace"]}' ``` 6. **Create Your Status Page** In the dashboard, create a new status page, add components for the services you want to display, and publish it. Your status page will be available at `http://localhost:3001`. ## Service architecture | Service | Container | Host Port | Purpose | |---------|-----------|-----------|---------| | libsql | openstatus-libsql | 8080 | Database (HTTP API) | | db-migrate | openstatus-db-migrate | — | One-shot database migration (exits after completion) | | dashboard | openstatus-dashboard | 3000 | Admin interface | | status-page | openstatus-status-page | 3001 | Public status page | ## Data persistence All application data is stored in the `openstatus-libsql-data` Docker volume. - `docker compose down` **preserves** your data. - `docker compose down -v` **destroys** the volume and all data. For production use, back up this volume regularly. ## Troubleshooting **Containers won't start:** Check the logs for the failing service: ```bash docker compose -f docker-compose-lightweight.yaml logs <service-name> ``` **Magic link emails not arriving:** Verify that `RESEND_API_KEY` is set correctly in `.env.docker`. **Dashboard shows errors on first load:** The `db-migrate` service may still be running. Check its status: ```bash docker compose -f docker-compose-lightweight.yaml ps ``` **Port conflicts:** If ports 3000, 3001, or 8080 are already in use on your machine, update the host port mappings in `docker-compose-lightweight.yaml`. For example, change `"3000:3000"` to `"4000:3000"` to use port 4000 instead. ## Next steps - **[Self-Host openstatus (Full)](/guides/self-hosting-openstatus)** — Add automated monitoring, analytics, and alerting - **[Join our Discord](https://www.openstatus.dev/discord)** — Get help from the community ================================================ FILE: apps/docs/src/content/docs/guides/self-hosting-openstatus.mdx ================================================ --- title: How to Self-Host openstatus description: Complete guide to deploying openstatus on your own infrastructure --- import { Image } from 'astro:assets'; import localOpenstatus from '../../../assets/guides/self-hosting-openstatus/local-openstatus.png'; ## Problem You want to run openstatus on your own infrastructure instead of using the hosted service. This gives you full control over your data, customization options, and the ability to monitor internal services not accessible from the public internet. ## Solution openstatus provides a Docker Compose setup that makes self-hosting straightforward. This guide walks you through deploying all necessary services and configuring your self-hosted instance. ## Prerequisites - Docker and Docker Compose installed - Basic understanding of Docker and containerization - Command line experience - Git installed ## Known limitations Self-hosting openstatus currently has these constraints: - It only works with private locations. You have to deploy our probes to the cloud provider of your choice. ## Step-by-step guide This guide is divided into three parts: launching the services, setting up the database and analytics, and configuring the application through the UI. ### Part 1: Initial Setup and Service Launch 1. **Clone the Repository** Get the latest version of openstatus: ```bash git clone https://github.com/openstatushq/openstatus cd openstatus ``` 2. **Configure Your Environment** Copy the example environment file. This file will hold all your configuration variables. ```bash cp .env.docker.example .env.docker ``` Open `.env.docker` in a text editor. At a minimum, you **must** set a value for `AUTH_SECRET` for authentication to work. For a complete setup, review the file for other variables like OAuth providers or email services. 3. **Build and Start Services** Use Docker Compose to build and run all openstatus services in the background. ```bash export DOCKER_BUILDKIT=1 docker compose up -d ``` You can check the status of the services with `docker compose ps`. It might take a few minutes for all services to be healthy. ### Part 2: Database and Analytics Setup 4. **Run Database Migrations** The database container starts with an empty database. You must run migrations to set up the required schema. ```bash # Make sure you are in the root of the openstatus project cd packages/db pnpm install pnpm migrate cd ../.. # Return to the project root ``` If you do not have or want to avoid installing the necessary tools on the host, you can run this command to create a one-shot container that will remove itself after completion. ``` # Make sure you are in the root of the openstatus project # For RHEL derivatives, make sure to end /work with :Z for SELinux, "$PWD":/work:Z sudo docker run --rm -it \ --network openstatus \ --env-file .env.docker \ -v "$PWD":/work \ -w /work/packages/db \ node:22-trixie \ bash -lc ' set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl ca-certificates unzip curl -fsSL https://bun.sh/install -o /tmp/bun-install.sh bash /tmp/bun-install.sh export PATH="$HOME/.bun/bin:$PATH" npm i -g pnpm pnpm install bun src/migrate.mts ' ``` 5. **Deploy Local Tinybird Analytics** Tinybird is used for analytics. Deploy the local datasources, pipes, and endpoints. ```bash # Make sure you are in the root of the openstatus project cd packages/tinybird pnpm install tb --local deploy cd ../.. # Return to the project root ``` 6. **Configure Tinybird API Key** You need to get your local Tinybird admin token and add it to your environment file. ```bash cd packages/tinybird tb --local open # This opens the Tinybird UI in your browser ``` In the Tinybird UI, find and copy your admin token. Then, add it to your `.env.docker` file in the root of the project: ```env TINY_BIRD_API_KEY="your-tinybird-admin-token" ``` After adding the token, restart your services for the changes to take effect: ```bash # Make sure you are in the root of the openstatus project docker compose restart ``` ### Part 3: Application Configuration Now that the services are running, you can access the dashboard and perform the final setup steps. - **Dashboard:** `http://localhost:3002` - **Status Pages:** `http://localhost:3003` 7. **Create a Workspace and Set Limits** - Navigate to the dashboard at `http://localhost:3002`. - Sign up and create a new workspace. - Because this is a self-hosted instance, you need to manually set the feature limits for your workspace directly in the database. The following command updates the limits for the workspace with `id = 1`. If your workspace has a different ID, change the `WHERE id = 1` part of the command. ```bash curl -X POST http://localhost:8080/ -H "Content-Type: application/json" \ -d '{"statements":["UPDATE workspace SET limits = '\''{\\"monitors\\":100,\\"periodicity\\":[\\"30s\\",\\"1m\\",\\"5m\\",\\"10m\\",\\"30m\\",\\"1h\\"],\\"multi-region\\":true,\\"data-retention\\":\\"24 months\\",\\"status-pages\\":20,\\"maintenance\\":true,\\"status-subscribers\\":true,\\"custom-domain\\":true,\\"password-protection\\":true,\\"white-label\\":true,\\"notifications\\":true,\\"sms\\":true,\\"pagerduty\\":true,\\"notification-channels\\":50,\\"members\\":\\"Unlimited\\",\\"audit-log\\":true,\\"private-locations\\":true}'\'' WHERE id = 1"]}' ``` You can find your workspace ID by inspecting the database with a command like `curl -X POST http://localhost:8080/ -H "Content-Type: application/json" -d '{"statements":["SELECT id, name FROM workspace"]}'`. If you want to unlock the paid features, you need to upgrade your workspace inside the database. The following command assumes that you want to change the payment plan for the workspace with the ID of 1, and that you want to change it to a "Pro" instance indefinitely. ``` curl -sS -X POST "http://localhost:8080/" \ -H "Content-Type: application/json" \ -d "{\"statements\":[ \"UPDATE workspace SET plan='team', paid_until=strftime('%s','now') + 315360000, ends_at=NULL WHERE id=1;\", \"SELECT id, plan, paid_until, ends_at FROM workspace WHERE id=1;\" ]}" ``` 8. **Deploy a Private Location** The self-hosted version relies on private locations to perform checks. - In the dashboard, navigate to **Settings -> Private Locations** and create a new one. - Copy the generated `OPENSTATUS_KEY`. - Deploy the private location probe to your infrastructure using the Docker image `ghcr.io/openstatushq/private-location:latest`. - When deploying, you must provide two environment variables to the container: - `OPENSTATUS_KEY`: The key you just copied. - `OPENSTATUS_INGEST_URL`: The URL of your self-hosted server's API endpoint (e.g., `http://<your-server-ip-or-domain>:3001`). - For a detailed guide on deploying a private location, see **[Deploy Private Locations on Cloudflare Containers](/guides/how-to-deploy-probes-cloudflare-containers)**. 9. **Create Monitors** You're all set! You can now create monitors in the dashboard. They will be checked by the private location you deployed. <Image src={localOpenstatus} alt="openstatus running locally with self-hosted services" /> ## Service architecture openstatus consists of multiple services running together: | Service | Port | Purpose | |---------|------|---------| | workflows | 3000 | Background jobs and scheduled tasks | | server | 3001 | API backend (tRPC) | | dashboard | 3002 | Admin interface for configuration | | status-page | 3003 | Public status pages | | private-location | 8081 | Monitoring agent for checks | | libsql | 8080 | Database (HTTP) | | libsql | 5001 | Database (gRPC) | | tinybird-local | 7181 | Analytics and metrics | ## What you've accomplished Congratulations! You've successfully: - ✅ Deployed openstatus on your own infrastructure - ✅ Configured all required services - ✅ Set up a private location for monitoring - ✅ Created your first self-hosted monitor ## Troubleshooting **Containers won't start**: Check Docker logs with `docker compose logs [service-name]` **Database migrations fail**: Ensure you're in the correct directory and have pnpm installed **Private location not connecting**: Verify the `OPENSTATUS_KEY` and `OPENSTATUS_INGEST_URL` are correct **Tinybird issues**: Make sure the Tinybird token is correctly set in `.env.docker` ## Next steps - **[Deploy Private Locations](/guides/how-to-deploy-probes-cloudflare-containers)** - Set up monitoring from multiple regions - **[Create Monitors](/tutorial/how-to-create-monitor)** - Start monitoring your services ## Additional resources - **[Docker Compose file](https://github.com/openstatusHQ/openstatus/blob/main/docker-compose.yaml)** - Review the complete configuration - **[Private Location Reference](/reference/private-location)** - Technical specifications - **[Join our Discord](https://www.openstatus.dev/discord)** - Get help from the community ================================================ FILE: apps/docs/src/content/docs/help/support.mdx ================================================ --- title: Need help? description: "We're always here to help." --- If you have any questions, feedback, or need help you can: - Schedule a [call](https://cal.com/team/openstatus/30min) with us. - Join our [Discord](https://www.openstatus.dev/discord) community. - Send us an email at [ping@openstatus.dev](mailto:ping@openstatus.dev) - Open an issue on our [GitHub](https://www.github.com/openstatushq/openstatus) repository. ================================================ FILE: apps/docs/src/content/docs/index.mdx ================================================ --- title: "OpenStatus Docs — Open Source Status Page & Uptime Monitoring" description: "OpenStatus documentation — learn how to create your open-source status page, monitor your websites and APIs from 35+ global locations, and configure alerts." template: doc topic: docs next: false hero: title: OpenStatus Documentation tagline: Learn how to create your status page, monitor your services, and keep your users informed — all open source. --- import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components"; import "../../custom.css"; ### What is openstatus? [openstatus](https://www.openstatus.dev) is an open-source status page platform with uptime monitoring. Monitor your websites, APIs, and services from multiple global locations and share real-time status updates with your users. ### Why OpenStatus? - **Open source** — fully open-source, self-hostable, and transparent - **Beautiful status pages** — keep your users informed with public or audience specific status pages - **30+ global locations** — monitor from your users' perspective, not just your own - **HTTP, TCP & DNS monitors** — check APIs, servers, and DNS resolution - **12 notification channels** — Slack, Discord, PagerDuty, OpsGenie, email, SMS, and more - **Monitoring as code** — define monitors in YAML, manage with CLI or Terraform - **OpenTelemetry export** — send metrics to Grafana, Datadog, Honeycomb, or any OTLP endpoint - **Private locations** — deploy lightweight probes inside your own infrastructure ### How to use this documentation Our documentation follows the [Diátaxis framework](https://diataxis.fr/), organizing content into four distinct categories to help you find what you need: <CardGrid> <LinkCard title="📚 Tutorials" href="/tutorial/getting-started/" description="Step-by-step lessons to learn openstatus from scratch. Start here if you're new!" /> <LinkCard title="🛠️ How-to Guides" href="/guides/getting-started/" description="Practical guides to solve specific problems and accomplish particular tasks." /> <LinkCard title="💡 Concepts" href="/concept/getting-started/" description="In-depth explanations of key concepts, design decisions, and best practices." /> <LinkCard title="📖 Reference" href="/reference" description="Technical specifications, API documentation, and configuration references." /> </CardGrid> **Not sure where to start?** - **New users**: Begin with our [tutorials](/tutorial/getting-started/) to create your first monitor - **Experienced users**: Check out [how-to guides](/guides/getting-started/) for specific tasks - **Need help?** Visit our [help section](/help/support/) or join our community ### Join the community Join the community to get help, share your ideas or just to say hi. <CardGrid> <LinkCard href="https://bsky.app/profile/openstatus.dev" title="Bluesky" /> <LinkCard href="https://www.openstatus.dev/discord" title="Discord"/> <LinkCard href="https://www.openstatus.dev/github" title="GitHub"/> </CardGrid> ================================================ FILE: apps/docs/src/content/docs/monitoring/overview.mdx ================================================ --- title: Monitoring Overview description: "Introduction to synthetic monitoring with openstatus" --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; import { Image } from 'astro:assets'; import overview from '../../../assets/monitor/overview/dashboard.png'; ## What is synthetic monitoring? With openstatus, you can simulate user requests to check the availability and performance of your website, API, or server from different locations around the globe. This proactive approach helps you find issues before your users do. Synthetic monitoring complements real user monitoring (RUM) by providing: - **Consistent baseline**: Predictable checks at regular intervals - **Early warning system**: Detect issues before they affect many users - **Global perspective**: Monitor from multiple regions simultaneously - **24/7 coverage**: Continuous monitoring, even when you have no traffic <Image src={overview} alt="openstatus dashboard showing status codes and response time charts" /> ## How it works We send a request to your specified endpoint on a regular schedule and record the response. If your website or API is down, timing out, or doesn't return the expected response, we'll alert you right away. ## What is a monitor? A **monitor** is a job that runs periodically to check the status of a service. This could be a website, an API, or any other service that can be automatically checked. Each monitor you create runs a request to your endpoint and records the results for you to review. ## Creating a Monitor You can create a new monitor in one of four ways: - Dashboard: Use our intuitive dashboard to quickly set up and manage your monitors. - API: Integrate monitor creation into your workflow using our [API](https://api.openstatus.dev/v1#tag/monitor/POST/monitor). - CLI: Use our command-line interface to create and manage monitors with [YAML configuration files](https://github.com/openstatusHQ/cli-template). - Terraform: Automate the process with our [Terraform provider](/reference/terraform/). ### Monitor types - **HTTP**: Check the availability and performance of your web services by sending HTTP requests and analyzing the responses. - **TCP**: Verify that your servers are accepting connections on specific ports, ensuring that critical - **DNS**: Monitor the health of your DNS records by performing lookups and validating responses. ## Getting started Ready to start monitoring? Follow these guides: 1. **[Create Your First Monitor](/tutorial/how-to-create-monitor)** - Step-by-step tutorial ## Learn more - **[Understanding Uptime Monitoring](/concept/uptime-monitoring)** - Core concepts explained ================================================ FILE: apps/docs/src/content/docs/reference/cli-reference.mdx ================================================ --- title: CLI Reference --- ## CLI interface - openstatus openstatus is a command line interface for managing your monitors and triggering your synthetics tests. This is openstatus Command Line Interface, the openstatus.dev CLI. Usage: ```bash $ openstatus [COMMAND] [COMMAND FLAGS] [ARGUMENTS...] ``` ### `monitors` command Manage your monitors. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors [ARGUMENTS...] ``` ### `monitors apply` subcommand Create or update monitors. > openstatus monitors apply [options] Creates or updates monitors according to the openstatus configuration file. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors apply [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-------------------------------------------------------|:-----------------:|:----------------------:| | `--config="…"` (`-c`) | The configuration file containing monitor information | `openstatus.yaml` | *none* | | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | | `--auto-accept` (`-y`) | Automatically accept the prompt | `false` | *none* | ### `monitors import` subcommand Import all your monitors. > openstatus monitors import [options] Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with 'apply'. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors import [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-----------------------------|:-----------------:|:----------------------:| | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | | `--output="…"` (`-o`) | The output file name | `openstatus.yaml` | *none* | ### `monitors info` subcommand Get a monitor information. > openstatus monitors info [MonitorID] Fetch the monitor information. The monitor information includes details such as name, description, endpoint, method, frequency, locations, active status, public status, timeout, degraded after, and body. The body is truncated to 40 characters. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors info [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-----------------------------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | ### `monitors list` subcommand List all monitors. > openstatus monitors list [options] List all monitors. The list shows all your monitors attached to your workspace. It displays the ID, name, and URL of each monitor. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors list [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-------------------------------------------|:-------------:|:----------------------:| | `--all` | List all monitors including inactive ones | `false` | *none* | | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | ### `monitors trigger` subcommand Trigger a monitor execution. > openstatus monitors trigger [MonitorId] [options] Trigger a monitor execution on demand. This command allows you to launch your tests on demand. Usage: ```bash $ openstatus [GLOBAL FLAGS] monitors trigger [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-----------------------------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | ### `run` command (aliases: `r`) Run your synthetics tests. > openstatus run [options] Run the synthetic tests defined in the config.openstatus.yaml. Usage: ```bash $ openstatus [GLOBAL FLAGS] run [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-----------------------------|:------------------------:|:----------------------:| | `--config="…"` | The configuration file | `config.openstatus.yaml` | *none* | | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | ### `whoami` command (aliases: `w`) Get your workspace information. > openstatus whoami [options] Get your current workspace information, display the workspace name, slug, and plan. Usage: ```bash $ openstatus [GLOBAL FLAGS] whoami [COMMAND FLAGS] [ARGUMENTS...] ``` The following flags are supported: | Name | Description | Default value | Environment variables | |-----------------------------|-----------------------------|:-------------:|:----------------------:| | `--access-token="…"` (`-t`) | openstatus API Access Token | | `OPENSTATUS_API_TOKEN` | ================================================ FILE: apps/docs/src/content/docs/reference/dns-monitor.mdx ================================================ --- title: DNS Monitor Reference description: Complete technical specification for DNS record monitoring --- ## Overview A DNS Monitor is a component designed to verify the availability and correctness of DNS records. It performs periodic lookups for specified DNS record types against a target domain or subdomain from various geographical locations. **Use cases:** - Validating domain name resolution - Monitoring changes to critical DNS records (e.g., A, CNAME, MX) - Ensuring proper load balancing via DNS (when combined with multi-region checks) - Detecting unauthorized DNS alterations ## Configuration ### URI **Type:** String (required) **Format:** Domain name or subdomain The fully qualified domain name or subdomain to be monitored. **Examples:** - `openstat.us` - `api.example.com` - `mail.example.org` ### Record Types The monitor supports fetching and validating the following DNS record types: - `A` (Address Record): Maps a domain name to an IPv4 address. - `AAAA` (IPv6 Address Record): Maps a domain name to an IPv6 address. - `CNAME` (Canonical Name Record): Maps an alias domain name to another canonical domain name. - `MX` (Mail Exchange Record): Specifies the mail servers responsible for accepting email messages on behalf of a domain name. - `NS` (Name Server Record): Delegates a domain or subdomain to a set of authoritative name servers. - `TXT` (Text Record): Carries arbitrary human-readable text and is also used for various purposes like SPF, DKIM, DMARC, and site verification. ### Regions The geographical locations from which the DNS monitoring checks are performed. This allows for verification of DNS propagation and performance across different networks. __Africa__ - Johannesburg, South Africa 🇿🇦 (free) __Asia__ - Hong Kong, Hong Kong 🇭🇰 (free) - Mumbai, India 🇮🇳 - Singapore, Singapore 🇸🇬 - Tokyo, Japan 🇯🇵 __Europe__ - Amsterdam, Netherlands 🇳🇱 (free) - Bucharest, Romania 🇷🇴 - Frankfurt, Germany 🇩🇪 - London, United Kingdom 🇬🇧 - Madrid, Spain 🇪🇸 - Paris, France 🇫🇷 - Stockholm, Sweden 🇸🇪 - Warsaw, Poland 🇵🇱 __North America__ - Ashburn, Virginia, USA 🇺🇸 (free) - Atlanta, Georgia, USA 🇺🇸 - Boston, Massachusetts, USA 🇺🇸 - Chicago, Illinois, USA 🇺🇸 - Dallas, Texas, USA 🇺🇸 - Denver, Colorado, USA 🇺🇸 - Guadalajara, Mexico 🇲🇽 - Los Angeles, California, USA 🇺🇸 - Miami, Florida, USA 🇺🇸 - Montreal, Canada 🇨🇦 - Phoenix, Arizona, USA 🇺🇸 - Queretaro, Mexico 🇲🇽 - Seattle, Washington, USA 🇺🇸 - San Jose, California, USA 🇺🇸 - Toronto, Canada 🇨🇦 __South America__ - Bogota, Colombia 🇨🇴 - Buenos Aires, Argentina 🇦🇷 - Rio de Janeiro, Brazil 🇧🇷 - Sao Paulo, Brazil 🇧🇷 (free) - Santiago, Chile 🇨🇱 __Oceania__ - Sydney, Australia 🇦🇺 (free) ### Frequency The interval at which the DNS checks are performed. Supported frequencies: - 30 seconds - 1 minute - 5 minutes - 10 minutes - 30 minutes - 1 hour ### Response Time Thresholds #### Timeout **Type:** Duration (optional) **Default:** `45 seconds` The maximum duration to wait for a DNS response. If the lookup exceeds this time, the check is considered failed. #### Degraded **Type:** Duration (optional) The duration after which a DNS response is considered degraded. This indicates a performance issue without being a complete failure. ### Retry **Type:** Integer (optional) **Default:** `3` The number of times the monitor will retry a failed DNS lookup before reporting a definitive failure. For example: `3` ## Related resources - **[CLI Reference](/reference/cli-reference)** - Manage monitors as code using the OpenStatus CLI. ================================================ FILE: apps/docs/src/content/docs/reference/http-monitor.mdx ================================================ --- title: HTTP Monitor Reference description: Complete technical specification for HTTP/HTTPS endpoint monitoring --- ## Overview An HTTP Monitor is a component that allows you to monitor the status of HTTP and HTTPS endpoints. It can be used to monitor websites, APIs, webhooks, or any other HTTP-accessible service. **Use cases:** - Website uptime monitoring - API health checks - Webhook endpoint validation - CDN performance monitoring - Authentication endpoint testing ## Configuration ### URL **Type:** String (required) **Format:** Full URL including protocol The URL of the HTTP endpoint you want to monitor. **Examples:** - `https://openstat.us` - `https://api.example.com/health` - `http://internal-service.local:8080/status` **Note:** We recommend using HTTPS for better security. ### Methods **Type:** String (required) **Default:** `GET` The HTTP method to use when making the request to the endpoint. **Available methods:** - `GET` - Retrieve data (most common for health checks) - `POST` - Send data to create/trigger actions - `PUT` - Update existing resources - `DELETE` - Remove resources - `HEAD` - Like GET but without response body - `OPTIONS` - Query supported methods - `PATCH` - Partial resource updates - `TRACE` - Echo request for debugging **Common usage:** - Health checks: `GET` - API testing: `POST`, `PUT`, `DELETE` - Webhook testing: `POST` ### Body **Type:** String (optional) **Available for:** `POST`, `PUT`, `PATCH` methods The request body to send with the HTTP request. Supports both text and binary data. **Text body examples:** ```json { "key": "value" } ``` **Binary data:** For binary content (e.g., images), use base64 encoding with data URI: ``` data:image/jpeg;base64,/9j... ``` **Content type:** Set the appropriate `Content-Type` header (e.g., `application/json`, `application/octet-stream`). ### Headers **Type:** Key-value pairs (optional) Custom HTTP headers to include with your request. **Common examples:** ``` Content-Type: application/json Authorization: Bearer your_token_here Accept: application/json User-Agent: Custom-Agent/1.0 ``` **Use cases:** - **Authentication:** Send API tokens or credentials - **Content negotiation:** Specify accepted response formats - **Custom identification:** Add tracking or debugging headers **Note:** openstatus automatically adds `User-Agent: openstatus/1.0` to all requests. ### Regions **Type:** Array of Strings (required) **Format:** Region identifiers (e.g., `iad`, `jnb`) The geographical regions from which the HTTP request will be triggered. This allows for monitoring global availability and performance. __Africa__ - Johannesburg, South Africa 🇿🇦 (free) __Asia__ - Hong Kong, Hong Kong 🇭🇰 (free) - Mumbai, India 🇮🇳 - Singapore, Singapore 🇸🇬 - Tokyo, Japan 🇯🇵 __Europe__ - Amsterdam, Netherlands 🇳🇱 (free) - Bucharest, Romania 🇷🇴 - Frankfurt, Germany 🇩🇪 - London, United Kingdom 🇬🇧 - Madrid, Spain 🇪🇸 - Paris, France 🇫🇷 - Stockholm, Sweden 🇸🇪 - Warsaw, Poland 🇵🇱 __North America__ - Ashburn, Virginia, USA 🇺🇸 (free) - Atlanta, Georgia, USA 🇺🇸 - Boston, Massachusetts, USA 🇺🇸 - Chicago, Illinois, USA 🇺🇸 - Dallas, Texas, USA 🇺🇸 - Denver, Colorado, USA 🇺🇸 - Guadalajara, Mexico 🇲🇽 - Los Angeles, California, USA 🇺🇸 - Miami, Florida, USA 🇺🇸 - Montreal, Canada 🇨🇦 - Phoenix, Arizona, USA 🇺🇸 - Queretaro, Mexico 🇲🇽 - Seattle, Washington, USA 🇺🇸 - San Jose, California, USA 🇺🇸 - Toronto, Canada 🇨🇦 __South America__ - Bogota, Colombia 🇨🇴 - Buenos Aires, Argentina 🇦🇷 - Rio de Janeiro, Brazil 🇧🇷 - Sao Paulo, Brazil 🇧🇷 (free) - Santiago, Chile 🇨🇱 __Oceania__ - Sydney, Australia 🇦🇺 (free) ### Frequency **Type:** String (required) **Format:** Duration string (e.g., `30s`, `1m`, `1h`) The interval at which the HTTP monitor will perform checks. Supported frequencies: - `30 seconds` - `1 minute` - `5 minutes` - `10 minutes` - `30 minutes` - `1 hour` ### Response Time Thresholds #### Timeout **Type:** Duration (optional) **Default:** `45 seconds` The maximum duration to wait for the HTTP request to complete. If the request exceeds this time, it is considered a failure. #### Degraded **Type:** Duration (optional) The duration after which the HTTP request is considered to be performing in a degraded state. This threshold allows for proactive alerting on performance issues before a complete outage. ### Retry **Type:** Integer (optional) **Default:** `3` The number of times the monitor will automatically retry the HTTP request upon failure before reporting a definitive error. For example: `3` ### Assertions Assertions allow you to validate specific aspects of the HTTP response. #### Body Assertions Validate the content of the HTTP response body. **Comparisons:** - `Contains`: The response body must include the specified string. - `Not Contains`: The response body must not include the specified string. - `Equal`: The response body must exactly match the specified string. - `Not Equal`: The response body must not exactly match the specified string. - `Empty`: The response body must be empty. #### Status Code Assertions Validate the HTTP status code of the response. **Comparisons:** - `Equal`: The status code must be exactly the specified value. - `Not Equal`: The status code must not be the specified value. - `Greater Than`: The status code must be greater than the specified value. - `Greater Than or Equal`: The status code must be greater than or equal to the specified value. - `Less Than`: The status code must be less than the specified value. - `Less Than or Equal`: The status code must be less than or equal to the specified value. #### Headers Assertions Validate the presence or content of specific HTTP response headers. **Purpose:** Verify cache headers, check security headers (e.g., `X-Frame-Options`), validate content-type. **Comparisons:** - `Contains`: A header's value must include the specified string. - `Not Contains`: A header's value must not include the specified string. - `Equal`: A header's value must exactly match the specified string. - `Not Equal`: A header's value must not exactly match the specified string. - `Empty`: A header's value must be empty or the header must not be present. **Example Use Cases:** - Verify `Cache-Control` headers are present and correct. - Check for the existence of security-related headers like `Strict-Transport-Security`. - Validate the `Content-Type` header in API responses. ### OpenTelemetry Configures the export of monitoring metrics to an OpenTelemetry-compatible observability platform. #### OTLP Endpoint **Type:** String (optional) **Protocol:** HTTP only The OTLP (OpenTelemetry Protocol) endpoint URL where collected metrics should be exported. **Example:** `https://otlp.example.com/v1/metrics` #### OTLP Headers **Type:** Key-value pairs (optional) Custom headers to include when sending metrics to your OTLP endpoint. Commonly used for authentication or tenant identification. **Common example:** ``` Authorization: Bearer <your_token> ``` ### Public **Type:** Boolean **Default:** `false` Controls the visibility of monitor data on your public status page. - `true`: Monitor metrics and status are visible to all visitors of your status page. - `false`: Monitor data remains private, accessible only within your OpenStatus dashboard. **Use cases for public visibility:** - Enhancing transparency with users regarding service health. - Providing public API status pages. - Displaying SaaS service availability to customers. ## Related resources - **[Create Your First Monitor](/tutorial/how-to-create-monitor)** - Step-by-step tutorial on setting up a monitor. - **[CLI Reference](/reference/cli-reference)** - Guide to managing OpenStatus monitors programmatically using the command-line interface. ================================================ FILE: apps/docs/src/content/docs/reference/incident.mdx ================================================ --- title: Incident Reference description: Technical specification for incident management and lifecycle --- ## Overview An incident in OpenStatus represents a detected problem or service disruption related to a monitored resource. Incidents are automatically generated when a monitor reports a failure condition that meets predefined criteria. They serve as a central point for tracking, managing, and resolving service impairments. **Key characteristics:** - Automatically triggered by monitor failures. - Aggregates related failure events for a single monitor. - Provides a clear status of service health. ## Incident Triggering An incident is triggered when a significant percentage of recent monitoring checks for a given monitor report a failed status. This mechanism prevents false positives from transient network issues. **Trigger Condition:** - **Failure Threshold:** An incident is initiated when at least 50% of the checks within a defined window (e.g., the last `N` checks or within a `T` duration) have reported a `failure` or `degraded` status. ## Incident Lifecycle and States Incidents progress through several states reflecting their current resolution status. These states are managed through status reports (see [Status Report Reference](/reference/status-report)). **Primary States:** - `investigating`: The incident has been detected, and the team is actively looking into the root cause. - `identified`: The root cause of the incident has been identified. - `monitoring`: A fix has been deployed or a mitigation is in place, and the service is being monitored to confirm resolution. - `resolved`: The incident has been fully resolved, and the service is operating normally. ## Properties While an incident is active, it collects and displays key information related to the service disruption. - **Monitor Association:** Each incident is directly linked to the monitor that triggered it, providing immediate context to the affected service. - **Start Time:** Timestamp indicating when the incident was first detected and created. - **Status Reports:** A chronological log of all updates and state changes applied to the incident. - **Impacted Locations:** Details on the geographical regions from which the monitor reported failures. ## Related resources - **[Status Report Reference](/reference/status-report)** - Details on how incident statuses are managed and reported. ================================================ FILE: apps/docs/src/content/docs/reference/location.mdx ================================================ --- title: Location Reference description: Complete technical specification for Location monitoring --- ## Overview OpenStatus monitors your endpoints from multiple global locations to ensure accurate uptime and latency reporting. Each monitoring location corresponds to a Fly.io region, with both IPv4 and IPv6 addresses available for each. You can use these locations to: - Configure region-specific checks - Allowlist monitoring IPs in your firewall - Understand where requests originate during synthetic monitoring ### Fly.io Regions & Monitoring IPs Below is the complete list of regions used for monitoring, along with their associated IPv4 and IPv6 addresses. | Region Code | Location Name | IPv4 Address | IPv6 Address | |-------------|-------------------|-------------------|-------------------------------| | ams | Amsterdam | 209.71.64.1 | 2a09:8280:e601:1:0:22:b79b:0 | | arn | Stockholm | 209.71.98.189 | 2a09:8280:e602:1:0:22:b79b:0 | | bom | Mumbai | 209.71.68.172 | 2a09:8280:e605:1:0:22:b79b:0 | | cdg | Paris | 209.71.86.183 | 2a09:8280:e607:1:0:22:b79b:0 | | dfw | Dallas | 209.71.71.89 | 2a09:8280:e609:1:0:22:b79b:0 | | ewr | Newark | 209.71.69.221 | 2a09:8280:e610:1:0:22:b79b:0 | | fra | Frankfurt | 209.71.90.204 | 2a09:8280:e612:1:0:22:b79b:0 | | gru | São Paulo | 209.71.94.28 | 2a09:8280:e615:1:0:22:b79b:0 | | iad | Washington, D.C. | 209.71.81.6 | 2a09:8280:e618:1:0:22:b79b:0 | | jnb | Johannesburg | 209.71.83.120 | 2a09:8280:e620:1:0:22:b79b:0 | | lax | Los Angeles | 209.71.91.96 | 2a09:8280:e621:1:0:22:b79b:0 | | lhr | London | 209.71.85.82 | 2a09:8280:e622:1:0:22:b79b:0 | | nrt | Tokyo | 209.71.88.150 | 2a09:8280:e625:1:0:22:b79b:0 | | ord | Chicago | 209.71.89.1 | 2a09:8280:e626:1:0:22:b79b:0 | | sin | Singapore | 209.71.80.112 | 2a09:8280:e632:1:0:22:b79b:0 | | sjc | San Jose | 209.71.101.37 | 2a09:8280:e633:1:0:22:b79b:0 | | syd | Sydney | 209.71.97.108 | 2a09:8280:e634:1:0:22:b79b:0 | | yyz | Toronto | 209.71.99.51 | 2a09:8280:e637:1:0:22:b79b:0 | --- ## Railway Regions & Monitoring IPs Below is the complete list of Railway regions used for monitoring, along with their associated IPv4 addresses. | Region Code | Location Name | IPv4 Address | |--------------------------|------------------|-------------------| | europe-west4-drams3a | Europe West | 208.77.244.15 | | asia-southeast1-eqsg3a | Asia Southeast | 208.77.246.15 | | us-east4-eqdc4a | US East | 162.220.234.15 | | us-west2 | US West | 162.220.232.99 | --- ## Koyeb Regions & Monitoring IPs Koyeb does not provide static IP addresses for their regions. For more information, please refer to Koyeb's [documentation](https://www.koyeb.com/docs/faqs/general#i-want-to-restrict-access-to-a-database-or-other-application-by-ip-address-what-ip-addresses-does-koyeb-use). --- **Note:** - Both IPv4 and IPv6 addresses are provided for allowlisting and diagnostics. - Location names are for reference and may be used in the dashboard UI. ================================================ FILE: apps/docs/src/content/docs/reference/notification.mdx ================================================ --- title: Notification Channels Reference description: Technical specification for OpenStatus notification channels and alert payloads. --- ## Overview Notifications in OpenStatus provide real-time alerts regarding changes in monitor status, such as recovery from an outage or detection of a new failure. By default, no notification channels are configured in a new workspace. Users must configure and enable specific channels to receive alerts. ## Notification Channels Each notification channel requires specific configuration parameters to enable alert delivery. ### Slack Integrates with Slack to send alerts to a designated channel. **Configuration:** - **Incoming Webhook URL:** (Required) A [Slack incoming webhook URL](https://api.slack.com/incoming-webhooks) where notifications will be posted. **Example**: `https://hooks.slack.com/services/XXX/YYY/ZZZ` You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. ### Email Sends alerts directly to a specified email address. **Configuration:** - **Email Address:** (Required) The recipient's email address. ### Discord Delivers alerts to a Discord channel via a webhook. **Configuration:** - **Webhook URL:** (Required) A [Discord webhook URL](https://support.discord.com/hc/en-us/articles/228383668) for the target channel. **Example:** `https://discordapp.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890` You can [download the openstatus logo](https://www.openstatus.dev/assets/logos/openstatus.jpeg) to add a custom logo. ### Grafana OnCall IRM Sends notifications to a Grafana OnCall IRM. **Configuration:** - **Webhook URL:** (Required) A [Grafana OnCall IRM webhook URL](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/configure/integrations/webhooks/incoming-webhooks/oncall-webhooks/). ### Google Chat Sends notifications to a Google Chat space. **Configuration:** - **Webhook URL:** (Required) A [Google Chat webhook URL](https://developers.google.com/workspace/chat/quickstart/webhooks) for the target space. ### SMS Sends alerts as SMS messages to a mobile phone number. **Configuration:** - **Phone Number:** (Required) The recipient's phone number in international format (e.g., `+14155552671`). **Note:** SMS delivery can vary by country due to provider routing. Contact support if delivery issues are encountered. WhatsApp notifications may be an alternative. ### WhatsApp Sends alerts as WhatsApp messages to a mobile phone number. **Configuration:** - **Phone Number:** (Required) The recipient's phone number in international format (e.g., `+14155552671`). ### Telegram Delivers alerts to a specified Telegram chat. **Configuration:** - **Chat ID:** (Required) The unique identifier for the Telegram chat. This typically requires manual retrieval; users can ask `@raw_info_bot` for their chat ID. **Bot ID:** The official OpenStatus Telegram bot ID is `@openstatushq_bot`. ### Webhook Sends HTTP POST requests to a custom endpoint with a JSON payload. **Configuration:** - **URL:** (Required) The endpoint URL to which the webhook payload will be sent. - **Headers:** (Optional) Custom HTTP headers to include with the webhook request (key-value pairs). #### Notification Payloads Webhook notifications utilize specific JSON payloads for different monitor status changes. ##### Monitor Recovery Payload Sent when a monitor recovers from a `degraded` or `error` state. ```json { "monitor": { "id": 1, "name": "test", "url": "http://openstat.us" }, "cronTimestamp": 1744023705307, "status": "recovered", "statusCode": 200, "latency": 1337 } ``` **Payload Fields:** | Field | Type | Description | | :------------ | :------- | :------------------------------------------------------------------- | | `monitor.id` | `number` | Unique identifier of the monitor. | | `monitor.name`| `string` | Name of the monitor. | | `monitor.url` | `string` | The URL or URI being monitored. | | `cronTimestamp`| `number` | Timestamp of the check execution in milliseconds since epoch. | | `status` | `string` | Indicates the monitor status: `"recovered"`. | | `statusCode` | `number` | (Optional) HTTP status code returned by the monitored service. | | `latency` | `number` | (Optional) Time taken to complete the check in milliseconds. | ##### Monitor Failure Payload Sent when a monitor enters an `error` or `degraded` state. ```json { "monitor": { "id": 1, "name": "test", "url": "http://openstat.us" }, "cronTimestamp": 1744023705307, "status": "error", "errorMessage": "Connection refused" } ``` **Payload Fields:** | Field | Type | Description | | :------------ | :------- | :------------------------------------------------------------------- | | `monitor.id` | `number` | Unique identifier of the monitor. | | `monitor.name`| `string` | Name of the monitor. | | `monitor.url` | `string` | The URL or URI being monitored. | | `cronTimestamp`| `number` | Timestamp of the check execution in milliseconds since epoch. | | `status` | `string` | Indicates the monitor status: `"degraded"` or `"error"`. | | `errorMessage`| `string` | (Optional) A description of the error encountered during the check. | #### Zod Schema The validation schema for webhook payloads: ```ts import { z } from "zod"; export const PayloadSchema = z.object({ monitor: z.object({ id: z.number(), name: z.string(), url: z.string(), }), cronTimestamp: z.number(), status: z.enum(["degraded", "error", "recovered"]), statusCode: z.number().optional(), latency: z.number().optional(), errorMessage: z.string().optional(), }); ``` ### OpsGenie Integrates with OpsGenie for incident management. **Configuration:** - **API Key:** (Required) An API key obtained from your OpsGenie account. ### PagerDuty Integrates with PagerDuty for incident alerting. **Configuration:** - **Integration Steps:** (Required) Follow the specific integration steps provided within the PagerDuty workflow to set up this channel. ### Ntfy Sends notifications to an Ntfy topic. **Configuration:** - **Ntfy Topic:** (Required) The topic name to which notifications will be published. - **Custom Server URL:** (Optional) The URL of a custom Ntfy server if not using the default. - **Bearer Token:** (Optional) An authentication token for accessing the Ntfy server. ## Related resources - **[Incident Reference](/reference/incident)** - Information about incident creation and management. ================================================ FILE: apps/docs/src/content/docs/reference/page-components.mdx ================================================ --- title: Page Components Reference description: Complete specification for OpenStatus page components on status pages. --- import { Aside } from '@astrojs/starlight/components'; ## Overview Page components are the individual elements displayed on your status page that show the operational status of your services. They provide a flexible way to organize and present both monitored services and static content on your status page. **Key features:** - Support for both monitor-linked and static components. - Organize components into logical groups. - Custom ordering and arrangement. - Individual status tracking with incidents, reports, and maintenances. - Granular control over what appears on your status page. ## Component Types Page components come in two distinct types, each serving different purposes on your status page. ### Monitor Components **Type:** `monitor` Monitor components are linked to an active monitor in your workspace. They automatically inherit the monitor's status and display real-time health information. **Characteristics:** - Display live monitor status (up, degraded, down). - Show active incidents from the linked monitor. - Include historical uptime data. - Reflect the monitor's current operational state. - Automatically update when the monitor changes. **Use cases:** - Displaying API endpoint health. - Showing website availability. - Tracking critical service dependencies. - Monitoring infrastructure components. #### Configuring Monitor Components When you create a monitor component, you link it to an existing monitor in your workspace. This connection provides several benefits: **Automatic incident tracking:** When your monitor detects a failure (connection timeout, HTTP error, assertion failure), an incident is automatically created and displayed on the status page. The component will show an **error** status until the monitor recovers. **Real-time status updates:** The component reflects the current operational state of your monitor. If the monitor is actively checking and healthy, your visitors see a **success** status. If checks fail, they immediately see the issue. **Historical data visualization:** Monitor components display historical uptime data through status trackers. Depending on your [tracker configuration](/tutorial/how-to-configure-status-page#1-tracker-configuration), you can show: - **Absolute bar with duration card**: Shows the time spent in each status (success, error, degraded, maintenance). - **Absolute bar with request card**: Shows the number of successful vs. failed requests. - **Manual bar**: Shows only the most significant status of each day. **Uptime calculations:** Monitor components calculate uptime percentages based on: - Duration of successful vs. failed checks (for duration-based tracking). - Number of successful vs. failed requests (for request-based tracking). - Includes incidents and status reports in the calculation. **Monitor selection:** When adding a monitor component, the dashboard shows you available monitors with indicators for: - **Public/Private status**: Whether the monitor is already public. - **Active status**: Only active monitors can be linked. - **Already linked**: Monitors already used on this status page are unavailable. <Aside type="tip"> You can customize the component name to be different from the monitor name. For example, your monitor might be named "prod-api-health-check" internally, but the component can display as "API Server" for your visitors. </Aside> **Status hierarchy:** 1. **Error** - Active incidents from the linked monitor. 2. **Degraded** - Unresolved status reports affecting this component. 3. **Info** - Ongoing scheduled maintenance. 4. **Success** - Healthy and operational. **What affects monitor components:** - ✅ Automatic incidents (from monitor failures) - ✅ Manual status reports - ✅ Scheduled maintenances ### Static Components **Type:** `static` Static components are independent elements not linked to any monitor. They allow you to display services or systems that you manually manage through status reports and maintenance windows only. **Characteristics:** - No automatic status updates. - Status controlled exclusively by manual status reports and scheduled maintenances. - Useful for third-party services or manual tracking. - Do not display incidents (no automatic incident creation). - Provide flexibility for non-monitored services. **Use cases:** - Third-party service dependencies (e.g., payment providers like Stripe, email services like SendGrid). - Manual status tracking for systems without monitors. - Services monitored through external tools. - Components that only need maintenance window communication. - Legacy systems without API endpoints to monitor. <Aside type="caution"> Static components **only** respond to manual status reports and scheduled maintenances. They never automatically detect issues or create incidents. If you need automatic failure detection, use a monitor component instead. </Aside> #### Managing Static Components Static components give you full manual control over what your visitors see: **Status reports:** Create status reports to indicate issues or degraded performance for static components. For example: - "Stripe payment processing experiencing delays" (degraded status). - "Email delivery service partially unavailable" (degraded status). Once you resolve the issue and mark the status report as resolved, the component returns to a success status. **Maintenance windows:** Schedule maintenance windows to inform visitors about planned downtime: - "Scheduled database backup - Sunday 2:00 AM - 4:00 AM" (info status). - "Third-party CDN maintenance window" (info status). During the maintenance window, the component shows an info status. After the window ends, it returns to its previous status. **No automatic monitoring:** Static components do not perform any health checks or generate incidents. You are responsible for: - Monitoring the service through other means. - Creating status reports when issues occur. - Updating reports when issues are resolved. - Communicating maintenance windows in advance. **Status hierarchy:** 1. **Degraded** - Unresolved status reports affecting this component. 2. **Info** - Ongoing scheduled maintenance. 3. **Success** - No active reports or maintenances. **What affects static components:** - ✅ Manual status reports - ✅ Scheduled maintenances - ❌ Automatic incidents (not supported) ## Component Groups Component groups allow you to organize related page components into logical sections on your status page. Groups improve readability and help visitors understand your service architecture. **Benefits:** - Visual organization of related services. - Collapsible sections for better page structure. - Independent ordering within groups. - Clear service categorization. **Examples of grouping strategies:** | Group Name | Components | |------------|------------| | **API Services** | Authentication API, Data API, WebSocket API | | **Infrastructure** | Database, Cache, Message Queue | | **External Dependencies** | Payment Provider, Email Service, CDN | | **Regional Services** | US Region, EU Region, APAC Region | **Group configuration:** - **Name:** The group heading displayed on your status page. - **Order:** Position of the group relative to other groups and ungrouped components. - **Components:** The page components contained within this group. ## Events and Status Page components can be affected by up to three types of events that influence their displayed status. The type of component determines which events apply: | Event Type | Monitor Components | Static Components | |------------|-------------------|-------------------| | **Incidents** | ✅ Automatic | ❌ Not supported | | **Status Reports** | ✅ Manual | ✅ Manual | | **Maintenances** | ✅ Manual | ✅ Manual | ### Incidents **Applies to:** Monitor components only Incidents are automatically generated when a monitor detects a failure. They represent unplanned outages or degraded performance discovered through active monitoring. **How incidents are created:** - Monitor check fails (connection timeout, HTTP error, DNS failure). - Monitor assertion fails (wrong status code, unexpected response body). - Monitor reaches degraded threshold (response time too slow). **Status impact:** Components with active incidents show an **error** status. This takes the highest priority in the status hierarchy. **Resolution:** Incidents are automatically resolved when the monitor recovers and checks succeed again. <Aside> Static components **never** generate incidents because they are not linked to monitors. Use status reports for manual issue tracking on static components. </Aside> ### Status Reports **Applies to:** Both monitor and static components Status reports are manually created updates about component health or issues. They provide a way to communicate problems that may not trigger automatic monitoring or to manually report issues with static components. **Status impact:** Components with unresolved status reports show a **degraded** status (unless overridden by an incident for monitor components). **Use cases for monitor components:** - Reporting known issues that don't cause complete outages. - Communicating performance degradation not captured by monitoring. - Providing context for intermittent issues. **Use cases for static components:** - Reporting third-party service issues (e.g., "Stripe processing delays"). - Communicating external service degradation. - Announcing partial outages of non-monitored systems. **Attaching to components:** When creating a status report, you can select which components are affected. Multiple components can be attached to a single report. ### Maintenances **Applies to:** Both monitor and static components Maintenances are scheduled maintenance windows that you create in advance. They inform visitors about planned downtime or service interruptions for both monitored and static components. **Status impact:** Components with ongoing maintenances show an **info** status (unless overridden by incidents or reports). **Use cases for monitor components:** - Scheduled system upgrades that will cause downtime. - Infrastructure changes that affect monitored services. - Planned deployments requiring service restarts. **Use cases for static components:** - Third-party maintenance windows (e.g., "Payment provider scheduled maintenance"). - External service upgrade notifications. - Planned downtime for non-monitored dependencies. **Scheduling:** Maintenances have a defined start and end time. The info status automatically appears during the window and disappears when the maintenance ends. **Attaching to components:** When creating a maintenance, you select which components will be affected. This allows you to communicate maintenance impact across multiple services. ## Managing Components ### Adding Components You can add components to your status page in two ways: 1. **Individual components:** Add a single component outside of any group. 2. **Components within groups:** Add a component directly into a new or existing group. When adding a monitor component, you can only select from monitors that: - Are currently active. - Have not been deleted. - Are not already linked to another component on this status page. ### Reordering Components Components and groups can be reordered using drag-and-drop functionality in the dashboard. The order determines how they appear on your status page from top to bottom. **Ordering tips:** - Place your most critical services at the top. - Group related services together. - Consider visitor priorities when ordering. ### Editing Components You can modify the following properties of existing components: - Component name and description. - Group assignment (move between groups or make ungrouped). - Display order. **Note:** You cannot change a component's type (monitor to static or vice versa) after creation. To change types, delete the component and create a new one. ### Deleting Components When you delete a component, any associations with status reports and maintenances are automatically removed. The linked monitor (if applicable) is not deleted and remains available in your workspace. **Warning:** Deletion is permanent and cannot be undone. Ensure you want to remove the component before confirming deletion. ## Deprecation Notice The legacy monitor-only system for status pages is deprecated in favor of the more flexible page component system. **Deprecated approach:** - Status pages directly referenced monitors. - No support for static content. - Limited organizational flexibility. **Current approach (page components):** - Status pages contain page components. - Components can be monitors or static content. - Full support for grouping and custom ordering. - Better separation between monitoring and status page presentation. ### API Compatibility **v1 API (backward compatibility):** The v1 API continues to display `monitorIds` and `monitors` fields in status page responses to avoid breaking changes for existing integrations. However, these fields now only include page components that are explicitly of type `monitor`. Static components are not included in these legacy fields. **Future API versions:** Newer API versions will primarily use the `pageComponents` structure. The legacy `monitors` and `monitorIds` fields will be removed in future API versions. We recommend migrating your integrations to use `pageComponents` for full feature support. ## Related resources - **[Status Page Reference](/reference/status-page)** - Complete status page configuration reference. - **[Status Report Reference](/reference/status-report)** - Details on creating and managing status reports. - **[Create Status Page](/tutorial/how-to-create-status-page)** - Step-by-step tutorial on creating a status page. - **[HTTP Monitor Reference](/reference/http-monitor)** - Technical specification for HTTP monitors that can be linked to components. ================================================ FILE: apps/docs/src/content/docs/reference/private-location.mdx ================================================ --- title: Private Location Reference description: Technical specification for configuring and utilizing private monitoring locations. --- ## Overview A private location in OpenStatus enables users to deploy monitoring probes within their own infrastructure or private networks. This capability is essential for monitoring internal services, APIs, or systems that are not publicly accessible from the internet, such as those behind firewalls or within a Virtual Private Cloud (VPC). **Key benefits:** - **Internal Monitoring:** Monitor services running on private networks. - **Security:** Keep sensitive internal endpoints protected from public exposure. - **Compliance:** Meet specific regulatory or security compliance requirements by controlling data paths. - **Reduced Latency:** Conduct checks closer to your services for more accurate performance metrics. ## How it Works When a private location is configured, OpenStatus provides a mechanism (e.g., a container image or agent) that you deploy within your private environment. This deployed component acts as a local monitoring probe, executing checks on behalf of your OpenStatus account. 1. **Deployment:** You deploy the OpenStatus private probe within your chosen infrastructure (e.g., a Docker container on a server, a Kubernetes pod). 2. **Secure Connection:** The private probe establishes a secure, outbound-only connection to the OpenStatus platform, eliminating the need for inbound firewall rules. 3. **Check Execution:** OpenStatus dispatches monitoring tasks to your private probe via this secure connection. The probe then executes the configured checks against your internal services. 4. **Result Reporting:** The private probe securely sends the monitoring results (e.g., status, latency, response data) back to the OpenStatus platform for processing, alerting, and visualization. ## Configuration Detailed steps for setting up a private location involve: 1. **Probe Deployment:** Provisioning a server or container environment within your private network. 2. **Agent Installation:** Deploying the OpenStatus private probe agent (e.g., Docker image) onto your infrastructure. 3. **Authentication:** Configuring the probe with necessary API keys or tokens to securely authenticate with your OpenStatus workspace. 4. **Network Access:** Ensuring the deployed probe has network access to the internal services it needs to monitor, as well as outbound access to the OpenStatus platform. **Example Use Cases:** - Monitoring an internal REST API that is only accessible from within your corporate network. - Checking the health of a database server running on a private subnet. - Performing synthetic transactions on an internal web application before it's exposed publicly. ## Related resources - **[How to Deploy Probes on Cloudflare Containers](/guides/how-to-deploy-probes-cloudflare-containers)** - A guide for deploying private probes using Cloudflare Workers/Containers. (Example deployment guide) - **[CLI Reference](/reference/cli-reference)** - Manage monitors as code, including those utilizing private locations. ================================================ FILE: apps/docs/src/content/docs/reference/status-page.mdx ================================================ --- title: Status Page Reference description: Complete technical specification for OpenStatus status page configuration. --- ## Overview A status page is a dedicated web interface provided by OpenStatus that publicly displays the operational status of your services and systems. It serves as a transparent communication tool during incidents and for showcasing overall service health. **Key features:** - Real-time service status updates. - Incident communication and history. - Customizable branding and domain. - Multiple access control options. ## Configuration OpenStatus provides several configuration options to customize your status page's appearance, accessibility, and functionality. ### Slug **Type:** String (required) **Format:** URL-friendly string (e.g., `my-service-status`) A unique identifier that forms part of your status page's default URL. For example, a slug of `status` will result in a URL like `https://status.openstatus.dev`. ### Custom Domain **Type:** String (optional) **Format:** Valid domain name (e.g., `status.example.com`) Allows you to host your status page on a custom domain. Once configured, your status page will be accessible at `https://your-custom-domain.com`. ### Password (Basic Auth) **Type:** String (optional) Enables basic password protection for your status page. If a password is set, users will be redirected to a login page (`/login`) to gain access. The password is stored in a cookie upon successful authentication. **Sharing with password:** You can provide direct access by appending the password as a URL search parameter: `https://[slug].openstatus.dev/?pw=your-secret-password`. This method is also useful for authenticating private RSS feeds. ### Magic Link (Session Auth) **Type:** Boolean (add-on feature) Restricts access to your status page to users with approved email domains. Users receive a magic link via email, which, upon clicking, authenticates them via a session token. This feature is typically available as a paid add-on for specific plans. ### Favicon **Type:** Image file (e.g., `.ico`, `.png`) Allows you to upload a custom favicon that will appear in browser tabs and bookmarks for your status page. ### JSON Feed **Type:** Read-only endpoint **Format:** JSON Provides a machine-readable JSON representation of your status page data. This feed can be accessed by appending `/feed/json` to your status page URL. **Example:** `https://status.openstatus.dev/feed/json` **Deprecation Notice:** The following fields are deprecated and will be removed in a future version: - **`monitors`** (top-level): Use `pageComponents` instead, which provides a more flexible component-based structure that supports both monitors and external services. - **`maintenances[].monitors`**: Use `maintenances[].pageComponents` instead, which references page component IDs rather than monitor IDs. - **`statusReports[].monitors`**: Use `statusReports[].pageComponents` instead, which references page component IDs rather than monitor IDs. These deprecated fields are currently maintained for backward compatibility but may be removed in future versions. ### SSH Command **Type:** Command-line utility Allows you to quickly check the current status page status directly from your terminal using an SSH command. **Usage:** ```bash ssh [slug]@ssh.openstatus.dev ``` **Example:** `ssh my-service@ssh.openstatus.dev` ### White Label **Type:** Boolean (add-on feature) Removes the "powered by openstatus.dev" footer from your status page, providing a fully branded experience. This feature is typically available as a paid add-on for Starter and Pro plans and is enabled via your workspace settings, affecting all status pages within that workspace. ## Related resources - **[Create Status Page](/tutorial/how-to-create-status-page)** - Step-by-step tutorial on creating a status page. - **[How to Configure Status Page](/tutorial/how-to-configure-status-page)** - Guide on advanced status page configuration. - **[Status Report Reference](/reference/status-report)** - Details on how incident statuses are managed and reported. ================================================ FILE: apps/docs/src/content/docs/reference/status-report.mdx ================================================ --- title: Status Report Reference description: Technical specification for incident status updates within OpenStatus. --- ## Overview A status report is a chronological update or event associated with an ongoing incident in OpenStatus. These reports are crucial for communicating the progress of an incident, from initial detection to final resolution, providing transparency to stakeholders. **Purpose:** - To document the lifecycle and progress of an incident. - To communicate current incident status and actions taken. - To provide historical context for post-incident analysis. ## Relationship to Incidents Each status report is directly linked to a specific incident. As an incident progresses through its resolution process, new status reports are added to provide updates, often accompanied by a change in the incident's overall status. ## Configuration and Properties A status report consists of several key properties that define its content and context. ### Status **Type:** Enumerated String (required) Represents the current stage or state of the associated incident at the time the report is issued. The available statuses are: - `investigating`: The incident has been detected, and the team is actively looking into the root cause. - `identified`: The root cause of the incident has been identified. - `monitoring`: A fix has been deployed or a mitigation is in place, and the service is being monitored to confirm resolution. - `resolved`: The incident has been fully resolved, and the service is operating normally. ### Date **Type:** Datetime (required) **Format:** ISO 8601 (e.g., `2026-01-05T12:30:00Z`) The timestamp indicating when the status report was created or when the reported status took effect. This provides a clear timeline for incident progression. ### Message **Type:** String (required) A descriptive message detailing the update, actions taken, or any relevant information regarding the incident at the time of the report. This message should be clear and concise, providing context to the status change. **Example Messages:** - `"Initial detection of elevated error rates on the API. Investigating potential upstream issues."` - `"Root cause identified as a misconfigured caching layer. Working on a rollback."` - `"Fix deployed to production. Monitoring service health for full recovery."` - `"All services restored to normal operation. Incident resolved."` ## Related resources - **[Incident Reference](/reference/incident)** - Detailed information on incident creation and lifecycle. - **[Status Page Reference](/reference/status-page)** - Information on how status reports are displayed on public status pages. ================================================ FILE: apps/docs/src/content/docs/reference/subscriber.mdx ================================================ --- title: Subscriber Reference description: Technical specification for managing status page subscribers and their notifications. --- ## Overview A subscriber in OpenStatus is an entity (typically a user or an integration) that opts to receive real-time notifications and updates regarding incidents and status changes on a specific status page. Subscribers play a crucial role in maintaining transparent communication during service disruptions. **Key functions of subscribers:** - Receive automated alerts when monitor statuses change or incidents are updated. - Stay informed about service health without actively monitoring the status page. - Choose preferred notification channels for receiving updates. ## Subscription Process Users typically subscribe to a status page's updates through a dedicated interface provided on the status page itself. The process involves: 1. **Inputting Contact Information:** Providing an email address, phone number, or other contact details depending on the available notification channels. 2. **Opt-in Confirmation:** Confirming their subscription, often through a verification link sent to the provided contact to prevent unwanted subscriptions. 3. **Channel Selection (Optional):** Selecting which specific notification channels (e.g., email, SMS, Slack webhook) they wish to receive updates through, if multiple options are available. ## Notification Types Received Subscribers receive notifications for key events affecting the monitored services linked to the status page: - **Incident Creation:** When a new incident is detected and published. - **Incident Updates:** When status reports are published for an ongoing incident (e.g., status changes from `investigating` to `identified`, `monitoring`, or `resolved`). - **Monitor Status Changes:** Direct alerts for individual monitor status changes if configured to do so (less common for public subscribers). ## Subscriber Management Status page administrators can manage their subscriber lists, including: - **Viewing Subscribers:** Accessing a list of all active subscribers for a status page. - **Adding/Removing Subscribers:** Manually adding or removing subscribers. - **Communication:** Sending ad-hoc notifications to the subscriber list (if supported by the platform). ## Related resources - **[Status Page Reference](/reference/status-page)** - Detailed information on managing and configuring status pages. - **[Notification Channels Reference](/reference/notification)** - Technical specifications for the various notification delivery methods. - **[Incident Reference](/reference/incident)** - Information about incident creation and management. ================================================ FILE: apps/docs/src/content/docs/reference/tcp-monitor.mdx ================================================ --- title: TCP Monitor Reference description: Complete technical specification for TCP service monitoring. --- ## Overview A TCP Monitor is a component that establishes a connection to a specified TCP endpoint (IP address and port) to verify its reachability and responsiveness. This is fundamental for monitoring the availability of services that communicate over TCP, such as databases, mail servers, and custom network services. **Use cases:** - Database server availability checks (e.g., PostgreSQL, MySQL). - Mail server (SMTP, IMAP, POP3) reachability. - Custom application service port monitoring. - Validating network connectivity to specific endpoints. ## Configuration ### URI **Type:** String (required) **Format:** Hostname or IP address with port (e.g., `example.com:8080`, `192.168.1.1:22`) The endpoint of the TCP service you want to monitor. This includes the hostname or IP address and the port number. **Examples:** - `openstat.us:443` - `db.internal:5432` - `10.0.0.5:3306` ### Regions **Type:** Array of Strings (required) **Format:** Region identifiers (e.g., `iad`, `jnb`) The geographical regions from which the TCP connection attempt will be initiated. This allows for verification of service availability and network latency across different global locations. __Africa__ - Johannesburg, South Africa 🇿🇦 (free) __Asia__ - Hong Kong, Hong Kong 🇭🇰 (free) - Mumbai, India 🇮🇳 - Singapore, Singapore 🇸🇬 - Tokyo, Japan 🇯🇵 __Europe__ - Amsterdam, Netherlands 🇳🇱 (free) - Bucharest, Romania 🇷🇴 - Frankfurt, Germany 🇩🇪 - London, United Kingdom 🇬🇧 - Madrid, Spain 🇪🇸 - Paris, France 🇫🇷 - Stockholm, Sweden 🇸🇪 - Warsaw, Poland 🇵🇱 __North America__ - Ashburn, Virginia, USA 🇺🇸 (free) - Atlanta, Georgia, USA 🇺🇸 - Boston, Massachusetts, USA 🇺🇸 - Chicago, Illinois, USA 🇺🇸 - Dallas, Texas, USA 🇺🇸 - Denver, Colorado, USA 🇺🇸 - Guadalajara, Mexico 🇲🇽 - Los Angeles, California, USA 🇺🇸 - Miami, Florida, USA 🇺🇸 - Montreal, Canada 🇨🇦 - Phoenix, Arizona, USA 🇺🇸 - Queretaro, Mexico 🇲🇽 - Seattle, Washington, USA 🇺🇸 - San Jose, California, USA 🇺🇸 - Toronto, Canada 🇨🇦 __South America__ - Bogota, Colombia 🇨🇴 - Buenos Aires, Argentina 🇦🇷 - Rio de Janeiro, Brazil 🇧🇷 - Sao Paulo, Brazil 🇧🇷 (free) - Santiago, Chile 🇨🇱 __Oceania__ - Sydney, Australia 🇦🇺 (free) ### Frequency **Type:** String (required) **Format:** Duration string (e.g., `30s`, `1m`, `1h`) The interval at which the TCP monitor will attempt to connect to the target URI. Supported frequencies: - `30 seconds` - `1 minute` - `5 minutes` - `10 minutes` - `30 minutes` - `1 hour` ### Response Time Thresholds #### Timeout **Type:** Duration (optional) **Default:** `45 seconds` The maximum duration to wait for a successful TCP connection. If the connection cannot be established within this time, the check is considered a failure. #### Degraded **Type:** Duration (optional) The duration after which a TCP connection attempt is considered to be in a degraded performance state. This allows for early warning of network latency or service slowdowns. ### Retry **Type:** Integer (optional) **Default:** `3` The number of times the monitor will automatically retry a failed TCP connection attempt before reporting a definitive error. For example: `3` ### OpenTelemetry Configures the export of monitoring metrics to an OpenTelemetry-compatible observability platform. #### OTLP Endpoint **Type:** String (optional) **Protocol:** HTTP only The OTLP (OpenTelemetry Protocol) endpoint URL where collected metrics should be exported. Only HTTP endpoints are supported for metric export. #### OTLP Headers **Type:** Key-value pairs (optional) Custom headers to include when sending metrics to your OTLP endpoint. Commonly used for authentication or tenant identification. **Common example:** ``` Authorization: Bearer <your_token> ``` ## Related resources - **[Create Your First Monitor](/tutorial/how-to-create-monitor)** - Step-by-step tutorial on setting up a monitor. - **[CLI Reference](/reference/cli-reference)** - Guide to managing OpenStatus monitors programmatically using the command-line interface. ================================================ FILE: apps/docs/src/content/docs/reference/terraform.mdx ================================================ --- title: Terraform Provider Reference description: Technical specification for the OpenStatus Terraform Provider. --- ## Overview The OpenStatus Terraform provider enables you to manage your OpenStatus monitors and status pages programmatically using HashiCorp Terraform. This allows for Infrastructure as Code (IaC) practices, version control, and automated deployment of your monitoring configurations. **Key capabilities:** - Define and manage OpenStatus monitors as Terraform resources. - Automate the deployment and updates of monitoring configurations. - Integrate OpenStatus into your existing IaC workflows. ## Installation To use the OpenStatus Terraform provider, declare it in your Terraform configuration file (`.tf`). Terraform will automatically download and install the provider when you run `terraform init`. ```terraform terraform { required_providers { openstatus = { source = "openstatusHQ/openstatus" version = "~> 0.1" # Use the latest version } } } ``` For the latest provider version, refer to the [official Terraform Registry](https://registry.terraform.io/providers/openstatusHQ/openstatus/latest). ## Provider Configuration The OpenStatus Terraform provider requires authentication via an API token. ### `openstatus_api_token` **Type:** String (required) **Description:** Your OpenStatus API Access Token. This token is used to authenticate your Terraform requests with the OpenStatus API. **Example:** ```terraform provider "openstatus" { openstatus_api_token = "YOUR_OPENSTATUS_API_TOKEN" } ``` ## Resources The provider currently supports managing `openstatus_monitor` resources. ### `openstatus_monitor` Manages an OpenStatus monitor. This resource allows you to define and control the parameters of a synthetic monitor. **Arguments:** | Argument | Type | Required | Default | Description | | :------------ | :-------------------- | :------- | :------ | :---------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | `string` | Yes | | The URL or URI of the endpoint to be monitored. Format depends on the monitor type (e.g., full URL for HTTP, host:port for TCP). | | `regions` | `list(string)` | Yes | | A list of region identifiers (e.g., `"iad"`, `"jnb"`) from where the monitor checks will be performed. | | `periodicity` | `string` | Yes | | The frequency at which the monitor will perform checks. Supported values: `"30s"`, `"1m"`, `"5m"`, `"10m"`, `"30m"`, `"1h"`. | | `name` | `string` | Yes | | A human-readable name for the monitor. | | `active` | `bool` | Yes | | Specifies whether the monitor is active (`true`) or paused (`false`). | | `description` | `string` (optional) | No | `""` | A detailed description of the monitor's purpose or configuration. | | `monitor_type` | `string` | Yes | | The type of monitor to create. Supported values: `"HTTP"`, `"TCP"`, `"DNS"` | | `method` | `string` (optional) | No | `"GET"` | (HTTP monitors only) The HTTP method to use for the request (e.g., `"GET"`, `"POST"`). | | `body` | `string` (optional) | No | `""` | (HTTP monitors only) The request body to send for `POST`, `PUT`, `PATCH` methods. | | `headers` | `map(string)` (optional)| No | `{}` | (HTTP monitors only) A map of custom HTTP headers to include with the request. | | `timeout` | `string` (optional) | No | `"45s"` | The maximum duration to wait for a response. Format: duration string (e.g., `"30s"`, `"1m"`). | | `degraded_after`| `string` (optional) | No | `""` | The duration after which a response is considered degraded. Format: duration string. | | `retries` | `number` (optional) | No | `3` | The number of times the monitor will retry a failed check. | | `public` | `bool` (optional) | No | `false` | Controls whether monitor data is accessible on your public status page. | | `otlp_endpoint`| `string` (optional) | No | `""` | The OTLP (OpenTelemetry Protocol) endpoint URL for exporting metrics. | | `otlp_headers`| `map(string)` (optional)| No | `{}` | Custom headers to include when exporting metrics to your OTLP endpoint. | **Example Usage:** ```terraform resource "openstatus_monitor" "my_website_monitor" { name = "My Website Availability" description = "Checks the main website for uptime and response time." url = "https://www.example.com" monitor_type = "HTTP" method = "GET" regions = ["us-east-1", "eu-west-1"] periodicity = "1m" active = true public = true timeout = "60s" } resource "openstatus_monitor" "internal_api_monitor" { name = "Internal API Health Check" description = "Monitors the health of a critical internal API." url = "https://api.internal.corp:8443/health" monitor_type = "HTTP" method = "GET" regions = ["private-location-id"] periodicity = "5m" active = true public = false headers = { "Authorization" = "Bearer ${var.internal_api_token}" "Accept" = "application/json" } } resource "openstatus_monitor" "database_port_monitor" { name = "Database TCP Port Check" description = "Ensures the PostgreSQL port is open and reachable." url = "db.example.com:5432" monitor_type = "TCP" regions = ["us-west-2"] periodicity = "30s" active = true } ``` ## Related resources - **[HTTP Monitor Reference](/reference/http-monitor)** - Detailed specification for HTTP monitor configuration. - **[TCP Monitor Reference](/reference/tcp-monitor)** - Detailed specification for TCP monitor configuration. - **[DNS Monitor Reference](/reference/dns-monitor)** - Detailed specification for DNS monitor configuration. - **[CLI Reference](/reference/cli-reference)** - Manage monitors using the OpenStatus command-line interface. ================================================ FILE: apps/docs/src/content/docs/reference.mdx ================================================ --- title: Reference Documentation description: Technical specifications, API documentation, and configuration references --- import { CardGrid, LinkCard } from '@astrojs/starlight/components'; ## 📖 Reference Reference documentation provides **technical specifications** and detailed information about openstatus components, APIs, and configuration options. This is where you look up exact parameter names, return types, and available options. ### When to use reference docs Reference documentation is ideal when you: - Need to look up specific API endpoints or parameters - Want to know all available configuration options - Are looking for technical specifications - Need to understand data structures and types ### Monitor Types Detailed specifications for each monitor type: <CardGrid> <LinkCard title="HTTP Monitor" href="/reference/http-monitor" description="Complete reference for HTTP/HTTPS endpoint monitoring" /> <LinkCard title="TCP Monitor" href="/reference/tcp-monitor" description="TCP port monitoring specifications" /> <LinkCard title="DNS Monitor" href="/reference/dns-monitor" description="DNS resolution monitoring reference" /> </CardGrid> ### Components & Features <CardGrid> <LinkCard title="Status Page" href="/reference/status-page" description="Status page configuration options" /> <LinkCard title="Status Report" href="/reference/status-report" description="Status report specifications" /> <LinkCard title="Subscriber" href="/reference/subscriber" description="Status page subscriber reference" /> <LinkCard title="Incident" href="/reference/incident" description="Incident management reference" /> <LinkCard title="Notification" href="/reference/notification" description="Notification channel specifications" /> </CardGrid> ### Infrastructure & Tools <CardGrid> <LinkCard title="CLI Reference" href="/reference/cli-reference" description="Complete command-line interface documentation" /> <LinkCard title="Terraform Provider" href="/reference/terraform" description="Infrastructure as code with Terraform" /> <LinkCard title="Private Location" href="/reference/private-location" description="Self-hosted monitoring agent reference" /> </CardGrid> ### API Documentation For programmatic access to openstatus: - **[REST API](https://api.openstatus.dev/v1)** - Full REST API reference with interactive examples ### Related sections - **[Tutorials](/tutorial/getting-started/)** - Learn how to use these features step-by-step - **[How-to guides](/guides/getting-started/)** - Practical examples of common use cases - **[Explanations](/concept/getting-started/)** - Understand the concepts behind the reference material ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/authentication.mdx ================================================ --- title: Authentication description: "Configure API key authentication for the OpenStatus Node.js SDK" --- ## Recommended: createOpenStatusClient Create a client with your API key. The key is automatically included in all requests via an interceptor. ```typescript import { createOpenStatusClient } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); // No headers needed on individual calls const { httpMonitors } = await client.monitor.v1.MonitorService.listMonitors({}); ``` ## Alternative: Manual Headers Use the default `openstatus` client and pass headers on each call. ```typescript import { openstatus } from "@openstatus/sdk-node"; const headers = { "x-openstatus-key": process.env.OPENSTATUS_API_KEY, }; await openstatus.monitor.v1.MonitorService.listMonitors({}, { headers }); ``` ## Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `OPENSTATUS_API_KEY` | Your OpenStatus API key | Required for authenticated calls | | `OPENSTATUS_API_URL` | Custom API endpoint | `https://api.openstatus.dev/rpc` | Get your API key from the [OpenStatus dashboard](https://www.openstatus.dev/app). ## Custom Base URL For self-hosted instances or staging environments: ```typescript import { createOpenStatusClient } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, baseUrl: "https://api.staging.example.com/rpc", }); ``` The `baseUrl` option takes precedence over the `OPENSTATUS_API_URL` environment variable. ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/error-handling.mdx ================================================ --- title: Error Handling description: "Handle errors and implement retry strategies with the OpenStatus Node.js SDK" --- The SDK uses ConnectRPC. Errors are thrown as `ConnectError` instances from the `@connectrpc/connect` package. ```typescript import { ConnectError } from "@connectrpc/connect"; try { await client.monitor.v1.MonitorService.deleteMonitor({ id: "invalid" }); } catch (error) { if (error instanceof ConnectError) { console.error(`Code: ${error.code}`); console.error(`Message: ${error.message}`); } } ``` ## Common Error Codes | Code | Description | |------|-------------| | `unauthenticated` | Missing or invalid API key | | `not_found` | Resource does not exist | | `invalid_argument` | Validation failure (e.g., missing required field, value out of range) | | `permission_denied` | No access to this workspace or resource | | `already_exists` | Duplicate resource (e.g., slug already taken) | ## Retry Strategy ConnectRPC does not retry by default. For transient failures (`unavailable`, `deadline_exceeded`), implement your own retry logic: ```typescript import { ConnectError } from "@connectrpc/connect"; async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if ( error instanceof ConnectError && (error.code === "unavailable" || error.code === "deadline_exceeded") && attempt < maxRetries ) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw error; } } throw new Error("Unreachable"); } const { httpMonitors } = await withRetry(() => client.monitor.v1.MonitorService.listMonitors({}) ); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/getting-started.mdx ================================================ --- title: Getting Started description: "Install and start using the OpenStatus Node.js SDK" --- import { Aside } from '@astrojs/starlight/components'; ## Get Your API Key Before using the SDK, you need an API key: 1. Log in to the [OpenStatus dashboard](https://www.openstatus.dev/app/login) 2. Go to **Settings** > **API Keys** 3. Click **Create API Key** and copy it <Aside type="tip">Store your API key as an environment variable (`OPENSTATUS_API_KEY`) — never commit it to source control.</Aside> ## Installation ### npm ```bash npm install @openstatus/sdk-node ``` ### JSR ```bash npx jsr add @openstatus/sdk-node ``` ### Deno ```typescript import { createOpenStatusClient } from "jsr:@openstatus/sdk-node"; ``` ### Bun ```bash bun add @openstatus/sdk-node ``` ## Quick Start ```typescript import { createOpenStatusClient, Periodicity, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); // Create an HTTP monitor const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "My API", url: "https://api.example.com/health", periodicity: Periodicity.PERIODICITY_1M, regions: [Region.FLY_AMS, Region.FLY_IAD, Region.FLY_SYD], active: true, }, }); console.log(`Monitor created: ${monitor?.id}`); // List all monitors const { httpMonitors, tcpMonitors, dnsMonitors, totalSize } = await client.monitor.v1.MonitorService.listMonitors({}); console.log(`Found ${totalSize} monitors`); ``` ## Runtime Support | Runtime | Version | Module Format | |---------|---------|---------------| | Node.js | 18+ | ESM and CJS | | Deno | 2+ | ESM (native) | | Bun | Latest | ESM | ## Full Workflow Example A complete example: create a monitor, set up a status page, add the monitor as a component, configure a Slack notification, and check overall status. ```typescript import { createOpenStatusClient, NotificationProvider, Periodicity, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); // 1. Check API health const health = await client.health.v1.HealthService.check({}); console.log(`API status: ${health.status}`); // 2. Create an HTTP monitor const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "Production API", url: "https://api.example.com/health", periodicity: Periodicity.PERIODICITY_1M, regions: [Region.FLY_AMS, Region.FLY_IAD, Region.FLY_SYD], active: true, }, }); // 3. Create a status page const { statusPage } = await client.statusPage.v1.StatusPageService .createStatusPage({ title: "Example Status", slug: "example-status", description: "Status page for Example services", }); // 4. Add the monitor as a component const { component } = await client.statusPage.v1.StatusPageService .addMonitorComponent({ pageId: statusPage!.id, monitorId: monitor!.id, name: "Production API", }); // 5. Set up Slack notifications const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Slack Alerts", provider: NotificationProvider.SLACK, data: { data: { case: "slack", value: { webhookUrl: "https://hooks.slack.com/services/..." }, }, }, monitorIds: [monitor!.id], }); // 6. Check overall status const { overallStatus } = await client.statusPage.v1.StatusPageService .getOverallStatus({ identifier: { case: "id", value: statusPage!.id }, }); console.log(`Overall status: ${overallStatus}`); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/health-service.mdx ================================================ --- title: Health Service description: "Check the OpenStatus API health status using the Node.js SDK" --- Check API health status. No authentication required. ```typescript import { openstatus, ServingStatus } from "@openstatus/sdk-node"; const { status } = await openstatus.health.v1.HealthService.check({}); console.log(ServingStatus[status]); // "SERVING" ``` Or with a configured client: ```typescript import { createOpenStatusClient, ServingStatus } from "@openstatus/sdk-node"; const client = createOpenStatusClient(); const { status } = await client.health.v1.HealthService.check({}); console.log(ServingStatus[status]); // "SERVING" ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/index.mdx ================================================ --- title: Getting Started description: "Install and start using the OpenStatus Node.js SDK" --- import { Aside } from '@astrojs/starlight/components'; ## Get Your API Key Before using the SDK, you need an API key: 1. Log in to the [OpenStatus dashboard](https://www.openstatus.dev/app/login) 2. Go to **Settings** > **API Keys** 3. Click **Create API Key** and copy it <Aside type="tip">Store your API key as an environment variable (`OPENSTATUS_API_KEY`) — never commit it to source control.</Aside> ## Installation ### npm ```bash npm install @openstatus/sdk-node ``` ### JSR ```bash npx jsr add @openstatus/sdk-node ``` ### Deno ```typescript import { createOpenStatusClient } from "jsr:@openstatus/sdk-node"; ``` ### Bun ```bash bun add @openstatus/sdk-node ``` ## Quick Start ```typescript import { createOpenStatusClient, Periodicity, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); // Create an HTTP monitor const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "My API", url: "https://api.example.com/health", periodicity: Periodicity.PERIODICITY_1M, regions: [Region.FLY_AMS, Region.FLY_IAD, Region.FLY_SYD], active: true, }, }); console.log(`Monitor created: ${monitor?.id}`); // List all monitors const { httpMonitors, tcpMonitors, dnsMonitors, totalSize } = await client.monitor.v1.MonitorService.listMonitors({}); console.log(`Found ${totalSize} monitors`); ``` ## Runtime Support | Runtime | Version | Module Format | |---------|---------|---------------| | Node.js | 18+ | ESM and CJS | | Deno | 2+ | ESM (native) | | Bun | Latest | ESM | ## Full Workflow Example A complete example: create a monitor, set up a status page, add the monitor as a component, configure a Slack notification, and check overall status. ```typescript import { createOpenStatusClient, NotificationProvider, Periodicity, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); // 1. Check API health const health = await client.health.v1.HealthService.check({}); console.log(`API status: ${health.status}`); // 2. Create an HTTP monitor const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "Production API", url: "https://api.example.com/health", periodicity: Periodicity.PERIODICITY_1M, regions: [Region.FLY_AMS, Region.FLY_IAD, Region.FLY_SYD], active: true, }, }); // 3. Create a status page const { statusPage } = await client.statusPage.v1.StatusPageService .createStatusPage({ title: "Example Status", slug: "example-status", description: "Status page for Example services", }); // 4. Add the monitor as a component const { component } = await client.statusPage.v1.StatusPageService .addMonitorComponent({ pageId: statusPage!.id, monitorId: monitor!.id, name: "Production API", }); // 5. Set up Slack notifications const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Slack Alerts", provider: NotificationProvider.SLACK, data: { data: { case: "slack", value: { webhookUrl: "https://hooks.slack.com/services/..." }, }, }, monitorIds: [monitor!.id], }); // 6. Check overall status const { overallStatus } = await client.statusPage.v1.StatusPageService .getOverallStatus({ identifier: { case: "id", value: statusPage!.id }, }); console.log(`Overall status: ${overallStatus}`); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/maintenance-service.mdx ================================================ --- title: Maintenance Service description: "Manage scheduled maintenance windows with the OpenStatus Node.js SDK" --- Manage scheduled maintenance windows. The Maintenance Service provides 5 RPC methods. ## Create Maintenance Window ```typescript const { maintenance } = await client.maintenance.v1.MaintenanceService .createMaintenance({ title: "Database Upgrade", message: "We will be upgrading our database infrastructure.", from: "2024-01-20T02:00:00Z", to: "2024-01-20T04:00:00Z", pageId: "page_123", pageComponentIds: ["comp_456"], notify: true, }); console.log(`Maintenance created: ${maintenance?.id}`); ``` All date fields (`from`, `to`) must be in RFC 3339 format. ## List Maintenances List maintenance windows with optional page filtering. ```typescript const { maintenances, totalSize } = await client.maintenance.v1 .MaintenanceService.listMaintenances({ limit: 10, offset: 0, pageId: "page_123", }); console.log(`Found ${totalSize} maintenance windows`); ``` ## Get / Update / Delete Maintenance Windows ### Get Maintenance ```typescript const { maintenance } = await client.maintenance.v1.MaintenanceService .getMaintenance({ id: "maint_123" }); console.log(`Title: ${maintenance?.title}`); console.log(`From: ${maintenance?.from}`); console.log(`To: ${maintenance?.to}`); ``` ### Update Maintenance ```typescript const { maintenance } = await client.maintenance.v1.MaintenanceService .updateMaintenance({ id: "maint_123", title: "Extended Database Upgrade", to: "2024-01-20T06:00:00Z", }); ``` ### Delete Maintenance ```typescript const { success } = await client.maintenance.v1.MaintenanceService .deleteMaintenance({ id: "maint_123" }); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/monitor-service.mdx ================================================ --- title: Monitor Service description: "Create and manage HTTP, TCP, and DNS monitors with the OpenStatus Node.js SDK" --- Manage HTTP, TCP, and DNS monitors. The Monitor Service provides 12 RPC methods for creating, updating, listing, triggering, deleting, and querying monitor status and metrics. All examples assume you have created a client: ```typescript import { createOpenStatusClient } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); ``` ## HTTP Monitors ### Create HTTP Monitor ```typescript import { createOpenStatusClient, HTTPMethod, NumberComparator, Periodicity, Region, StringComparator, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "My API", url: "https://api.example.com/health", periodicity: Periodicity.PERIODICITY_1M, method: HTTPMethod.HTTP_METHOD_GET, regions: [Region.FLY_AMS, Region.FLY_IAD, Region.FLY_SYD], active: true, timeout: BigInt(30000), retry: BigInt(3), followRedirects: true, degradedAt: BigInt(5000), headers: [ { key: "Authorization", value: "Bearer my-token" }, ], statusCodeAssertions: [ { comparator: NumberComparator.EQUAL, target: BigInt(200) }, ], bodyAssertions: [ { comparator: StringComparator.CONTAINS, target: '"status":"ok"' }, ], headerAssertions: [ { key: "content-type", comparator: StringComparator.CONTAINS, target: "application/json", }, ], description: "Health check for the production API", public: false, openTelemetry: { endpoint: "https://otel.example.com/v1/traces", headers: [{ key: "Authorization", value: "Bearer otel-token" }], }, }, }); console.log(`Created monitor: ${monitor?.id}`); ``` ### Update HTTP Monitor Updates are partial — only include the fields you want to change. ```typescript const { monitor } = await client.monitor.v1.MonitorService.updateHTTPMonitor({ id: "mon_123", monitor: { name: "Updated API Monitor", active: false, }, }); ``` ### HTTP Monitor Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `name` | string | Yes | Monitor name (max 256 chars) | | `url` | string | Yes | URL to monitor (max 2048 chars) | | `periodicity` | Periodicity | Yes | Check interval | | `method` | HTTPMethod | No | HTTP method (default: GET) | | `body` | string | No | Request body | | `headers` | Headers[] | No | Custom headers `{ key, value }[]` | | `timeout` | bigint | No | Timeout in ms (default: 45000, max: 120000) | | `retry` | bigint | No | Retry attempts (default: 3, max: 10) | | `followRedirects` | boolean | No | Follow redirects (default: true) | | `regions` | Region[] | No | Regions for checks | | `active` | boolean | No | Enable monitoring (default: false) | | `public` | boolean | No | Public visibility (default: false) | | `degradedAt` | bigint | No | Latency threshold (ms) for degraded status | | `description` | string | No | Monitor description (max 1024 chars) | | `statusCodeAssertions` | StatusCodeAssertion[] | No | Status code assertions | | `bodyAssertions` | BodyAssertion[] | No | Body assertions | | `headerAssertions` | HeaderAssertion[] | No | Header assertions | | `openTelemetry` | OpenTelemetryConfig | No | OpenTelemetry export configuration | ## TCP Monitors ### Create TCP Monitor ```typescript import { createOpenStatusClient, Periodicity, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { monitor } = await client.monitor.v1.MonitorService.createTCPMonitor({ monitor: { name: "Database", uri: "db.example.com:5432", periodicity: Periodicity.PERIODICITY_5M, regions: [Region.FLY_AMS, Region.FLY_IAD], active: true, }, }); ``` ### Update TCP Monitor ```typescript const { monitor } = await client.monitor.v1.MonitorService.updateTCPMonitor({ id: "mon_123", monitor: { name: "Updated Database Monitor", }, }); ``` ### TCP Monitor Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `name` | string | Yes | Monitor name (max 256 chars) | | `uri` | string | Yes | `host:port` to monitor (max 2048 chars) | | `periodicity` | Periodicity | Yes | Check interval | | `timeout` | bigint | No | Timeout in ms (default: 45000, max: 120000) | | `retry` | bigint | No | Retry attempts (default: 3, max: 10) | | `regions` | Region[] | No | Regions for checks | | `active` | boolean | No | Enable monitoring (default: false) | | `public` | boolean | No | Public visibility (default: false) | | `degradedAt` | bigint | No | Latency threshold (ms) for degraded status | | `description` | string | No | Monitor description (max 1024 chars) | | `openTelemetry` | OpenTelemetryConfig | No | OpenTelemetry export configuration | ## DNS Monitors ### Create DNS Monitor ```typescript import { createOpenStatusClient, Periodicity, RecordComparator, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { monitor } = await client.monitor.v1.MonitorService.createDNSMonitor({ monitor: { name: "DNS Check", uri: "example.com", periodicity: Periodicity.PERIODICITY_10M, regions: [Region.FLY_AMS], active: true, recordAssertions: [ { record: "A", comparator: RecordComparator.EQUAL, target: "93.184.216.34", }, { record: "CNAME", comparator: RecordComparator.CONTAINS, target: "cdn", }, ], }, }); ``` ### Update DNS Monitor ```typescript const { monitor } = await client.monitor.v1.MonitorService.updateDNSMonitor({ id: "mon_123", monitor: { name: "Updated DNS Check", }, }); ``` ### DNS Monitor Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `name` | string | Yes | Monitor name (max 256 chars) | | `uri` | string | Yes | Domain to resolve (max 2048 chars) | | `periodicity` | Periodicity | Yes | Check interval | | `timeout` | bigint | No | Timeout in ms (default: 45000, max: 120000) | | `retry` | bigint | No | Retry attempts (default: 3, max: 10) | | `regions` | Region[] | No | Regions for checks | | `active` | boolean | No | Enable monitoring (default: false) | | `public` | boolean | No | Public visibility (default: false) | | `degradedAt` | bigint | No | Latency threshold (ms) for degraded status | | `description` | string | No | Monitor description (max 1024 chars) | | `recordAssertions` | RecordAssertion[] | No | DNS record assertions | | `openTelemetry` | OpenTelemetryConfig | No | OpenTelemetry export configuration | ## List Monitors List all monitors with offset-based pagination. Returns monitors grouped by type. ```typescript const { httpMonitors, tcpMonitors, dnsMonitors, totalSize } = await client.monitor.v1.MonitorService.listMonitors({ limit: 10, offset: 0, }); console.log(`Total: ${totalSize}`); console.log(`HTTP: ${httpMonitors.length}`); console.log(`TCP: ${tcpMonitors.length}`); console.log(`DNS: ${dnsMonitors.length}`); ``` Pagination parameters: | Parameter | Type | Description | |-----------|------|-------------| | `limit` | number (optional) | Max results to return (1–100, default 50) | | `offset` | number (optional) | Number of results to skip (default 0) | ## Get Monitor Get a single monitor by ID. The response uses a `MonitorConfig` oneof type that contains one of HTTP, TCP, or DNS configuration. ```typescript const { monitor } = await client.monitor.v1.MonitorService.getMonitor({ id: "mon_123", }); if (monitor?.config.case === "http") { console.log(`HTTP Monitor: ${monitor.config.value.name} — ${monitor.config.value.url}`); } else if (monitor?.config.case === "tcp") { console.log(`TCP Monitor: ${monitor.config.value.name} — ${monitor.config.value.uri}`); } else if (monitor?.config.case === "dns") { console.log(`DNS Monitor: ${monitor.config.value.name} — ${monitor.config.value.uri}`); } ``` ## Trigger Monitor Trigger an immediate check for a monitor. ```typescript const { success } = await client.monitor.v1.MonitorService.triggerMonitor({ id: "mon_123", }); console.log(`Trigger successful: ${success}`); ``` ## Delete Monitor ```typescript const { success } = await client.monitor.v1.MonitorService.deleteMonitor({ id: "mon_123", }); ``` ## Get Monitor Status Get the current status of a monitor across all configured regions. ```typescript import { createOpenStatusClient, MonitorStatus, Region, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { id, regions } = await client.monitor.v1.MonitorService.getMonitorStatus( { id: "mon_123" }, ); for (const { region, status } of regions) { console.log(`${Region[region]}: ${MonitorStatus[status]}`); } ``` ## Get Monitor Summary Get aggregated metrics and latency percentiles for a monitor over a time range. ```typescript import { createOpenStatusClient, TimeRange } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const summary = await client.monitor.v1.MonitorService.getMonitorSummary({ id: "mon_123", timeRange: TimeRange.TIME_RANGE_7D, regions: [], }); console.log(`Last ping: ${summary.lastPingAt}`); console.log(`Successful: ${summary.totalSuccessful}`); console.log(`Degraded: ${summary.totalDegraded}`); console.log(`Failed: ${summary.totalFailed}`); console.log(`P50: ${summary.p50}ms`); console.log(`P75: ${summary.p75}ms`); console.log(`P90: ${summary.p90}ms`); console.log(`P95: ${summary.p95}ms`); console.log(`P99: ${summary.p99}ms`); ``` The latency fields (`p50`, `p75`, `p90`, `p95`, `p99`) and count fields (`totalSuccessful`, `totalDegraded`, `totalFailed`) are `bigint` values. The `regions` parameter is optional — pass an empty array to get metrics across all regions. ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/notification-service.mdx ================================================ --- title: Notification Service description: "Manage notification channels and providers with the OpenStatus Node.js SDK" --- Manage notification channels for monitor alerts. Supports 12 providers. The Notification Service provides 7 RPC methods. ## Create Notification ```typescript import { createOpenStatusClient, NotificationProvider, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Slack Alerts", provider: NotificationProvider.SLACK, data: { data: { case: "slack", value: { webhookUrl: "https://hooks.slack.com/services/..." }, }, }, monitorIds: ["mon_123", "mon_456"], }); console.log(`Notification created: ${notification?.id}`); ``` The `data` field uses a nested oneof pattern: the outer `data` is the `NotificationData` message, and `data.data` is the oneof that selects the provider-specific configuration. The `case` must match the provider type in lowercase. ## Provider Configurations Each provider shown as a complete `createNotification` call. ### Slack ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Slack Alerts", provider: NotificationProvider.SLACK, data: { data: { case: "slack", value: { webhookUrl: "https://hooks.slack.com/services/..." }, }, }, monitorIds: ["mon_123"], }); ``` ### Discord ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Discord Alerts", provider: NotificationProvider.DISCORD, data: { data: { case: "discord", value: { webhookUrl: "https://discord.com/api/webhooks/..." }, }, }, monitorIds: ["mon_123"], }); ``` ### Email ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Email Alerts", provider: NotificationProvider.EMAIL, data: { data: { case: "email", value: { email: "alerts@example.com" }, }, }, monitorIds: ["mon_123"], }); ``` ### PagerDuty ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "PagerDuty Alerts", provider: NotificationProvider.PAGERDUTY, data: { data: { case: "pagerduty", value: { integrationKey: "your-integration-key" }, }, }, monitorIds: ["mon_123"], }); ``` ### Opsgenie ```typescript import { NotificationProvider, OpsgenieRegion } from "@openstatus/sdk-node"; const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Opsgenie Alerts", provider: NotificationProvider.OPSGENIE, data: { data: { case: "opsgenie", value: { apiKey: "your-api-key", region: OpsgenieRegion.US }, }, }, monitorIds: ["mon_123"], }); ``` ### Telegram ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Telegram Alerts", provider: NotificationProvider.TELEGRAM, data: { data: { case: "telegram", value: { chatId: "123456789" }, }, }, monitorIds: ["mon_123"], }); ``` ### Google Chat ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Google Chat Alerts", provider: NotificationProvider.GOOGLE_CHAT, data: { data: { case: "googleChat", value: { webhookUrl: "https://chat.googleapis.com/v1/spaces/..." }, }, }, monitorIds: ["mon_123"], }); ``` ### Grafana OnCall ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Grafana OnCall", provider: NotificationProvider.GRAFANA_ONCALL, data: { data: { case: "grafanaOncall", value: { webhookUrl: "https://oncall.example.com/..." }, }, }, monitorIds: ["mon_123"], }); ``` ### Ntfy ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Ntfy Alerts", provider: NotificationProvider.NTFY, data: { data: { case: "ntfy", value: { topic: "my-alerts", serverUrl: "https://ntfy.sh", token: "tk_...", }, }, }, monitorIds: ["mon_123"], }); ``` ### SMS ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "SMS Alerts", provider: NotificationProvider.SMS, data: { data: { case: "sms", value: { phoneNumber: "+1234567890" }, }, }, monitorIds: ["mon_123"], }); ``` ### WhatsApp ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "WhatsApp Alerts", provider: NotificationProvider.WHATSAPP, data: { data: { case: "whatsapp", value: { phoneNumber: "+1234567890" }, }, }, monitorIds: ["mon_123"], }); ``` ### Custom Webhook ```typescript const { notification } = await client.notification.v1.NotificationService .createNotification({ name: "Custom Webhook", provider: NotificationProvider.WEBHOOK, data: { data: { case: "webhook", value: { endpoint: "https://api.example.com/webhook", headers: [ { key: "Authorization", value: "Bearer token" }, { key: "X-Custom-Header", value: "value" }, ], }, }, }, monitorIds: ["mon_123"], }); ``` ## Send Test Notification Verify a notification configuration without creating a channel. ```typescript import { NotificationProvider } from "@openstatus/sdk-node"; const { success, errorMessage } = await client.notification.v1 .NotificationService.sendTestNotification({ provider: NotificationProvider.SLACK, data: { data: { case: "slack", value: { webhookUrl: "https://hooks.slack.com/services/..." }, }, }, }); if (success) { console.log("Test notification sent successfully"); } else { console.log(`Test failed: ${errorMessage}`); } ``` ## Check Notification Limits Check if the workspace has reached its notification channel limit. ```typescript const { limitReached, currentCount, maxCount } = await client.notification.v1 .NotificationService.checkNotificationLimit({}); console.log(`${currentCount}/${maxCount} notification channels used`); if (limitReached) { console.log("Notification limit reached — upgrade your plan"); } ``` ## List / Get / Update / Delete Notifications ### List Notifications ```typescript const { notifications, totalSize } = await client.notification.v1 .NotificationService.listNotifications({ limit: 10, offset: 0 }); console.log(`Found ${totalSize} notification channels`); ``` ### Get Notification ```typescript import { NotificationProvider } from "@openstatus/sdk-node"; const { notification } = await client.notification.v1.NotificationService .getNotification({ id: "notif_123" }); console.log(`Name: ${notification?.name}`); console.log(`Provider: ${NotificationProvider[notification?.provider ?? 0]}`); ``` ### Update Notification ```typescript const { notification } = await client.notification.v1.NotificationService .updateNotification({ id: "notif_123", name: "Updated Slack Alerts", monitorIds: ["mon_123", "mon_456", "mon_789"], }); ``` ### Delete Notification ```typescript const { success } = await client.notification.v1.NotificationService .deleteNotification({ id: "notif_123" }); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/reference.mdx ================================================ --- title: Reference description: "Complete reference for enums, regions, assertions, and TypeScript type exports in the OpenStatus Node.js SDK" --- ## Enums ### Periodicity | Value | Description | |-------|-------------| | `PERIODICITY_30S` | Every 30 seconds | | `PERIODICITY_1M` | Every 1 minute | | `PERIODICITY_5M` | Every 5 minutes | | `PERIODICITY_10M` | Every 10 minutes | | `PERIODICITY_30M` | Every 30 minutes | | `PERIODICITY_1H` | Every 1 hour | ### HTTPMethod | Value | Description | |-------|-------------| | `HTTP_METHOD_GET` | GET | | `HTTP_METHOD_POST` | POST | | `HTTP_METHOD_HEAD` | HEAD | | `HTTP_METHOD_PUT` | PUT | | `HTTP_METHOD_PATCH` | PATCH | | `HTTP_METHOD_DELETE` | DELETE | | `HTTP_METHOD_TRACE` | TRACE | | `HTTP_METHOD_CONNECT` | CONNECT | | `HTTP_METHOD_OPTIONS` | OPTIONS | ### MonitorStatus | Value | Description | |-------|-------------| | `ACTIVE` | Monitor is healthy | | `DEGRADED` | Latency threshold exceeded | | `ERROR` | Monitor is failing | ### TimeRange | Value | Description | |-------|-------------| | `TIME_RANGE_1D` | Last 24 hours | | `TIME_RANGE_7D` | Last 7 days | | `TIME_RANGE_14D` | Last 14 days | ### StatusReportStatus | Value | Description | |-------|-------------| | `INVESTIGATING` | Actively investigating the issue | | `IDENTIFIED` | Root cause has been identified | | `MONITORING` | Fix deployed, monitoring | | `RESOLVED` | Issue fully resolved | ### OverallStatus | Value | Description | |-------|-------------| | `OPERATIONAL` | All systems operational | | `DEGRADED` | Performance is degraded | | `PARTIAL_OUTAGE` | Some systems are down | | `MAJOR_OUTAGE` | Major systems are down | | `MAINTENANCE` | Scheduled maintenance | | `UNKNOWN` | Status cannot be determined | ### NotificationProvider | Value | Description | |-------|-------------| | `DISCORD` | Discord webhook | | `EMAIL` | Email notification | | `GOOGLE_CHAT` | Google Chat webhook | | `GRAFANA_ONCALL` | Grafana OnCall | | `NTFY` | Ntfy push service | | `PAGERDUTY` | PagerDuty | | `OPSGENIE` | Opsgenie | | `SLACK` | Slack webhook | | `SMS` | SMS notification | | `TELEGRAM` | Telegram bot | | `WEBHOOK` | Custom webhook | | `WHATSAPP` | WhatsApp | ### OpsgenieRegion | Value | Description | |-------|-------------| | `US` | US region | | `EU` | EU region | ### PageAccessType | Value | Description | |-------|-------------| | `PUBLIC` | Publicly accessible | | `PASSWORD_PROTECTED` | Requires password | | `AUTHENTICATED` | Requires authentication | ### PageTheme | Value | Description | |-------|-------------| | `SYSTEM` | Follow system theme | | `LIGHT` | Light theme | | `DARK` | Dark theme | ### PageComponentType | Value | Description | |-------|-------------| | `MONITOR` | Linked to a monitor | | `STATIC` | Static component (manual) | ### NumberComparator | Value | Description | |-------|-------------| | `EQUAL` | Equal to target | | `NOT_EQUAL` | Not equal to target | | `GREATER_THAN` | Greater than target | | `GREATER_THAN_OR_EQUAL` | Greater than or equal | | `LESS_THAN` | Less than target | | `LESS_THAN_OR_EQUAL` | Less than or equal | ### StringComparator | Value | Description | |-------|-------------| | `CONTAINS` | Contains target string | | `NOT_CONTAINS` | Does not contain target | | `EQUAL` | Equal to target | | `NOT_EQUAL` | Not equal to target | | `EMPTY` | Value is empty | | `NOT_EMPTY` | Value is not empty | | `GREATER_THAN` | Lexicographically greater | | `GREATER_THAN_OR_EQUAL` | Lexicographically greater than or equal to target | | `LESS_THAN` | Lexicographically less | | `LESS_THAN_OR_EQUAL` | Lexicographically less than or equal to target | ### RecordComparator | Value | Description | |-------|-------------| | `EQUAL` | Equal to target | | `NOT_EQUAL` | Not equal to target | | `CONTAINS` | Contains target string | | `NOT_CONTAINS` | Does not contain target | ### ServingStatus | Value | Description | |-------|-------------| | `SERVING` | Service is healthy and serving | | `NOT_SERVING` | Service is not healthy | ## Regions Monitor from 28 global locations across multiple providers. ```typescript import { Region } from "@openstatus/sdk-node"; regions: [Region.FLY_AMS, Region.FLY_IAD, Region.KOYEB_FRA]; ``` ### Fly.io Regions (18) | Enum Value | Location | |------------|----------| | `FLY_AMS` | Amsterdam | | `FLY_ARN` | Stockholm | | `FLY_BOM` | Mumbai | | `FLY_CDG` | Paris | | `FLY_DFW` | Dallas | | `FLY_EWR` | Newark | | `FLY_FRA` | Frankfurt | | `FLY_GRU` | São Paulo | | `FLY_IAD` | Washington D.C. | | `FLY_JNB` | Johannesburg | | `FLY_LAX` | Los Angeles | | `FLY_LHR` | London | | `FLY_NRT` | Tokyo | | `FLY_ORD` | Chicago | | `FLY_SJC` | San Jose | | `FLY_SIN` | Singapore | | `FLY_SYD` | Sydney | | `FLY_YYZ` | Toronto | ### Koyeb Regions (6) | Enum Value | Location | |------------|----------| | `KOYEB_FRA` | Frankfurt | | `KOYEB_PAR` | Paris | | `KOYEB_SFO` | San Francisco | | `KOYEB_SIN` | Singapore | | `KOYEB_TYO` | Tokyo | | `KOYEB_WAS` | Washington | ### Railway Regions (4) | Enum Value | Location | |------------|----------| | `RAILWAY_US_WEST2` | US West | | `RAILWAY_US_EAST4` | US East | | `RAILWAY_EUROPE_WEST4` | Europe West | | `RAILWAY_ASIA_SOUTHEAST1` | Asia Southeast | ## Assertions ### Status Code Assertions Validate HTTP response status codes using `NumberComparator`. ```typescript import { NumberComparator } from "@openstatus/sdk-node"; statusCodeAssertions: [ { comparator: NumberComparator.EQUAL, target: BigInt(200) }, { comparator: NumberComparator.LESS_THAN, target: BigInt(400) }, ]; ``` ### Body Assertions Validate response body content using `StringComparator`. ```typescript import { StringComparator } from "@openstatus/sdk-node"; bodyAssertions: [ { comparator: StringComparator.CONTAINS, target: '"status":"ok"' }, { comparator: StringComparator.NOT_EMPTY, target: "" }, ]; ``` ### Header Assertions Validate response headers using `StringComparator` with a header `key`. ```typescript import { StringComparator } from "@openstatus/sdk-node"; headerAssertions: [ { key: "content-type", comparator: StringComparator.CONTAINS, target: "application/json", }, ]; ``` ### DNS Record Assertions Validate DNS records using `RecordComparator`. Supported record types: `A`, `AAAA`, `CNAME`, `MX`, `TXT`. ```typescript import { RecordComparator } from "@openstatus/sdk-node"; recordAssertions: [ { record: "A", comparator: RecordComparator.EQUAL, target: "93.184.216.34", }, { record: "CNAME", comparator: RecordComparator.CONTAINS, target: "cdn", }, ]; ``` ## TypeScript Type Exports All types and enums exported from `@openstatus/sdk-node`: ### Monitor Types - `HTTPMonitor`, `Headers`, `OpenTelemetryConfig` — HTTP monitor configuration - `TCPMonitor` — TCP monitor configuration - `DNSMonitor` — DNS monitor configuration - `StatusCodeAssertion`, `BodyAssertion`, `HeaderAssertion`, `RecordAssertion` — assertion types - `CreateHTTPMonitorRequest`, `CreateHTTPMonitorResponse` — HTTP monitor CRUD - `CreateTCPMonitorRequest`, `CreateTCPMonitorResponse` — TCP monitor CRUD - `CreateDNSMonitorRequest`, `CreateDNSMonitorResponse` — DNS monitor CRUD - `UpdateHTTPMonitorRequest`, `UpdateHTTPMonitorResponse` - `UpdateTCPMonitorRequest`, `UpdateTCPMonitorResponse` - `UpdateDNSMonitorRequest`, `UpdateDNSMonitorResponse` - `ListMonitorsRequest`, `ListMonitorsResponse` - `DeleteMonitorRequest`, `DeleteMonitorResponse` - `TriggerMonitorRequest`, `TriggerMonitorResponse` - `GetMonitorStatusRequest`, `GetMonitorStatusResponse`, `RegionStatus` - `GetMonitorSummaryRequest`, `GetMonitorSummaryResponse` ### Monitor Enums - `Periodicity` — check interval - `Region` — monitoring region - `MonitorStatus` — active / degraded / error - `HTTPMethod` — HTTP methods - `TimeRange` — metrics time range - `NumberComparator`, `StringComparator`, `RecordComparator` — assertion comparators ### Health Types - `CheckRequest`, `CheckResponse` - `ServingStatus` — serving / not serving ### Status Report Types - `StatusReport`, `StatusReportSummary`, `StatusReportUpdate` - `CreateStatusReportRequest`, `CreateStatusReportResponse` - `GetStatusReportRequest`, `GetStatusReportResponse` - `ListStatusReportsRequest`, `ListStatusReportsResponse` - `UpdateStatusReportRequest`, `UpdateStatusReportResponse` - `DeleteStatusReportRequest`, `DeleteStatusReportResponse` - `AddStatusReportUpdateRequest`, `AddStatusReportUpdateResponse` - `StatusReportStatus` — investigating / identified / monitoring / resolved ### Status Page Types - `StatusPage`, `StatusPageSummary` - `PageComponent`, `PageComponentGroup` - `PageSubscriber` - `CreateStatusPageRequest`, `CreateStatusPageResponse` - `GetStatusPageRequest`, `GetStatusPageResponse` - `ListStatusPagesRequest`, `ListStatusPagesResponse` - `UpdateStatusPageRequest`, `UpdateStatusPageResponse` - `DeleteStatusPageRequest`, `DeleteStatusPageResponse` - `AddMonitorComponentRequest`, `AddMonitorComponentResponse` - `AddStaticComponentRequest`, `AddStaticComponentResponse` - `RemoveComponentRequest`, `RemoveComponentResponse` - `UpdateComponentRequest`, `UpdateComponentResponse` - `CreateComponentGroupRequest`, `CreateComponentGroupResponse` - `DeleteComponentGroupRequest`, `DeleteComponentGroupResponse` - `UpdateComponentGroupRequest`, `UpdateComponentGroupResponse` - `SubscribeToPageRequest`, `SubscribeToPageResponse` - `UnsubscribeFromPageRequest`, `UnsubscribeFromPageResponse` - `ListSubscribersRequest`, `ListSubscribersResponse` - `GetStatusPageContentRequest`, `GetStatusPageContentResponse` - `GetOverallStatusRequest`, `GetOverallStatusResponse`, `ComponentStatus` - `OverallStatus`, `PageAccessType`, `PageTheme`, `PageComponentType` ### Maintenance Types - `Maintenance`, `MaintenanceSummary` - `CreateMaintenanceRequest`, `CreateMaintenanceResponse` - `GetMaintenanceRequest`, `GetMaintenanceResponse` - `ListMaintenancesRequest`, `ListMaintenancesResponse` - `UpdateMaintenanceRequest`, `UpdateMaintenanceResponse` - `DeleteMaintenanceRequest`, `DeleteMaintenanceResponse` ### Notification Types - `Notification`, `NotificationSummary` - `NotificationData` - `DiscordData`, `EmailData`, `GoogleChatData`, `GrafanaOncallData`, `NtfyData`, `OpsgenieData`, `PagerDutyData`, `SlackData`, `SmsData`, `TelegramData`, `WebhookData`, `WebhookHeader`, `WhatsappData` - `CreateNotificationRequest`, `CreateNotificationResponse` - `GetNotificationRequest`, `GetNotificationResponse` - `ListNotificationsRequest`, `ListNotificationsResponse` - `UpdateNotificationRequest`, `UpdateNotificationResponse` - `DeleteNotificationRequest`, `DeleteNotificationResponse` - `SendTestNotificationRequest`, `SendTestNotificationResponse` - `CheckNotificationLimitRequest`, `CheckNotificationLimitResponse` - `NotificationProvider`, `OpsgenieRegion` ### Client Types - `OpenStatusClient` — client interface - `OpenStatusClientOptions` — client configuration - `createOpenStatusClient` — factory function - `openstatus` — default client instance ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/status-page-service.mdx ================================================ --- title: Status Page Service description: "Manage status pages, components, groups, and subscribers with the OpenStatus Node.js SDK" --- Manage status pages, components, component groups, and subscribers. The Status Page Service provides 17 RPC methods. ## Status Page CRUD ### Create Status Page ```typescript const { statusPage } = await client.statusPage.v1.StatusPageService .createStatusPage({ title: "My Service Status", slug: "my-service", description: "Status page for My Service", homepageUrl: "https://example.com", contactUrl: "https://example.com/contact", }); console.log(`Status page created: ${statusPage?.id}`); ``` ### Get Status Page ```typescript const { statusPage } = await client.statusPage.v1.StatusPageService .getStatusPage({ id: "page_123" }); ``` ### List Status Pages ```typescript const { statusPages, totalSize } = await client.statusPage.v1.StatusPageService .listStatusPages({ limit: 10, offset: 0 }); console.log(`Found ${totalSize} status pages`); ``` ### Update Status Page ```typescript const { statusPage } = await client.statusPage.v1.StatusPageService .updateStatusPage({ id: "page_123", title: "Updated Title", description: "Updated description", }); ``` ### Delete Status Page ```typescript const { success } = await client.statusPage.v1.StatusPageService .deleteStatusPage({ id: "page_123" }); ``` ## Components Components represent individual services on a status page. They can be linked to a monitor (automatically reflects monitor status) or static (manually managed). ### Add Monitor Component ```typescript const { component } = await client.statusPage.v1.StatusPageService .addMonitorComponent({ pageId: "page_123", monitorId: "mon_456", name: "API Server", description: "Main API endpoint", order: 1, groupId: "group_789", }); ``` ### Add Static Component ```typescript const { component } = await client.statusPage.v1.StatusPageService .addStaticComponent({ pageId: "page_123", name: "Third-party Service", description: "External dependency", order: 2, }); ``` ### Update Component ```typescript const { component } = await client.statusPage.v1.StatusPageService .updateComponent({ id: "comp_123", name: "Updated Component Name", description: "Updated description", order: 3, groupId: "group_789", groupOrder: 1, }); ``` ### Remove Component ```typescript const { success } = await client.statusPage.v1.StatusPageService .removeComponent({ id: "comp_123" }); ``` ## Component Groups Group related components together on a status page. ### Create Component Group ```typescript const { group } = await client.statusPage.v1.StatusPageService .createComponentGroup({ pageId: "page_123", name: "Core Services", }); ``` ### Update Component Group ```typescript const { group } = await client.statusPage.v1.StatusPageService .updateComponentGroup({ id: "group_123", name: "Updated Group Name", }); ``` ### Delete Component Group ```typescript const { success } = await client.statusPage.v1.StatusPageService .deleteComponentGroup({ id: "group_123" }); ``` ## Subscribers Manage email subscriptions to status page updates. ### Subscribe to Page ```typescript const { subscriber } = await client.statusPage.v1.StatusPageService .subscribeToPage({ pageId: "page_123", email: "user@example.com", }); ``` ### Unsubscribe from Page Unsubscribe by email or subscriber ID using the `identifier` oneof: ```typescript // By email const { success } = await client.statusPage.v1.StatusPageService .unsubscribeFromPage({ pageId: "page_123", identifier: { case: "email", value: "user@example.com" }, }); // By subscriber ID const { success: success2 } = await client.statusPage.v1.StatusPageService .unsubscribeFromPage({ pageId: "page_123", identifier: { case: "id", value: "sub_456" }, }); ``` ### List Subscribers ```typescript const { subscribers, totalSize } = await client.statusPage.v1.StatusPageService .listSubscribers({ pageId: "page_123", limit: 50, offset: 0, includeUnsubscribed: false, }); ``` ## Get Status Page Content Get the full content of a status page including components, groups, active status reports, and maintenance windows. Identify the page by ID or slug. ```typescript const content = await client.statusPage.v1.StatusPageService .getStatusPageContent({ identifier: { case: "slug", value: "my-service" }, }); console.log(`Page: ${content.statusPage?.title}`); console.log(`Components: ${content.components.length}`); console.log(`Groups: ${content.groups.length}`); console.log(`Active reports: ${content.statusReports.length}`); console.log(`Maintenances: ${content.maintenances.length}`); ``` ## Get Overall Status Get the aggregated status of a status page and per-component statuses. ```typescript import { createOpenStatusClient, OverallStatus } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { overallStatus, componentStatuses } = await client.statusPage.v1 .StatusPageService.getOverallStatus({ identifier: { case: "id", value: "page_123" }, }); console.log(`Overall: ${OverallStatus[overallStatus]}`); for (const { componentId, status } of componentStatuses) { console.log(` ${componentId}: ${OverallStatus[status]}`); } ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/status-report-service.mdx ================================================ --- title: Status Report Service description: "Manage incident reports and status updates with the OpenStatus Node.js SDK" --- Manage incident reports with update timelines. The Status Report Service provides 6 RPC methods. ## Create Status Report ```typescript import { createOpenStatusClient, StatusReportStatus, } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { statusReport } = await client.statusReport.v1.StatusReportService .createStatusReport({ title: "API Degradation", status: StatusReportStatus.INVESTIGATING, message: "We are investigating reports of increased latency.", date: "2024-01-15T10:30:00Z", pageId: "page_123", pageComponentIds: ["comp_456"], notify: true, }); console.log(`Status report created: ${statusReport?.id}`); ``` ## Add Status Report Update Add a new entry to a status report's timeline. ```typescript import { StatusReportStatus } from "@openstatus/sdk-node"; const { statusReport } = await client.statusReport.v1.StatusReportService .addStatusReportUpdate({ statusReportId: "sr_123", status: StatusReportStatus.IDENTIFIED, message: "The issue has been identified as a database connection problem.", date: "2024-01-15T11:00:00Z", notify: true, }); ``` ## List Status Reports List status reports with optional status filtering and pagination. ```typescript import { StatusReportStatus } from "@openstatus/sdk-node"; const { statusReports, totalSize } = await client.statusReport.v1 .StatusReportService.listStatusReports({ limit: 10, offset: 0, statuses: [StatusReportStatus.INVESTIGATING, StatusReportStatus.IDENTIFIED], }); console.log(`Found ${totalSize} status reports`); ``` ## Get / Update / Delete Status Reports ### Get Status Report Returns the full report including the updates timeline. ```typescript import { StatusReportStatus } from "@openstatus/sdk-node"; const { statusReport } = await client.statusReport.v1.StatusReportService .getStatusReport({ id: "sr_123" }); console.log(`Title: ${statusReport?.title}`); console.log(`Status: ${StatusReportStatus[statusReport?.status ?? 0]}`); for (const update of statusReport?.updates ?? []) { console.log(` ${update.date}: [${StatusReportStatus[update.status]}] ${update.message}`); } ``` ### Update Status Report ```typescript const { statusReport } = await client.statusReport.v1.StatusReportService .updateStatusReport({ id: "sr_123", title: "Updated Title", pageComponentIds: ["comp_456", "comp_789"], }); ``` ### Delete Status Report ```typescript const { success } = await client.statusReport.v1.StatusReportService .deleteStatusReport({ id: "sr_123" }); ``` ================================================ FILE: apps/docs/src/content/docs/sdk/nodejs/typescript-tips.mdx ================================================ --- title: TypeScript Tips description: "Tips for working with bigint fields, oneof types, and migrating clients in the OpenStatus Node.js SDK" --- ## Working with bigint Fields Protocol Buffers `int64` fields map to `bigint` in TypeScript. This affects: - **Monitor configuration**: `timeout`, `retry`, `degradedAt` - **Assertions**: `StatusCodeAssertion.target` - **Monitor summary**: `totalSuccessful`, `totalDegraded`, `totalFailed`, `p50`, `p75`, `p90`, `p95`, `p99` Use `BigInt()` to create values: ```typescript const { monitor } = await client.monitor.v1.MonitorService.createHTTPMonitor({ monitor: { name: "My API", url: "https://example.com", periodicity: Periodicity.PERIODICITY_1M, active: true, timeout: BigInt(30000), // 30 seconds retry: BigInt(5), // 5 retries degradedAt: BigInt(3000), // degraded after 3s statusCodeAssertions: [ { comparator: NumberComparator.EQUAL, target: BigInt(200) }, ], }, }); ``` Reading bigint values: ```typescript const summary = await client.monitor.v1.MonitorService.getMonitorSummary({ id: "mon_123", timeRange: TimeRange.TIME_RANGE_7D, regions: [], }); // bigint values — use Number() for display if values are safe console.log(`P95 latency: ${summary.p95}ms`); console.log(`Total checks: ${summary.totalSuccessful + summary.totalDegraded + summary.totalFailed}`); ``` ## Handling oneof Types Several responses use protobuf `oneof` fields, which map to discriminated unions in TypeScript. ### MonitorConfig (getMonitor) ```typescript const { monitor } = await client.monitor.v1.MonitorService.getMonitor({ id: "mon_123", }); switch (monitor?.config.case) { case "http": // monitor.config.value is HTTPMonitor console.log(`URL: ${monitor.config.value.url}`); break; case "tcp": // monitor.config.value is TCPMonitor console.log(`URI: ${monitor.config.value.uri}`); break; case "dns": // monitor.config.value is DNSMonitor console.log(`Domain: ${monitor.config.value.uri}`); break; } ``` ### NotificationData (createNotification) The `data.data` field selects the provider-specific configuration: ```typescript // The case string matches the provider in camelCase data: { data: { case: "slack", value: { webhookUrl: "..." } } } data: { data: { case: "googleChat", value: { webhookUrl: "..." } } } data: { data: { case: "grafanaOncall", value: { webhookUrl: "..." } } } ``` ### Status Page Identifiers `getStatusPageContent` and `getOverallStatus` accept a page identifier by ID or slug: ```typescript // By ID { identifier: { case: "id", value: "page_123" } } // By slug { identifier: { case: "slug", value: "my-service" } } ``` ### Unsubscribe Identifier `unsubscribeFromPage` accepts an email or subscriber ID: ```typescript // By email { identifier: { case: "email", value: "user@example.com" } } // By subscriber ID { identifier: { case: "id", value: "sub_456" } } ``` ## Migrating from Default Client to createOpenStatusClient **Before** — manual headers on every call: ```typescript import { openstatus } from "@openstatus/sdk-node"; const headers = { "x-openstatus-key": process.env.OPENSTATUS_API_KEY }; const { httpMonitors } = await openstatus.monitor.v1.MonitorService .listMonitors({}, { headers }); const { monitor } = await openstatus.monitor.v1.MonitorService .createHTTPMonitor({ monitor: { name: "API", url: "https://example.com", periodicity: 2, active: true }, }, { headers }); ``` **After** — configure once, use everywhere: ```typescript import { createOpenStatusClient, Periodicity } from "@openstatus/sdk-node"; const client = createOpenStatusClient({ apiKey: process.env.OPENSTATUS_API_KEY, }); const { httpMonitors } = await client.monitor.v1.MonitorService .listMonitors({}); const { monitor } = await client.monitor.v1.MonitorService .createHTTPMonitor({ monitor: { name: "API", url: "https://example.com", periodicity: Periodicity.PERIODICITY_1M, active: true, }, }); ``` ================================================ FILE: apps/docs/src/content/docs/tutorial/get-started-with-openstatus-cli.mdx ================================================ --- title: Get Started with openstatus CLI description: "Step-by-step tutorial to install and use the openstatus CLI for monitoring as code" --- import { Image } from 'astro:assets'; import { Aside } from '@astrojs/starlight/components'; import CLI from '../../../assets/tutorial/get-started-with-openstatus-cli/CLI.png'; ## What you'll learn | | | |---|---| | **Time** | ~10 minutes | | **Level** | Intermediate | | **Prerequisites** | openstatus account, command line experience | In this tutorial, you'll learn how to use the openstatus CLI to manage your monitors as code. This enables you to version control your monitoring configuration, automate deployments, and implement GitOps workflows. ### Prerequisites - An openstatus account - Command line experience - API token from your openstatus workspace (Settings → API) ### What you'll build By the end of this tutorial, you'll have: - openstatus CLI installed on your system - Monitors exported to a YAML configuration file - Understanding of monitoring as code workflows - Ability to manage monitors programmatically <Image src={CLI} alt="openstatus CLI in action showing monitor management" /> ## Installation Install the openstatus CLI to manage your monitors directly from code. ### macOS Using Homebrew (recommended): ```bash brew install openstatusHQ/cli/openstatus --cask ``` Or using the install script: ```bash curl -fsSL https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.sh | bash ``` ### Linux ```bash curl -fsSL https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.sh | bash ``` ### Windows ```powershell iwr https://raw.githubusercontent.com/openstatusHQ/cli/refs/heads/main/install.ps1 | iex ``` ### Verify installation Run the following command to confirm the CLI is installed: ```bash openstatus --version ``` You should see output like: ``` openstatus version x.x.x ``` ## Configure API authentication Create an API key in your workspace settings (Settings → API), then set it as an environment variable: ```bash # macOS / Linux export OPENSTATUS_API_TOKEN=<your-api-token> ``` ```powershell # Windows PowerShell $env:OPENSTATUS_API_TOKEN="<your-api-token>" ``` <Aside>Add this to your shell profile (`~/.bashrc`, `~/.zshrc`) to persist across sessions.</Aside> ## Import existing monitors Start by importing your existing monitors from your workspace to a YAML file: ```bash openstatus monitors import ``` You should see output confirming the import: ``` Successfully imported X monitors to openstatus.yaml ``` This creates an `openstatus.yaml` file containing all your current monitors. This file becomes your single source of truth for monitoring configuration. **Checkpoint:** Open the `openstatus.yaml` file and verify it contains your monitors. You should see entries with your monitor names and URLs. ## Manage monitors as code Now you can add, remove, or update monitors in the YAML file and apply your changes: ```bash openstatus monitors apply ``` The CLI will show you a diff of changes before applying them, ensuring you're aware of what will be modified. ## What you've accomplished Excellent work! You've successfully: - ✅ Installed the openstatus CLI - ✅ Configured API authentication - ✅ Imported monitors to a YAML file - ✅ Learned the monitoring as code workflow ## Troubleshooting ### "command not found: openstatus" **Cause:** The CLI binary is not in your PATH. **Fix (macOS/Homebrew):** ```bash brew reinstall openstatusHQ/cli/openstatus --cask ``` **Fix (install script):** Ensure `~/.local/bin` is in your PATH: ```bash export PATH="$HOME/.local/bin:$PATH" ``` ### "unauthorized" or "invalid token" error **Cause:** Your API token is missing or incorrect. **Fix:** 1. Verify the token is set: `echo $OPENSTATUS_API_TOKEN` 2. Regenerate the token in your workspace settings (Settings → API) 3. Make sure there are no extra spaces or newlines in the token value ### "no monitors found" on import **Cause:** Your workspace has no monitors, or the token belongs to a different workspace. **Fix:** Create at least one monitor in the dashboard first, then retry the import. ## What's next? Now that you have the CLI set up, you can: - **[Monitor Your MCP Server](/guides/how-to-monitor-mcp-server/)** - Example of CLI-based monitor configuration - **[CLI Reference](/reference/cli-reference)** - Complete command documentation - **[Set up CI/CD](/guides/how-to-run-synthetic-test-github-action/)** - Automate monitoring in your pipeline ### Advanced workflows With the CLI, you can: - Version control your monitoring configuration with Git - Review monitoring changes in pull requests - Automate monitor creation for new services - Sync monitors across multiple environments - Implement GitOps for infrastructure monitoring ## Learn more - **[Monitoring as Code Concept](/concept/uptime-monitoring-as-code)** - Why manage monitors as code - **[CLI Reference](/reference/cli-reference)** - All available commands - **[YAML Configuration Examples](https://github.com/openstatusHQ/cli-template)** - Sample configurations ================================================ FILE: apps/docs/src/content/docs/tutorial/getting-started.mdx ================================================ --- title: Tutorials Overview description: "Step-by-step tutorials to create monitors, status pages, and private locations with OpenStatus." sidebar: label: Tutorials Overview order: 1 --- ## Tutorials ### What you'll learn Our tutorials are designed to help you: - Get your first monitor up and running - Create and configure status pages - Set up monitoring infrastructure - Use openstatus CLI for automation ### Core Tutorials Start your journey with openstatus: - **[Create Your First Monitor](/tutorial/how-to-create-monitor)** (~5 min) - Learn the fundamentals by setting up uptime monitoring for your first endpoint - **[Create a Status Page](/tutorial/how-to-create-status-page)** (~5 min) - Build a public status page to communicate service health to your users - **[Configure Your Status Page](/tutorial/how-to-configure-status-page)** (~10 min) - Customize your status page with monitors, domains, and protection - **[Set Up the Slack Agent](/tutorial/how-to-setup-slack-agent)** (~5 min) - Manage incidents directly from Slack ### Advanced Tutorials Once you're comfortable with the basics: - **[Create a Private Location (Beta)](/tutorial/how-to-create-private-location)** (~15 min) - Set up monitoring from your own infrastructure - **[Get Started with openstatus CLI](/tutorial/get-started-with-openstatus-cli)** (~10 min) - Automate monitor management with our command-line tool ### What's next? After completing these tutorials, you'll be ready to: - Explore [how-to guides](/guides/getting-started) for specific tasks and advanced scenarios - Dive into [explanations](/concept/getting-started) to understand the concepts behind the features - Reference our [technical documentation](/reference/cli-reference) for detailed specifications ================================================ FILE: apps/docs/src/content/docs/tutorial/how-to-configure-status-page.mdx ================================================ --- title: Configure Your Status Page description: "A step-by-step tutorial to customize and configure your status page" --- import { Image } from 'astro:assets'; import { Aside } from '@astrojs/starlight/components'; import { ShowcaseYouTube } from 'starlight-showcases' import ConfigureStatusPage1 from '../../../assets/tutorial/configure-status-page/configure-status-page-1.png'; import StatusPageFloating from '../../../assets/tutorial/configure-status-page/status-page-floating.png'; import StatusPageBeta1 from '../../../assets/tutorial/configure-status-page/status-page-beta-1.png'; import StatusPageBeta2 from '../../../assets/tutorial/configure-status-page/status-page-beta-2.png'; import StatusPageBeta3 from '../../../assets/tutorial/configure-status-page/status-page-beta-3.png'; import StatusPageBeta4 from '../../../assets/tutorial/configure-status-page/status-page-beta-4.png'; ## What you'll learn In this tutorial, you'll learn how to customize your status page's appearance and behavior. You'll explore different display options, themes, and configuration settings to create a status page that matches your brand and communication style. ### Prerequisites - An openstatus account - A status page already created (see [Create a Status Page](/tutorial/how-to-create-status-page)) - At least one monitor added to your status page ### What you'll build By the end of this tutorial, you'll have: - A customized status page with your preferred theme - Configured status trackers displaying data your way - Links to important resources - Preview and live configuration experience ## Status Page Customization OpenStatus offers enhanced status page customization with multiple themes and display options. Explore available themes: [https://themes.openstatus.dev](https://themes.openstatus.dev) ## Get started Go to the **Status Page Redesign** section in your status page settings and toggle `Enable New Version`. Once enabled, you'll see three subsections: 1. **Tracker Configuration** 2. **Theme Explorer** 3. **Links** <Image src={ConfigureStatusPage1} alt="Create your status page" /> ### View and configure status page Before choosing to enable the new page, we provide you with a way to check the configuration first. Click on the **View and configure status page** and you'll get forwarded to your status page and a bottom right floating button will appear. Once you're done, click on the **Dashboard** and you'll be forwarded to your page where you get asked to save the config before continuing. <Image src={StatusPageFloating} alt="Status page floating configuration popover" /> --- <Aside>Once enabled, the subdomain will have a rewrite to the new project. We are adding a `maxAge: 600` request cookie to improve the page load. **If you decide to deactivate the new version, it might take up to 10 minutes for the user to see the old page.**</Aside> ### 1. Tracker Configuration We have three new status tracker configurations to provide you with a maximum choice of displaying the collected data. **Bar Type**: How every 'day' is displayed in for a status tracker. Either **absolute** or **manual**. **Card Type**: The card type is only configurable if the bar type is **absolute**. You'll then be able to choose between **duration**, which will show the duration of "success", "error", "degraded" or "maintenance" reports or **requests** where we will share the number of request status itself. If **manual** bar type is chosen, we will only show the most significant status of the day. **Show Uptime**: The uptime is calculated by either the **duration** of the different reports _or_ the **request** values depending on what you've chosen for the **absolute** value (incl. incidents). If you've chosen **manual**, it only gets calculated by the duration of your status reports. A few examples to understand it: Example of **absolute bar** with **duration card** and **showing uptime** <Image src={StatusPageBeta3} alt="absolute bar type with duration card and showing uptime" /> Example of **absolute bar** with **request card** and **hiding uptime** <Image src={StatusPageBeta4} alt="absolute bar type with request card and hiding uptime" /> Example of **manual bar** with **simple card** and **hiding uptime** <Image src={StatusPageBeta2} alt="manual bar type and hiding uptime" /> ### 2. Theme Explorer You can choose between different themes. We start with the following three: - `default` (openstatus) - `supabase` - `github-high-contrast` Visit [themes.openstatus.dev](https://themes.openstatus.dev) to see the list of supported themes. If you want, you can contribute your own to the list. ### 3. Links Let's have a closer look at your status page header navigation: <Image src={StatusPageBeta1} alt="Header navigation of your status page" /> **Homepage URL**: Your logo will support linking to your own website. **Contact URL**: If filled out, you will see a `Message` icon that users can click to forward them to a contact page. This can also be an email client by starting the input with `mailto:` (e.g. `mailto:support@openstatus.dev`). --- We are continuously adding new features. Feel free to let us know what's missing! ## What you've accomplished Excellent work! You've successfully: - ✅ Enabled and configured the new status page design - ✅ Customized status tracker display options - ✅ Explored and applied theme settings - ✅ Added navigation links to your status page - ✅ Previewed changes before making them live ## What's next? Now that your status page is configured, you can: - **[Building Trust with Status Pages](/concept/best-practices-status-page)** - Learn effective incident communication - **[Add Status Subscribers](/reference/subscriber)** - Let users subscribe to updates ### Learn more - **[Status Page Reference](/reference/status-page)** - Complete configuration options - **[Uptime Calculation Values](/concept/uptime-calculation-and-values)** - How uptime percentages work ## Video Tutorial <ShowcaseYouTube entries={[ { href: 'https://www.youtube.com/watch?v=igMbSrej6RQ', title: 'View, configure and enable the new status page', }, ]} /> ================================================ FILE: apps/docs/src/content/docs/tutorial/how-to-create-monitor.mdx ================================================ --- title: Create an Uptime Monitor in 5 Minutes description: "Set up your first uptime monitor with OpenStatus — track response time, status codes, and availability from 35+ global locations." --- import { Image } from 'astro:assets'; import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components'; import monitorOverview from '../../../assets/tutorial/create-monitor/monitor-overview.png'; import createMonitor from '../../../assets/tutorial/create-monitor/create-monitor-1.png'; import createMonitor2 from '../../../assets/tutorial/create-monitor/create-monitor-2.png'; import { ShowcaseYouTube } from 'starlight-showcases' ## What you'll learn In this tutorial, you'll learn how to create your first monitor an automated watchdog for your services. A monitor periodically checks your endpoints to ensure they are available, performant, and returning the correct data. Think of it as a `curl` command that runs 24/7, providing continuous insights into the health of your application. ### Prerequisites - An openstatus account (free tier available at [openstatus.dev](https://www.openstatus.dev)) - A URL endpoint to monitor (can be your own service or any public URL) ### What you'll build By the end of this tutorial, you'll have: - A working uptime monitor checking your endpoint every minute - Real-time metrics showing response time and status codes - Understanding of how to customize HTTP requests for different scenarios ## Get Started in 1 Minute Let's get your first uptime check up and running. ### 1. Create the Monitor <Image src={createMonitor} alt="Monitors page showing the Create Monitor button in the sidebar" /> Navigate to the **Monitors** page from the sidebar and click the **Create Monitor** button. This will open a new configuration screen. ### 2. Configure the Basics <Image src={createMonitor2} alt="Monitor creation form with name and URL fields" /> To get your monitor started, you only need to provide two essential pieces of information: - **Name:** A clear, descriptive name for your monitor (e.g., "Production API Health Check" or "Homepage Uptime"). - **URL:** The full URL of the endpoint you want to test (e.g., `https://openstat.us`). As soon as you enter the URL, our monitoring tool will automatically begin tracking key performance metrics for every check, including: - **Response Time:** The total time it takes for the request to complete. - **Status Code:** The HTTP status code returned by the server (e.g., 200, 404, 500). - **Response Headers:** A detailed view of the headers returned by the server. - **Detailed Timing Metrics:** A breakdown of the time spent on each phase of the request (DNS lookup, TCP connection, TLS handshake, etc.) <Image src={monitorOverview} alt="Monitor overview dashboard showing response time and status code charts" /> **Checkpoint:** After saving, you should see your monitor's overview page with response time and status code charts. If data appears within a few seconds, your monitor is running. ### 3. Customizing the HTTP Request Your monitor doesn't just have to be a simple `GET` request. You can customize the HTTP request to simulate real-world traffic and test specific scenarios. #### HTTP Method Choose the appropriate HTTP method for your check. While `GET` is the default and most common for simple health checks, you can also select `POST`, `HEAD`, `OPTIONS`, `PUT`, `PATCH`, `DELETE`, or `TRACE`. - `GET`: Retrieve data from an endpoint. The most common choice for health checks. - `POST`: Send data to an endpoint, for example, to test a form submission or API creation endpoint. - `HEAD`: Same as `GET`, but without the response body. Useful for quickly checking if a resource exists. - `OPTIONS`: Retrieve the supported HTTP methods for a resource. - `PUT`: Update an existing resource. - `PATCH`: Partially update an existing resource. - `DELETE`: Delete a resource. - `TRACE`: Echo the request back to the client. #### Request Body If you select the `POST` method, you can add a Request Body to your monitor's configuration. This is essential for testing API endpoints that require a JSON, form-encoded, or other data payload. Simply enter the data you want to send in the provided text area. #### Custom Headers You can add any number of custom HTTP headers to your request. This is particularly useful for: - **Authentication:** Sending an Authorization token (e.g., a Bearer token) to test a protected endpoint. - **Content Type:** Setting a `content-type` header to specify a content type (e.g., `application/json`). - **Content Negotiation:** Setting an Accept header to request a specific content type from the server (e.g., `application/json`). - **Simulating Clients:** Adding a User-Agent header to simulate traffic from a specific browser or device. <Aside title="We've got your User-Agent covered!"> openstatus automatically includes the `"User-Agent": "openstatus/1.0"` header in every request. This makes it easy to identify and filter out our monitoring traffic from your server logs or analytics. </Aside> ## Important Considerations ### Monitoring Third-Party Endpoints <Aside>If you're monitoring a URL you don't own (like `google.com` or a partner API), your requests might be blocked by firewalls or rate limiters (e.g., Cloudflare). This is a security measure on their end to prevent scraping or denial-of-service attacks.</Aside> ## What you've accomplished Congratulations! You've successfully: - ✅ Created your first uptime monitor - ✅ Configured basic HTTP monitoring settings - ✅ Learned about customizing requests with methods, headers, and body - ✅ Understood key monitoring metrics (response time, status codes, timing breakdown) ## What's next? Now that you have a monitor running, you can: - **[Create a Status Page](/tutorial/how-to-create-status-page)** - Share your service status publicly with users ### Learn more - **[Understanding Uptime Monitoring](/concept/uptime-monitoring)** - Deep dive into monitoring concepts - **[HTTP Monitor Reference](/reference/http-monitor)** - Complete technical specifications ## Video Tutorial <ShowcaseYouTube entries={[ { href: 'https://www.youtube.com/embed/nYti3DjHoWY?si=RBGFHzoHFmwphRf', title: 'Create your first monitor', }, ]} /> ================================================ FILE: apps/docs/src/content/docs/tutorial/how-to-create-private-location.mdx ================================================ --- title: Create a Private Location description: "Set up monitoring from your own infrastructure using Docker-based private probes" --- import { Aside } from '@astrojs/starlight/components'; ## What you'll learn | | | |---|---| | **Time** | ~15 minutes | | **Level** | Advanced | | **Prerequisites** | OpenStatus account, Docker installed | In this tutorial, you'll set up a **private location** — a monitoring probe running on your own infrastructure. This lets you monitor internal applications, private APIs, and network resources behind your firewall without exposing them to the public internet. ### Prerequisites - An OpenStatus account ([openstatus.dev](https://www.openstatus.dev)) - Docker installed on your server or local machine (`docker --version` to verify) - At least one monitor already created (see [Create Your First Monitor](/tutorial/how-to-create-monitor)) ### What you'll build By the end of this tutorial, you'll have: - A private location configured in OpenStatus - A Docker container running the monitoring probe on your infrastructure - Monitors assigned to check from your private location <Aside type="caution">Private locations are currently in **beta**. Some features like incident creation and public status page support are not yet available for private locations.</Aside> ## What are private locations? Private locations allow you to monitor internal applications from within your own infrastructure, rather than solely relying on our public cloud-based regions. By deploying monitoring probes as Docker containers on your own machines, you can: - Monitor internal APIs and services behind your firewall - Get detailed timing and latency data from your specific deployment location - Monitor from on-prem servers, Raspberry Pi devices, or any Docker-capable machine ## Step 1: Create a private location 1. Navigate to **Settings** > **Private Locations** in your OpenStatus dashboard 2. Click **Create Private Location** 3. Give your location a descriptive name (e.g., "Office Network", "AWS us-east-1 VPC") 4. Save the configuration After creation, you'll receive a **token**. Copy this token — you'll need it in the next step. <Aside type="caution">Keep your token secure. It authenticates your probe with the OpenStatus platform.</Aside> ## Step 2: Deploy the Docker probe Run the monitoring probe on your server using Docker: ```bash docker run -d \ --name openstatus-probe \ --restart unless-stopped \ -e OPENSTATUS_TOKEN=<your-token> \ ghcr.io/openstatushq/probe:latest ``` Replace `<your-token>` with the token from Step 1. ### Verify the container is running ```bash docker ps --filter name=openstatus-probe ``` You should see the container listed with a status of `Up`: ``` CONTAINER ID IMAGE STATUS NAMES abc123 ghcr.io/openstatushq/probe:latest Up 2 minutes openstatus-probe ``` ## Step 3: Assign monitors to your private location 1. Go to **Settings** > **Private Locations** and select your location 2. Choose which monitors should run from this private location 3. Alternatively, edit an individual monitor and select your private location in its settings **Checkpoint:** Within a couple of minutes, you should see monitoring data appearing in your monitor's overview from your private location. ## What you've accomplished You've successfully: - ✅ Created a private location in OpenStatus - ✅ Deployed a monitoring probe on your own infrastructure - ✅ Assigned monitors to check from your private location ## Current limitations - Incidents are **not yet created** for monitors running via private locations. Continue using OpenStatus public regions if you need alerting. - Public monitors on status pages **do not yet support** private locations. ## Troubleshooting ### Container exits immediately Check the container logs for errors: ```bash docker logs openstatus-probe ``` Common causes: - **Invalid token:** Double-check the token value — ensure no extra spaces or newlines - **Network issues:** Ensure the container can reach `api.openstatus.dev` on port 443 ### No data appearing in the dashboard 1. Verify the container is running: `docker ps --filter name=openstatus-probe` 2. Check that you've assigned at least one monitor to the private location 3. Wait 2-3 minutes — there may be a short delay before the first check runs ### Container can't reach internal services If the probe needs to reach services on your host machine, use Docker's host networking: ```bash docker run -d \ --name openstatus-probe \ --restart unless-stopped \ --network host \ -e OPENSTATUS_TOKEN=<your-token> \ ghcr.io/openstatushq/probe:latest ``` ## What's next? - **[Read our Raspberry Pi deployment guide](https://www.openstatus.dev/blog/deploy-private-locations-raspberry-pi)** — Deploy probes on low-cost hardware - **[Create a Monitor](/tutorial/how-to-create-monitor)** — Set up more monitors to run from your private location ================================================ FILE: apps/docs/src/content/docs/tutorial/how-to-create-status-page.mdx ================================================ --- title: Create a Status Page description: "A step-by-step tutorial to create and publish your first status page" --- import { Image } from 'astro:assets'; import { Aside } from '@astrojs/starlight/components'; import CreateStatusPage1 from '../../../assets/tutorial/create-status-page/create-status-page-1.png'; import CreateStatusPage2 from '../../../assets/tutorial/create-status-page/create-status-page-2.png'; ## What you'll learn | | | |---|---| | **Time** | ~5 minutes | | **Level** | Beginner | | **Prerequisites** | OpenStatus account, at least one monitor | In this tutorial, you'll create a public status page to communicate your service's health to users. A status page is a transparent way to show real-time uptime information and keep your users informed during incidents. ### Prerequisites - An openstatus account - At least one monitor created (see [Create Your First Monitor](/tutorial/how-to-create-monitor)) ### What you'll build By the end of this tutorial, you'll have: - A public status page showing your service health - Monitors displayed on your status page - Understanding of privacy and security options ## Get started ### 1. Create the status page Navigate to the **Status Pages** page from the sidebar and click the **Create Status Page** button. This will open a new configuration screen. <Image src={CreateStatusPage1} alt="Status Pages sidebar with the Create Status Page button highlighted" /> ### 2. Configure the status page Fill in the basic details for your status page: - **Title:** A name for your status page (e.g., "Acme Status" or "API Health") - **Slug:** The URL path for your status page (e.g., `acme` creates `acme.openstatus.dev`) <Image src={CreateStatusPage2} alt="Status page configuration form showing title, slug, and monitor selection" /> #### Add monitors Select the monitors you want to display on your status page. Each monitor will show as a separate service with its own uptime bar. You can add multiple monitors to give users a complete picture of your infrastructure health. #### Custom domain You can use your own domain (e.g., `status.yourdomain.com`) instead of the default `*.openstatus.dev` subdomain. To set this up: 1. Enter your custom domain in the **Custom Domain** field 2. Add a CNAME record pointing to `status.openstatus.dev` in your DNS provider 3. Wait for DNS propagation (usually a few minutes, up to 48 hours) See the [Status Page Reference](/reference/status-page) for detailed DNS configuration instructions. #### Password protection Enable password protection to restrict access to your status page. This is useful for internal status pages that should only be visible to your team or specific customers. Enter a password, and visitors will be prompted to authenticate before viewing the page. **Checkpoint:** After saving, click the link to your status page (shown at the top of the settings). You should see your monitors listed with uptime bars. ## What you've accomplished Great work! You've successfully: - ✅ Created your first status page - ✅ Added monitors to display service health - ✅ Learned about custom domains and password protection ## What's next? Now that you have a basic status page, you can: - **[Configure Your Status Page](/tutorial/how-to-configure-status-page)** - Customize appearance and add more features - **[Building Trust with Status Pages](/concept/best-practices-status-page)** - Learn how to communicate effectively ### Learn more - **[Status Page Reference](/reference/status-page)** - Complete configuration options - **[Understanding Uptime Values](/concept/uptime-calculation-and-values)** - How uptime is calculated ================================================ FILE: apps/docs/src/content/docs/tutorial/how-to-setup-slack-agent.mdx ================================================ --- title: Set Up the OpenStatus Slack Agent description: "A step-by-step tutorial to install the OpenStatus Slack agent and manage incidents directly from Slack" --- import { Aside } from '@astrojs/starlight/components'; ## What you'll learn | | | |---|---| | **Time** | ~5 minutes | | **Level** | Beginner | | **Prerequisites** | OpenStatus account, Slack workspace admin access | In this tutorial, you'll learn how to install the OpenStatus Slack agent so you can manage incidents directly from your Slack workspace — no need to switch to the dashboard. ### Prerequisites - An OpenStatus account ([openstatus.dev](https://www.openstatus.dev)) - A Slack workspace where you have permission to install apps ### What you'll get By the end of this tutorial, you'll have: - The OpenStatus Slack agent installed in your workspace - The ability to create, update, and resolve incidents from Slack ## Install the Slack Agent ### 1. Go to Settings Navigate to **Settings** > **Integrations** in your OpenStatus dashboard. ### 2. Install the Slack Integration Click the **Install Slack** button. You'll be redirected to Slack's authorization page where you can select the workspace and channel you want to connect. Grant the requested permissions and click **Allow** to complete the installation. <Aside>Make sure you select the correct Slack workspace if you belong to multiple workspaces.</Aside> ### 3. Verify the Installation After completing the OAuth flow, go to the Slack channel you selected and type: ``` @openstatus what's the status of my monitors? ``` The bot should respond with a summary of your monitors. If you see a response, the installation is working. <Aside type="tip">The bot only responds when mentioned with `@openstatus` — it won't interrupt your conversations.</Aside> ### 4. Start Managing Incidents from Slack Here are some examples of what you can do: #### Create an incident Notify your subscribers about a new issue. ``` @openstatus create an incident for the payment API – high latency detected. ``` #### Update an incident Keep your status page updated while you investigate. ``` @openstatus keep the status page updated that we are still monitoring the issue. ``` #### Resolve an incident Close an active incident and let your subscribers know. ``` @openstatus resolve the ongoing incident on my API status page. ``` #### Schedule maintenance Plan downtime so subscribers are informed in advance. ``` @openstatus schedule a maintenance window for my database next Friday from 2–3 PM. ``` ## What you've accomplished Congratulations! You've successfully: - ✅ Installed the OpenStatus Slack agent in your workspace - ✅ Connected your OpenStatus account to Slack - ✅ Learned how to manage incidents directly from Slack ## Troubleshooting ### The bot doesn't respond when mentioned 1. **Check the channel:** Make sure you're mentioning `@openstatus` in the channel you selected during installation. 2. **Check permissions:** Go to **Settings** > **Integrations** and verify the Slack integration shows as connected. 3. **Reinstall:** If the integration appears disconnected, click **Install Slack** again to re-authorize. ### "Not authorized" or permission errors Your Slack workspace admin may need to approve the app. Ask your workspace admin to go to **Slack Admin** > **Manage Apps** and approve the OpenStatus integration. ### Bot responds but can't find monitors Make sure you have at least one monitor and one status page created in your OpenStatus workspace before using incident commands. ## What's next? - **[Create a Status Page](/tutorial/how-to-create-status-page)** — share your service status publicly with users - **[Create a Monitor](/tutorial/how-to-create-monitor)** — set up uptime monitoring for your endpoints ================================================ FILE: apps/docs/src/custom.css ================================================ @layer my-reset, starlight; .card { border-radius: 0rem; } .card .icon { border: 1px solid var(--sl-color-gray-5); background-color: var(--sl-color-black); padding: 0.2em; border-radius: 0.25rem; } :is(h1, h2, h3, h4, h5, h6) { font-family: "CommitMono", sans-serif; } sl-sidebar-state-persist summary { font-family: "CommitMono", sans-serif; } .sl-link-card { border-radius: 0; } a { border-radius: 0; } button{ border-radius: 0; } .card .title { font-family: "CommitMono", sans-serif; } .sl-link-card .title { font-family: "CommitMono", sans-serif; } ================================================ FILE: apps/docs/src/env.d.ts ================================================ /// <reference path="../.astro/types.d.ts" /> /// <reference types="astro/client" /> ================================================ FILE: apps/docs/src/global.css ================================================ @layer base, starlight, theme, components, utilities; @import '@astrojs/starlight-tailwind'; @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); @theme { --font-cal: "calsans", "sans-serif"; --font-mono: 'CommitMono', 'sans-serif'; --font-sans: 'Inter', 'sans-serif'; } @layer base { @font-face { font-family: 'calsans'; src: url('/fonts/CalSans-SemiBold.ttf') format('truetype'); font-weight: 600; font-style: normal; font-display: swap; } @font-face { font-family: 'CommitMono'; src: url('/fonts/CommitMono-400-Regular.otf') format('opentype'); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: 'CommitMono'; src: url('/fonts/CommitMono-700-Regular.otf') format('opentype'); font-weight: 700; font-style: normal; font-display: swap; } @font-face { font-family: 'Inter Variable'; font-style: normal; font-display: swap; font-weight: 100 900; src: url(@fontsource-variable/inter/files/inter-latin-wght-normal.woff2) format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } } @layer base { h1 { letter-spacing: var(--tracking-tight); } h2 { letter-spacing: var(--tracking-tighter); } :root[data-theme='light'] { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --ring: 215 20.2% 65.1%; --radius: 0.5rem; /** Chart Colors */ --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; /* Status Tracker Colors - Radix Color */ --status-degraded: 50 100% 52%; /* Amber 10 */ --status-operational: 131 39% 51%; /* Grass 10 */ --status-down: 11 82% 59%; /* Tomato 10 */ --status-monitoring: 210 100% 62%; /* Blue 10 */ } :root { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; --ring: 217.2 32.6% 17.5%; /* Chart Colors */ --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; /* Status Tracker Colors - Radix Color */ --status-degraded: 50 100% 52%; /* Amber 10 */ --status-operational: 131 39% 51%; /* Grass 10 */ --status-down: 11 82% 59%; /* Tomato 10 */ --status-monitoring: 210 100% 62%; /* Blue 10 */ } } /* https://ui.shadcn.com/colors */ /* Dark mode colors. */ :root { --sl-color-accent-low: #020817; --sl-color-accent: #f8fafc; --sl-color-accent-high: #f1f5f9; --sl-color-white: #f8fafc; --sl-color-gray-1: #f1f5f9; --sl-color-gray-2: #94a3b8; --sl-color-gray-3: #64748b; --sl-color-gray-4: #475569; --sl-color-gray-5: #1e293b; --sl-color-gray-6: #0f172a; --sl-color-black: #020817; } /* Light mode colors. */ :root[data-theme='light'] { --sl-color-accent-low: #f8fafc; --sl-color-accent: #020817; --sl-color-accent-high: #0f172a; --sl-color-white: #020817; --sl-color-gray-1: #0f172a; --sl-color-gray-2: #1e293b; --sl-color-gray-3: #475569; --sl-color-gray-4: #64748b; --sl-color-gray-5: #94a3b8; --sl-color-gray-6: #f1f5f9; --sl-color-gray-7: #f8fafc; --sl-color-black: #ffffff; } ================================================ FILE: apps/docs/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "compilerOptions": { "types": ["unplugin-icons/types/astro"] } } ================================================ FILE: apps/private-location/.air.toml ================================================ root = "." testdata_dir = "testdata" tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/server/main.go" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" poll = false poll_interval = 0 post_cmd = [] pre_cmd = [] rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false [color] app = "" build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [misc] clean_on_exit = false [screen] clear_on_rebuild = false keep_scroll = true ================================================ FILE: apps/private-location/.dockerignore ================================================ # This file is generated by Dofigen v2.6.0 # See https://github.com/lenra-io/dofigen ================================================ FILE: apps/private-location/.gitignore ================================================ /tmp main ================================================ FILE: apps/private-location/.golangci.yml ================================================ version: "2" linters: default: fast ================================================ FILE: apps/private-location/Dockerfile ================================================ # syntax=docker/dockerfile:1.19.0 # This file is generated by Dofigen v2.6.0 # See https://github.com/lenra-io/dofigen # builder FROM golang@sha256:d4c4845f5d60c6a974c6000ce58ae079328d03ab7f721a0734277e69905473e5 AS builder ARG TARGETARCH ARG TARGETOS LABEL \ org.opencontainers.image.base.digest="sha256:d4c4845f5d60c6a974c6000ce58ae079328d03ab7f721a0734277e69905473e5" \ org.opencontainers.image.base.name="docker.io/golang:1.26-alpine" \ org.opencontainers.image.stage="builder" ENV \ TZ="UTC" \ CGO_ENABLED="0" WORKDIR /go/src/app COPY \ --link \ "." "." RUN <<EOF apk add --no-cache tzdata go mod download GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w" -o private-location ./cmd/server EOF # runtime FROM alpine@sha256:5405e8f36ce1878720f71217d664aa3dea32e5e5df11acbf07fc78ef5661465b AS runtime LABEL \ io.dofigen.version="2.6.0" \ org.opencontainers.image.authors="OpenStatus Team" \ org.opencontainers.image.base.digest="sha256:5405e8f36ce1878720f71217d664aa3dea32e5e5df11acbf07fc78ef5661465b" \ org.opencontainers.image.base.name="docker.io/alpine:3.21" \ org.opencontainers.image.description="Private location orchestrator for OpenStatus" \ org.opencontainers.image.source="https://github.com/openstatusHQ/openstatus" \ org.opencontainers.image.title="OpenStatus Private Location" \ org.opencontainers.image.vendor="OpenStatus" ENV \ GIN_MODE="release" \ TZ="UTC" \ USER="1000" WORKDIR /opt/bin COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/etc/ssl/certs/ca-certificates.crt" "/etc/ssl/certs/" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/usr/share/zoneinfo" "/usr/share/zoneinfo" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/go/src/app/private-location" "/opt/bin/private-location" USER 1000:1000 EXPOSE 8080 HEALTHCHECK \ --interval=15s \ --timeout=10s \ --start-period=30s \ --retries=3 \ CMD wget --spider -q http://localhost:8080/health || exit 1 CMD ["/opt/bin/private-location"] ================================================ FILE: apps/private-location/README.md ================================================ # Private Location Orchestrator A server that allows private regions to register and ingest data from them. ================================================ FILE: apps/private-location/cmd/server/main.go ================================================ package main import ( "context" "fmt" "log" "net/http" "os/signal" "syscall" "time" "github.com/openstatushq/openstatus/apps/private-location/internal/server" ) func gracefulShutdown(apiServer *http.Server, cleanup func(context.Context), done chan bool) { // Create context that listens for the interrupt signal from the OS. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // Listen for the interrupt signal. <-ctx.Done() fmt.Println("shutting down gracefully, press Ctrl+C again to force") stop() // Allow Ctrl+C to force shutdown // The context is used to inform the server it has 5 seconds to finish // the request it is currently handling ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := apiServer.Shutdown(ctx); err != nil { log.Printf("Server forced to shutdown with error: %v", err) } // Cleanup log provider cleanup(ctx) log.Println("Server exiting") // Notify the main goroutine that the shutdown is complete done <- true } func main() { server, cleanup := server.NewServer() // Create a done channel to signal when the shutdown is complete done := make(chan bool, 1) // Run graceful shutdown in a separate goroutine go gracefulShutdown(server, cleanup, done) err := server.ListenAndServe() if err != nil && err != http.ErrServerClosed { panic(fmt.Sprintf("http server error: %s", err)) } // Wait for the graceful shutdown to complete <-done log.Println("Graceful shutdown complete.") } ================================================ FILE: apps/private-location/dofigen.yml ================================================ builders: # Stage 1: Build Go binary builder: fromImage: golang:1.26-alpine platform: $BUILDPLATFORM label: org.opencontainers.image.stage: builder workdir: /go/src/app # Build-time arguments (overwritten by .env.docker at runtime) args: TARGETOS: "" TARGETARCH: "" env: TZ: UTC CGO_ENABLED: "0" copy: # Copy source code - . . run: - apk add --no-cache tzdata - go mod download # Build optimized binary # -trimpath: Remove file system paths from binary # -ldflags "-s -w": Strip debug info and symbol table - GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w" -o private-location ./cmd/server # Runtime stage fromImage: alpine:3.21 # Metadata labels label: org.opencontainers.image.title: OpenStatus Private Location org.opencontainers.image.description: Private location orchestrator for OpenStatus org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus org.opencontainers.image.vendor: OpenStatus org.opencontainers.image.authors: OpenStatus Team workdir: /opt/bin # Copy artifacts from builder copy: - fromBuilder: builder source: /etc/ssl/certs/ca-certificates.crt target: /etc/ssl/certs/ - fromBuilder: builder source: /usr/share/zoneinfo target: /usr/share/zoneinfo - fromBuilder: builder source: /go/src/app/private-location target: /opt/bin/private-location env: TZ: UTC USER: "1000" GIN_MODE: release # Security: run as non-root user user: "1000:1000" # Expose port expose: "8080" # Health check healthcheck: interval: 15s timeout: 10s start: 30s retries: 3 cmd: wget --spider -q http://localhost:8080/health || exit 1 # Start application cmd: - /opt/bin/private-location ================================================ FILE: apps/private-location/fly.toml ================================================ # fly.toml app configuration file generated for openstatus-checker on 2023-11-30T20:23:20+01:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = "openstatus-private-location" primary_region = "ams" [build] dockerfile = "./Dockerfile" [deploy] strategy = "canary" [env] PORT = "8080" [http_service] internal_port = 8080 force_https = true auto_stop_machines = "off" auto_start_machines = false processes = ["app"] [[vm]] cpu_kind = "shared" cpus = 1 memory_mb = 256 [[http_service.checks]] grace_period = "10s" interval = "15s" method = "GET" timeout = "5s" path = "/health" [http_service.concurrency] type = "requests" hard_limit = 1000 soft_limit = 500 ================================================ FILE: apps/private-location/go.mod ================================================ module github.com/openstatushq/openstatus/apps/private-location go 1.25.2 require ( connectrpc.com/connect v1.19.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/render v1.0.3 github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.22 github.com/openstatushq/openstatus/apps/checker v0.0.0-20251012205355-e366f661c23e github.com/stretchr/testify v1.11.1 github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 go.opentelemetry.io/otel/log v0.15.0 go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/sdk/log v0.15.0 google.golang.org/protobuf v1.36.10 ) require ( github.com/ajg/form v1.5.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/zerolog v1.34.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.77.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: apps/private-location/go.sum ================================================ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/openstatushq/openstatus/apps/checker v0.0.0-20251012205355-e366f661c23e h1:54C0zQNHzGszQseO2QcNzM8fL7vyAYk03pRtrJIyoV0= github.com/openstatushq/openstatus/apps/checker v0.0.0-20251012205355-e366f661c23e/go.mod h1:R84xAJYFys7XOZTDk/AyjJi4Ga9ovtLhJsfTLgTsYKg= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0= go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY= go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE= go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: apps/private-location/internal/database/database.go ================================================ package database import ( "database/sql" "fmt" "os" "github.com/jmoiron/sqlx" _ "github.com/joho/godotenv/autoload" _ "github.com/tursodatabase/libsql-client-go/libsql" ) var ( dbUrl = os.Getenv("DB_URL") authToken = os.Getenv("DB_AUTH_TOKEN") dbInstance *sqlx.DB ) // New returns a database connection, reusing an existing connection if available. func New() *sqlx.DB { // Reuse Connection if dbInstance != nil { return dbInstance } url := fmt.Sprintf("%s?auth_token=%s", dbUrl, authToken) c, err := sql.Open("libsql", url) if err != nil { fmt.Fprintf(os.Stderr, "failed to open db %s: %s", url, err) os.Exit(1) } db := sqlx.NewDb(c, "sqlite3") dbInstance = db return db } // Close closes the database connection. func Close() error { if dbInstance != nil { err := dbInstance.Close() dbInstance = nil return err } return nil } ================================================ FILE: apps/private-location/internal/database/models.go ================================================ package database import "database/sql" // JobType represents the type of job for a monitor. type JobType string const ( JobTypeTCP JobType = "tcp" JobTypeUDP JobType = "udp" JobTypeHTTP JobType = "http" JobTypeDNS JobType = "dns" ) type Monitor struct { ID int `db:"id"` Active bool `db:"active"` WorkspaceID int `db:"workspace_id"` JobType JobType `db:"job_type"` Periodicity string `db:"periodicity"` URL string `db:"url"` Headers string `db:"headers"` Body string `db:"body"` Method string `db:"method"` Timeout int64 `db:"timeout"` DegradedAfter sql.NullInt64 `db:"degraded_after"` Assertions sql.NullString `db:"assertions"` Retry int `db:"retry"` FollowRedirects bool `db:"follow_redirects"` OtelEndpoint sql.NullString `db:"otel_endpoint" json:"-"` OtelHeaders sql.NullString `db:"otel_headers" json:"-"` Name string `db:"name" json:"-"` ExternalName sql.NullString `db:"external_name" json:"-"` Description string `db:"description" json:"-"` CreatedAt int `db:"created_at" json:"-"` UpdatedAt int `db:"updated_at" json:"-"` DeletedAt sql.NullInt64 `db:"deleted_at" json:"-"` Regions string `db:"regions" json:"-"` Status string `db:"status" json:"-"` Public bool `db:"public" json:"-"` } type PrivateLocation struct { ID int `db:"id"` } ================================================ FILE: apps/private-location/internal/logs/logs.go ================================================ package logs import ( "log/slog" "math/rand/v2" "time" ) func ShouldSample(event map[string]any) bool { statusCode, _ := event["status_code"].(int) durationMs, _ := event["duration_ms"].(int) // Always capture: server errors if statusCode >= 500 { return true } // Always capture: explicit errors if _, hasError := event["error"]; hasError { return true } // Always capture: slow requests (above p99 - 2s threshold) if durationMs > 2000 { return true } // Higher sampling for client errors (4xx) - 100% if statusCode >= 400 && statusCode < 500 { return true } // Random sample successful, fast requests at 20% return rand.Float64() < 0.2 } // MapToAttrs converts a map[string]any to a slice of slog.Attr func MapToAttrs(m map[string]any) []slog.Attr { attrs := make([]slog.Attr, 0, len(m)) for k, v := range m { attrs = append(attrs, toAttr(k, v)) } return attrs } func toAttr(key string, value any) slog.Attr { switch v := value.(type) { case string: return slog.String(key, v) case int: return slog.Int(key, v) case int64: return slog.Int64(key, v) case float64: return slog.Float64(key, v) case bool: return slog.Bool(key, v) case time.Time: return slog.Time(key, v) case time.Duration: return slog.Duration(key, v) case map[string]any: return slog.Group(key, MapToAny(v)...) default: return slog.Any(key, v) } } func MapToAny(m map[string]any) []any { args := make([]any, 0, len(m)*2) for k, v := range m { args = append(args, toAttr(k, v)) } return args } ================================================ FILE: apps/private-location/internal/logs/logs_test.go ================================================ package logs_test import ( "log/slog" "testing" "time" "github.com/openstatushq/openstatus/apps/private-location/internal/logs" ) func TestShouldSample(t *testing.T) { tests := []struct { name string event map[string]any expected bool }{ { name: "server error 500 should always sample", event: map[string]any{ "status_code": 500, }, expected: true, }, { name: "server error 503 should always sample", event: map[string]any{ "status_code": 503, }, expected: true, }, { name: "server error 599 should always sample", event: map[string]any{ "status_code": 599, }, expected: true, }, { name: "explicit error should always sample", event: map[string]any{ "status_code": 200, "error": "something went wrong", }, expected: true, }, { name: "slow request above 2000ms should always sample", event: map[string]any{ "status_code": 200, "duration_ms": 2001, }, expected: true, }, { name: "slow request exactly 2000ms should not always sample", event: map[string]any{ "status_code": 200, "duration_ms": 2000, }, expected: false, // This will be randomly sampled at 20% }, { name: "client error 400 should always sample", event: map[string]any{ "status_code": 400, }, expected: true, }, { name: "client error 404 should always sample", event: map[string]any{ "status_code": 404, }, expected: true, }, { name: "client error 499 should always sample", event: map[string]any{ "status_code": 499, }, expected: true, }, { name: "status code 399 should not always sample (below client error range)", event: map[string]any{ "status_code": 399, }, expected: false, // Random 20% sampling }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := logs.ShouldSample(tt.event) if tt.expected && !result { t.Errorf("ShouldSample() = %v, expected %v (should always sample)", result, tt.expected) } // For cases where expected is false, we can't deterministically test // because the function uses random sampling. We just verify it doesn't // always return true. }) } } func TestShouldSample_RandomSampling(t *testing.T) { // Test that successful, fast requests are sometimes sampled event := map[string]any{ "status_code": 200, "duration_ms": 100, } // Run multiple times to verify random sampling works sampledCount := 0 iterations := 1000 for i := 0; i < iterations; i++ { if logs.ShouldSample(event) { sampledCount++ } } // With 20% sampling, we expect roughly 200 samples out of 1000 // Allow for some variance (between 10% and 30%) minExpected := iterations / 10 // 10% maxExpected := iterations * 3 / 10 // 30% if sampledCount < minExpected || sampledCount > maxExpected { t.Errorf("Random sampling seems off: got %d samples out of %d (expected roughly 20%%)", sampledCount, iterations) } } func TestShouldSample_EmptyEvent(t *testing.T) { // Empty event should fall through to random sampling event := map[string]any{} // Just verify it doesn't panic _ = logs.ShouldSample(event) } func TestShouldSample_MissingFields(t *testing.T) { // Event with no status_code or duration_ms event := map[string]any{ "path": "/api/test", "method": "GET", } // Should fall through to random sampling without panic _ = logs.ShouldSample(event) } func TestMapToAttrs(t *testing.T) { tests := []struct { name string input map[string]any expected int // expected number of attributes }{ { name: "empty map", input: map[string]any{}, expected: 0, }, { name: "single string value", input: map[string]any{ "key": "value", }, expected: 1, }, { name: "multiple types", input: map[string]any{ "string_key": "value", "int_key": 42, "bool_key": true, }, expected: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { attrs := logs.MapToAttrs(tt.input) if len(attrs) != tt.expected { t.Errorf("MapToAttrs() returned %d attrs, expected %d", len(attrs), tt.expected) } }) } } func TestMapToAttrs_TypeConversions(t *testing.T) { now := time.Now() duration := 5 * time.Second input := map[string]any{ "string_val": "hello", "int_val": 42, "int64_val": int64(1234567890), "float64_val": 3.14, "bool_val": true, "time_val": now, "duration_val": duration, } attrs := logs.MapToAttrs(input) // Verify correct number of attributes if len(attrs) != 7 { t.Errorf("Expected 7 attributes, got %d", len(attrs)) } // Verify each attribute type attrMap := make(map[string]slog.Attr) for _, attr := range attrs { attrMap[attr.Key] = attr } // Check string if attr, ok := attrMap["string_val"]; ok { if attr.Value.Kind() != slog.KindString { t.Errorf("string_val should be String kind, got %v", attr.Value.Kind()) } if attr.Value.String() != "hello" { t.Errorf("string_val should be 'hello', got %v", attr.Value.String()) } } // Check int if attr, ok := attrMap["int_val"]; ok { if attr.Value.Kind() != slog.KindInt64 { t.Errorf("int_val should be Int64 kind, got %v", attr.Value.Kind()) } if attr.Value.Int64() != 42 { t.Errorf("int_val should be 42, got %v", attr.Value.Int64()) } } // Check bool if attr, ok := attrMap["bool_val"]; ok { if attr.Value.Kind() != slog.KindBool { t.Errorf("bool_val should be Bool kind, got %v", attr.Value.Kind()) } if attr.Value.Bool() != true { t.Errorf("bool_val should be true, got %v", attr.Value.Bool()) } } } func TestMapToAttrs_NestedMap(t *testing.T) { input := map[string]any{ "outer": map[string]any{ "inner_string": "nested_value", "inner_int": 123, }, } attrs := logs.MapToAttrs(input) if len(attrs) != 1 { t.Errorf("Expected 1 attribute (group), got %d", len(attrs)) } // The nested map should be converted to a Group if attrs[0].Key != "outer" { t.Errorf("Expected key 'outer', got %s", attrs[0].Key) } if attrs[0].Value.Kind() != slog.KindGroup { t.Errorf("Expected Group kind for nested map, got %v", attrs[0].Value.Kind()) } } func TestMapToAttrs_UnknownType(t *testing.T) { type customType struct { Field string } input := map[string]any{ "custom": customType{Field: "test"}, } attrs := logs.MapToAttrs(input) if len(attrs) != 1 { t.Errorf("Expected 1 attribute, got %d", len(attrs)) } // Unknown types should be converted using slog.Any if attrs[0].Key != "custom" { t.Errorf("Expected key 'custom', got %s", attrs[0].Key) } if attrs[0].Value.Kind() != slog.KindAny { t.Errorf("Expected Any kind for unknown type, got %v", attrs[0].Value.Kind()) } } func TestMapToAny(t *testing.T) { input := map[string]any{ "key1": "value1", "key2": 42, } result := logs.MapToAny(input) // MapToAny returns []any containing slog.Attr values if len(result) != 2 { t.Errorf("Expected 2 items, got %d", len(result)) } // Verify each item is an slog.Attr for _, item := range result { if _, ok := item.(slog.Attr); !ok { t.Errorf("Expected slog.Attr, got %T", item) } } } func TestMapToAny_EmptyMap(t *testing.T) { input := map[string]any{} result := logs.MapToAny(input) if len(result) != 0 { t.Errorf("Expected empty slice, got %d items", len(result)) } } ================================================ FILE: apps/private-location/internal/models/assertions.go ================================================ package models import "encoding/json" type AssertionType string const ( AssertionHeader AssertionType = "header" AssertionTextBody AssertionType = "textBody" AssertionStatus AssertionType = "status" AssertionJsonBody AssertionType = "jsonBody" AssertionDnsRecord AssertionType = "dnsRecord" ) type StringComparator string func (c StringComparator) String() string { return string(c) } func (c NumberComparator) String() string { return string(c) } const ( StringContains StringComparator = "contains" StringNotContains StringComparator = "not_contains" StringEquals StringComparator = "eq" StringNotEquals StringComparator = "not_eq" StringEmpty StringComparator = "empty" StringNotEmpty StringComparator = "not_empty" StringGreaterThan StringComparator = "gt" StringGreaterThanEqual StringComparator = "gte" StringLowerThan StringComparator = "lt" StringLowerThanEqual StringComparator = "lte" ) type NumberComparator string const ( NumberEquals NumberComparator = "eq" NumberNotEquals NumberComparator = "not_eq" NumberGreaterThan NumberComparator = "gt" NumberGreaterThanEqual NumberComparator = "gte" NumberLowerThan NumberComparator = "lt" NumberLowerThanEqual NumberComparator = "lte" ) type Assertion struct { AssertionType AssertionType `json:"type"` Comparator json.RawMessage `json:"compare"` RawTarget json.RawMessage `json:"target"` } type StatusTarget struct { AssertionType AssertionType `json:"type"` Comparator NumberComparator `json:"compare"` Target int64 `json:"target"` } type HeaderTarget struct { AssertionType AssertionType `json:"type"` Comparator StringComparator `json:"compare"` Target string `json:"target"` Key string `json:"key"` } type StringTargetType struct { Comparator StringComparator `json:"compare"` Target string `json:"target"` } type BodyString struct { AssertionType AssertionType `json:"type"` Comparator StringComparator `json:"compare"` Target string `json:"target"` } type RecordComparator string const ( RecordEquals RecordComparator = "eq" RecordNotEquals RecordComparator = "not_eq" RecordContains RecordComparator = "contains" RecordNotContains RecordComparator = "not_contains" ) type RecordTarget struct { AssertionType AssertionType `json:"type"` Comparator RecordComparator `json:"compare"` Target string `json:"target"` Key string `json:"key"` } ================================================ FILE: apps/private-location/internal/server/db_testdata ================================================ DROP TABLE IF EXISTS "__drizzle_migrations"; CREATE TABLE "__drizzle_migrations" ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric ); DROP TABLE IF EXISTS "page"; CREATE TABLE `page` ( `id` integer PRIMARY KEY NOT NULL, `workspace_id` integer NOT NULL, `title` text NOT NULL, `description` text NOT NULL, `icon` text(256), `slug` text(256) NOT NULL, `custom_domain` text(256) NOT NULL, `published` integer DEFAULT false, "created_at" integer DEFAULT (strftime('%s', 'now')), `updated_at` integer, `password` text(256), `password_protected` integer DEFAULT false, `show_monitor_values` integer DEFAULT true, `force_theme` text DEFAULT 'system' NOT NULL, `legacy_page` integer DEFAULT true NOT NULL, `configuration` text, `homepage_url` text(256), `contact_url` text(256), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "monitors_to_pages"; CREATE TABLE `monitors_to_pages` ( `monitor_id` integer NOT NULL, `page_id` integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `order` integer DEFAULT 0, PRIMARY KEY(`monitor_id`, `page_id`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "user"; CREATE TABLE `user` ( `id` integer PRIMARY KEY NOT NULL, `tenant_id` text(256), "created_at" integer DEFAULT (strftime('%s', 'now')) , `first_name` text DEFAULT '', `last_name` text DEFAULT '', `email` text DEFAULT '', `photo_url` text DEFAULT '', `updated_at` integer, `name` text, `emailVerified` integer); DROP TABLE IF EXISTS "users_to_workspaces"; CREATE TABLE `users_to_workspaces` ( `user_id` integer NOT NULL, `workspace_id` integer NOT NULL, `role` text DEFAULT 'owner' NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY(`user_id`, `workspace_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "workspace"; CREATE TABLE `workspace` ( `id` integer PRIMARY KEY NOT NULL, `slug` text NOT NULL, `stripe_id` text(256), `name` text, "created_at" integer DEFAULT (strftime('%s', 'now')) , `updated_at` integer, `subscription_id` text, `plan` text(3), `ends_at` integer, `paid_until` integer, `dsn` text, `limits` text DEFAULT '{}' NOT NULL); DROP TABLE IF EXISTS "status_report_update"; CREATE TABLE "status_report_update" ( `id` integer PRIMARY KEY NOT NULL, "status" text NOT NULL, `date` integer NOT NULL, `message` text NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), "status_report_id" integer NOT NULL, FOREIGN KEY ("status_report_id") REFERENCES "status_report"(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "status_report_to_monitors"; CREATE TABLE "status_report_to_monitors" ( `monitor_id` integer NOT NULL, "status_report_id" integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY("status_report_id", `monitor_id`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY ("status_report_id") REFERENCES "status_report"(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "monitor"; CREATE TABLE "monitor" ( `id` integer PRIMARY KEY NOT NULL, `job_type` text(3) DEFAULT 'other' NOT NULL, `periodicity` text(6) DEFAULT 'other' NOT NULL, `active` integer DEFAULT false, `url` text(512) NOT NULL, `name` text(256) DEFAULT '' NOT NULL, `description` text DEFAULT '' NOT NULL, `workspace_id` integer, `headers` text DEFAULT '', `body` text DEFAULT '', `method` text(5) DEFAULT 'GET', `created_at` integer DEFAULT (strftime('%s', 'now')), `regions` text DEFAULT '' NOT NULL, `updated_at` integer, `status` text(2) DEFAULT 'active' NOT NULL, `assertions` text, `deleted_at` integer, `public` integer DEFAULT false, `timeout` integer DEFAULT 45000 NOT NULL, `degraded_after` integer, `otel_endpoint` text, `otel_headers` text, `retry` integer DEFAULT 3, `follow_redirects` integer DEFAULT true, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "integration"; CREATE TABLE `integration` ( `id` integer PRIMARY KEY NOT NULL, `name` text(256) NOT NULL, `workspace_id` integer, `credential` text, `external_id` text NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), `data` text NOT NULL, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "notification"; CREATE TABLE `notification` ( `id` integer PRIMARY KEY NOT NULL, `name` text NOT NULL, `provider` text NOT NULL, `data` text DEFAULT '{}', `workspace_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "notifications_to_monitors"; CREATE TABLE `notifications_to_monitors` ( `monitor_id` integer NOT NULL, `notification_id` integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY(`monitor_id`, `notification_id`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "status_reports_to_pages"; CREATE TABLE "status_reports_to_pages" ( `page_id` integer NOT NULL, "status_report_id" integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY("status_report_id", `page_id`), FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY ("status_report_id") REFERENCES "status_report"(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "monitor_status"; CREATE TABLE `monitor_status` ( `monitor_id` integer NOT NULL, `region` text DEFAULT '' NOT NULL, `status` text DEFAULT 'active' NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY(`monitor_id`, `region`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "invitation"; CREATE TABLE `invitation` ( `id` integer PRIMARY KEY NOT NULL, `email` text NOT NULL, `role` text DEFAULT 'member' NOT NULL, `workspace_id` integer NOT NULL, `token` text NOT NULL, `expires_at` integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `accepted_at` integer ); DROP TABLE IF EXISTS "incident"; CREATE TABLE "incident" ( `id` integer PRIMARY KEY NOT NULL, `title` text DEFAULT '' NOT NULL, `summary` text DEFAULT '' NOT NULL, `status` text DEFAULT 'triage' NOT NULL, `monitor_id` integer, `workspace_id` integer, `started_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, `acknowledged_at` integer, `acknowledged_by` integer, `resolved_at` integer, `resolved_by` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), `auto_resolved` integer DEFAULT false, `incident_screenshot_url` text, `recovery_screenshot_url` text, FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE set default, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`acknowledged_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`resolved_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "monitor_tag"; CREATE TABLE `monitor_tag` ( `id` integer PRIMARY KEY NOT NULL, `workspace_id` integer NOT NULL, `name` text NOT NULL, `color` text NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "monitor_tag_to_monitor"; CREATE TABLE `monitor_tag_to_monitor` ( `monitor_id` integer NOT NULL, `monitor_tag_id` integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY(`monitor_id`, `monitor_tag_id`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`monitor_tag_id`) REFERENCES `monitor_tag`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "account"; CREATE TABLE `account` ( `user_id` integer NOT NULL, `type` text NOT NULL, `provider` text NOT NULL, `provider_account_id` text NOT NULL, `refresh_token` text, `access_token` text, `expires_at` integer, `token_type` text, `scope` text, `id_token` text, `session_state` text, PRIMARY KEY(`provider`, `provider_account_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "session"; CREATE TABLE `session` ( `session_token` text PRIMARY KEY NOT NULL, `user_id` integer NOT NULL, `expires` integer NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "verification_token"; CREATE TABLE `verification_token` ( `identifier` text NOT NULL, `token` text NOT NULL, `expires` integer NOT NULL, PRIMARY KEY(`identifier`, `token`) ); DROP TABLE IF EXISTS "application"; CREATE TABLE `application` ( `id` integer PRIMARY KEY NOT NULL, `name` text, `dsn` text, `workspace_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "maintenance_to_monitor"; CREATE TABLE `maintenance_to_monitor` ( `monitor_id` integer NOT NULL, `maintenance_id` integer NOT NULL, `created_at` integer DEFAULT (strftime('%s', 'now')), PRIMARY KEY(`maintenance_id`, `monitor_id`), FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`maintenance_id`) REFERENCES `maintenance`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "check"; CREATE TABLE `check` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `regions` text DEFAULT '' NOT NULL, `url` text(4096) NOT NULL, `headers` text DEFAULT '', `body` text DEFAULT '', `method` text DEFAULT 'GET', `count_requests` integer DEFAULT 1, `workspace_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "monitor_run"; CREATE TABLE `monitor_run` ( `id` integer PRIMARY KEY NOT NULL, `workspace_id` integer, `monitor_id` integer, `runned_at` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "page_subscriber"; CREATE TABLE "page_subscriber" ( `id` integer PRIMARY KEY NOT NULL, `email` text NOT NULL, `page_id` integer NOT NULL, `token` text, `accepted_at` integer, `expires_at` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "status_report"; CREATE TABLE "status_report" ( `id` integer PRIMARY KEY NOT NULL, `status` text NOT NULL, `title` text(256) NOT NULL, `workspace_id` integer, `page_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "notification_trigger"; CREATE TABLE "notification_trigger" ( `id` integer PRIMARY KEY NOT NULL, `monitor_id` integer, `notification_id` integer, `cron_timestamp` integer NOT NULL, FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "maintenance"; CREATE TABLE "maintenance" ( `id` integer PRIMARY KEY NOT NULL, `title` text(256) NOT NULL, `message` text NOT NULL, `from` integer NOT NULL, `to` integer NOT NULL, `workspace_id` integer, `page_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade ); DROP TABLE IF EXISTS "private_location"; CREATE TABLE `private_location` ( `id` integer PRIMARY KEY NOT NULL, `name` text NOT NULL, `token` text NOT NULL, `last_seen_at` integer, `workspace_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `updated_at` integer DEFAULT (strftime('%s', 'now')), FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); DROP TABLE IF EXISTS "private_location_to_monitor"; CREATE TABLE `private_location_to_monitor` ( `private_location_id` integer, `monitor_id` integer, `created_at` integer DEFAULT (strftime('%s', 'now')), `deleted_at` integer, FOREIGN KEY (`private_location_id`) REFERENCES `private_location`(`id`) ON UPDATE no action ON DELETE no action, FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE no action ); INSERT INTO "__drizzle_migrations" ("id", "hash", "created_at") VALUES (NULL, 'ea497587bb639bbeae27f3f644634b7429f37df241c999e22f3acbf3cce74ec9', '1690309905039'), (NULL, '680e79fc5537135bcfa4da88cc0c06a7a9eaf810ab87f8ae06ef67f7b4802fad', '1690892003254'), (NULL, 'b8ae7d5887e4cd8416a443f193753dabc6bc1ee76a6cbcfd4b338fbc6d3d10e7', '1691573899721'), (NULL, '790cf15e409b0c02f0e164824289aaacacb7aee21f5e84ab4f5a48df4b46e528', '1691614487733'), (NULL, 'ac5db9b31935382412791565e1c11ce1c99e69bd6f826da9ff08fb4ea64acc67', '1691850907670'), (NULL, '4cc5cc57b2e087276e6283cbd639e2ccd27c800eec6def5bb3ff7ea3ee3d9ba9', '1691930414569'), (NULL, 'cc99f49622240771a187a79a7c5646ed7f1e6e86b6eefcc04e9a6be78e57a5b0', '1692646649111'), (NULL, '49e497500b2fdb01624090863808ab15db1e50d4bc393c1e6e820960cb070d60', '1694362217174'), (NULL, 'a76eb5c9a12c6828bdef95e6ca65483055e7dabdf94f0b95fdae437c811b6b55', '1695756345957'), (NULL, '223292dcce0a81148dfbf7338ed4fe5878503d6bf785e1a13042b7c7b7b5bf24', '1697285841283'), (NULL, '5cc395568f9f61ebf4b57f69fd6397da2661062db813a85c5433d56841cecc9c', '1700586221141'), (NULL, '4ceefb80e855b8fde6c430758d9b557cef09c42afca4575c8fe4236628112d33', '1701100570578'), (NULL, '4c8d34ebf56874f41a1b22cb93db50e022233ff53f763eb73a73d2e01223fa39', '1701713135829'), (NULL, '4f47a6efc84f60e41437134c6eec5b2a3be68aae2a0525f2109b61f7473bb5ea', '1702144660818'), (NULL, '6f1b9eac8a3c7cf72703171402a9f42ea7523c091e393b3972fd231ab6eac6f9', '1702227904130'), (NULL, '4ae3b4c679065c4ca450126e14a73dbb3d21d71b7b1de2be182c32500b5c16ea', '1705856545397'), (NULL, '04e2ca02dbf77bce1755dc28e624d1620d662bec94373cea08983f4801fe778d', '1706111184826'), (NULL, 'd3c0f8670dd8e3666b3e0bd3ef1d75aec6b593c11d35e5a75bf58abe6208c642', '1707411900987'), (NULL, 'a028a05328ef4480fb5d79caed96da17b68793f7c9af037dbe8bb77ce42f5e36', '1707770189561'), (NULL, '826d9207a73609ff0f15acb2109fed80a253beb826836a93e122157584f9ca35', '1707899175705'), (NULL, '0055fd4b62b8b1c7029ed978cdd2f946084acae39966cb8cf85c26923a87d1cc', '1707905605592'), (NULL, 'af0035a9de14837b62dddba9e40bb85bf249a0c75683867be2a2d6f70f28da6d', '1710677383007'), (NULL, '498de5adf9c0ea5ed4c3d57b6a0905b709a58550e79c157909f13e1269896c5a', '1711307113089'), (NULL, 'ce825562d32c32c28da76e2d06b5e76f4c1603d178ce82ff97e1436819f3273c', '1712311348272'), (NULL, '45fc9d668e1a0d69701267444ca79da8e392180635761f24e41d9a7ac30a6d8c', '1712354121499'), (NULL, 'e9a11163fbaa7bdb2e2350265cc3e260bdb24ae1eb42d975fbf03cba7b3c8ce7', '1713095971713'), (NULL, '62f1cd998d6258df5c2a89222f5b71082d575b474f6d86b75fb4bf3aefe1d063', '1713384976187'), (NULL, '4c0d7cd418b66257e0a49454c54e8e65c388c5a246b3f5a51df0618da1faa67d', '1714586658374'), (NULL, 'd785a89fd7aa7bad6dbae4db4a3daaaa26c5f14374ed045cfd22420453cc4cea', '1715173356076'), (NULL, '9216b9d717de25f7b615f625659b7acfbbb09adacd3b121fa4f05ec0796fbd3c', '1716215342026'), (NULL, 'a9ced3ac1a3f9e8de9d8240ccdeed9d9e9c80b1bb6caefea59a7dbf7cf411fa7', '1716364430118'), (NULL, '9d0ae1d1e4bc742555e5ae5d990b62d23bb3dcefd7f62079df0bfa908f74133b', '1717837961923'), (NULL, '698cdffe2cad7d5b65190d09cb8f2b2ba906b1218fedb1a727fb72049090dfb6', '1718027484219'), (NULL, '6255470457dc29e4e1f7b8aa84f349a68b04ba3d51b9380006ddac53d5ce90a2', '1719740057514'), (NULL, 'bbf2f3122e76c5cbd3b76bc36e97d8f8efdddfab25c98c16f8024154e9bb2609', '1720727898360'), (NULL, '812b9def5df2ffe21b91680764590eda65e5ac4674500966d921d9e8627ca2f0', '1721159796428'), (NULL, '56181582f13b5eb66f2598a28fc7800ec6a0b9dd31d74c99cea1299ea7321917', '1723459608109'), (NULL, '82e57027c88612343af361f30edb148f2fca91919d6fa44048e992462d71163d', '1729533101998'), (NULL, '215e2ab2354eaa50923566a10a99e79f8d8514d7e3d4ca5c5720a9f5d16714ee', '1729579461221'), (NULL, 'fca63d63ad18ce6864418f1597b7bb0013ab2a3e0b4c681326ee4b257de2eb66', '1739193014150'), (NULL, '42d3cbf03d76541be2bdd9036e2f97864aa2807fe625f3b4c2b4f6440db60bd4', '1740684132626'), (NULL, '1a8b01776fbc88eaad155e2d0d2c69fc41d970411193ddd97806d70e1c66248b', '1741936835660'), (NULL, '427ca6a7f11dce1f4c0a20344d32af5f3f146a128adce1e87c7f76c80ab080fd', '1747410497521'), (NULL, '150ecc57f8a96341713848b1eec1293e713289b8b87854067d543fce3ff03849', '1747908803707'), (NULL, '8980cf55bf7b9b7f9a2cb00e5f21be8454260dd2826644b7683aaad65ff40df6', '1753730490635'), (NULL, 'bd5e4767fcfcd79179bb9ac9576b58a0c51c0a3af65fc0140e53f57c40e56a9c', '1756185045968'), (NULL, 'c7180363acc6879b749b2e9c34d3b9ac012f13aed0c62bf7ed2104e936f72085', '1757580216081'), (NULL, '0b90478b428afe479c6da99669ba33dd78ca2b8b5039ca2305bd9771213f4089', '1757840904190'), (NULL, 'b2c7b149f424a30adbf9dbe4c1db363e85d5dfe9aca3845969b14238c1117178', '1759865914553'); INSERT INTO "page" ("id", "workspace_id", "title", "description", "icon", "slug", "custom_domain", "published", "created_at", "updated_at", "password", "password_protected", "show_monitor_values", "force_theme", "legacy_page", "configuration", "homepage_url", "contact_url") VALUES ('1', '1', 'Test Page', 'hello', 'https://www.openstatus.dev/favicon.ico', 'status', '', '1', '1760358329', '1760358329', NULL, '0', '1', 'system', '1', NULL, NULL, NULL); INSERT INTO "monitors_to_pages" ("monitor_id", "page_id", "created_at", "order") VALUES ('1', '1', '1760358329', '0'); INSERT INTO "user" ("id", "tenant_id", "created_at", "first_name", "last_name", "email", "photo_url", "updated_at", "name", "emailVerified") VALUES ('1', '1', '1760358329', 'Speed', 'Matters', 'ping@openstatus.dev', '', '1760358329', NULL, NULL); INSERT INTO "users_to_workspaces" ("user_id", "workspace_id", "role", "created_at") VALUES ('1', '1', 'member', '1760358329'); INSERT INTO "workspace" ("id", "slug", "stripe_id", "name", "created_at", "updated_at", "subscription_id", "plan", "ends_at", "paid_until", "dsn", "limits") VALUES ('1', 'love-openstatus', 'stripeId1', 'test', '1760358329', '1760358329', 'subscriptionId', 'team', NULL, NULL, NULL, '{"monitors":50,"synthetic-checks":150000,"periodicity":["30s","1m","5m","10m","30m","1h"],"multi-region":true,"max-regions":35,"data-retention":"24 months","status-pages":20,"maintenance":true,"status-subscribers":true,"custom-domain":true,"password-protection":true,"white-label":true,"notifications":true,"sms":true,"pagerduty":true,"notification-channels":50,"members":"Unlimited","audit-log":true,"regions":["ams","arn","atl","bog","bom","bos","cdg","den","dfw","ewr","eze","fra","gdl","gig","gru","hkg","iad","jnb","lax","lhr","mad","mia","nrt","ord","otp","phx","qro","scl","sea","sin","sjc","syd","waw","yul","yyz"]}'), ('2', 'test2', 'stripeId2', 'test2', '1760358329', '1760358329', 'subscriptionId2', 'free', NULL, NULL, NULL, '{}'), ('3', 'test3', 'stripeId3', 'test3', '1760358329', '1760358329', 'subscriptionId3', 'team', NULL, NULL, NULL, '{}'); INSERT INTO "status_report_update" ("id", "status", "date", "message", "created_at", "updated_at", "status_report_id") VALUES ('1', 'investigating', '1760358329', 'Message', '1760358329', '1760358329', '1'), ('2', 'investigating', '1760358329', 'Message', '1760358329', '1760358329', '2'), ('3', 'monitoring', '1760358329', 'test', '1760358329', '1760358329', '1'); INSERT INTO "status_report_to_monitors" ("monitor_id", "status_report_id", "created_at") VALUES ('1', '2', '1760358329'), ('2', '2', '1760358329'); INSERT INTO "monitor" ("id", "job_type", "periodicity", "active", "url", "name", "description", "workspace_id", "headers", "body", "method", "created_at", "regions", "updated_at", "status", "assertions", "deleted_at", "public", "timeout", "degraded_after", "otel_endpoint", "otel_headers", "retry", "follow_redirects") VALUES ('1', 'http', '1m', '1', 'https://www.openstatus.dev', 'OpenStatus', 'OpenStatus website', '1', '[{"key":"key", "value":"value"}]', '{"hello":"world"}', 'POST', '1760358329', 'ams', '1760358329', 'active', NULL, NULL, '0', '45000', NULL, NULL, NULL, '3', '1'), ('2', 'http', '10m', '0', 'https://www.google.com', '', '', '1', '', '', 'GET', '1760358329', 'gru', '1760358329', 'active', NULL, NULL, '1', '45000', NULL, NULL, NULL, '3', '1'), ('3', 'http', '1m', '1', 'https://www.openstatus.dev', 'OpenStatus', 'OpenStatus website', '1', '[{"key":"key", "value":"value"}]', '{"hello":"world"}', 'GET', '1760358329', 'ams', '1760358329', 'active', NULL, NULL, '0', '45000', NULL, NULL, NULL, '3', '1'), ('4', 'http', '10m', '1', 'https://www.google.com', '', '', '1', '', '', 'GET', '1760358329', 'gru', '1760358329', 'active', NULL, NULL, '1', '45000', NULL, 'https://otel.com:4337', '[{"key":"Authorization","value":"Basic"}]', '3', '1'), ('5', 'http', '10m', '1', 'https://openstat.us', '', '', '3', '', '', 'GET', '1760358329', 'ams', '1760358329', 'active', NULL, NULL, '1', '45000', NULL, NULL, NULL, '3', '1'), ('6', 'tcp', '5m', '1', 'tcp://db.example.com:5432', 'Database TCP', 'Database TCP check', '3', '', '', '', '1760358329', 'ams', '1760358329', 'active', NULL, NULL, '0', '30000', '5000', NULL, NULL, '2', '0'), ('7', 'dns', '5m', '1', 'openstatus.dev', 'DNS Check', 'DNS check for openstatus.dev', '3', '', '', '', '1760358329', 'ams', '1760358329', 'active', '[{"version":"v1","type":"dnsRecord","key":"A","compare":"contains","target":"76.76.21.21"}]', NULL, '0', '30000', '3000', NULL, NULL, '2', '0'); INSERT INTO "notification" ("id", "name", "provider", "data", "workspace_id", "created_at", "updated_at") VALUES ('1', 'sample test notification', 'email', '{"email":"ping@openstatus.dev"}', '1', '1760358329', '1760358329'); INSERT INTO "notifications_to_monitors" ("monitor_id", "notification_id", "created_at") VALUES ('1', '1', '1760358329'); INSERT INTO "incident" ("id", "title", "summary", "status", "monitor_id", "workspace_id", "started_at", "acknowledged_at", "acknowledged_by", "resolved_at", "resolved_by", "created_at", "updated_at", "auto_resolved", "incident_screenshot_url", "recovery_screenshot_url") VALUES ('1', '', '', 'triage', '1', '1', '1760358329', NULL, NULL, NULL, NULL, '1760358329', '1760358329', '0', NULL, NULL), ('2', '', '', 'triage', '1', '1', '1760358330', NULL, NULL, NULL, NULL, '1760358329', '1760358329', '0', NULL, NULL); INSERT INTO "maintenance_to_monitor" ("monitor_id", "maintenance_id", "created_at") VALUES ('1', '1', '1760358329'); INSERT INTO "status_report" ("id", "status", "title", "workspace_id", "page_id", "created_at", "updated_at") VALUES ('1', 'monitoring', 'Test Status Report', '1', '1', '1760358329', '1760358329'), ('2', 'investigating', 'Test Status Report', '1', '1', '1760358329', '1760358329'); INSERT INTO "maintenance" ("id", "title", "message", "from", "to", "workspace_id", "page_id", "created_at", "updated_at") VALUES ('1', 'Test Maintenance', 'Test message', '1760358329', '1760358330', '1', '1', '1760358329', '1760358329'); INSERT INTO "private_location" ("id", "name", "token", "last_seen_at", "workspace_id", "created_at", "updated_at") VALUES ('1', 'My Home', 'my-secret-key', NULL, '3', '1760358329', '1760358329'); INSERT INTO "private_location_to_monitor" ("private_location_id", "monitor_id", "created_at", "deleted_at") VALUES ('1', '5', '1760358329', NULL), ('1', '6', '1760358329', NULL), ('1', '7', '1760358329', NULL); ================================================ FILE: apps/private-location/internal/server/errors.go ================================================ package server import "errors" // Common errors used across the server package var ( ErrMissingToken = errors.New("missing token") ErrMonitorNotFound = errors.New("monitor not found") ErrPrivateLocationNotFound = errors.New("private location not found") ) ================================================ FILE: apps/private-location/internal/server/ingest_common.go ================================================ package server import ( "context" "time" "github.com/openstatushq/openstatus/apps/private-location/internal/database" ) // ingestContext holds common data needed for ingestion type ingestContext struct { Monitor database.Monitor Region database.PrivateLocation } // getIngestContext retrieves monitor and private location data for ingestion func (h *privateLocationHandler) getIngestContext(ctx context.Context, token string, monitorID string) (*ingestContext, error) { var monitor database.Monitor err := h.db.Get(&monitor, "SELECT monitor.id, monitor.workspace_id, monitor.url, monitor.method, monitor.assertions FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.id = ?", token, monitorID) if err != nil { if holder := GetEvent(ctx); holder != nil { holder.Event["error"] = map[string]any{ "message": err.Error(), "source": "database", "type": "monitor_lookup", } } return nil, err } var region database.PrivateLocation err = h.db.Get(®ion, "SELECT private_location.id FROM private_location join private_location_to_monitor a ON private_location.id = a.private_location_id WHERE a.monitor_id = ? AND private_location.token = ?", monitor.ID, token) if err != nil { if holder := GetEvent(ctx); holder != nil { holder.Event["error"] = map[string]any{ "message": err.Error(), "source": "database", "type": "private_location_lookup", } } return nil, err } return &ingestContext{ Monitor: monitor, Region: region, }, nil } // sendEventAndUpdateLastSeen sends the event to Tinybird and updates the last_seen_at timestamp func (h *privateLocationHandler) sendEventAndUpdateLastSeen(ctx context.Context, data any, dataSourceName string, regionID int) { start := time.Now() err := h.TbClient.SendEvent(ctx, data, dataSourceName) duration := time.Since(start).Milliseconds() // Enrich wide event with Tinybird operation context if holder := GetEvent(ctx); holder != nil { holder.Event["tinybird"] = map[string]any{ "datasource": dataSourceName, "duration_ms": duration, "success": err == nil, } if err != nil { holder.Event["error"] = map[string]any{ "message": err.Error(), "source": "tinybird", } } } _, dbErr := h.db.NamedExec("UPDATE private_location SET last_seen_at = :last_seen_at WHERE id = :id", map[string]any{ "last_seen_at": time.Now().Unix(), "id": regionID, }) if dbErr != nil { if holder := GetEvent(ctx); holder != nil { holder.Event["db_update_error"] = map[string]any{ "message": dbErr.Error(), "type": "last_seen_update", } } } } ================================================ FILE: apps/private-location/internal/server/ingest_dns.go ================================================ package server import ( "context" "strconv" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) type DNSResponse struct { ID string `json:"id"` Timing string `json:"timing"` ErrorMessage string `json:"errorMessage"` Region string `json:"region"` Trigger string `json:"trigger"` URI string `json:"uri"` RequestStatus string `json:"requestStatus,omitempty"` Records map[string][]string `json:"records"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` MonitorID int64 `json:"monitorId"` Timestamp int64 `json:"timestamp"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` } func (h *privateLocationHandler) IngestDNS(ctx context.Context, req *connect.Request[private_locationv1.IngestDNSRequest]) (*connect.Response[private_locationv1.IngestDNSResponse], error) { token := req.Header().Get("openstatus-token") if token == "" { return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) } if err := ValidateIngestDNSRequest(req.Msg); err != nil { return nil, NewValidationError(err) } ic, err := h.getIngestContext(ctx, token, req.Msg.Id) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } // Enrich wide event with business context if holder := GetEvent(ctx); holder != nil { holder.Event["private_location"] = map[string]any{ "monitor_id": req.Msg.MonitorId, "workspace_id": ic.Monitor.WorkspaceID, "region_id": ic.Region.ID, "datasource": tinybird.DatasourceDNS, } } records := make(map[string][]string) for _, record := range req.Msg.Records { r := []string{} for _, value := range record.GetRecord() { r = append(r, value) } records[record.String()] = r } data := DNSResponse{ ID: req.Msg.Id, WorkspaceID: int64(ic.Monitor.WorkspaceID), Timestamp: req.Msg.Timestamp, Error: uint8(req.Msg.Error), Region: strconv.Itoa(ic.Region.ID), MonitorID: int64(ic.Monitor.ID), Timing: req.Msg.Timing, Latency: req.Msg.Latency, CronTimestamp: req.Msg.CronTimestamp, Trigger: "cron", URI: req.Msg.Uri, RequestStatus: req.Msg.RequestStatus, Records: records, } h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceDNS, ic.Region.ID) return connect.NewResponse(&private_locationv1.IngestDNSResponse{}), nil } ================================================ FILE: apps/private-location/internal/server/ingest_dns_test.go ================================================ package server_test import ( "context" "net/http" "testing" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/server" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) func TestIngestDNS_Unauthenticated(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{}) // No token header resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for missing token, got nil") } if connect.CodeOf(err) != connect.CodeUnauthenticated { t.Errorf("expected unauthenticated code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_ValidationError_EmptyID(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_ValidationError_InvalidTimestamp(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "dns-123", Timestamp: 0, // Invalid - must be positive }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_ValidationError_NegativeLatency(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: -100, Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_DBError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "nonexistent-monitor", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "invalid-token") resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for db failure, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_MonitorNotExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "nonexistent-monitor", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err == nil { t.Fatalf("expected error for monitor not found, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestDNS_MonitorExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "5", Timestamp: 1234567890, Latency: 50, CronTimestamp: 1234567800, Uri: "dns://example.com", Timing: "50ms", Records: nil, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } func TestIngestDNS_WithRecords(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "5", Timestamp: 1234567890, Latency: 50, CronTimestamp: 1234567800, Uri: "dns://example.com", Timing: "50ms", Records: map[string]*private_locationv1.Records{ "A": { Record: []string{"192.168.1.1", "192.168.1.2"}, }, "AAAA": { Record: []string{"::1"}, }, }, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } func TestIngestDNS_WithError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestDNSRequest{ Id: "5", Timestamp: 1234567890, Latency: 0, CronTimestamp: 1234567800, Uri: "dns://example.com", Error: 1, // Error occurred Message: "DNS resolution failed", }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestDNS(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } ================================================ FILE: apps/private-location/internal/server/ingest_http.go ================================================ package server import ( "context" "strconv" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) type EventHolder struct { Event map[string]any } type PingData struct { ID string `json:"id"` WorkspaceID string `json:"workspaceId"` MonitorID string `json:"monitorId"` URL string `json:"url"` Method string `json:"method"` Region string `json:"region"` Message string `json:"message,omitempty"` Timing string `json:"timing,omitempty"` Headers string `json:"headers,omitempty"` Assertions string `json:"assertions"` Body string `json:"body,omitempty"` Trigger string `json:"trigger,omitempty"` RequestStatus string `json:"requestStatus,omitempty"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` Error uint8 `json:"error"` } func (h *privateLocationHandler) IngestHTTP(ctx context.Context, req *connect.Request[private_locationv1.IngestHTTPRequest]) (*connect.Response[private_locationv1.IngestHTTPResponse], error) { token := req.Header().Get("openstatus-token") if token == "" { return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) } if err := ValidateIngestHTTPRequest(req.Msg); err != nil { return nil, NewValidationError(err) } ic, err := h.getIngestContext(ctx, token, req.Msg.MonitorId) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } // Enrich wide event with business context if holder := GetEvent(ctx); holder != nil { holder.Event["private_location"] = map[string]any{ "monitor_id": req.Msg.MonitorId, "workspace_id": ic.Monitor.WorkspaceID, "region_id": ic.Region.ID, "datasource": tinybird.DatasourceHTTP, } } data := PingData{ ID: req.Msg.Id, Latency: req.Msg.Latency, StatusCode: int(req.Msg.StatusCode), MonitorID: req.Msg.MonitorId, Region: strconv.Itoa(ic.Region.ID), WorkspaceID: strconv.Itoa(ic.Monitor.WorkspaceID), Timestamp: req.Msg.Timestamp, CronTimestamp: req.Msg.CronTimestamp, URL: ic.Monitor.URL, Method: ic.Monitor.Method, Timing: req.Msg.Timing, Headers: req.Msg.Headers, Body: req.Msg.Body, Trigger: "cron", RequestStatus: req.Msg.RequestStatus, Assertions: ic.Monitor.Assertions.String, } h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceHTTP, ic.Region.ID) return connect.NewResponse(&private_locationv1.IngestHTTPResponse{}), nil } ================================================ FILE: apps/private-location/internal/server/ingest_http_test.go ================================================ package server_test import ( "context" "log" "net/http" "os" "testing" "connectrpc.com/connect" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/openstatushq/openstatus/apps/private-location/internal/server" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) func testDB() *sqlx.DB { f, err := os.CreateTemp("", "db") if err != nil { log.Fatalln(err) } db, err := sqlx.Connect("sqlite3", f.Name()) if err != nil { log.Fatalln(err) } dat, err := os.ReadFile("./db_testdata") db.MustExec(string(dat)) return db } type interceptorHTTPClient struct { f func(req *http.Request) (*http.Response, error) } func (i *interceptorHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { return i.f(req) } func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { return &http.Client{ Transport: i, } } func getTBClient(ctx context.Context) tinybird.Client { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusAccepted, }, nil }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") return client } func TestIngestHTTP_Unauthenticated(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{}) // No token header resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for missing token, got nil") } if connect.CodeOf(err) != connect.CodeUnauthenticated { t.Errorf("expected unauthenticated code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_DBError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{}) req.Header().Set("openstatus-token", "token123") req.Msg.Id = "monitor1" req.Msg.MonitorId = "nonexistent" req.Msg.Timestamp = 1234567890 resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for db failure, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_MonitorNotExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{}) req.Header().Set("openstatus-token", "my-secret-key") req.Msg.Id = "monitor1" req.Msg.MonitorId = "nonexistent" req.Msg.Timestamp = 1234567890 resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for db failure, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_MonitorExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{}) req.Header().Set("openstatus-token", "my-secret-key") req.Msg.Id = "monitor1" req.Msg.MonitorId = "5" req.Msg.Timestamp = 1234567890 resp, err := h.IngestHTTP(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got %v", resp) } } func TestIngestHTTP_ValidationError_EmptyMonitorID(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{ MonitorId: "", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_ValidationError_InvalidTimestamp(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{ MonitorId: "5", Timestamp: 0, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_ValidationError_NegativeLatency(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{ MonitorId: "5", Latency: -100, Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestHTTP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestHTTP_WithFullData(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{ Id: "request-1", MonitorId: "5", Timestamp: 1234567890, Latency: 150, CronTimestamp: 1234567800, Url: "https://example.com/api", StatusCode: 200, Timing: "150ms", Body: `{"status": "ok"}`, Headers: `{"Content-Type": "application/json"}`, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestHTTP(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } func TestIngestHTTP_WithError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestHTTPRequest{ Id: "request-1", MonitorId: "5", Timestamp: 1234567890, Latency: 0, CronTimestamp: 1234567800, Url: "https://example.com/api", Error: 1, Message: "Connection timeout", }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestHTTP(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } ================================================ FILE: apps/private-location/internal/server/ingest_tcp.go ================================================ package server import ( "context" "strconv" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) type TCPData struct { ID string `json:"id"` Timing string `json:"timing"` ErrorMessage string `json:"errorMessage"` Region string `json:"region"` Trigger string `json:"trigger"` URI string `json:"uri"` RequestStatus string `json:"requestStatus,omitempty"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` MonitorID int64 `json:"monitorId"` Timestamp int64 `json:"timestamp"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` } func (h *privateLocationHandler) IngestTCP(ctx context.Context, req *connect.Request[private_locationv1.IngestTCPRequest]) (*connect.Response[private_locationv1.IngestTCPResponse], error) { token := req.Header().Get("openstatus-token") if token == "" { return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) } if err := ValidateIngestTCPRequest(req.Msg); err != nil { return nil, NewValidationError(err) } ic, err := h.getIngestContext(ctx, token, req.Msg.Id) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } // Enrich wide event with business context if holder := GetEvent(ctx); holder != nil { holder.Event["private_location"] = map[string]any{ "monitor_id": req.Msg.MonitorId, "workspace_id": ic.Monitor.WorkspaceID, "region_id": ic.Region.ID, "datasource": tinybird.DatasourceTCP, } } data := TCPData{ ID: req.Msg.Id, WorkspaceID: int64(ic.Monitor.WorkspaceID), Timestamp: req.Msg.Timestamp, Error: uint8(req.Msg.Error), Region: strconv.Itoa(ic.Region.ID), MonitorID: int64(ic.Monitor.ID), Timing: req.Msg.Timing, Latency: req.Msg.Latency, CronTimestamp: req.Msg.CronTimestamp, Trigger: "cron", URI: req.Msg.Uri, RequestStatus: req.Msg.RequestStatus, } h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceTCP, ic.Region.ID) return connect.NewResponse(&private_locationv1.IngestTCPResponse{}), nil } ================================================ FILE: apps/private-location/internal/server/ingest_tcp_test.go ================================================ package server_test import ( "context" "net/http" "testing" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/server" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) func TestIngestTCP_Unauthenticated(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{}) // No token header resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for missing token, got nil") } if connect.CodeOf(err) != connect.CodeUnauthenticated { t.Errorf("expected unauthenticated code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_DBError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{}) req.Header().Set("openstatus-token", "token123") req.Msg.Id = "monitor1" req.Msg.Timestamp = 1234567890 resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for db failure, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_ValidationError_EmptyID(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_ValidationError_InvalidTimestamp(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "tcp-123", Timestamp: 0, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_ValidationError_NegativeLatency(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: -100, Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for validation failure, got nil") } if connect.CodeOf(err) != connect.CodeInvalidArgument { t.Errorf("expected invalid argument code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_MonitorNotExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), tinybird.NewClient(http.DefaultClient, "")) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "nonexistent-monitor", Timestamp: 1234567890, }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err == nil { t.Fatalf("expected error for monitor not found, got nil") } if connect.CodeOf(err) != connect.CodeInternal { t.Errorf("expected internal code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestIngestTCP_MonitorExist(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "5", Timestamp: 1234567890, Latency: 50, CronTimestamp: 1234567800, Uri: "tcp://example.com:8080", Timing: "50ms", }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } func TestIngestTCP_WithError(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.IngestTCPRequest{ Id: "5", Timestamp: 1234567890, Latency: 0, CronTimestamp: 1234567800, Uri: "tcp://example.com:8080", Error: 1, Message: "Connection refused", }) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.IngestTCP(context.Background(), req) if err != nil { t.Fatalf("expected nil error, got %v", err) } if resp == nil { t.Errorf("expected not nil response, got nil") } } ================================================ FILE: apps/private-location/internal/server/monitors.go ================================================ package server import ( "context" "database/sql" "encoding/json" "strconv" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/database" "github.com/openstatushq/openstatus/apps/private-location/internal/models" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) // Converts models.NumberComparator to proto NumberComparator func convertNumberComparator(m models.NumberComparator) private_locationv1.NumberComparator { switch m { case models.NumberNotEquals: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL case models.NumberEquals: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_EQUAL case models.NumberGreaterThan: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN case models.NumberGreaterThanEqual: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL case models.NumberLowerThan: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN case models.NumberLowerThanEqual: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL default: return private_locationv1.NumberComparator_NUMBER_COMPARATOR_UNSPECIFIED } } // Converts models.StringComparator to proto StringComparator func convertStringComparator(m models.StringComparator) private_locationv1.StringComparator { switch m { case models.StringNotEquals: return private_locationv1.StringComparator_STRING_COMPARATOR_NOT_EQUAL case models.StringEquals: return private_locationv1.StringComparator_STRING_COMPARATOR_EQUAL case models.StringContains: return private_locationv1.StringComparator_STRING_COMPARATOR_CONTAINS case models.StringNotContains: return private_locationv1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS case models.StringEmpty: return private_locationv1.StringComparator_STRING_COMPARATOR_EMPTY case models.StringNotEmpty: return private_locationv1.StringComparator_STRING_COMPARATOR_NOT_EMPTY default: return private_locationv1.StringComparator_STRING_COMPARATOR_UNSPECIFIED } } // Converts models.RecordComparator to proto RecordComparator func convertRecordComparator(m models.RecordComparator) private_locationv1.RecordComparator { switch m { case models.RecordEquals: return private_locationv1.RecordComparator_RECORD_COMPARATOR_EQUAL case models.RecordNotEquals: return private_locationv1.RecordComparator_RECORD_COMPARATOR_NOT_EQUAL case models.RecordContains: return private_locationv1.RecordComparator_RECORD_COMPARATOR_CONTAINS case models.RecordNotContains: return private_locationv1.RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS default: return private_locationv1.RecordComparator_RECORD_COMPARATOR_UNSPECIFIED } } // addParseError records a parsing error in the wide event context func addParseError(ctx context.Context, errorType string, err error) { if holder := GetEvent(ctx); holder != nil { errors, ok := holder.Event["parse_errors"].([]map[string]any) if !ok { errors = []map[string]any{} } errors = append(errors, map[string]any{ "type": errorType, "message": err.Error(), }) holder.Event["parse_errors"] = errors } } // Helper to parse assertions func ParseAssertions(ctx context.Context, assertions sql.NullString) ( statusAssertions []*private_locationv1.StatusCodeAssertion, headerAssertions []*private_locationv1.HeaderAssertion, bodyAssertions []*private_locationv1.BodyAssertion, ) { if !assertions.Valid { return } var rawAssertions []json.RawMessage if err := json.Unmarshal([]byte(assertions.String), &rawAssertions); err != nil { addParseError(ctx, "assertions_unmarshal", err) return } for _, a := range rawAssertions { var assert models.Assertion if err := json.Unmarshal(a, &assert); err != nil { addParseError(ctx, "assertion_unmarshal", err) continue } switch assert.AssertionType { case models.AssertionStatus: var target models.StatusTarget if err := json.Unmarshal(a, &target); err != nil { addParseError(ctx, "status_target_unmarshal", err) continue } statusAssertions = append(statusAssertions, &private_locationv1.StatusCodeAssertion{ Target: target.Target, Comparator: convertNumberComparator(target.Comparator), }) case models.AssertionHeader: var target models.HeaderTarget if err := json.Unmarshal(a, &target); err != nil { addParseError(ctx, "header_target_unmarshal", err) continue } headerAssertions = append(headerAssertions, &private_locationv1.HeaderAssertion{ Key: target.Key, Target: target.Target, Comparator: convertStringComparator(target.Comparator), }) case models.AssertionTextBody: var target models.BodyString if err := json.Unmarshal(a, &target); err != nil { addParseError(ctx, "body_target_unmarshal", err) continue } bodyAssertions = append(bodyAssertions, &private_locationv1.BodyAssertion{ Target: target.Target, Comparator: convertStringComparator(target.Comparator), }) } } return } // Helper to parse DNS record assertions func ParseRecordAssertions(ctx context.Context, assertions sql.NullString) []*private_locationv1.RecordAssertion { if !assertions.Valid { return nil } var rawAssertions []json.RawMessage if err := json.Unmarshal([]byte(assertions.String), &rawAssertions); err != nil { addParseError(ctx, "record_assertions_unmarshal", err) return nil } var recordAssertions []*private_locationv1.RecordAssertion for _, a := range rawAssertions { var assert models.Assertion if err := json.Unmarshal(a, &assert); err != nil { addParseError(ctx, "record_assertion_unmarshal", err) continue } if assert.AssertionType == models.AssertionDnsRecord { var target models.RecordTarget if err := json.Unmarshal(a, &target); err != nil { addParseError(ctx, "record_target_unmarshal", err) continue } recordAssertions = append(recordAssertions, &private_locationv1.RecordAssertion{ Record: target.Key, Comparator: convertRecordComparator(target.Comparator), Target: target.Target, }) } } return recordAssertions } func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Request[private_locationv1.MonitorsRequest]) (*connect.Response[private_locationv1.MonitorsResponse], error) { token := req.Header().Get("openstatus-token") if token == "" { return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) } var monitors []database.Monitor err := h.db.Select(&monitors, "SELECT monitor.id, monitor.job_type, monitor.url, monitor.periodicity, monitor.method, monitor.body, monitor.timeout, monitor.degraded_after, monitor.follow_redirects, monitor.headers, monitor.assertions, monitor.workspace_id, monitor.retry FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.active = 1", token) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } var workspaceId int var httpMonitors []*private_locationv1.HTTPMonitor var tcpMonitors []*private_locationv1.TCPMonitor var dnsMonitors []*private_locationv1.DNSMonitor for _, monitor := range monitors { if workspaceId == 0 { workspaceId = monitor.WorkspaceID } switch monitor.JobType { case database.JobTypeHTTP: var headers []*private_locationv1.Headers if err := json.Unmarshal([]byte(monitor.Headers), &headers); err != nil { addParseError(ctx, "headers_unmarshal", err) headers = nil } statusAssertions, headerAssertions, bodyAssertions := ParseAssertions(ctx, monitor.Assertions) httpMonitors = append(httpMonitors, &private_locationv1.HTTPMonitor{ Url: monitor.URL, Periodicity: monitor.Periodicity, Id: strconv.Itoa(monitor.ID), Method: monitor.Method, Body: monitor.Body, Timeout: monitor.Timeout, DegradedAt: &monitor.DegradedAfter.Int64, Retry: int64(monitor.Retry), FollowRedirects: monitor.FollowRedirects, Headers: headers, StatusCodeAssertions: statusAssertions, HeaderAssertions: headerAssertions, BodyAssertions: bodyAssertions, }) case database.JobTypeTCP: tcpMonitors = append(tcpMonitors, &private_locationv1.TCPMonitor{ Id: strconv.Itoa(monitor.ID), Uri: monitor.URL, Timeout: monitor.Timeout, DegradedAt: &monitor.DegradedAfter.Int64, Periodicity: monitor.Periodicity, Retry: int64(monitor.Retry), }) case database.JobTypeDNS: recordAssertions := ParseRecordAssertions(ctx, monitor.Assertions) dnsMonitors = append(dnsMonitors, &private_locationv1.DNSMonitor{ Id: strconv.Itoa(monitor.ID), Uri: monitor.URL, Timeout: monitor.Timeout, DegradedAt: &monitor.DegradedAfter.Int64, Periodicity: monitor.Periodicity, Retry: int64(monitor.Retry), RecordAssertions: recordAssertions, }) } } // Enrich wide event with monitor counts if holder := GetEvent(ctx); holder != nil { holder.Event["private_location"] = map[string]any{ "workspace_id": workspaceId, "http_monitors": len(httpMonitors), "tcp_monitors": len(tcpMonitors), "dns_monitors": len(dnsMonitors), "total_monitors": len(monitors), } } return connect.NewResponse(&private_locationv1.MonitorsResponse{ HttpMonitors: httpMonitors, TcpMonitors: tcpMonitors, DnsMonitors: dnsMonitors, }), nil } ================================================ FILE: apps/private-location/internal/server/monitors_test.go ================================================ package server_test import ( "context" "database/sql" "testing" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/server" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) func TestParseAssertions_TextBodyContains(t *testing.T) { // Input JSON for the test input := `[{"version":"v1","type":"textBody","compare":"contains","target":"mydata"}]` assertions := sql.NullString{ String: input, Valid: true, } _, _, bodyAssertions := server.ParseAssertions(context.Background(), assertions) if len(bodyAssertions) != 1 { t.Fatalf("expected 1 body assertion, got %d", len(bodyAssertions)) } got := bodyAssertions[0] if got.Target != "mydata" { t.Errorf("expected Target to be 'mydata', got '%s'", got.Target) } if got.Comparator != private_locationv1.StringComparator_STRING_COMPARATOR_CONTAINS { t.Errorf("expected Comparator to be STRING_COMPARATOR_CONTAINS, got %v", got.Comparator) } } func TestParseAssertions_HttpStatusEquals(t *testing.T) { // Input JSON for the test input := `[{"version":"v1","type":"status","compare":"eq","target":200}]` assertions := sql.NullString{ String: input, Valid: true, } statusAssertion, _, _ := server.ParseAssertions(context.Background(), assertions) if len(statusAssertion) != 1 { t.Fatalf("expected 1 body assertion, got %d", len(statusAssertion)) } got := statusAssertion[0] if got.Target != 200 { t.Errorf("expected Target to be 'mydata', got '%d'", got.Target) } if got.Comparator != private_locationv1.NumberComparator_NUMBER_COMPARATOR_EQUAL { t.Errorf("expected Comparator to be STRING_COMPARATOR_CONTAINS, got %v", got.Comparator) } } func TestParseAssertions_InvalidJSON(t *testing.T) { assertions := sql.NullString{ String: "not valid json", Valid: true, } statusAssertions, headerAssertions, bodyAssertions := server.ParseAssertions(context.Background(), assertions) if len(statusAssertions) != 0 || len(headerAssertions) != 0 || len(bodyAssertions) != 0 { t.Errorf("expected empty assertions for invalid JSON, got status=%d, header=%d, body=%d", len(statusAssertions), len(headerAssertions), len(bodyAssertions)) } } func TestParseAssertions_NullString(t *testing.T) { assertions := sql.NullString{ String: "", Valid: false, } statusAssertions, headerAssertions, bodyAssertions := server.ParseAssertions(context.Background(), assertions) if len(statusAssertions) != 0 || len(headerAssertions) != 0 || len(bodyAssertions) != 0 { t.Errorf("expected empty assertions for null string, got status=%d, header=%d, body=%d", len(statusAssertions), len(headerAssertions), len(bodyAssertions)) } } func TestParseAssertions_HeaderAssertion(t *testing.T) { input := `[{"version":"v1","type":"header","compare":"eq","key":"Content-Type","target":"application/json"}]` assertions := sql.NullString{ String: input, Valid: true, } _, headerAssertions, _ := server.ParseAssertions(context.Background(), assertions) if len(headerAssertions) != 1 { t.Fatalf("expected 1 header assertion, got %d", len(headerAssertions)) } got := headerAssertions[0] if got.Key != "Content-Type" { t.Errorf("expected Key to be 'Content-Type', got '%s'", got.Key) } if got.Target != "application/json" { t.Errorf("expected Target to be 'application/json', got '%s'", got.Target) } if got.Comparator != private_locationv1.StringComparator_STRING_COMPARATOR_EQUAL { t.Errorf("expected Comparator to be STRING_COMPARATOR_EQUAL, got %v", got.Comparator) } } func TestParseAssertions_MultipleAssertions(t *testing.T) { input := `[ {"version":"v1","type":"status","compare":"eq","target":200}, {"version":"v1","type":"header","compare":"contains","key":"X-Request-Id","target":"req-"}, {"version":"v1","type":"textBody","compare":"notContains","target":"error"} ]` assertions := sql.NullString{ String: input, Valid: true, } statusAssertions, headerAssertions, bodyAssertions := server.ParseAssertions(context.Background(), assertions) if len(statusAssertions) != 1 { t.Errorf("expected 1 status assertion, got %d", len(statusAssertions)) } if len(headerAssertions) != 1 { t.Errorf("expected 1 header assertion, got %d", len(headerAssertions)) } if len(bodyAssertions) != 1 { t.Errorf("expected 1 body assertion, got %d", len(bodyAssertions)) } } func TestMonitors_Unauthenticated(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) // No token header resp, err := h.Monitors(context.Background(), req) if err == nil { t.Fatalf("expected error for missing token, got nil") } if connect.CodeOf(err) != connect.CodeUnauthenticated { t.Errorf("expected unauthenticated code, got %v", connect.CodeOf(err)) } if resp != nil { t.Errorf("expected nil response, got %v", resp) } } func TestMonitors_InvalidToken(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "invalid-token") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error for invalid token (just empty results), got %v", err) } if resp == nil { t.Fatalf("expected non-nil response") } if len(resp.Msg.HttpMonitors) != 0 { t.Errorf("expected 0 HTTP monitors for invalid token, got %d", len(resp.Msg.HttpMonitors)) } if len(resp.Msg.TcpMonitors) != 0 { t.Errorf("expected 0 TCP monitors for invalid token, got %d", len(resp.Msg.TcpMonitors)) } if len(resp.Msg.DnsMonitors) != 0 { t.Errorf("expected 0 DNS monitors for invalid token, got %d", len(resp.Msg.DnsMonitors)) } } func TestMonitors_ReturnsHTTPTCPAndDNSMonitors(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if resp == nil { t.Fatalf("expected non-nil response") } // Should have HTTP monitor (monitor ID 5) if len(resp.Msg.HttpMonitors) != 1 { t.Errorf("expected 1 HTTP monitor, got %d", len(resp.Msg.HttpMonitors)) } // Should have TCP monitor (monitor ID 6) if len(resp.Msg.TcpMonitors) != 1 { t.Errorf("expected 1 TCP monitor, got %d", len(resp.Msg.TcpMonitors)) } // Should have DNS monitor (monitor ID 7) if len(resp.Msg.DnsMonitors) != 1 { t.Errorf("expected 1 DNS monitor, got %d", len(resp.Msg.DnsMonitors)) } } func TestMonitors_HTTPMonitorFields(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(resp.Msg.HttpMonitors) != 1 { t.Fatalf("expected 1 HTTP monitor, got %d", len(resp.Msg.HttpMonitors)) } httpMonitor := resp.Msg.HttpMonitors[0] if httpMonitor.Id != "5" { t.Errorf("expected ID '5', got '%s'", httpMonitor.Id) } if httpMonitor.Url != "https://openstat.us" { t.Errorf("expected URL 'https://openstat.us', got '%s'", httpMonitor.Url) } if httpMonitor.Periodicity != "10m" { t.Errorf("expected Periodicity '10m', got '%s'", httpMonitor.Periodicity) } if httpMonitor.Method != "GET" { t.Errorf("expected Method 'GET', got '%s'", httpMonitor.Method) } if httpMonitor.Timeout != 45000 { t.Errorf("expected Timeout 45000, got %d", httpMonitor.Timeout) } } func TestMonitors_TCPMonitorFields(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(resp.Msg.TcpMonitors) != 1 { t.Fatalf("expected 1 TCP monitor, got %d", len(resp.Msg.TcpMonitors)) } tcpMonitor := resp.Msg.TcpMonitors[0] if tcpMonitor.Id != "6" { t.Errorf("expected ID '6', got '%s'", tcpMonitor.Id) } if tcpMonitor.Uri != "tcp://db.example.com:5432" { t.Errorf("expected URI 'tcp://db.example.com:5432', got '%s'", tcpMonitor.Uri) } if tcpMonitor.Periodicity != "5m" { t.Errorf("expected Periodicity '5m', got '%s'", tcpMonitor.Periodicity) } if tcpMonitor.Timeout != 30000 { t.Errorf("expected Timeout 30000, got %d", tcpMonitor.Timeout) } if tcpMonitor.Retry != 2 { t.Errorf("expected Retry 2, got %d", tcpMonitor.Retry) } if tcpMonitor.DegradedAt == nil || *tcpMonitor.DegradedAt != 5000 { t.Errorf("expected DegradedAt 5000, got %v", tcpMonitor.DegradedAt) } } func TestParseRecordAssertions_DnsRecordContains(t *testing.T) { input := `[{"version":"v1","type":"dnsRecord","key":"A","compare":"contains","target":"76.76.21.21"}]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions)) } got := recordAssertions[0] if got.Record != "A" { t.Errorf("expected Record to be 'A', got '%s'", got.Record) } if got.Target != "76.76.21.21" { t.Errorf("expected Target to be '76.76.21.21', got '%s'", got.Target) } if got.Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_CONTAINS { t.Errorf("expected Comparator to be RECORD_COMPARATOR_CONTAINS, got %v", got.Comparator) } } func TestParseRecordAssertions_DnsRecordEquals(t *testing.T) { input := `[{"version":"v1","type":"dnsRecord","key":"CNAME","compare":"eq","target":"openstatus.dev"}]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions)) } got := recordAssertions[0] if got.Record != "CNAME" { t.Errorf("expected Record to be 'CNAME', got '%s'", got.Record) } if got.Target != "openstatus.dev" { t.Errorf("expected Target to be 'openstatus.dev', got '%s'", got.Target) } if got.Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_EQUAL { t.Errorf("expected Comparator to be RECORD_COMPARATOR_EQUAL, got %v", got.Comparator) } } func TestParseRecordAssertions_MultipleRecordTypes(t *testing.T) { input := `[ {"version":"v1","type":"dnsRecord","key":"A","compare":"eq","target":"192.168.1.1"}, {"version":"v1","type":"dnsRecord","key":"AAAA","compare":"not_eq","target":"::1"}, {"version":"v1","type":"dnsRecord","key":"MX","compare":"not_contains","target":"spam"} ]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 3 { t.Fatalf("expected 3 record assertions, got %d", len(recordAssertions)) } // Check A record if recordAssertions[0].Record != "A" { t.Errorf("expected first Record to be 'A', got '%s'", recordAssertions[0].Record) } if recordAssertions[0].Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_EQUAL { t.Errorf("expected first Comparator to be RECORD_COMPARATOR_EQUAL, got %v", recordAssertions[0].Comparator) } // Check AAAA record if recordAssertions[1].Record != "AAAA" { t.Errorf("expected second Record to be 'AAAA', got '%s'", recordAssertions[1].Record) } if recordAssertions[1].Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_NOT_EQUAL { t.Errorf("expected second Comparator to be RECORD_COMPARATOR_NOT_EQUAL, got %v", recordAssertions[1].Comparator) } // Check MX record if recordAssertions[2].Record != "MX" { t.Errorf("expected third Record to be 'MX', got '%s'", recordAssertions[2].Record) } if recordAssertions[2].Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS { t.Errorf("expected third Comparator to be RECORD_COMPARATOR_NOT_CONTAINS, got %v", recordAssertions[2].Comparator) } } func TestParseRecordAssertions_InvalidJSON(t *testing.T) { assertions := sql.NullString{ String: "not valid json", Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 0 { t.Errorf("expected empty assertions for invalid JSON, got %d", len(recordAssertions)) } } func TestParseRecordAssertions_NullString(t *testing.T) { assertions := sql.NullString{ String: "", Valid: false, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if recordAssertions != nil { t.Errorf("expected nil for null string, got %v", recordAssertions) } } func TestParseRecordAssertions_MixedAssertionTypes(t *testing.T) { // Test that only dnsRecord assertions are parsed, not other types input := `[ {"version":"v1","type":"status","compare":"eq","target":200}, {"version":"v1","type":"dnsRecord","key":"A","compare":"contains","target":"192.168.1.1"}, {"version":"v1","type":"header","compare":"eq","key":"Content-Type","target":"application/json"} ]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion (only dnsRecord), got %d", len(recordAssertions)) } if recordAssertions[0].Record != "A" { t.Errorf("expected Record to be 'A', got '%s'", recordAssertions[0].Record) } } func TestMonitors_DNSMonitorFields(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(resp.Msg.DnsMonitors) != 1 { t.Fatalf("expected 1 DNS monitor, got %d", len(resp.Msg.DnsMonitors)) } dnsMonitor := resp.Msg.DnsMonitors[0] if dnsMonitor.Id != "7" { t.Errorf("expected ID '7', got '%s'", dnsMonitor.Id) } if dnsMonitor.Uri != "openstatus.dev" { t.Errorf("expected URI 'openstatus.dev', got '%s'", dnsMonitor.Uri) } if dnsMonitor.Periodicity != "5m" { t.Errorf("expected Periodicity '5m', got '%s'", dnsMonitor.Periodicity) } if dnsMonitor.Timeout != 30000 { t.Errorf("expected Timeout 30000, got %d", dnsMonitor.Timeout) } if dnsMonitor.Retry != 2 { t.Errorf("expected Retry 2, got %d", dnsMonitor.Retry) } if dnsMonitor.DegradedAt == nil || *dnsMonitor.DegradedAt != 3000 { t.Errorf("expected DegradedAt 3000, got %v", dnsMonitor.DegradedAt) } } func TestParseRecordAssertions_EmptyArray(t *testing.T) { assertions := sql.NullString{ String: "[]", Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 0 { t.Errorf("expected empty slice for empty JSON array, got %d", len(recordAssertions)) } } func TestParseRecordAssertions_UnknownComparator(t *testing.T) { input := `[{"version":"v1","type":"dnsRecord","key":"A","compare":"unknown_comparator","target":"192.168.1.1"}]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions)) } got := recordAssertions[0] if got.Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_UNSPECIFIED { t.Errorf("expected Comparator to be RECORD_COMPARATOR_UNSPECIFIED for unknown comparator, got %v", got.Comparator) } } func TestParseRecordAssertions_UnknownRecordType(t *testing.T) { input := `[{"version":"v1","type":"dnsRecord","key":"INVALID_RECORD_TYPE","compare":"eq","target":"test"}]` assertions := sql.NullString{ String: input, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions)) } got := recordAssertions[0] // The record type is passed through as-is, even if invalid if got.Record != "INVALID_RECORD_TYPE" { t.Errorf("expected Record to be 'INVALID_RECORD_TYPE', got '%s'", got.Record) } } func TestParseRecordAssertions_MissingRequiredFields(t *testing.T) { // Missing "key" field inputMissingKey := `[{"version":"v1","type":"dnsRecord","compare":"eq","target":"test"}]` assertions := sql.NullString{ String: inputMissingKey, Valid: true, } recordAssertions := server.ParseRecordAssertions(context.Background(), assertions) if len(recordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions)) } // Missing key results in empty string if recordAssertions[0].Record != "" { t.Errorf("expected empty Record for missing key, got '%s'", recordAssertions[0].Record) } // Missing "compare" field inputMissingCompare := `[{"version":"v1","type":"dnsRecord","key":"A","target":"test"}]` assertions2 := sql.NullString{ String: inputMissingCompare, Valid: true, } recordAssertions2 := server.ParseRecordAssertions(context.Background(), assertions2) if len(recordAssertions2) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions2)) } // Missing compare results in unspecified comparator if recordAssertions2[0].Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_UNSPECIFIED { t.Errorf("expected RECORD_COMPARATOR_UNSPECIFIED for missing compare, got %v", recordAssertions2[0].Comparator) } // Missing "target" field inputMissingTarget := `[{"version":"v1","type":"dnsRecord","key":"A","compare":"eq"}]` assertions3 := sql.NullString{ String: inputMissingTarget, Valid: true, } recordAssertions3 := server.ParseRecordAssertions(context.Background(), assertions3) if len(recordAssertions3) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(recordAssertions3)) } // Missing target results in empty string if recordAssertions3[0].Target != "" { t.Errorf("expected empty Target for missing target, got '%s'", recordAssertions3[0].Target) } } func TestMonitors_DNSMonitorWithRecordAssertions(t *testing.T) { h := server.NewPrivateLocationServer(testDB(), getTBClient(context.Background())) req := connect.NewRequest(&private_locationv1.MonitorsRequest{}) req.Header().Set("openstatus-token", "my-secret-key") resp, err := h.Monitors(context.Background(), req) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(resp.Msg.DnsMonitors) != 1 { t.Fatalf("expected 1 DNS monitor, got %d", len(resp.Msg.DnsMonitors)) } dnsMonitor := resp.Msg.DnsMonitors[0] if len(dnsMonitor.RecordAssertions) != 1 { t.Fatalf("expected 1 record assertion, got %d", len(dnsMonitor.RecordAssertions)) } assertion := dnsMonitor.RecordAssertions[0] if assertion.Record != "A" { t.Errorf("expected Record 'A', got '%s'", assertion.Record) } if assertion.Target != "76.76.21.21" { t.Errorf("expected Target '76.76.21.21', got '%s'", assertion.Target) } if assertion.Comparator != private_locationv1.RecordComparator_RECORD_COMPARATOR_CONTAINS { t.Errorf("expected Comparator RECORD_COMPARATOR_CONTAINS, got %v", assertion.Comparator) } } ================================================ FILE: apps/private-location/internal/server/routes.go ================================================ package server import ( "context" "log/slog" "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/joho/godotenv/autoload" "github.com/openstatushq/openstatus/apps/private-location/internal/logs" "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" v1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) type contextKey string const ( requestIDKey contextKey = "request_id" eventKey contextKey = "event" ) // responseWriter wraps http.ResponseWriter to capture the status code type responseWriter struct { http.ResponseWriter status int } func (rw *responseWriter) WriteHeader(code int) { rw.status = code rw.ResponseWriter.WriteHeader(code) } func (rw *responseWriter) Write(b []byte) (int, error) { if rw.status == 0 { rw.status = http.StatusOK } return rw.ResponseWriter.Write(b) } // Logger returns a Chi middleware that logs request details func Logger() func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startTime := time.Now() // Generate or get request ID requestID := r.Header.Get("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } // Build wide event context at request start scheme := "http" if r.TLS != nil { scheme = "https" } fullURL := scheme + "://" + r.Host + r.RequestURI holder := &EventHolder{ Event: map[string]any{ "timestamp": startTime.Format(time.RFC3339), "request_id": requestID, "method": r.Method, "path": r.URL.Path, "url": fullURL, "user_agent": r.Header.Get("User-Agent"), "content_type": r.Header.Get("Content-Type"), "service": map[string]any{ "name": "openstatus-private-location", "instance_id": instanceID, }, }, } // Store in context ctx := context.WithValue(r.Context(), requestIDKey, requestID) ctx = context.WithValue(ctx, eventKey, holder) r = r.WithContext(ctx) // Wrap response writer to capture status code wrapped := &responseWriter{ResponseWriter: w, status: 0} // Process request next.ServeHTTP(wrapped, r) // After request - capture response details duration := time.Since(startTime).Milliseconds() status := wrapped.status if status == 0 { status = http.StatusOK } holder.Event["status_code"] = status holder.Event["duration_ms"] = duration if status >= 400 { holder.Event["outcome"] = "error" } else { holder.Event["outcome"] = "success" } if logs.ShouldSample(holder.Event) { attrs := logs.MapToAttrs(holder.Event) slog.LogAttrs(r.Context(), slog.LevelInfo, "request done", attrs...) } }) } } // GetRequestID retrieves the request ID from context func GetRequestID(ctx context.Context) string { if id, ok := ctx.Value(requestIDKey).(string); ok { return id } return "" } // GetEvent retrieves the event holder from context func GetEvent(ctx context.Context) *EventHolder { if holder, ok := ctx.Value(eventKey).(*EventHolder); ok { return holder } return nil } type privateLocationHandler struct { db *sqlx.DB TbClient tinybird.Client } func NewPrivateLocationServer(db *sqlx.DB, tbClient tinybird.Client) *privateLocationHandler { return &privateLocationHandler{ db: db, TbClient: tbClient, } } // RegisterRoutes sets up the HTTP routes for the server. func (s *Server) RegisterRoutes() http.Handler { r := chi.NewRouter() r.Use(Logger()) r.Get("/health", s.healthHandler) tinyBirdToken := os.Getenv("TINYBIRD_TOKEN") httpClient := &http.Client{ Timeout: 45 * time.Second, } tinybirdClient := tinybird.NewClient(httpClient, tinyBirdToken) privateLocationServer := NewPrivateLocationServer(s.db, tinybirdClient) path, handler := v1.NewPrivateLocationServiceHandler(privateLocationServer) r.Group(func(r chi.Router) { r.Mount(path, handler) }) return r } // healthHandler responds with the health status of the server. func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { status := "ok" httpStatus := http.StatusOK dbOK := true // Check database connection if err := s.db.PingContext(r.Context()); err != nil { status = "degraded" httpStatus = http.StatusServiceUnavailable dbOK = false } // Enrich wide event with health check context if holder := GetEvent(r.Context()); holder != nil { holder.Event["health_check"] = map[string]any{ "status": status, "db_ping": dbOK, } } render.Status(r, httpStatus) render.JSON(w, r, map[string]any{ "status": status, }) } ================================================ FILE: apps/private-location/internal/server/server.go ================================================ package server import ( "context" "fmt" "log/slog" "net/http" "os" "strconv" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/joho/godotenv/autoload" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/log/global" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" "github.com/openstatushq/openstatus/apps/private-location/internal/database" ) type Server struct { port int db *sqlx.DB logger *slog.Logger logProvider *sdklog.LoggerProvider } // instanceID is generated once at startup var instanceID = uuid.New().String() // NewServer returns an HTTP server and a cleanup function to shutdown the log provider. func NewServer() (*http.Server, func(context.Context)) { portStr := os.Getenv("PORT") if portStr == "" { portStr = "8080" } port, err := strconv.Atoi(portStr) if err != nil { fmt.Fprintf(os.Stderr, "invalid PORT value %q: %v\n", portStr, err) os.Exit(1) } logger, logProvider := setupLogger() newServer := &Server{ port: port, db: database.New(), logger: logger, logProvider: logProvider, } // Declare Server config server := &http.Server{ Addr: fmt.Sprintf(":%d", newServer.port), Handler: newServer.RegisterRoutes(), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, } // Startup logging slog.Info("server starting", "port", port, "instance_id", instanceID, "axiom_configured", os.Getenv("AXIOM_TOKEN") != "", "tinybird_configured", os.Getenv("TINYBIRD_TOKEN") != "", ) // Return cleanup function for graceful shutdown cleanup := func(ctx context.Context) { if logProvider != nil { logProvider.Shutdown(ctx) } database.Close() } return server, cleanup } func setupLogger() (*slog.Logger, *sdklog.LoggerProvider) { ctx := context.Background() axiomToken := env("AXIOM_TOKEN", "") axiomDataset := env("AXIOM_DATASET", "dev") // If no Axiom token, return a standard logger if axiomToken == "" { logger := slog.Default() return logger, nil } environment := env("ENVIRONMENT", "production") res := resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceNameKey.String("openstatus-private-location"), attribute.String("environment", environment), attribute.String("instance_id", instanceID), ) // Set up OTLP log exporter for Axiom exporter, err := otlploghttp.New(ctx, otlploghttp.WithEndpointURL("https://eu-central-1.aws.edge.axiom.co/v1/logs"), otlploghttp.WithHeaders(map[string]string{ "Authorization": "Bearer " + axiomToken, "X-Axiom-Dataset": axiomDataset, }), ) if err != nil { fmt.Fprintf(os.Stderr, "failed to create OTLP exporter: %v\n", err) return slog.Default(), nil } // Create log provider with resource and batch processor logProvider := sdklog.NewLoggerProvider( sdklog.WithResource(res), sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), ) global.SetLoggerProvider(logProvider) logger := otelslog.NewLogger("openstatus-private-location") slog.SetDefault(logger) return logger, logProvider } func env(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } ================================================ FILE: apps/private-location/internal/server/validation.go ================================================ package server import ( "errors" "fmt" "connectrpc.com/connect" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) // Validation errors var ( ErrEmptyMonitorID = errors.New("monitor_id is required") ErrEmptyID = errors.New("id is required") ErrInvalidLatency = errors.New("latency must be non-negative") ErrInvalidTimestamp = errors.New("timestamp must be positive") ) // ValidateIngestHTTPRequest validates an HTTP ingest request func ValidateIngestHTTPRequest(req *private_locationv1.IngestHTTPRequest) error { if req.MonitorId == "" { return ErrEmptyMonitorID } if req.Latency < 0 { return ErrInvalidLatency } if req.Timestamp <= 0 { return ErrInvalidTimestamp } return nil } // ValidateIngestTCPRequest validates a TCP ingest request func ValidateIngestTCPRequest(req *private_locationv1.IngestTCPRequest) error { if req.Id == "" { return ErrEmptyID } if req.Latency < 0 { return ErrInvalidLatency } if req.Timestamp <= 0 { return ErrInvalidTimestamp } return nil } // ValidateIngestDNSRequest validates a DNS ingest request func ValidateIngestDNSRequest(req *private_locationv1.IngestDNSRequest) error { if req.Id == "" { return ErrEmptyID } if req.Latency < 0 { return ErrInvalidLatency } if req.Timestamp <= 0 { return ErrInvalidTimestamp } return nil } // NewValidationError creates a Connect error for validation failures func NewValidationError(err error) *connect.Error { return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) } ================================================ FILE: apps/private-location/internal/server/validation_test.go ================================================ package server_test import ( "testing" "connectrpc.com/connect" "github.com/openstatushq/openstatus/apps/private-location/internal/server" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) func TestValidateIngestHTTPRequest(t *testing.T) { tests := []struct { name string req *private_locationv1.IngestHTTPRequest wantErr error }{ { name: "valid request", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "monitor-123", Latency: 100, Timestamp: 1234567890, }, wantErr: nil, }, { name: "valid request with zero latency", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "monitor-123", Latency: 0, Timestamp: 1234567890, }, wantErr: nil, }, { name: "empty monitor_id", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "", Latency: 100, Timestamp: 1234567890, }, wantErr: server.ErrEmptyMonitorID, }, { name: "negative latency", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "monitor-123", Latency: -1, Timestamp: 1234567890, }, wantErr: server.ErrInvalidLatency, }, { name: "zero timestamp", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "monitor-123", Latency: 100, Timestamp: 0, }, wantErr: server.ErrInvalidTimestamp, }, { name: "negative timestamp", req: &private_locationv1.IngestHTTPRequest{ MonitorId: "monitor-123", Latency: 100, Timestamp: -1, }, wantErr: server.ErrInvalidTimestamp, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := server.ValidateIngestHTTPRequest(tt.req) if err != tt.wantErr { t.Errorf("ValidateIngestHTTPRequest() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestValidateIngestTCPRequest(t *testing.T) { tests := []struct { name string req *private_locationv1.IngestTCPRequest wantErr error }{ { name: "valid request", req: &private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: 100, Timestamp: 1234567890, }, wantErr: nil, }, { name: "valid request with zero latency", req: &private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: 0, Timestamp: 1234567890, }, wantErr: nil, }, { name: "empty id", req: &private_locationv1.IngestTCPRequest{ Id: "", Latency: 100, Timestamp: 1234567890, }, wantErr: server.ErrEmptyID, }, { name: "negative latency", req: &private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: -1, Timestamp: 1234567890, }, wantErr: server.ErrInvalidLatency, }, { name: "zero timestamp", req: &private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: 100, Timestamp: 0, }, wantErr: server.ErrInvalidTimestamp, }, { name: "negative timestamp", req: &private_locationv1.IngestTCPRequest{ Id: "tcp-123", Latency: 100, Timestamp: -1, }, wantErr: server.ErrInvalidTimestamp, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := server.ValidateIngestTCPRequest(tt.req) if err != tt.wantErr { t.Errorf("ValidateIngestTCPRequest() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestValidateIngestDNSRequest(t *testing.T) { tests := []struct { name string req *private_locationv1.IngestDNSRequest wantErr error }{ { name: "valid request", req: &private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: 100, Timestamp: 1234567890, }, wantErr: nil, }, { name: "valid request with zero latency", req: &private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: 0, Timestamp: 1234567890, }, wantErr: nil, }, { name: "empty id", req: &private_locationv1.IngestDNSRequest{ Id: "", Latency: 100, Timestamp: 1234567890, }, wantErr: server.ErrEmptyID, }, { name: "negative latency", req: &private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: -1, Timestamp: 1234567890, }, wantErr: server.ErrInvalidLatency, }, { name: "zero timestamp", req: &private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: 100, Timestamp: 0, }, wantErr: server.ErrInvalidTimestamp, }, { name: "negative timestamp", req: &private_locationv1.IngestDNSRequest{ Id: "dns-123", Latency: 100, Timestamp: -1, }, wantErr: server.ErrInvalidTimestamp, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := server.ValidateIngestDNSRequest(tt.req) if err != tt.wantErr { t.Errorf("ValidateIngestDNSRequest() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestNewValidationError(t *testing.T) { tests := []struct { name string err error wantCode connect.Code wantContains string }{ { name: "empty monitor id error", err: server.ErrEmptyMonitorID, wantCode: connect.CodeInvalidArgument, wantContains: "monitor_id is required", }, { name: "empty id error", err: server.ErrEmptyID, wantCode: connect.CodeInvalidArgument, wantContains: "id is required", }, { name: "invalid latency error", err: server.ErrInvalidLatency, wantCode: connect.CodeInvalidArgument, wantContains: "latency must be non-negative", }, { name: "invalid timestamp error", err: server.ErrInvalidTimestamp, wantCode: connect.CodeInvalidArgument, wantContains: "timestamp must be positive", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { connErr := server.NewValidationError(tt.err) if connErr.Code() != tt.wantCode { t.Errorf("NewValidationError() code = %v, want %v", connErr.Code(), tt.wantCode) } if connErr.Message() == "" { t.Error("NewValidationError() message should not be empty") } }) } } ================================================ FILE: apps/private-location/internal/tinybird/client.go ================================================ package tinybird import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "os" ) // Datasource names for Tinybird events const ( DatasourceHTTP = "ping_response__v8" DatasourceTCP = "tcp_response__v0" DatasourceDNS = "tcp_dns__v0" ) func getBaseURL() string { // Use local Tinybird container if available (Docker/self-hosted) // https://www.tinybird.co/docs/api-reference if tinybirdURL := os.Getenv("TINYBIRD_URL"); tinybirdURL != "" { return tinybirdURL + "/v0/events" } return "https://api.tinybird.co/v0/events" } type Client interface { SendEvent(ctx context.Context, event any, dataSourceName string) error } type client struct { httpClient *http.Client apiKey string baseURL string } func NewClient(httpClient *http.Client, apiKey string) Client { return client{ httpClient: httpClient, apiKey: apiKey, baseURL: getBaseURL(), } } func (c client) SendEvent(ctx context.Context, event any, dataSourceName string) error { requestURL, err := url.Parse(c.baseURL) if err != nil { return fmt.Errorf("unable to parse url: %w", err) } q := requestURL.Query() q.Add("name", dataSourceName) requestURL.RawQuery = q.Encode() var payload bytes.Buffer if err := json.NewEncoder(&payload).Encode(event); err != nil { return fmt.Errorf("unable to encode payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload.Bytes())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("unable to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } return nil } ================================================ FILE: apps/private-location/internal/tinybird/client_test.go ================================================ package tinybird_test import ( "fmt" "net/http" "testing" "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" "github.com/stretchr/testify/require" ) type interceptorHTTPClient struct { f func(req *http.Request) (*http.Response, error) } func (i *interceptorHTTPClient) RoundTrip(req *http.Request) (*http.Response, error) { return i.f(req) } func (i *interceptorHTTPClient) GetHTTPClient() *http.Client { return &http.Client{ Transport: i, } } func TestSendEvent(t *testing.T) { t.Parallel() ctx := t.Context() t.Run("it should return an error if it can not send the event", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unable to send request") }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.Error(t, err) }) t.Run("it should return an error if the response status code is not 200", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusInternalServerError, }, nil }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.Error(t, err) }) t.Run("it should succeed and return nothing", func(t *testing.T) { var url string interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { url = req.URL.String() return &http.Response{ StatusCode: http.StatusAccepted, }, nil }, } client := tinybird.NewClient(interceptor.GetHTTPClient(), "apiKey") err := client.SendEvent(ctx, "event", "test") require.NoError(t, err) require.Equal(t, "https://api.tinybird.co/v0/events?name=test", url) }) } ================================================ FILE: apps/private-location/justfile ================================================ # Simple Makefile for a Go project # Build the application all: build test build: echo "Building..." go build -o main cmd/server/main.go dev: air # Run the application run: go run cmd/server/main.go # Test the application test: echo "Testing..." go test ./... -v # Clean the binary clean: echo "Cleaning..." rm -f main ================================================ FILE: apps/private-location/proto/private_location/v1/assertions.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/assertions.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type NumberComparator int32 const ( NumberComparator_NUMBER_COMPARATOR_UNSPECIFIED NumberComparator = 0 NumberComparator_NUMBER_COMPARATOR_EQUAL NumberComparator = 1 NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL NumberComparator = 2 NumberComparator_NUMBER_COMPARATOR_GREATER_THAN NumberComparator = 3 NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL NumberComparator = 4 NumberComparator_NUMBER_COMPARATOR_LESS_THAN NumberComparator = 5 NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL NumberComparator = 6 ) // Enum value maps for NumberComparator. var ( NumberComparator_name = map[int32]string{ 0: "NUMBER_COMPARATOR_UNSPECIFIED", 1: "NUMBER_COMPARATOR_EQUAL", 2: "NUMBER_COMPARATOR_NOT_EQUAL", 3: "NUMBER_COMPARATOR_GREATER_THAN", 4: "NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL", 5: "NUMBER_COMPARATOR_LESS_THAN", 6: "NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL", } NumberComparator_value = map[string]int32{ "NUMBER_COMPARATOR_UNSPECIFIED": 0, "NUMBER_COMPARATOR_EQUAL": 1, "NUMBER_COMPARATOR_NOT_EQUAL": 2, "NUMBER_COMPARATOR_GREATER_THAN": 3, "NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL": 4, "NUMBER_COMPARATOR_LESS_THAN": 5, "NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL": 6, } ) func (x NumberComparator) Enum() *NumberComparator { p := new(NumberComparator) *p = x return p } func (x NumberComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (NumberComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[0].Descriptor() } func (NumberComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[0] } func (x NumberComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use NumberComparator.Descriptor instead. func (NumberComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{0} } type StringComparator int32 const ( StringComparator_STRING_COMPARATOR_UNSPECIFIED StringComparator = 0 StringComparator_STRING_COMPARATOR_CONTAINS StringComparator = 1 StringComparator_STRING_COMPARATOR_NOT_CONTAINS StringComparator = 2 StringComparator_STRING_COMPARATOR_EQUAL StringComparator = 3 StringComparator_STRING_COMPARATOR_NOT_EQUAL StringComparator = 4 StringComparator_STRING_COMPARATOR_EMPTY StringComparator = 5 StringComparator_STRING_COMPARATOR_NOT_EMPTY StringComparator = 6 StringComparator_STRING_COMPARATOR_GREATER_THAN StringComparator = 7 StringComparator_STRING_COMPARATOR_GREATER_THAN_OR_EQUAL StringComparator = 8 StringComparator_STRING_COMPARATOR_LESS_THAN StringComparator = 9 StringComparator_STRING_COMPARATOR_LESS_THAN_OR_EQUAL StringComparator = 10 ) // Enum value maps for StringComparator. var ( StringComparator_name = map[int32]string{ 0: "STRING_COMPARATOR_UNSPECIFIED", 1: "STRING_COMPARATOR_CONTAINS", 2: "STRING_COMPARATOR_NOT_CONTAINS", 3: "STRING_COMPARATOR_EQUAL", 4: "STRING_COMPARATOR_NOT_EQUAL", 5: "STRING_COMPARATOR_EMPTY", 6: "STRING_COMPARATOR_NOT_EMPTY", 7: "STRING_COMPARATOR_GREATER_THAN", 8: "STRING_COMPARATOR_GREATER_THAN_OR_EQUAL", 9: "STRING_COMPARATOR_LESS_THAN", 10: "STRING_COMPARATOR_LESS_THAN_OR_EQUAL", } StringComparator_value = map[string]int32{ "STRING_COMPARATOR_UNSPECIFIED": 0, "STRING_COMPARATOR_CONTAINS": 1, "STRING_COMPARATOR_NOT_CONTAINS": 2, "STRING_COMPARATOR_EQUAL": 3, "STRING_COMPARATOR_NOT_EQUAL": 4, "STRING_COMPARATOR_EMPTY": 5, "STRING_COMPARATOR_NOT_EMPTY": 6, "STRING_COMPARATOR_GREATER_THAN": 7, "STRING_COMPARATOR_GREATER_THAN_OR_EQUAL": 8, "STRING_COMPARATOR_LESS_THAN": 9, "STRING_COMPARATOR_LESS_THAN_OR_EQUAL": 10, } ) func (x StringComparator) Enum() *StringComparator { p := new(StringComparator) *p = x return p } func (x StringComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (StringComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[1].Descriptor() } func (StringComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[1] } func (x StringComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use StringComparator.Descriptor instead. func (StringComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } type RecordComparator int32 const ( RecordComparator_RECORD_COMPARATOR_UNSPECIFIED RecordComparator = 0 RecordComparator_RECORD_COMPARATOR_EQUAL RecordComparator = 1 RecordComparator_RECORD_COMPARATOR_NOT_EQUAL RecordComparator = 2 RecordComparator_RECORD_COMPARATOR_CONTAINS RecordComparator = 3 RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS RecordComparator = 4 ) // Enum value maps for RecordComparator. var ( RecordComparator_name = map[int32]string{ 0: "RECORD_COMPARATOR_UNSPECIFIED", 1: "RECORD_COMPARATOR_EQUAL", 2: "RECORD_COMPARATOR_NOT_EQUAL", 3: "RECORD_COMPARATOR_CONTAINS", 4: "RECORD_COMPARATOR_NOT_CONTAINS", } RecordComparator_value = map[string]int32{ "RECORD_COMPARATOR_UNSPECIFIED": 0, "RECORD_COMPARATOR_EQUAL": 1, "RECORD_COMPARATOR_NOT_EQUAL": 2, "RECORD_COMPARATOR_CONTAINS": 3, "RECORD_COMPARATOR_NOT_CONTAINS": 4, } ) func (x RecordComparator) Enum() *RecordComparator { p := new(RecordComparator) *p = x return p } func (x RecordComparator) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (RecordComparator) Descriptor() protoreflect.EnumDescriptor { return file_private_location_v1_assertions_proto_enumTypes[2].Descriptor() } func (RecordComparator) Type() protoreflect.EnumType { return &file_private_location_v1_assertions_proto_enumTypes[2] } func (x RecordComparator) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use RecordComparator.Descriptor instead. func (RecordComparator) EnumDescriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} } type StatusCodeAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target int64 `protobuf:"varint,1,opt,name=target,proto3" json:"target,omitempty"` Comparator NumberComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.NumberComparator" json:"comparator,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StatusCodeAssertion) Reset() { *x = StatusCodeAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StatusCodeAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*StatusCodeAssertion) ProtoMessage() {} func (x *StatusCodeAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StatusCodeAssertion.ProtoReflect.Descriptor instead. func (*StatusCodeAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{0} } func (x *StatusCodeAssertion) GetTarget() int64 { if x != nil { return x.Target } return 0 } func (x *StatusCodeAssertion) GetComparator() NumberComparator { if x != nil { return x.Comparator } return NumberComparator_NUMBER_COMPARATOR_UNSPECIFIED } type BodyAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` Comparator StringComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.StringComparator" json:"comparator,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BodyAssertion) Reset() { *x = BodyAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *BodyAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*BodyAssertion) ProtoMessage() {} func (x *BodyAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BodyAssertion.ProtoReflect.Descriptor instead. func (*BodyAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{1} } func (x *BodyAssertion) GetTarget() string { if x != nil { return x.Target } return "" } func (x *BodyAssertion) GetComparator() StringComparator { if x != nil { return x.Comparator } return StringComparator_STRING_COMPARATOR_UNSPECIFIED } type HeaderAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Target string `protobuf:"bytes,1,opt,name=target,proto3" json:"target,omitempty"` Comparator StringComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.StringComparator" json:"comparator,omitempty"` Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HeaderAssertion) Reset() { *x = HeaderAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HeaderAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*HeaderAssertion) ProtoMessage() {} func (x *HeaderAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HeaderAssertion.ProtoReflect.Descriptor instead. func (*HeaderAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{2} } func (x *HeaderAssertion) GetTarget() string { if x != nil { return x.Target } return "" } func (x *HeaderAssertion) GetComparator() StringComparator { if x != nil { return x.Comparator } return StringComparator_STRING_COMPARATOR_UNSPECIFIED } func (x *HeaderAssertion) GetKey() string { if x != nil { return x.Key } return "" } type RecordAssertion struct { state protoimpl.MessageState `protogen:"open.v1"` Record string `protobuf:"bytes,1,opt,name=record,proto3" json:"record,omitempty"` Comparator RecordComparator `protobuf:"varint,2,opt,name=comparator,proto3,enum=private_location.v1.RecordComparator" json:"comparator,omitempty"` Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RecordAssertion) Reset() { *x = RecordAssertion{} mi := &file_private_location_v1_assertions_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RecordAssertion) String() string { return protoimpl.X.MessageStringOf(x) } func (*RecordAssertion) ProtoMessage() {} func (x *RecordAssertion) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_assertions_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RecordAssertion.ProtoReflect.Descriptor instead. func (*RecordAssertion) Descriptor() ([]byte, []int) { return file_private_location_v1_assertions_proto_rawDescGZIP(), []int{3} } func (x *RecordAssertion) GetRecord() string { if x != nil { return x.Record } return "" } func (x *RecordAssertion) GetComparator() RecordComparator { if x != nil { return x.Comparator } return RecordComparator_RECORD_COMPARATOR_UNSPECIFIED } func (x *RecordAssertion) GetTarget() string { if x != nil { return x.Target } return "" } var File_private_location_v1_assertions_proto protoreflect.FileDescriptor const file_private_location_v1_assertions_proto_rawDesc = "" + "\n" + "$private_location/v1/assertions.proto\x12\x13private_location.v1\"t\n" + "\x13StatusCodeAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\x03R\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.NumberComparatorR\n" + "comparator\"n\n" + "\rBodyAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\tR\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\"\x82\x01\n" + "\x0fHeaderAssertion\x12\x16\n" + "\x06target\x18\x01 \x01(\tR\x06target\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.StringComparatorR\n" + "comparator\x12\x10\n" + "\x03key\x18\x03 \x01(\tR\x03key\"\x88\x01\n" + "\x0fRecordAssertion\x12\x16\n" + "\x06record\x18\x01 \x01(\tR\x06record\x12E\n" + "\n" + "comparator\x18\x02 \x01(\x0e2%.private_location.v1.RecordComparatorR\n" + "comparator\x12\x16\n" + "\x06target\x18\x03 \x01(\tR\x06target*\x8f\x02\n" + "\x10NumberComparator\x12!\n" + "\x1dNUMBER_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17NUMBER_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + "\x1bNUMBER_COMPARATOR_NOT_EQUAL\x10\x02\x12\"\n" + "\x1eNUMBER_COMPARATOR_GREATER_THAN\x10\x03\x12+\n" + "'NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\x04\x12\x1f\n" + "\x1bNUMBER_COMPARATOR_LESS_THAN\x10\x05\x12(\n" + "$NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL\x10\x06*\x91\x03\n" + "\x10StringComparator\x12!\n" + "\x1dSTRING_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSTRING_COMPARATOR_CONTAINS\x10\x01\x12\"\n" + "\x1eSTRING_COMPARATOR_NOT_CONTAINS\x10\x02\x12\x1b\n" + "\x17STRING_COMPARATOR_EQUAL\x10\x03\x12\x1f\n" + "\x1bSTRING_COMPARATOR_NOT_EQUAL\x10\x04\x12\x1b\n" + "\x17STRING_COMPARATOR_EMPTY\x10\x05\x12\x1f\n" + "\x1bSTRING_COMPARATOR_NOT_EMPTY\x10\x06\x12\"\n" + "\x1eSTRING_COMPARATOR_GREATER_THAN\x10\a\x12+\n" + "'STRING_COMPARATOR_GREATER_THAN_OR_EQUAL\x10\b\x12\x1f\n" + "\x1bSTRING_COMPARATOR_LESS_THAN\x10\t\x12(\n" + "$STRING_COMPARATOR_LESS_THAN_OR_EQUAL\x10\n" + "*\xb7\x01\n" + "\x10RecordComparator\x12!\n" + "\x1dRECORD_COMPARATOR_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17RECORD_COMPARATOR_EQUAL\x10\x01\x12\x1f\n" + "\x1bRECORD_COMPARATOR_NOT_EQUAL\x10\x02\x12\x1e\n" + "\x1aRECORD_COMPARATOR_CONTAINS\x10\x03\x12\"\n" + "\x1eRECORD_COMPARATOR_NOT_CONTAINS\x10\x04BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_assertions_proto_rawDescOnce sync.Once file_private_location_v1_assertions_proto_rawDescData []byte ) func file_private_location_v1_assertions_proto_rawDescGZIP() []byte { file_private_location_v1_assertions_proto_rawDescOnce.Do(func() { file_private_location_v1_assertions_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc))) }) return file_private_location_v1_assertions_proto_rawDescData } var file_private_location_v1_assertions_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_private_location_v1_assertions_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_private_location_v1_assertions_proto_goTypes = []any{ (NumberComparator)(0), // 0: private_location.v1.NumberComparator (StringComparator)(0), // 1: private_location.v1.StringComparator (RecordComparator)(0), // 2: private_location.v1.RecordComparator (*StatusCodeAssertion)(nil), // 3: private_location.v1.StatusCodeAssertion (*BodyAssertion)(nil), // 4: private_location.v1.BodyAssertion (*HeaderAssertion)(nil), // 5: private_location.v1.HeaderAssertion (*RecordAssertion)(nil), // 6: private_location.v1.RecordAssertion } var file_private_location_v1_assertions_proto_depIdxs = []int32{ 0, // 0: private_location.v1.StatusCodeAssertion.comparator:type_name -> private_location.v1.NumberComparator 1, // 1: private_location.v1.BodyAssertion.comparator:type_name -> private_location.v1.StringComparator 1, // 2: private_location.v1.HeaderAssertion.comparator:type_name -> private_location.v1.StringComparator 2, // 3: private_location.v1.RecordAssertion.comparator:type_name -> private_location.v1.RecordComparator 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_assertions_proto_init() } func file_private_location_v1_assertions_proto_init() { if File_private_location_v1_assertions_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_assertions_proto_rawDesc), len(file_private_location_v1_assertions_proto_rawDesc)), NumEnums: 3, NumMessages: 4, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_assertions_proto_goTypes, DependencyIndexes: file_private_location_v1_assertions_proto_depIdxs, EnumInfos: file_private_location_v1_assertions_proto_enumTypes, MessageInfos: file_private_location_v1_assertions_proto_msgTypes, }.Build() File_private_location_v1_assertions_proto = out.File file_private_location_v1_assertions_proto_goTypes = nil file_private_location_v1_assertions_proto_depIdxs = nil } ================================================ FILE: apps/private-location/proto/private_location/v1/dns_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/dns_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type DNSMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` RecordAssertions []*RecordAssertion `protobuf:"bytes,13,rep,name=record_assertions,json=recordAssertions,proto3" json:"record_assertions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DNSMonitor) Reset() { *x = DNSMonitor{} mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DNSMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*DNSMonitor) ProtoMessage() {} func (x *DNSMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_dns_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DNSMonitor.ProtoReflect.Descriptor instead. func (*DNSMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_dns_monitor_proto_rawDescGZIP(), []int{0} } func (x *DNSMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *DNSMonitor) GetUri() string { if x != nil { return x.Uri } return "" } func (x *DNSMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *DNSMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *DNSMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *DNSMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } func (x *DNSMonitor) GetRecordAssertions() []*RecordAssertion { if x != nil { return x.RecordAssertions } return nil } var File_private_location_v1_dns_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_dns_monitor_proto_rawDesc = "" + "\n" + "%private_location/v1/dns_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"\x89\x02\n" + "\n" + "DNSMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12 \n" + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12Q\n" + "\x11record_assertions\x18\r \x03(\v2$.private_location.v1.RecordAssertionR\x10recordAssertionsB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_dns_monitor_proto_rawDescOnce sync.Once file_private_location_v1_dns_monitor_proto_rawDescData []byte ) func file_private_location_v1_dns_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_dns_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_dns_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc))) }) return file_private_location_v1_dns_monitor_proto_rawDescData } var file_private_location_v1_dns_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_private_location_v1_dns_monitor_proto_goTypes = []any{ (*DNSMonitor)(nil), // 0: private_location.v1.DNSMonitor (*RecordAssertion)(nil), // 1: private_location.v1.RecordAssertion } var file_private_location_v1_dns_monitor_proto_depIdxs = []int32{ 1, // 0: private_location.v1.DNSMonitor.record_assertions:type_name -> private_location.v1.RecordAssertion 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name } func init() { file_private_location_v1_dns_monitor_proto_init() } func file_private_location_v1_dns_monitor_proto_init() { if File_private_location_v1_dns_monitor_proto != nil { return } file_private_location_v1_assertions_proto_init() file_private_location_v1_dns_monitor_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_dns_monitor_proto_rawDesc), len(file_private_location_v1_dns_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_dns_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_dns_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_dns_monitor_proto_msgTypes, }.Build() File_private_location_v1_dns_monitor_proto = out.File file_private_location_v1_dns_monitor_proto_goTypes = nil file_private_location_v1_dns_monitor_proto_depIdxs = nil } ================================================ FILE: apps/private-location/proto/private_location/v1/http_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/http_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Headers struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Headers) Reset() { *x = Headers{} mi := &file_private_location_v1_http_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Headers) String() string { return protoimpl.X.MessageStringOf(x) } func (*Headers) ProtoMessage() {} func (x *Headers) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_http_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Headers.ProtoReflect.Descriptor instead. func (*Headers) Descriptor() ([]byte, []int) { return file_private_location_v1_http_monitor_proto_rawDescGZIP(), []int{0} } func (x *Headers) GetKey() string { if x != nil { return x.Key } return "" } func (x *Headers) GetValue() string { if x != nil { return x.Value } return "" } type HTTPMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` Periodicity string `protobuf:"bytes,3,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"` Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"` Timeout int64 `protobuf:"varint,6,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,7,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Retry int64 `protobuf:"varint,8,opt,name=retry,proto3" json:"retry,omitempty"` FollowRedirects bool `protobuf:"varint,9,opt,name=follow_redirects,json=followRedirects,proto3" json:"follow_redirects,omitempty"` Headers []*Headers `protobuf:"bytes,10,rep,name=headers,proto3" json:"headers,omitempty"` StatusCodeAssertions []*StatusCodeAssertion `protobuf:"bytes,11,rep,name=status_code_assertions,json=statusCodeAssertions,proto3" json:"status_code_assertions,omitempty"` BodyAssertions []*BodyAssertion `protobuf:"bytes,12,rep,name=body_assertions,json=bodyAssertions,proto3" json:"body_assertions,omitempty"` HeaderAssertions []*HeaderAssertion `protobuf:"bytes,13,rep,name=header_assertions,json=headerAssertions,proto3" json:"header_assertions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *HTTPMonitor) Reset() { *x = HTTPMonitor{} mi := &file_private_location_v1_http_monitor_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *HTTPMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*HTTPMonitor) ProtoMessage() {} func (x *HTTPMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_http_monitor_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use HTTPMonitor.ProtoReflect.Descriptor instead. func (*HTTPMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_http_monitor_proto_rawDescGZIP(), []int{1} } func (x *HTTPMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *HTTPMonitor) GetUrl() string { if x != nil { return x.Url } return "" } func (x *HTTPMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *HTTPMonitor) GetMethod() string { if x != nil { return x.Method } return "" } func (x *HTTPMonitor) GetBody() string { if x != nil { return x.Body } return "" } func (x *HTTPMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *HTTPMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *HTTPMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } func (x *HTTPMonitor) GetFollowRedirects() bool { if x != nil { return x.FollowRedirects } return false } func (x *HTTPMonitor) GetHeaders() []*Headers { if x != nil { return x.Headers } return nil } func (x *HTTPMonitor) GetStatusCodeAssertions() []*StatusCodeAssertion { if x != nil { return x.StatusCodeAssertions } return nil } func (x *HTTPMonitor) GetBodyAssertions() []*BodyAssertion { if x != nil { return x.BodyAssertions } return nil } func (x *HTTPMonitor) GetHeaderAssertions() []*HeaderAssertion { if x != nil { return x.HeaderAssertions } return nil } var File_private_location_v1_http_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_http_monitor_proto_rawDesc = "" + "\n" + "&private_location/v1/http_monitor.proto\x12\x13private_location.v1\x1a$private_location/v1/assertions.proto\"1\n" + "\aHeaders\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"\xc6\x04\n" + "\vHTTPMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03url\x18\x02 \x01(\tR\x03url\x12 \n" + "\vperiodicity\x18\x03 \x01(\tR\vperiodicity\x12\x16\n" + "\x06method\x18\x04 \x01(\tR\x06method\x12\x12\n" + "\x04body\x18\x05 \x01(\tR\x04body\x12\x18\n" + "\atimeout\x18\x06 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\a \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12\x14\n" + "\x05retry\x18\b \x01(\x03R\x05retry\x12)\n" + "\x10follow_redirects\x18\t \x01(\bR\x0ffollowRedirects\x126\n" + "\aheaders\x18\n" + " \x03(\v2\x1c.private_location.v1.HeadersR\aheaders\x12^\n" + "\x16status_code_assertions\x18\v \x03(\v2(.private_location.v1.StatusCodeAssertionR\x14statusCodeAssertions\x12K\n" + "\x0fbody_assertions\x18\f \x03(\v2\".private_location.v1.BodyAssertionR\x0ebodyAssertions\x12Q\n" + "\x11header_assertions\x18\r \x03(\v2$.private_location.v1.HeaderAssertionR\x10headerAssertionsB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_http_monitor_proto_rawDescOnce sync.Once file_private_location_v1_http_monitor_proto_rawDescData []byte ) func file_private_location_v1_http_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_http_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_http_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_http_monitor_proto_rawDesc), len(file_private_location_v1_http_monitor_proto_rawDesc))) }) return file_private_location_v1_http_monitor_proto_rawDescData } var file_private_location_v1_http_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_private_location_v1_http_monitor_proto_goTypes = []any{ (*Headers)(nil), // 0: private_location.v1.Headers (*HTTPMonitor)(nil), // 1: private_location.v1.HTTPMonitor (*StatusCodeAssertion)(nil), // 2: private_location.v1.StatusCodeAssertion (*BodyAssertion)(nil), // 3: private_location.v1.BodyAssertion (*HeaderAssertion)(nil), // 4: private_location.v1.HeaderAssertion } var file_private_location_v1_http_monitor_proto_depIdxs = []int32{ 0, // 0: private_location.v1.HTTPMonitor.headers:type_name -> private_location.v1.Headers 2, // 1: private_location.v1.HTTPMonitor.status_code_assertions:type_name -> private_location.v1.StatusCodeAssertion 3, // 2: private_location.v1.HTTPMonitor.body_assertions:type_name -> private_location.v1.BodyAssertion 4, // 3: private_location.v1.HTTPMonitor.header_assertions:type_name -> private_location.v1.HeaderAssertion 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_private_location_v1_http_monitor_proto_init() } func file_private_location_v1_http_monitor_proto_init() { if File_private_location_v1_http_monitor_proto != nil { return } file_private_location_v1_assertions_proto_init() file_private_location_v1_http_monitor_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_http_monitor_proto_rawDesc), len(file_private_location_v1_http_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 2, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_http_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_http_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_http_monitor_proto_msgTypes, }.Build() File_private_location_v1_http_monitor_proto = out.File file_private_location_v1_http_monitor_proto_goTypes = nil file_private_location_v1_http_monitor_proto_depIdxs = nil } ================================================ FILE: apps/private-location/proto/private_location/v1/private_location.connect.go ================================================ // Code generated by protoc-gen-connect-go. DO NOT EDIT. // // Source: private_location/v1/private_location.proto package v1 import ( connect "connectrpc.com/connect" context "context" errors "errors" http "net/http" strings "strings" ) // This is a compile-time assertion to ensure that this generated file and the connect package are // compatible. If you get a compiler error that this constant is not defined, this code was // generated with a version of connect newer than the one compiled into your binary. You can fix the // problem by either regenerating this code with an older version of connect or updating the connect // version compiled into your binary. const _ = connect.IsAtLeastVersion1_13_0 const ( // PrivateLocationServiceName is the fully-qualified name of the PrivateLocationService service. PrivateLocationServiceName = "private_location.v1.PrivateLocationService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( // PrivateLocationServiceMonitorsProcedure is the fully-qualified name of the // PrivateLocationService's Monitors RPC. PrivateLocationServiceMonitorsProcedure = "/private_location.v1.PrivateLocationService/Monitors" // PrivateLocationServiceIngestTCPProcedure is the fully-qualified name of the // PrivateLocationService's IngestTCP RPC. PrivateLocationServiceIngestTCPProcedure = "/private_location.v1.PrivateLocationService/IngestTCP" // PrivateLocationServiceIngestHTTPProcedure is the fully-qualified name of the // PrivateLocationService's IngestHTTP RPC. PrivateLocationServiceIngestHTTPProcedure = "/private_location.v1.PrivateLocationService/IngestHTTP" // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService // service. type PrivateLocationServiceClient interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceClient constructs a client for the // private_location.v1.PrivateLocationService service. By default, it uses the Connect protocol with // the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To use // the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() options. // // The URL supplied here should be the base URL for the Connect or gRPC server (for example, // http://api.acme.com or https://acme.com/grpc). func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PrivateLocationServiceClient { baseURL = strings.TrimRight(baseURL, "/") privateLocationServiceMethods := File_private_location_v1_private_location_proto.Services().ByName("PrivateLocationService").Methods() return &privateLocationServiceClient{ monitors: connect.NewClient[MonitorsRequest, MonitorsResponse]( httpClient, baseURL+PrivateLocationServiceMonitorsProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("Monitors")), connect.WithClientOptions(opts...), ), ingestTCP: connect.NewClient[IngestTCPRequest, IngestTCPResponse]( httpClient, baseURL+PrivateLocationServiceIngestTCPProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestTCP")), connect.WithClientOptions(opts...), ), ingestHTTP: connect.NewClient[IngestHTTPRequest, IngestHTTPResponse]( httpClient, baseURL+PrivateLocationServiceIngestHTTPProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithClientOptions(opts...), ), ingestDNS: connect.NewClient[IngestDNSRequest, IngestDNSResponse]( httpClient, baseURL+PrivateLocationServiceIngestDNSProcedure, connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), } } // privateLocationServiceClient implements PrivateLocationServiceClient. type privateLocationServiceClient struct { monitors *connect.Client[MonitorsRequest, MonitorsResponse] ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. func (c *privateLocationServiceClient) Monitors(ctx context.Context, req *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) { return c.monitors.CallUnary(ctx, req) } // IngestTCP calls private_location.v1.PrivateLocationService.IngestTCP. func (c *privateLocationServiceClient) IngestTCP(ctx context.Context, req *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) { return c.ingestTCP.CallUnary(ctx, req) } // IngestHTTP calls private_location.v1.PrivateLocationService.IngestHTTP. func (c *privateLocationServiceClient) IngestHTTP(ctx context.Context, req *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return c.ingestHTTP.CallUnary(ctx, req) } // IngestDNS calls private_location.v1.PrivateLocationService.IngestDNS. func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return c.ingestDNS.CallUnary(ctx, req) } // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It // returns the path on which to mount the handler and the handler itself. // // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf // and JSON codecs. They also support gzip compression. func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { privateLocationServiceMethods := File_private_location_v1_private_location_proto.Services().ByName("PrivateLocationService").Methods() privateLocationServiceMonitorsHandler := connect.NewUnaryHandler( PrivateLocationServiceMonitorsProcedure, svc.Monitors, connect.WithSchema(privateLocationServiceMethods.ByName("Monitors")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestTCPHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestTCPProcedure, svc.IngestTCP, connect.WithSchema(privateLocationServiceMethods.ByName("IngestTCP")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestHTTPHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestHTTPProcedure, svc.IngestHTTP, connect.WithSchema(privateLocationServiceMethods.ByName("IngestHTTP")), connect.WithHandlerOptions(opts...), ) privateLocationServiceIngestDNSHandler := connect.NewUnaryHandler( PrivateLocationServiceIngestDNSProcedure, svc.IngestDNS, connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: privateLocationServiceMonitorsHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestTCPProcedure: privateLocationServiceIngestTCPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestHTTPProcedure: privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } }) } // UnimplementedPrivateLocationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedPrivateLocationServiceHandler struct{} func (UnimplementedPrivateLocationServiceHandler) Monitors(context.Context, *connect.Request[MonitorsRequest]) (*connect.Response[MonitorsResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.Monitors is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestTCP is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestHTTP is not implemented")) } func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } ================================================ FILE: apps/private-location/proto/private_location/v1/private_location.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/private_location.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" _ "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type MonitorsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MonitorsRequest) Reset() { *x = MonitorsRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MonitorsRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*MonitorsRequest) ProtoMessage() {} func (x *MonitorsRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MonitorsRequest.ProtoReflect.Descriptor instead. func (*MonitorsRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{0} } type MonitorsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MonitorsResponse) Reset() { *x = MonitorsResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MonitorsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*MonitorsResponse) ProtoMessage() {} func (x *MonitorsResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MonitorsResponse.ProtoReflect.Descriptor instead. func (*MonitorsResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{1} } func (x *MonitorsResponse) GetHttpMonitors() []*HTTPMonitor { if x != nil { return x.HttpMonitors } return nil } func (x *MonitorsResponse) GetTcpMonitors() []*TCPMonitor { if x != nil { return x.TcpMonitors } return nil } func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { if x != nil { return x.DnsMonitors } return nil } type IngestTCPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` Message string `protobuf:"bytes,7,opt,name=message,proto3" json:"message,omitempty"` RequestStatus string `protobuf:"bytes,8,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Error int64 `protobuf:"varint,9,opt,name=error,proto3" json:"error,omitempty"` Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestTCPRequest) Reset() { *x = IngestTCPRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestTCPRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestTCPRequest) ProtoMessage() {} func (x *IngestTCPRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestTCPRequest.ProtoReflect.Descriptor instead. func (*IngestTCPRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{2} } func (x *IngestTCPRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestTCPRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestTCPRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestTCPRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestTCPRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestTCPRequest) GetUri() string { if x != nil { return x.Uri } return "" } func (x *IngestTCPRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestTCPRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestTCPRequest) GetError() int64 { if x != nil { return x.Error } return 0 } func (x *IngestTCPRequest) GetTiming() string { if x != nil { return x.Timing } return "" } type IngestTCPResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestTCPResponse) Reset() { *x = IngestTCPResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestTCPResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestTCPResponse) ProtoMessage() {} func (x *IngestTCPResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestTCPResponse.ProtoReflect.Descriptor instead. func (*IngestTCPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{3} } type IngestHTTPRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Url string `protobuf:"bytes,6,opt,name=url,proto3" json:"url,omitempty"` RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` Body string `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` Headers string `protobuf:"bytes,10,opt,name=headers,proto3" json:"headers,omitempty"` Timing string `protobuf:"bytes,11,opt,name=timing,proto3" json:"timing,omitempty"` StatusCode int64 `protobuf:"varint,12,opt,name=statusCode,proto3" json:"statusCode,omitempty"` Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestHTTPRequest) Reset() { *x = IngestHTTPRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestHTTPRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestHTTPRequest) ProtoMessage() {} func (x *IngestHTTPRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestHTTPRequest.ProtoReflect.Descriptor instead. func (*IngestHTTPRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{4} } func (x *IngestHTTPRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestHTTPRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestHTTPRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestHTTPRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestHTTPRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestHTTPRequest) GetUrl() string { if x != nil { return x.Url } return "" } func (x *IngestHTTPRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestHTTPRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestHTTPRequest) GetBody() string { if x != nil { return x.Body } return "" } func (x *IngestHTTPRequest) GetHeaders() string { if x != nil { return x.Headers } return "" } func (x *IngestHTTPRequest) GetTiming() string { if x != nil { return x.Timing } return "" } func (x *IngestHTTPRequest) GetStatusCode() int64 { if x != nil { return x.StatusCode } return 0 } func (x *IngestHTTPRequest) GetError() int64 { if x != nil { return x.Error } return 0 } type IngestHTTPResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestHTTPResponse) Reset() { *x = IngestHTTPResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestHTTPResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestHTTPResponse) ProtoMessage() {} func (x *IngestHTTPResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestHTTPResponse.ProtoReflect.Descriptor instead. func (*IngestHTTPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{5} } type Records struct { state protoimpl.MessageState `protogen:"open.v1"` Record []string `protobuf:"bytes,1,rep,name=record,proto3" json:"record,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Records) Reset() { *x = Records{} mi := &file_private_location_v1_private_location_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Records) String() string { return protoimpl.X.MessageStringOf(x) } func (*Records) ProtoMessage() {} func (x *Records) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Records.ProtoReflect.Descriptor instead. func (*Records) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{6} } func (x *Records) GetRecord() []string { if x != nil { return x.Record } return nil } type IngestDNSRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` RequestStatus string `protobuf:"bytes,7,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` Records map[string]*Records `protobuf:"bytes,9,rep,name=records,proto3" json:"records,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` Timing string `protobuf:"bytes,10,opt,name=timing,proto3" json:"timing,omitempty"` Error int64 `protobuf:"varint,11,opt,name=error,proto3" json:"error,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestDNSRequest) Reset() { *x = IngestDNSRequest{} mi := &file_private_location_v1_private_location_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestDNSRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestDNSRequest) ProtoMessage() {} func (x *IngestDNSRequest) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestDNSRequest.ProtoReflect.Descriptor instead. func (*IngestDNSRequest) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{7} } func (x *IngestDNSRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *IngestDNSRequest) GetMonitorId() string { if x != nil { return x.MonitorId } return "" } func (x *IngestDNSRequest) GetLatency() int64 { if x != nil { return x.Latency } return 0 } func (x *IngestDNSRequest) GetTimestamp() int64 { if x != nil { return x.Timestamp } return 0 } func (x *IngestDNSRequest) GetCronTimestamp() int64 { if x != nil { return x.CronTimestamp } return 0 } func (x *IngestDNSRequest) GetUri() string { if x != nil { return x.Uri } return "" } func (x *IngestDNSRequest) GetRequestStatus() string { if x != nil { return x.RequestStatus } return "" } func (x *IngestDNSRequest) GetMessage() string { if x != nil { return x.Message } return "" } func (x *IngestDNSRequest) GetRecords() map[string]*Records { if x != nil { return x.Records } return nil } func (x *IngestDNSRequest) GetTiming() string { if x != nil { return x.Timing } return "" } func (x *IngestDNSRequest) GetError() int64 { if x != nil { return x.Error } return 0 } type IngestDNSResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *IngestDNSResponse) Reset() { *x = IngestDNSResponse{} mi := &file_private_location_v1_private_location_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *IngestDNSResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*IngestDNSResponse) ProtoMessage() {} func (x *IngestDNSResponse) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_private_location_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use IngestDNSResponse.ProtoReflect.Descriptor instead. func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + "\x0fMonitorsRequest\"\xe1\x01\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + "\amessage\x18\a \x01(\tR\amessage\x12$\n" + "\rrequestStatus\x18\b \x01(\tR\rrequestStatus\x12\x14\n" + "\x05error\x18\t \x01(\x03R\x05error\x12\x16\n" + "\x06timing\x18\n" + " \x01(\tR\x06timing\"\x13\n" + "\x11IngestTCPResponse\"\xed\x02\n" + "\x11IngestHTTPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03url\x18\x06 \x01(\tR\x03url\x12$\n" + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + "\amessage\x18\b \x01(\tR\amessage\x12\x12\n" + "\x04body\x18\t \x01(\tR\x04body\x12\x18\n" + "\aheaders\x18\n" + " \x01(\tR\aheaders\x12\x16\n" + "\x06timing\x18\v \x01(\tR\x06timing\x12\x1e\n" + "\n" + "statusCode\x18\f \x01(\x03R\n" + "statusCode\x12\x14\n" + "\x05error\x18\r \x01(\x03R\x05error\"\x14\n" + "\x12IngestHTTPResponse\"!\n" + "\aRecords\x12\x16\n" + "\x06record\x18\x01 \x03(\tR\x06record\"\xc6\x03\n" + "\x10IngestDNSRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1c\n" + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + "\x03uri\x18\x06 \x01(\tR\x03uri\x12$\n" + "\rrequestStatus\x18\a \x01(\tR\rrequestStatus\x12\x18\n" + "\amessage\x18\b \x01(\tR\amessage\x12L\n" + "\arecords\x18\t \x03(\v22.private_location.v1.IngestDNSRequest.RecordsEntryR\arecords\x12\x16\n" + "\x06timing\x18\n" + " \x01(\tR\x06timing\x12\x14\n" + "\x05error\x18\v \x01(\x03R\x05error\x1aX\n" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + "\x11IngestDNSResponse2\x90\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once file_private_location_v1_private_location_proto_rawDescData []byte ) func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { file_private_location_v1_private_location_proto_rawDescOnce.Do(func() { file_private_location_v1_private_location_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc))) }) return file_private_location_v1_private_location_proto_rawDescData } var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse (*IngestTCPRequest)(nil), // 2: private_location.v1.IngestTCPRequest (*IngestTCPResponse)(nil), // 3: private_location.v1.IngestTCPResponse (*IngestHTTPRequest)(nil), // 4: private_location.v1.IngestHTTPRequest (*IngestHTTPResponse)(nil), // 5: private_location.v1.IngestHTTPResponse (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse 9, // [9:13] is the sub-list for method output_type 5, // [5:9] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } func file_private_location_v1_private_location_proto_init() { if File_private_location_v1_private_location_proto != nil { return } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, NumMessages: 10, NumExtensions: 0, NumServices: 1, }, GoTypes: file_private_location_v1_private_location_proto_goTypes, DependencyIndexes: file_private_location_v1_private_location_proto_depIdxs, MessageInfos: file_private_location_v1_private_location_proto_msgTypes, }.Build() File_private_location_v1_private_location_proto = out.File file_private_location_v1_private_location_proto_goTypes = nil file_private_location_v1_private_location_proto_depIdxs = nil } ================================================ FILE: apps/private-location/proto/private_location/v1/tcp_monitor.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: private_location/v1/tcp_monitor.proto package v1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type TCPMonitor struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TCPMonitor) Reset() { *x = TCPMonitor{} mi := &file_private_location_v1_tcp_monitor_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *TCPMonitor) String() string { return protoimpl.X.MessageStringOf(x) } func (*TCPMonitor) ProtoMessage() {} func (x *TCPMonitor) ProtoReflect() protoreflect.Message { mi := &file_private_location_v1_tcp_monitor_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TCPMonitor.ProtoReflect.Descriptor instead. func (*TCPMonitor) Descriptor() ([]byte, []int) { return file_private_location_v1_tcp_monitor_proto_rawDescGZIP(), []int{0} } func (x *TCPMonitor) GetId() string { if x != nil { return x.Id } return "" } func (x *TCPMonitor) GetUri() string { if x != nil { return x.Uri } return "" } func (x *TCPMonitor) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *TCPMonitor) GetDegradedAt() int64 { if x != nil && x.DegradedAt != nil { return *x.DegradedAt } return 0 } func (x *TCPMonitor) GetPeriodicity() string { if x != nil { return x.Periodicity } return "" } func (x *TCPMonitor) GetRetry() int64 { if x != nil { return x.Retry } return 0 } var File_private_location_v1_tcp_monitor_proto protoreflect.FileDescriptor const file_private_location_v1_tcp_monitor_proto_rawDesc = "" + "\n" + "%private_location/v1/tcp_monitor.proto\x12\x13private_location.v1\"\xb6\x01\n" + "\n" + "TCPMonitor\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + "degradedAt\x88\x01\x01\x12 \n" + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + "\x05retry\x18\x06 \x01(\x03R\x05retryB\x0e\n" + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_tcp_monitor_proto_rawDescOnce sync.Once file_private_location_v1_tcp_monitor_proto_rawDescData []byte ) func file_private_location_v1_tcp_monitor_proto_rawDescGZIP() []byte { file_private_location_v1_tcp_monitor_proto_rawDescOnce.Do(func() { file_private_location_v1_tcp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_tcp_monitor_proto_rawDesc), len(file_private_location_v1_tcp_monitor_proto_rawDesc))) }) return file_private_location_v1_tcp_monitor_proto_rawDescData } var file_private_location_v1_tcp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_private_location_v1_tcp_monitor_proto_goTypes = []any{ (*TCPMonitor)(nil), // 0: private_location.v1.TCPMonitor } var file_private_location_v1_tcp_monitor_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type 0, // [0:0] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_private_location_v1_tcp_monitor_proto_init() } func file_private_location_v1_tcp_monitor_proto_init() { if File_private_location_v1_tcp_monitor_proto != nil { return } file_private_location_v1_tcp_monitor_proto_msgTypes[0].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_tcp_monitor_proto_rawDesc), len(file_private_location_v1_tcp_monitor_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_private_location_v1_tcp_monitor_proto_goTypes, DependencyIndexes: file_private_location_v1_tcp_monitor_proto_depIdxs, MessageInfos: file_private_location_v1_tcp_monitor_proto_msgTypes, }.Build() File_private_location_v1_tcp_monitor_proto = out.File file_private_location_v1_tcp_monitor_proto_goTypes = nil file_private_location_v1_tcp_monitor_proto_depIdxs = nil } ================================================ FILE: apps/railway-proxy/Dockerfile ================================================ FROM golang:1.26-alpine # Set the working directory WORKDIR /app # Copy go.mod and go.sum files (if they exist) COPY go.mod ./ # Copy the source code COPY . . # Build the application RUN go build -o main . # Expose the port your app runs on EXPOSE 8080 # Run the application CMD ["./main"] ================================================ FILE: apps/railway-proxy/go.mod ================================================ module github.com/openstatushq/openstatus/apps/railway-proxy go 1.25.1 require ( github.com/gin-gonic/gin v1.11.0 github.com/rs/zerolog v1.34.0 ) require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) ================================================ FILE: apps/railway-proxy/go.sum ================================================ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: apps/railway-proxy/main.go ================================================ package main import ( "context" "errors" "fmt" "net/http" "net/http/httputil" "net/url" "os" "os/signal" "syscall" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ) func proxy(c *gin.Context) { var targetUrl string region := c.Request.Header.Get("railway-region") switch region { case "europe-west4-drams3a": targetUrl = "http://openstatus-checker-eu-west.railway.internal:8080" case "us-east4-eqdc4a": targetUrl = "http://openstatus-checker-us-east.railway.internal:8080" case "us-west2": targetUrl = "http://openstatus-checker-us-west.railway.internal:8080" case "asia-southeast1-eqsg3a": targetUrl = "http://checker-southeast-asia.railway.internal:8080" default: fmt.Println("No region") } remote, err := url.Parse(targetUrl) if err != nil { panic(err) } proxy := httputil.NewSingleHostReverseProxy(remote) c.Request.Host = remote.Host proxy.ServeHTTP(c.Writer, c.Request) } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { <-done cancel() }() cloudProvider := env("CLOUD_PROVIDER", "railway") region := env("RAILWAY_REPLICA_REGION", env("REGION", "local")) router := gin.New() //Create a catchall route router.NoRoute(proxy) router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) }) httpServer := &http.Server{ Addr: fmt.Sprintf("0.0.0.0:%s", env("PORT", "8080")), Handler: router, } go func() { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Ctx(ctx).Error().Err(err).Msg("failed to start http server") cancel() } }() <-ctx.Done() if err := httpServer.Shutdown(ctx); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to shutdown http server") return } } func env(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } return fallback } ================================================ FILE: apps/screenshot-service/.dockerignore ================================================ # flyctl launch added from .gitignore **/node_modules **/dist **/.wrangler **/.dev.vars # Change them to your taste: **/package-lock.json **/yarn.lock **/pnpm-lock.yaml **/bun.lockb fly.toml node_modules ================================================ FILE: apps/screenshot-service/.gitignore ================================================ node_modules dist .wrangler .dev.vars # Change them to your taste: package-lock.json yarn.lock pnpm-lock.yaml bun.lockb ================================================ FILE: apps/screenshot-service/Dockerfile ================================================ FROM node:22-bookworm as dep RUN npx -y playwright@1.46.0 install --with-deps RUN npm install -g bun RUN npm install -g pnpm WORKDIR /app COPY . . # To keep the image small ;) RUN rm -rf /app/apps/docs &&\ rm -rf /app/apps/web &&\ rm -rf /app/apps/server &&\ rm -rf /app/packages/api &&\ rm -rf /app/packages/integrations/vercel RUN pnpm install EXPOSE 3000 WORKDIR /app/apps/screenshot-service CMD ["bun", "start"] ================================================ FILE: apps/screenshot-service/README.md ================================================ # Screenshot Worker This is not used anymore. Will be used for browser check ================================================ FILE: apps/screenshot-service/fly.toml ================================================ # fly.toml app configuration file generated for openstatus-screenshot on 2024-04-06T11:12:20+02:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = 'openstatus-screenshot' primary_region = 'ams' [build] dockerfile = './Dockerfile' [deploy] strategy = 'canary' [http_service] internal_port = 3000 force_https = true auto_stop_machines = "suspend" auto_start_machines = true min_machines_running = 0 processes = ['app'] [http_service.concurrency] type = 'requests' hard_limit = 4 soft_limit = 2 [[http_service.checks]] interval = '15s' timeout = '5s' grace_period = '10s' method = 'GET' path = '/ping' [[vm]] cpu_kind = 'shared' cpus = 2 memory_mb = 2048 ================================================ FILE: apps/screenshot-service/package.json ================================================ { "name": "@openstatus/screenshot-service", "version": "0.0.1", "scripts": { "dev": "bun run --hot src/index.ts", "start": "NODE_ENV=production bun run src/index.ts" }, "dependencies": { "@aws-sdk/client-s3": "3.550.0", "@hono/zod-validator": "0.2.2", "@openstatus/db": "workspace:*", "@openstatus/utils": "workspace:^", "@t3-oss/env-core": "0.13.10", "@upstash/qstash": "2.6.2", "hono": "4.5.3", "playwright": "1.46.0", "zod": "4.1.13" }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", "typescript": "5.9.3" } } ================================================ FILE: apps/screenshot-service/src/env.ts ================================================ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; export const env = createEnv({ server: { R2_TOKEN: z.string().min(1), R2_URL: z.string().min(1), R2_ACCESS_KEY: z.string().min(1), R2_SECRET_KEY: z.string().min(1), HEADER_TOKEN: z.string().min(1), QSTASH_SIGNING_SECRET: z.string().min(1), QSTASH_NEXT_SIGNING_SECRET: z.string().min(1), }, /** * What object holds the environment variables at runtime. This is usually * `process.env` or `import.meta.env`. */ runtimeEnv: process.env, /** * By default, this library will feed the environment variables directly to * the Zod validator. * * This means that if you have an empty string for a value that is supposed * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag * it as a type mismatch violation. Additionally, if you have an empty string * for a value that is supposed to be a string with a default value (e.g. * `DOMAIN=` in an ".env" file), the default value will never be applied. * * In order to solve these issues, we recommend that all new projects * explicitly specify this option as true. */ skipValidation: true, }); ================================================ FILE: apps/screenshot-service/src/index.ts ================================================ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import playwright from "playwright"; import { z } from "zod"; import { db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents/incident"; import { Receiver } from "@upstash/qstash"; import { env } from "./env"; const S3 = new S3Client({ region: "auto", endpoint: env.R2_URL, credentials: { accessKeyId: env.R2_ACCESS_KEY, secretAccessKey: env.R2_SECRET_KEY, }, }); const receiver = new Receiver({ currentSigningKey: env.QSTASH_SIGNING_SECRET, nextSigningKey: env.QSTASH_NEXT_SIGNING_SECRET, }); const app = new Hono(); app.get("/ping", (c) => c.json({ ping: "pong", region: process.env.FLY_REGION }, 200), ); app.post( "/", zValidator( "json", z.object({ url: z.url(), incidentId: z.number(), kind: z.enum(["incident", "recovery"]), }), ), async (c) => { const signature = c.req.header("Upstash-Signature"); // if (auth !== `Basic ${env.HEADER_TOKEN}`) { // console.error("Unauthorized"); // return c.text("Unauthorized", 401); // } const data = c.req.valid("json"); const isValid = receiver.verify({ signature: signature || "", body: JSON.stringify(data), }); if (!isValid) { console.error("Unauthorized"); return c.text("Unauthorized", 401); } const browser = await playwright.chromium.launch({ headless: true, // set this to true }); try { const page = await browser.newPage(); await page.goto(data.url, { waitUntil: "load" }); const img = await page.screenshot({ fullPage: true }); const id = `${data.incidentId}-${Date.now()}.png`; const url = `https://screenshot.openstat.us/${id}`; await S3.send( new PutObjectCommand({ Body: img, Bucket: "incident-screenshot", Key: id, ContentType: "image/png", }), ); if (data.kind === "incident") { await db .update(incidentTable) .set({ incidentScreenshotUrl: url }) .where(eq(incidentTable.id, data.incidentId)) .run(); } if (data.kind === "recovery") { await db .update(incidentTable) .set({ recoveryScreenshotUrl: url }) .where(eq(incidentTable.id, data.incidentId)) .run(); } } catch (e) { console.log("could not take screenshot timeout"); if (data.kind === "incident") { await db .update(incidentTable) .set({ incidentScreenshotUrl: "https://screenshot.openstat.us/err-connection-timed-out.jpg", }) .where(eq(incidentTable.id, data.incidentId)) .run(); } // console.log(e); } return c.text("Screenshot saved"); }, ); export default app; ================================================ FILE: apps/screenshot-service/tsconfig.json ================================================ { "extends": "@openstatus/tsconfig/base.json", "include": ["src", "*.ts", "**/*.ts"], "compilerOptions": {} } ================================================ FILE: apps/server/.dockerignore ================================================ # This file is generated by Dofigen v2.7.0 # See https://github.com/lenra-io/dofigen node_modules /apps/docs /apps/screenshot-service /apps/web /apps/dashboard /apps/status-page /apps/workflows /packages/api /packages/integrations/vercel ================================================ FILE: apps/server/.gitignore ================================================ node_modules ================================================ FILE: apps/server/CONNECTRPC_SPEC.md ================================================ # ConnectRPC API Specification ## Overview This document specifies the implementation of a ConnectRPC API for OpenStatus server. ConnectRPC will be used for **new features only** while the existing REST API remains for current functionality. ## Architecture Decisions ### Transport & Protocol - **Protocol**: Connect protocol only (HTTP/1.1 compatible) - **Streaming**: Unary calls only (request-response, no streaming) - **Mounting**: Same port as REST, mounted at `/rpc/*` path prefix on the existing Hono app ### Schema Management - **Approach**: Schema-first with `.proto` files - **Tooling**: Buf (buf.yaml, buf.gen.yaml) - **Location**: `packages/proto` (shared package for monorepo consumption) - **Package naming**: `openstatus.<domain>.v1` (e.g., `openstatus.monitor.v1`) ### Code Generation Targets - TypeScript (`@bufbuild/protobuf` + `@connectrpc/connect`) - Go (for potential backend service consumers) --- ## Authentication & Authorization ### Supported Methods Both authentication methods resolve to the same workspace context: 1. **API Key** (existing system) - Header: `x-openstatus-key` - Formats: `os_[32-char-hex]` (custom) or Unkey keys - Super admin: `sa_` prefix ### Workspace Context - Workspace ID inferred from authenticated credentials - **Super-admin override**: Tokens with `sa_` prefix can specify target workspace via `x-workspace-id` metadata header --- ## Error Handling ### Error Model Use ConnectRPC error codes with Google ErrorInfo for structured details: ```protobuf // Error codes used: NOT_FOUND, INVALID_ARGUMENT, PERMISSION_DENIED, // UNAUTHENTICATED, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE // Include ErrorInfo details: // - domain: "openstatus.com" // - reason: Machine-readable error reason (e.g., "MONITOR_NOT_FOUND") // - metadata: Additional context (requestId, resourceId, etc.) ``` ### Mapping to Existing Errors Reuse `OpenStatusApiError` codes, map to ConnectRPC equivalents in interceptor. --- ## First Service: Monitor Management ### Service Definition ```protobuf syntax = "proto3"; package openstatus.monitor.v1; import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; // MonitorService provides CRUD and operational commands for monitors. service MonitorService { // CreateMonitor creates a new monitor in the workspace. rpc CreateMonitor(CreateMonitorRequest) returns (CreateMonitorResponse); // GetMonitor retrieves a single monitor by ID. rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse); // ListMonitors returns a paginated list of monitors. rpc ListMonitors(ListMonitorsRequest) returns (ListMonitorsResponse); // DeleteMonitor removes a monitor. rpc DeleteMonitor(DeleteMonitorRequest) returns (DeleteMonitorResponse); // TriggerMonitor initiates an immediate check. rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse); } ``` ### Monitor Type Modeling Separate message types for each monitor kind: ```protobuf // HttpMonitor configuration for HTTP/HTTPS endpoint monitoring. message HttpMonitor { // The URL to monitor (required). string url = 1 [(buf.validate.field).string.uri = true]; // HTTP method to use. HttpMethod method = 2; // Request headers to include. map<string, string> headers = 3; // Request body for POST/PUT/PATCH. optional string body = 4; // Timeout in milliseconds (default: 30000). int32 timeout_ms = 5 [(buf.validate.field).int32 = {gte: 1000, lte: 60000}]; // Assertions to validate the response. repeated HttpAssertion assertions = 6; } // TcpMonitor configuration for TCP connection monitoring. message TcpMonitor { // Host to connect to (required). string host = 1 [(buf.validate.field).string.min_len = 1]; // Port number (required). int32 port = 2 [(buf.validate.field).int32 = {gte: 1, lte: 65535}]; // Timeout in milliseconds. int32 timeout_ms = 3; } // DnsMonitor configuration for DNS record monitoring. message DnsMonitor { // Domain name to query (required). string domain = 1 [(buf.validate.field).string.hostname = true]; // DNS record type to check. DnsRecordType record_type = 2; // Expected values for the record. repeated string expected_values = 3; } ``` ### Pagination Offset-based pagination for list operations (page_token is the numeric offset): ```protobuf message ListMonitorsRequest { // Maximum number of monitors to return (default: 50, max: 100). int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}]; // Token from previous response for pagination. optional string page_token = 2; // Filter by monitor status. optional MonitorStatus status_filter = 3; // Filter by monitor type. optional MonitorType type_filter = 4; } message ListMonitorsResponse { // The monitors in this page. repeated Monitor monitors = 1; // Token for retrieving the next page, empty if no more results. string next_page_token = 2; // Total count of monitors matching the filter. int32 total_count = 3; } ``` --- ## Validation ### Approach Use **protovalidate** (Buf ecosystem) for request validation: - Validation rules defined in proto annotations - Runs before handler via interceptor - Returns `INVALID_ARGUMENT` with field-level details on failure ### Example Annotations ```protobuf message CreateMonitorRequest { string name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 256}]; string description = 2 [(buf.validate.field).string.max_len = 1024]; int32 periodicity = 3 [(buf.validate.field).int32 = {in: [60, 300, 600, 1800, 3600]}]; repeated string regions = 4 [(buf.validate.field).repeated = {min_items: 1, max_items: 35}]; } ``` --- ## Code Organization ### Shared Service Layer Both REST and RPC handlers call the same business logic: ``` packages/proto/ # Shared proto definitions ├── buf.yaml # Buf configuration ├── buf.gen.yaml # Code generation config ├── openstatus/ │ └── monitor/ │ └── v1/ │ ├── monitor.proto # Message definitions │ └── service.proto # Service definition └── gen/ # Generated code ├── ts/ # TypeScript output └── go/ # Go output apps/server/src/ ├── services/ # Shared business logic (NEW) │ └── monitor/ │ ├── create.ts │ ├── get.ts │ ├── list.ts │ ├── update.ts │ ├── delete.ts │ └── operations.ts # trigger, pause, resume ├── routes/ │ └── v1/ # REST handlers (existing) │ └── monitors/ └── rpc/ # ConnectRPC handlers (NEW) ├── index.ts # Mount point ├── interceptors/ │ ├── auth.ts # Auth interceptor │ ├── logging.ts # Request logging │ └── error.ts # Error mapping └── handlers/ └── monitor.ts # MonitorService implementation ``` ### Handler Pattern ```typescript // apps/server/src/rpc/handlers/monitor.ts import type { ConnectRouter } from "@connectrpc/connect"; import { MonitorService } from "@openstatus/proto/gen/ts/openstatus/monitor/v1/service_connect"; import * as monitorService from "../../services/monitor"; export default (router: ConnectRouter) => router.service(MonitorService, { async createMonitor(req, ctx) { const workspace = ctx.values.get(workspaceKey); return await monitorService.create(workspace, req); }, // ... other methods }); ``` --- ## Interceptors ### Authentication Interceptor ```typescript // Extracts and validates auth from headers // Sets workspace context for downstream handlers // Supports both API key and Bearer token ``` ### Logging Interceptor ```typescript // Integrates with existing LogTape setup // Logs: method, duration, status, workspace, requestId ``` ### Error Interceptor ```typescript // Maps internal errors to ConnectRPC codes // Attaches ErrorInfo details // Reports to Sentry (filtered for client errors) ``` --- ## Observability ### Logging - Integrate with existing LogTape JSON logging - Log fields: `rpc.method`, `rpc.status_code`, `duration_ms`, `workspace_id`, `request_id` ### Error Tracking - Sentry integration via interceptor - Filter client errors (INVALID_ARGUMENT, NOT_FOUND, etc.) - Include request context in error reports --- ## Rate Limiting Use existing infrastructure: - Hono middleware / upstream proxy handles rate limiting - No RPC-specific rate limiting interceptors needed --- ## Testing Strategy ### Unit Tests - Test handlers directly with mocked service layer - Test interceptors in isolation - Test proto validation rules ### Integration Tests - Spin up real server instance - Use generated TypeScript client to make RPC calls - Test full request lifecycle including auth ### Test File Structure ``` apps/server/src/rpc/ ├── __tests__/ │ ├── handlers/ │ │ └── monitor.test.ts # Handler unit tests │ ├── interceptors/ │ │ └── auth.test.ts # Interceptor tests │ └── integration/ │ └── monitor.integration.ts # Full flow tests ``` --- ## Additional Considerations ### Health Check Endpoint Add a simple `Health` service for load balancer probes at `/rpc`: ```protobuf service HealthService { rpc Check(HealthCheckRequest) returns (HealthCheckResponse); } message HealthCheckRequest {} message HealthCheckResponse { enum ServingStatus { UNKNOWN = 0; SERVING = 1; NOT_SERVING = 2; } ServingStatus status = 1; } ``` ### Request ID Propagation - Generate `x-request-id` in logging interceptor if not present in request headers - Propagate request ID to all downstream services and log entries - Include request ID in error responses for debugging ### Go Code Generation - Defer Go codegen until there are concrete Go service consumers - Reduces maintenance burden and build complexity initially - Can be enabled later by adding Go target to `buf.gen.yaml` ### Proto Dependency Pinning - Use `buf.lock` to pin versions of: - `buf.build/bufbuild/protovalidate` - `buf.build/googleapis/googleapis` (if using google.protobuf types) - Run `buf mod update` to generate/update lock file --- ## Configuration Details ### CORS Handling - `/rpc` endpoint should inherit existing CORS configuration from Hono app - If different CORS rules needed, configure via Hono middleware before mounting RPC routes - Connect protocol uses standard HTTP methods (POST), no special CORS requirements ### Content-Type Support Enable both JSON and binary formats for flexibility: - `application/json` - Human-readable, easier debugging, slightly larger payloads - `application/proto` - Binary format, smaller payloads, better performance - Connect clients auto-negotiate based on `Content-Type` header ### Deadline/Timeout Propagation - Client-specified timeouts via `connect-timeout-ms` header - Server interceptor should: - Read timeout from request metadata - Create context with deadline - Cancel operations if deadline exceeded - Return `DEADLINE_EXCEEDED` error code on timeout --- ## Dependencies ### New Packages (packages/proto) ```json { "devDependencies": { "@bufbuild/buf": "latest", "@bufbuild/protoc-gen-es": "latest", "@connectrpc/protoc-gen-connect-es": "latest" }, "dependencies": { "@bufbuild/protobuf": "^2.0.0", "@bufbuild/protobuf-conformance": "^2.0.0" } } ``` ### Server App Additions ```json { "dependencies": { "@connectrpc/connect": "^2.0.0", "@connectrpc/connect-node": "^2.0.0", "@bufbuild/protovalidate": "^0.3.0", "@openstatus/proto": "workspace:*" } } ``` --- ## Migration & Rollout ### Phase 1: Foundation 1. Create `packages/proto` with Buf setup 2. Define monitor service proto 3. Generate TypeScript and Go clients 4. Add protovalidate annotations ### Phase 2: Server Integration 1. Add ConnectRPC dependencies to server 2. Implement interceptors (auth, logging, error) 3. Mount RPC routes at `/rpc` on Hono app 4. Extract shared service layer from REST handlers ### Phase 3: Handler Implementation 1. Implement MonitorService handlers 2. Write unit tests 3. Write integration tests 4. Internal testing ### Phase 4: Release 1. Documentation 2. Client SDK examples 3. Gradual rollout via feature flag (optional) --- ## Open Questions (Resolved) | Question | Decision | |----------|----------| | REST replacement or parallel? | New features only | | Transport protocol | Connect protocol only | | Streaming | Unary only | | Schema approach | Schema-first (.proto) | | Auth mechanism | Both API key + JWT | | Proto location | Shared package | | Tooling | Buf | | Error details | With ErrorInfo | | Code sharing | Shared service layer | | Client targets | TypeScript + Go | | Validation | protovalidate | | Type modeling | Separate messages | | Port strategy | Same port, /rpc prefix | | Pagination | Offset-based | | Rate limiting | Existing infrastructure | | Operations style | Separate methods | | Observability | Sentry + LogTape | | Testing | Unit + Integration | | Health check | Yes, HealthService | | Request ID | Generated + propagated | | Go codegen | Deferred | | CORS | Inherit from Hono | | Content-Type | JSON + Binary | | Timeouts | connect-timeout-ms header | --- ## References - [ConnectRPC Documentation](https://connectrpc.com/docs) - [Buf Documentation](https://buf.build/docs) - [protovalidate](https://github.com/bufbuild/protovalidate) - [Google Error Model](https://cloud.google.com/apis/design/errors) ## Future work - Implement additional services and procedure: // PauseMonitor suspends monitoring. rpc PauseMonitor(PauseMonitorRequest) returns (PauseMonitorResponse); // ResumeMonitor resumes a paused monitor. rpc ResumeMonitor(ResumeMonitorRequest) returns (ResumeMonitorResponse); // UpdateMonitor modifies an existing monitor. rpc UpdateMonitor(UpdateMonitorRequest) returns (UpdateMonitorResponse); ================================================ FILE: apps/server/Dockerfile ================================================ # syntax=docker/dockerfile:1.19.0 # This file is generated by Dofigen v2.7.0 # See https://github.com/lenra-io/dofigen # install FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS install LABEL \ org.opencontainers.image.base.digest="sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a" \ org.opencontainers.image.base.name="docker.io/oven/bun:1.3.6" \ org.opencontainers.image.stage="install" WORKDIR /app/ RUN \ --mount=type=bind,target=bunfig.toml,source=bunfig.toml \ --mount=type=bind,target=package.json,source=package.json \ --mount=type=bind,target=apps/server/package.json,source=apps/server/package.json \ --mount=type=bind,target=packages/analytics/package.json,source=packages/analytics/package.json \ --mount=type=bind,target=packages/db/package.json,source=packages/db/package.json \ --mount=type=bind,target=packages/proto/package.json,source=packages/proto/package.json \ --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ --mount=type=bind,target=packages/notifications/base/package.json,source=packages/notifications/base/package.json \ --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ --mount=type=bind,target=packages/notifications/grafana-oncall/package.json,source=packages/notifications/grafana-oncall/package.json \ --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ --mount=type=bind,target=packages/notifications/ntfy/package.json,source=packages/notifications/ntfy/package.json \ --mount=type=bind,target=packages/notifications/opsgenie/package.json,source=packages/notifications/opsgenie/package.json \ --mount=type=bind,target=packages/notifications/pagerduty/package.json,source=packages/notifications/pagerduty/package.json \ --mount=type=bind,target=packages/notifications/slack/package.json,source=packages/notifications/slack/package.json \ --mount=type=bind,target=packages/notifications/telegram/package.json,source=packages/notifications/telegram/package.json \ --mount=type=bind,target=packages/notifications/twillio-whatsapp/package.json,source=packages/notifications/twillio-whatsapp/package.json \ --mount=type=bind,target=packages/notifications/twillio-sms/package.json,source=packages/notifications/twillio-sms/package.json \ --mount=type=bind,target=packages/notifications/webhook/package.json,source=packages/notifications/webhook/package.json \ --mount=type=bind,target=packages/error/package.json,source=packages/error/package.json \ --mount=type=bind,target=packages/regions/package.json,source=packages/regions/package.json \ --mount=type=bind,target=packages/tinybird/package.json,source=packages/tinybird/package.json \ --mount=type=bind,target=packages/tracker/package.json,source=packages/tracker/package.json \ --mount=type=bind,target=packages/upstash/package.json,source=packages/upstash/package.json \ --mount=type=bind,target=packages/utils/package.json,source=packages/utils/package.json \ --mount=type=bind,target=packages/tsconfig/package.json,source=packages/tsconfig/package.json \ --mount=type=bind,target=packages/subscriptions/package.json,source=packages/subscriptions/package.json \ --mount=type=bind,target=packages/assertions/package.json,source=packages/assertions/package.json \ --mount=type=bind,target=packages/theme-store/package.json,source=packages/theme-store/package.json \ --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ bun install --production --frozen-lockfile --verbose # build FROM oven/bun@sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a AS build LABEL \ org.opencontainers.image.base.digest="sha256:f20d9cf365ab35529384f1717687c739c92e6f39157a35a95ef06f4049a10e4a" \ org.opencontainers.image.base.name="docker.io/oven/bun:1.3.6" \ org.opencontainers.image.stage="build" ENV NODE_ENV="production" WORKDIR /app/apps/server COPY \ --link \ "." "/app/" COPY \ --from=install \ --link \ "/app/node_modules" "/app/node_modules" RUN bun build --compile --sourcemap src/index.ts --outfile=app # runtime FROM debian@sha256:4333240150a6924f878e05ec2c998aec95238010e0e4d2fec6161c90128c4652 AS runtime LABEL \ io.dofigen.version="2.7.0" \ org.opencontainers.image.authors="OpenStatus Team" \ org.opencontainers.image.base.digest="sha256:4333240150a6924f878e05ec2c998aec95238010e0e4d2fec6161c90128c4652" \ org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \ org.opencontainers.image.description="REST API server with Hono framework for OpenStatus" \ org.opencontainers.image.source="https://github.com/openstatusHQ/openstatus" \ org.opencontainers.image.title="OpenStatus Server" \ org.opencontainers.image.vendor="OpenStatus" COPY \ --from=build \ --chown=1000:1000 \ --chmod=555 \ --link \ "/app/apps/server/app" "/bin/" USER 0:0 RUN <<EOF apt-get update apt-get install -y --no-install-recommends curl rm -rf /var/lib/apt/lists/* EOF USER 1000:1000 EXPOSE 3000 HEALTHCHECK \ --interval=30s \ --timeout=10s \ --start-period=30s \ --retries=3 \ CMD curl -f http://localhost:3000/ping || exit 1 ENTRYPOINT ["/bin/app"] ================================================ FILE: apps/server/README.md ================================================ # OpenStatus Server ## Tech - Bun - HonoJS ## Deploy From root ```bash flyctl deploy --config apps/server/fly.toml --dockerfile apps/server/Dockerfile ``` ## Docker The Dockerfile is generated thanks to [Dofigen](https://github.com/lenra-io/dofigen). To generate the Dockerfile, run the following command from the `apps/server` directory: ```bash # Update the dependent image versions dofigen update # Generate the Dockerfile dofigen gen ``` Build the docker image locally ```bash docker build . -t registry.fly.io/openstatus-docker:openstatus-docker-v0 --file ./apps/server/Dockerfile --platform linux/amd64 ``` if you want to run the docker image locally ```bash docker run -p 3000:3000 registry.fly.io/openstatus-docker:openstatus-docker-v0 ``` Push to Fly Registry ```bash docker push registry.fly.io/openstatus-docker:openstatus-docker-v0 ``` Deploy to Fly ```bash flyctl deploy --app openstatus-docker \ --image registry.fly.io/openstatus-docker:openstatus-docker-v0 ``` ================================================ FILE: apps/server/bunfig.toml ================================================ [test] preload = ["./src/libs/test/preload.ts"] ================================================ FILE: apps/server/docker-compose.yaml ================================================ name: server services: server: build: context: ../.. dockerfile: apps/server/Dockerfile ports: - 3000:3000 image: server command: . ================================================ FILE: apps/server/dofigen.yml ================================================ # Files to exclude from Docker context ignore: - node_modules - /apps/docs - /apps/screenshot-service - /apps/web - /apps/dashboard - /apps/status-page - /apps/workflows - /packages/api - /packages/integrations/vercel builders: # Stage 1: Install production dependencies install: fromImage: oven/bun:1.3.6 workdir: /app/ labels: org.opencontainers.image.stage: install bind: - bunfig.toml - package.json - apps/server/package.json - packages/analytics/package.json - packages/db/package.json - packages/proto/package.json - packages/emails/package.json - packages/notifications/base/package.json - packages/notifications/discord/package.json - packages/notifications/email/package.json - packages/notifications/grafana-oncall/package.json - packages/notifications/google-chat/package.json - packages/notifications/ntfy/package.json - packages/notifications/opsgenie/package.json - packages/notifications/pagerduty/package.json - packages/notifications/slack/package.json - packages/notifications/telegram/package.json - packages/notifications/twillio-whatsapp/package.json - packages/notifications/twillio-sms/package.json - packages/notifications/webhook/package.json - packages/error/package.json - packages/regions/package.json - packages/tinybird/package.json - packages/tracker/package.json - packages/upstash/package.json - packages/utils/package.json - packages/tsconfig/package.json - packages/subscriptions/package.json - packages/assertions/package.json - packages/theme-store/package.json run: bun install --production --frozen-lockfile --verbose cache: - /root/.bun/install/cache # Stage 2: Build application (compile to binary) build: fromImage: oven/bun:1.3.6 workdir: /app/apps/server labels: org.opencontainers.image.stage: build env: NODE_ENV: production copy: - . /app/ - fromBuilder: install source: /app/node_modules target: /app/node_modules run: bun build --compile --sourcemap src/index.ts --outfile=app # Runtime stage fromImage: debian:bullseye-slim # Metadata labels labels: org.opencontainers.image.title: OpenStatus Server org.opencontainers.image.description: REST API server with Hono framework for OpenStatus org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus org.opencontainers.image.vendor: OpenStatus org.opencontainers.image.authors: OpenStatus Team # Copy compiled binary copy: - fromBuilder: build source: /app/apps/server/app target: /bin/ chmod: "555" # Install curl for health checks root: run: - apt-get update - apt-get install -y --no-install-recommends curl - rm -rf /var/lib/apt/lists/* # Security: run as non-root user user: "1000:1000" # Expose port expose: "3000" # Health check healthcheck: interval: 30s timeout: 10s start: 30s retries: 3 cmd: curl -f http://localhost:3000/ping || exit 1 # Start application entrypoint: /bin/app ================================================ FILE: apps/server/env.ts ================================================ const file = Bun.file("./.env.example"); await Bun.write("./.env", file); ================================================ FILE: apps/server/fly.sh ================================================ #!/bin/bash fly machines list --json | jq .[].id | sed 's/"//g' | while read machine; do fly machines update $machine --restart always --yes done ================================================ FILE: apps/server/fly.toml ================================================ # fly.toml app configuration file generated for openstatus-api on 2023-09-13T17:29:05+02:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = "openstatus-api" primary_region = "ams" [build] dockerfile = "./Dockerfile" [[vm]] cpu_kind = "shared" cpus = 1 memory_mb = 512 [http_service] internal_port = 3000 force_https = true auto_stop_machines = "suspend" auto_start_machines = true min_machines_running = 1 processes = ["app"] [http_service.concurrency] type = "requests" hard_limit = 1000 soft_limit = 500 [deploy] strategy = "bluegreen" [[http_service.checks]] grace_period = "10s" interval = "15s" method = "GET" timeout = "5s" path = "/ping" [env] NODE_ENV = "production" PORT = "3000" # [checks] # [checks.name_of_your_http_check] # port = 3000 # type = "http" # interval = "15s" # timeout = "10s" # grace_period = "30s" # method = "get" # path = "/ping" # [[services.http_checks]] # interval = 10000 # grace_period = "5s" # method = "get" # path = "/ping" # protocol = "https" # timeout = 2000 # tls_skip_verify = false ================================================ FILE: apps/server/log/fly.toml ================================================ # fly.toml app configuration file generated for openstatus-log on 2023-10-19T00:44:54+02:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = "openstatus-log" primary_region = "ams" [build] image = "ghcr.io/superfly/fly-log-shipper:latest" [http_service] internal_port = 8686 force_https = true auto_stop_machines = "suspend" auto_start_machines = true min_machines_running = 1 processes = ["app"] ================================================ FILE: apps/server/package.json ================================================ { "name": "@openstatus/server", "version": "0.0.1", "description": "", "type": "module", "main": "src/index.ts", "scripts": { "env": "bun env.ts", "dev": "bun run --hot src/index.ts", "start": "NODE_ENV=production bun run src/index.ts", "test": "bun test", "tsc": "tsc --noEmit" }, "dependencies": { "@bufbuild/protobuf": "2.10.2", "@bufbuild/protovalidate": "^1.1.1", "@connectrpc/connect": "2.1.1", "@connectrpc/connect-node": "2.1.1", "@connectrpc/validate": "^0.2.0", "@hono/sentry": "1.2.2", "@hono/zod-openapi": "1.1.5", "@hono/zod-validator": "0.7.6", "@logtape/logtape": "2.0.1", "@logtape/otel": "2.0.1", "@logtape/sentry": "2.0.1", "@openstatus/analytics": "workspace:*", "@openstatus/subscriptions": "workspace:*", "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-google-chat": "workspace:*", "@openstatus/notification-grafana-oncall": "workspace:*", "@openstatus/notification-ntfy": "workspace:*", "@openstatus/notification-opsgenie": "workspace:*", "@openstatus/notification-pagerduty": "workspace:*", "@openstatus/notification-slack": "workspace:*", "@openstatus/notification-telegram": "workspace:*", "@openstatus/notification-twillio-whatsapp": "workspace:*", "@openstatus/notification-webhook": "workspace:*", "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", "@openstatus/proto": "workspace:*", "@openstatus/regions": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "1.38.0", "@scalar/hono-api-reference": "0.8.5", "@t3-oss/env-core": "0.13.10", "@unkey/api": "2.2.0", "@upstash/qstash": "2.6.2", "hono": "4.11.3", "nanoid": "5.0.7", "percentile": "1.6.0", "validator": "13.12.0", "@slack/web-api": "7.15.0", "@ai-sdk/anthropic": "1.2.0", "ai": "6.0.94", "zod": "4.1.13" }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", "@types/validator": "13.12.0", "bun-types": "1.3.1", "dotenv": "16.3.1" } } ================================================ FILE: apps/server/src/env.ts ================================================ import { monitorRegions } from "@openstatus/db/src/schema/constants"; import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; export const env = createEnv({ server: { UNKEY_API_ID: z.string().min(1), UNKEY_TOKEN: z.string().min(1), TINY_BIRD_API_KEY: z.string().min(1), UPSTASH_REDIS_REST_URL: z.string().min(1), UPSTASH_REDIS_REST_TOKEN: z.string().min(1), FLY_REGION: z.enum(monitorRegions), CRON_SECRET: z.string(), SCREENSHOT_SERVICE_URL: z.string(), QSTASH_TOKEN: z.string(), NODE_ENV: z.string().prefault("development"), SUPER_ADMIN_TOKEN: z.string(), RESEND_API_KEY: z.string(), AXIOM_TOKEN: z.string(), AXIOM_DATASET: z.string(), SLACK_SIGNING_SECRET: z.string().optional(), SLACK_CLIENT_ID: z.string().optional(), SLACK_CLIENT_SECRET: z.string().optional(), SLACK_REDIRECT_URI: z.string().optional(), AI_GATEWAY_API_KEY: z.string().optional(), }, /** * What object holds the environment variables at runtime. This is usually * `process.env` or `import.meta.env`. */ runtimeEnv: process.env, /** * By default, this library will feed the environment variables directly to * the Zod validator. * * This means that if you have an empty string for a value that is supposed * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag * it as a type mismatch violation. Additionally, if you have an empty string * for a value that is supposed to be a string with a default value (e.g. * `DOMAIN=` in an ".env" file), the default value will never be applied. * * In order to solve these issues, we recommend that all new projects * explicitly specify this option as true. */ skipValidation: true, }); ================================================ FILE: apps/server/src/index.ts ================================================ import { AsyncLocalStorage } from "node:async_hooks"; import { sentry } from "@hono/sentry"; import { configure, // configureSync, getConsoleSink, getLogger, jsonLinesFormatter, withContext, } from "@logtape/logtape"; import { getOpenTelemetrySink } from "@logtape/otel"; import { Hono } from "hono"; import { showRoutes } from "hono/dev"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from "@opentelemetry/semantic-conventions/incubating"; import { Scalar } from "@scalar/hono-api-reference"; import { prettyJSON } from "hono/pretty-json"; import { requestId } from "hono/request-id"; import openapiV1Json from "../static/openapi-v1.json" with { type: "json" }; import openapiYaml from "../static/openapi.yaml" with { type: "text" }; import { env } from "./env"; import { handleError } from "./libs/errors"; import { publicRoute } from "./routes/public"; import { mountRpcRoutes } from "./routes/rpc"; import { slackRoute } from "./routes/slack"; import { api } from "./routes/v1"; type Env = { Variables: { event: Record<string, unknown>; }; }; // Export app before any top-level await to avoid "Cannot access before initialization" errors in tests export const app = new Hono<Env>({ strict: false, }); const logger = getLogger("api-server"); const otelLogger = getLogger("api-server-otel"); /** * Configure logging asynchronously without blocking module initialization. * This allows tests to import `app` immediately. */ const defaultLogger = getOpenTelemetrySink({ serviceName: "openstatus-server", otlpExporterConfig: { url: "https://eu-central-1.aws.edge.axiom.co/v1/logs", headers: { Authorization: `Bearer ${env.AXIOM_TOKEN}`, "X-Axiom-Dataset": env.AXIOM_DATASET, }, }, additionalResource: resourceFromAttributes({ [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: env.NODE_ENV, }), }); await configure({ sinks: { console: getConsoleSink({ formatter: jsonLinesFormatter }), otel: defaultLogger, }, loggers: [ { category: "api-server", lowestLevel: "info", sinks: ["console"], }, { category: "api-server-otel", lowestLevel: "info", sinks: ["otel"], }, { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"], }, ], contextLocalStorage: new AsyncLocalStorage(), }); /* biome-ignore lint/suspicious/noExplicitAny: <explanation> */ function shouldSample(event: Record<string, any>): boolean { // Always keep errors if (event.status_code >= 500) return true; if (event.error) return true; // Always keep slow requests (above p99) if (event.duration_ms > 2000) return true; // Random sample the rest at 20% return Math.random() < 0.2; } /** * Middleware */ app.use("*", sentry({ dsn: process.env.SENTRY_DSN })); app.use("*", requestId()); app.use("*", prettyJSON()); app.use("*", async (c, next) => { const reqId = c.get("requestId"); const startTime = Date.now(); await withContext( { request_id: reqId, method: c.req.method, url: c.req.url, user_agent: c.req.header("User-Agent"), }, async () => { // Initialize wide event - one canonical log line per request const event: Record<string, unknown> = { timestamp: new Date().toISOString(), request_id: reqId, // Request context method: c.req.method, path: c.req.path, url: c.req.url, // Client context user_agent: c.req.header("User-Agent"), // Request metadata content_type: c.req.header("Content-Type"), // Environment characteristics service: "api-server", environment: env.NODE_ENV, region: env.FLY_REGION, }; c.set("event", event); await next(); // Performance const duration = Date.now() - startTime; event.duration_ms = duration; // Response context event.status_code = c.res.status; // Outcome if (c.error) { event.outcome = "error"; event.error = { type: c.error.name, message: c.error.message, stack: c.error.stack, }; } else { event.outcome = c.res.status < 400 ? "success" : "failure"; } // Emit single canonical log line (sampled for otel, always for console in dev) if (shouldSample(event)) { otelLogger.info("request", { ...event }); } // Console logging only for errors in production if (env.NODE_ENV !== "production" || c.res.status >= 500) { logger.info("request", { request_id: requestId, method: c.req.method, path: c.req.path, status_code: c.res.status, duration_ms: duration, outcome: event.outcome, }); } }, ); }); app.onError(handleError); /** * ConnectRPC Routes API v2 ftw */ mountRpcRoutes(app); /** * Public Routes */ app.route("/public", publicRoute); /** * Ping Pong */ app.get("/ping", (c) => { return c.json( { ping: "pong", region: env.FLY_REGION, requestId: c.get("requestId") }, 200, ); }); app.get("/openapi.yaml", (c) => { return c.text(openapiYaml, 200, { "Content-Type": "application/yaml" }); }); app.get("/openapi-v1.json", (c) => { return c.text(JSON.stringify(openapiV1Json), 200, { "Content-Type": "application/json", }); }); app.get( "/openapi", Scalar({ url: "/openapi.yaml", servers: [ { url: "https://api.openstatus.dev/", description: "Production server", }, { url: "http://localhost:3000/", description: "Dev server", }, ], metaData: { title: "OpenStatus API", description: "Start building with OpenStatus API", ogDescription: "API Reference", ogTitle: "OpenStatus API", ogImage: "https://openstatus.dev/api/og?title=OpenStatus%20API&description=API%20Reference", twitterCard: "summary_large_image", }, }), ); /** * API Routes v1 */ app.route("/v1", api); /** * Slack Agent Routes */ app.route("/slack", slackRoute); /** * TODO: move to `workflows` app * This route is used by our checker to update the status of the monitors, * create incidents, and send notifications. */ const isDev = process.env.NODE_ENV === "development"; const port = 3000; if (isDev) showRoutes(app, { verbose: true, colorize: true }); logger.info("Starting server", { port, environment: env.NODE_ENV }); const server = { port, fetch: app.fetch }; export default server; ================================================ FILE: apps/server/src/libs/checker/index.ts ================================================ export * from "./utils"; ================================================ FILE: apps/server/src/libs/checker/utils.ts ================================================ import { OpenStatusApiError } from "@/libs/errors"; import type { z } from "@hono/zod-openapi"; import type { selectMonitorSchema } from "@openstatus/db/src/schema"; import { type httpPayloadSchema, type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; export function getCheckerPayload( monitor: z.infer<typeof selectMonitorSchema>, status: z.infer<typeof selectMonitorSchema>["status"], ): z.infer<typeof httpPayloadSchema> | z.infer<typeof tpcPayloadSchema> { const timestamp = new Date().getTime(); switch (monitor.jobType) { case "http": return { workspaceId: String(monitor.workspaceId), monitorId: String(monitor.id), url: monitor.url, method: monitor.method || "GET", cronTimestamp: timestamp, body: monitor.body, headers: monitor.headers, status: status, assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, degradedAfter: monitor.degradedAfter, timeout: monitor.timeout, trigger: "api", otelConfig: monitor.otelEndpoint ? { endpoint: monitor.otelEndpoint, headers: transformHeaders(monitor.otelHeaders), } : undefined, retry: monitor.retry ?? 0, followRedirects: monitor.followRedirects ?? false, }; case "tcp": return { workspaceId: String(monitor.workspaceId), monitorId: String(monitor.id), uri: monitor.url, status: status, assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, cronTimestamp: timestamp, degradedAfter: monitor.degradedAfter, timeout: monitor.timeout, trigger: "api", otelConfig: monitor.otelEndpoint ? { endpoint: monitor.otelEndpoint, headers: transformHeaders(monitor.otelHeaders), } : undefined, retry: monitor.retry ?? 0, followRedirects: monitor.followRedirects ?? false, }; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Invalid jobType, currently only 'http' and 'tcp' are supported", }); } } export function getCheckerUrl( monitor: z.infer<typeof selectMonitorSchema>, opts: { trigger?: "api" | "cron"; data?: boolean } = { trigger: "api", data: false, }, ): string { switch (monitor.jobType) { case "http": return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; case "tcp": return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Invalid jobType, currently only 'http' and 'tcp' are supported", }); } } ================================================ FILE: apps/server/src/libs/clients.ts ================================================ import { env } from "@/env"; import { OSTinybird } from "@openstatus/tinybird"; import { Redis } from "@openstatus/upstash"; /** * Shared singleton instances for external services. * Using singletons prevents memory leaks from creating multiple instances * and ensures proper connection pooling. */ // Tinybird client singleton export const tb = new OSTinybird(env.TINY_BIRD_API_KEY); // Redis client singleton export const redis = Redis.fromEnv(); ================================================ FILE: apps/server/src/libs/errors/index.ts ================================================ export * from "./utils"; export * from "./openapi-error-responses"; ================================================ FILE: apps/server/src/libs/errors/openapi-error-responses.ts ================================================ import type { RouteConfig } from "@hono/zod-openapi"; import { createErrorSchema } from "./utils"; export const openApiErrorResponses = { 400: { description: "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", content: { "application/json": { schema: createErrorSchema("BAD_REQUEST").openapi("ErrBadRequest"), }, }, }, 401: { description: "The client must authenticate itself to get the requested response.", content: { "application/json": { schema: createErrorSchema("UNAUTHORIZED").openapi("ErrUnauthorized"), }, }, }, 402: { description: "A higher pricing plan is required to access the resource.", content: { "application/json": { schema: createErrorSchema("PAYMENT_REQUIRED").openapi("ErrPaymentRequired"), }, }, }, 403: { description: "The client does not have the necessary permissions to access the resource.", content: { "application/json": { schema: createErrorSchema("FORBIDDEN").openapi("ErrForbidden"), }, }, }, 404: { description: "The server can't find the requested resource.", content: { "application/json": { schema: createErrorSchema("NOT_FOUND").openapi("ErrNotFound"), }, }, }, 409: { description: "The request could not be completed due to a conflict mainly due to unique constraints.", content: { "application/json": { schema: createErrorSchema("CONFLICT").openapi("ErrConflict"), }, }, }, 500: { description: "The server has encountered a situation it doesn't know how to handle.", content: { "application/json": { schema: createErrorSchema("INTERNAL_SERVER_ERROR").openapi( "ErrInternalServerError", ), }, }, }, } satisfies RouteConfig["responses"]; ================================================ FILE: apps/server/src/libs/errors/utils.ts ================================================ // Props to Unkey: https://github.com/unkeyed/unkey/blob/main/apps/api/src/pkg/errors/http.ts import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; import type { ErrorCode } from "@openstatus/error"; import { ErrorCodes, SchemaError, codeToStatus, statusToCode, } from "@openstatus/error"; import { z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import { ZodError } from "zod"; const logger = getLogger("api-server"); export class OpenStatusApiError extends HTTPException { public readonly code: ErrorCode; constructor({ code, message, }: { code: ErrorCode; message: HTTPException["message"]; }) { const status = codeToStatus(code); super(status, { message }); this.code = code; } } export function handleError(err: Error, c: Context): Response { if (err instanceof ZodError) { const error = SchemaError.fromZod(err, c); // If the error is a client error, we disable Sentry c.get("sentry").setEnabled(false); return c.json<ErrorSchema>( { code: "BAD_REQUEST", message: error.message, docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", requestId: c.get("requestId"), }, { status: 400 }, ); } /** * This is a custom error that we throw in our code so we can handle it */ if (err instanceof OpenStatusApiError) { const code = statusToCode(err.status); // If the error is a client error, we disable Sentry if (err.status < 499) { c.get("sentry").setEnabled(false); } return c.json<ErrorSchema>( { code: code, message: err.message, docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, requestId: c.get("requestId"), }, { status: err.status }, ); } if (err instanceof HTTPException) { const code = statusToCode(err.status); return c.json<ErrorSchema>( { code: code, message: err.message, docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, requestId: c.get("requestId"), }, { status: err.status }, ); } logger.error("Request error", { error: { name: err.name, message: err.message, stack: err.stack, }, method: c.req.method, url: c.req.url, }); c.get("sentry").captureException(err); return c.json<ErrorSchema>( { code: "INTERNAL_SERVER_ERROR", message: err.message ?? "Something went wrong", docs: "https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR", requestId: c.get("requestId"), }, { status: 500 }, ); } export function handleZodError( result: | { success: true; data: unknown; } | { success: false; error: ZodError; }, c: Context, ) { if (!result.success) { const error = SchemaError.fromZod(result.error, c); return c.json<z.infer<ReturnType<typeof createErrorSchema>>>( { code: "BAD_REQUEST", docs: "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST", message: error.message, requestId: c.get("requestId"), }, { status: 400 }, ); } } export function createErrorSchema(code: ErrorCode) { return z.object({ code: z.enum(ErrorCodes).openapi({ example: code, description: "The error code related to the status code.", }), message: z.string().openapi({ description: "A human readable message describing the issue.", example: "<string>", }), docs: z.string().openapi({ description: "A link to the documentation for the error.", example: `https://docs.openstatus.dev/api-references/errors/code/${code}`, }), requestId: z.string().openapi({ description: "The request id to be used for debugging and error reporting.", example: "<uuid>", }), }); } export type ErrorSchema = z.infer<ReturnType<typeof createErrorSchema>>; ================================================ FILE: apps/server/src/libs/middlewares/auth.ts ================================================ import { UnkeyCore } from "@unkey/api/core"; import { keysVerifyKey } from "@unkey/api/funcs/keysVerifyKey"; import type { Context, Next } from "hono"; import { env } from "@/env"; import { OpenStatusApiError } from "@/libs/errors"; import type { Variables } from "@/types"; import { getLogger } from "@logtape/logtape"; import { db, eq } from "@openstatus/db"; import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; import { apiKey } from "@openstatus/db/src/schema/api-keys"; import { shouldUpdateLastUsed, verifyApiKeyHash, } from "@openstatus/db/src/utils/api-key"; const logger = getLogger("api-server"); /** * Looks up a workspace by ID and validates the data. * Throws OpenStatusApiError if workspace is not found or invalid. */ export async function lookupWorkspace(workspaceId: number) { const _workspace = await db .select() .from(workspace) .where(eq(workspace.id, workspaceId)) .get(); if (!_workspace) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: "Workspace not found, please contact support", }); } const validation = selectWorkspaceSchema.safeParse(_workspace); if (!validation.success) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Workspace data is invalid", }); } return validation.data; } export async function authMiddleware( c: Context<{ Variables: Variables }, "/*">, next: Next, ) { const key = c.req.header("x-openstatus-key"); if (!key) throw new OpenStatusApiError({ code: "UNAUTHORIZED", message: "Missing 'x-openstatus-key' header", }); const { error, result } = await validateKey(key); if (error) { throw new OpenStatusApiError({ code: "UNAUTHORIZED", message: error.message, }); } if (!result.valid || !result.ownerId) { throw new OpenStatusApiError({ code: "UNAUTHORIZED", message: "Invalid API Key", }); } const ownerId = Number.parseInt(result.ownerId); if (Number.isNaN(ownerId)) { throw new OpenStatusApiError({ code: "UNAUTHORIZED", message: "API Key is Not a Number", }); } const workspaceData = await lookupWorkspace(ownerId); const event = c.get("event"); event.workspace = { id: workspaceData.id, name: workspaceData.name, plan: workspaceData.plan, stripe_id: workspaceData.stripeId, }; event.auth_method = result.authMethod; c.set("workspace", workspaceData); await next(); } export async function validateKey(key: string): Promise<{ result: { valid: boolean; ownerId?: string; authMethod?: string }; error?: { message: string }; }> { if (env.NODE_ENV === "production") { /** * Both custom and Unkey API keys use the `os_` prefix for seamless transition. * Custom keys are checked first in the database, then falls back to Unkey. */ if (key.startsWith("os_")) { // 1. Try custom DB first const prefix = key.slice(0, 11); // "os_" (3 chars) + 8 hex chars = 11 total const customKey = await db .select() .from(apiKey) .where(eq(apiKey.prefix, prefix)) .get(); if (customKey) { // Verify hash using bcrypt-compatible verification if (!(await verifyApiKeyHash(key, customKey.hashedToken))) { return { result: { valid: false }, error: { message: "Invalid API Key" }, }; } // Check expiration if (customKey.expiresAt && customKey.expiresAt < new Date()) { return { result: { valid: false }, error: { message: "API Key expired" }, }; } // Update lastUsedAt (debounced) if (shouldUpdateLastUsed(customKey.lastUsedAt)) { await db .update(apiKey) .set({ lastUsedAt: new Date() }) .where(eq(apiKey.id, customKey.id)); } return { result: { valid: true, ownerId: String(customKey.workspaceId), authMethod: "custom_key", }, }; } // 2. Fall back to Unkey (transition period) const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); const res = await keysVerifyKey(unkey, { key }); if (!res.ok) { logger.error("Unkey Error {*}", { ...res.error }); return { result: { valid: false, ownerId: undefined }, error: { message: "Invalid API verification" }, }; } return { result: { valid: res.value.data.valid, ownerId: res.value.data.identity?.externalId, authMethod: "unkey", }, error: undefined, }; } // Special bypass for our workspace if (key.startsWith("sa_") && key === env.SUPER_ADMIN_TOKEN) { return { result: { valid: true, ownerId: "1", authMethod: "super_admin" }, }; } // In production, we only accept Unkey keys throw new OpenStatusApiError({ code: "UNAUTHORIZED", message: "Invalid API Key", }); } // In dev / test mode we can use the key as the ownerId return { result: { valid: true, ownerId: key, authMethod: "dev" } }; } ================================================ FILE: apps/server/src/libs/middlewares/index.ts ================================================ export * from "./auth"; export * from "./track"; export * from "./plan"; ================================================ FILE: apps/server/src/libs/middlewares/plan.ts ================================================ import type { Variables } from "@/types"; import { type Workspace, workspacePlanHierarchy, } from "@openstatus/db/src/schema"; import type { Context, Next } from "hono"; import { OpenStatusApiError } from "../errors"; /** * Checks if the workspace has a minimum required plan to access the endpoint */ export function minPlanMiddleware({ plan }: { plan: Workspace["plan"] }) { return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { const workspace = c.get("workspace"); if (workspacePlanHierarchy[workspace.plan] < workspacePlanHierarchy[plan]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "You need to upgrade your plan to access this feature", }); } await next(); }; } ================================================ FILE: apps/server/src/libs/middlewares/track.ts ================================================ import type { Variables } from "@/types"; import { getLogger } from "@logtape/logtape"; import { type EventProps, parseInputToProps, setupAnalytics, } from "@openstatus/analytics"; import type { Context, Next } from "hono"; const logger = getLogger("api-server"); export function trackMiddleware(event: EventProps, eventProps?: string[]) { return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { await next(); // REMINDER: only track the event if the request was successful const isValid = c.res.status.toString().startsWith("2") && !c.error; if (isValid) { // We have checked the request to be valid already let json: unknown; if (c.req.raw.bodyUsed) { try { json = await c.req.json(); } catch { json = {}; } } const additionalProps = parseInputToProps(json, eventProps); const workspace = c.get("workspace"); setupAnalytics({ userId: `api_${workspace.id}`, workspaceId: `${workspace.id}`, plan: workspace.plan, location: c.req.raw.headers.get("x-forwarded-for") ?? undefined, userAgent: c.req.raw.headers.get("user-agent") ?? undefined, }) .then((analytics) => analytics.track({ ...event, additionalProps })) .catch(() => { logger.warn( "Failed to send analytics event {event} for workspace {workspaceId}", { event: event.name, workspaceId: workspace.id }, ); }); } }; } ================================================ FILE: apps/server/src/libs/test/preload.ts ================================================ import { mock } from "bun:test"; // Subscription dispatch spies — accessible in tests via globalThis.__subscriptionSpies const dispatchStatusReportUpdateSpy = mock((_id: number) => Promise.resolve()); const dispatchMaintenanceUpdateSpy = mock((_id: number) => Promise.resolve()); (globalThis as Record<string, unknown>).__subscriptionSpies = { dispatchStatusReportUpdate: dispatchStatusReportUpdateSpy, dispatchMaintenanceUpdate: dispatchMaintenanceUpdateSpy, }; mock.module("@openstatus/subscriptions", () => ({ dispatchStatusReportUpdate: dispatchStatusReportUpdateSpy, dispatchMaintenanceUpdate: dispatchMaintenanceUpdateSpy, })); const testRedisStore = new Map<string, string>(); (globalThis as Record<string, unknown>).__testRedisStore = testRedisStore; mock.module("@openstatus/upstash", () => ({ Redis: { fromEnv() { return { get: (key: string) => Promise.resolve(testRedisStore.get(key) ?? null), set: (key: string, value: string) => { testRedisStore.set(key, value); return Promise.resolve("OK"); }, del: (key: string) => { const existed = testRedisStore.has(key) ? 1 : 0; testRedisStore.delete(key); return Promise.resolve(existed); }, getdel: (key: string) => { const value = testRedisStore.get(key) ?? null; testRedisStore.delete(key); return Promise.resolve(value); }, expire: (_key: string, _seconds: number) => { return Promise.resolve(1); }, }; }, }, })); mock.module("@openstatus/tinybird", () => ({ OSTinybird: class { get legacy_httpStatus45d() { return () => Promise.resolve({ data: [] }); } get legacy_tcpStatus45d() { return () => Promise.resolve({ data: [] }); } // HTTP metrics for GetMonitorSummary get httpMetricsDaily() { return () => Promise.resolve({ data: [] }); } get httpMetricsWeekly() { return () => Promise.resolve({ data: [] }); } get httpMetricsBiweekly() { return () => Promise.resolve({ data: [] }); } // TCP metrics for GetMonitorSummary get tcpMetricsDaily() { return () => Promise.resolve({ data: [] }); } get tcpMetricsWeekly() { return () => Promise.resolve({ data: [] }); } get tcpMetricsBiweekly() { return () => Promise.resolve({ data: [] }); } // DNS metrics for GetMonitorSummary get dnsMetricsDaily() { return () => Promise.resolve({ data: [] }); } get dnsMetricsWeekly() { return () => Promise.resolve({ data: [] }); } get dnsMetricsBiweekly() { return () => Promise.resolve({ data: [] }); } }, })); ================================================ FILE: apps/server/src/routes/public/index.ts ================================================ import { Hono } from "hono"; import { cors } from "hono/cors"; import { timing } from "hono/timing"; import { status } from "./status"; import { unsubscribe } from "./unsubscribe"; export const publicRoute = new Hono(); publicRoute.use("*", cors()); publicRoute.use("*", timing()); publicRoute.route("/status", status); publicRoute.route("/unsubscribe", unsubscribe); ================================================ FILE: apps/server/src/routes/public/status.test.ts ================================================ import { afterAll, beforeAll, beforeEach, describe, expect, test, } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; const testRedisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string> | undefined; import { incidentTable, maintenance, monitor, page, pageComponent, statusReport, } from "@openstatus/db/src/schema"; /** * Status Route Tests: Verify the status route uses pageComponents with single DB call * * These tests verify that the /public/status/:slug endpoint: * - Uses pageComponents instead of monitorsToPages * - Makes a single database query * - Correctly filters for active monitors only * - Correctly identifies ongoing incidents * - Correctly identifies unresolved status reports * - Correctly identifies ongoing maintenances * - Returns correct status based on the Tracker logic */ const TEST_PREFIX = "status-test"; let testPageId: number; let testMonitorId: number; let testMonitor2Id: number; let testIncidentId: number; let testStatusReportId: number; let testMaintenanceId: number; beforeAll(async () => { // Clean up any existing test data by slug/name before creating new ones await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-page`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-private-page`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-cache-test`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor-1`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor-2`)); // Create test page const testPage = await db .insert(page) .values({ workspaceId: 1, title: "Status Test Page", description: "A test page for status route tests", slug: `${TEST_PREFIX}-page`, customDomain: "", accessType: "public", }) .returning() .get(); testPageId = testPage.id; // Create first test monitor (active) const testMonitor = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor-1`, url: "https://status-test-1.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); testMonitorId = testMonitor.id; // Create second test monitor (inactive) const testMonitor2 = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor-2`, url: "https://status-test-2.example.com", periodicity: "1m", active: false, // Inactive monitor regions: "ams", jobType: "http", }) .returning() .get(); testMonitor2Id = testMonitor2.id; // Create page components for both monitors await db.insert(pageComponent).values({ workspaceId: 1, pageId: testPageId, monitorId: testMonitorId, type: "monitor", name: `${TEST_PREFIX}-monitor-1`, order: 1, }); await db.insert(pageComponent).values({ workspaceId: 1, pageId: testPageId, monitorId: testMonitor2Id, type: "monitor", name: `${TEST_PREFIX}-monitor-2`, order: 2, }); }); beforeEach(() => { testRedisStore?.clear(); }); afterAll(async () => { // Clean up test data if (testIncidentId) { await db .delete(incidentTable) .where(eq(incidentTable.id, testIncidentId)) .catch(() => {}); } if (testStatusReportId) { await db .delete(statusReport) .where(eq(statusReport.id, testStatusReportId)) .catch(() => {}); } if (testMaintenanceId) { await db .delete(maintenance) .where(eq(maintenance.id, testMaintenanceId)) .catch(() => {}); } await db .delete(pageComponent) .where(eq(pageComponent.pageId, testPageId)) .catch(() => {}); await db .delete(page) .where(eq(page.id, testPageId)) .catch(() => {}); await db .delete(monitor) .where(eq(monitor.id, testMonitorId)) .catch(() => {}); await db .delete(monitor) .where(eq(monitor.id, testMonitor2Id)) .catch(() => {}); }); describe("Status Route: Basic functionality", () => { test("returns operational status for page with no incidents", async () => { const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("operational"); }); test("returns unknown status for non-existent page", async () => { const res = await app.request("/public/status/non-existent-page"); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("unknown"); }); test("returns unknown status for non-public page", async () => { // Create a private page const privatePage = await db .insert(page) .values({ workspaceId: 1, title: "Private Test Page", description: "A private test page", slug: `${TEST_PREFIX}-private-page`, customDomain: "", accessType: "password", password: "secret", }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-private-page`); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("unknown"); // Clean up await db.delete(page).where(eq(page.id, privatePage.id)); }); }); describe("Status Route: Active monitor filtering", () => { test("only considers active monitors for status calculation", async () => { // Create an incident for the inactive monitor const inactiveIncident = await db .insert(incidentTable) .values({ monitorId: testMonitor2Id, title: "Inactive Monitor Incident", status: "investigating", }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); // Status should still be operational because the monitor is inactive expect(data.status).toBe("operational"); // Clean up await db .delete(incidentTable) .where(eq(incidentTable.id, inactiveIncident.id)); }); }); describe("Status Route: Incident detection", () => { test("returns incident status with ongoing incident", async () => { // Create an ongoing incident for the active monitor const incident = await db .insert(incidentTable) .values({ monitorId: testMonitorId, title: "Test Incident", status: "investigating", // resolvedAt is null, meaning it's ongoing }) .returning() .get(); testIncidentId = incident.id; const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("incident"); // Clean up await db.delete(incidentTable).where(eq(incidentTable.id, testIncidentId)); testIncidentId = 0; }); test("ignores resolved incidents", async () => { // First clean up the ongoing incident from previous test if it still exists if (testIncidentId) { await db .delete(incidentTable) .where(eq(incidentTable.id, testIncidentId)) .catch(() => {}); testIncidentId = 0; } // Create a resolved incident const resolvedIncident = await db .insert(incidentTable) .values({ monitorId: testMonitorId, title: "Resolved Incident", status: "resolved", resolvedAt: new Date(), }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); // Status should be operational because the incident is resolved expect(data.status).toBe("operational"); // Clean up await db .delete(incidentTable) .where(eq(incidentTable.id, resolvedIncident.id)); }); }); describe("Status Route: Status report detection", () => { test("returns degraded_performance status with unresolved status report", async () => { // Create an unresolved status report const report = await db .insert(statusReport) .values({ workspaceId: 1, pageId: testPageId, title: "Test Status Report", status: "investigating", }) .returning() .get(); testStatusReportId = report.id; const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("degraded_performance"); // Clean up await db .delete(statusReport) .where(eq(statusReport.id, testStatusReportId)); testStatusReportId = 0; }); test("ignores resolved status reports", async () => { // First clean up the ongoing status report from previous test if it still exists if (testStatusReportId) { await db .delete(statusReport) .where(eq(statusReport.id, testStatusReportId)) .catch(() => {}); testStatusReportId = 0; } // Create a resolved status report const resolvedReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: testPageId, title: "Resolved Status Report", status: "resolved", }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); // Status should be operational because the report is resolved expect(data.status).toBe("operational"); // Clean up await db.delete(statusReport).where(eq(statusReport.id, resolvedReport.id)); }); }); describe("Status Route: Maintenance detection", () => { test("returns under_maintenance status with ongoing maintenance", async () => { // Create an ongoing maintenance const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000); const maint = await db .insert(maintenance) .values({ workspaceId: 1, pageId: testPageId, title: "Test Maintenance", message: "Ongoing maintenance", from: oneHourAgo, to: oneHourFromNow, }) .returning() .get(); testMaintenanceId = maint.id; const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); expect(data.status).toBe("under_maintenance"); // Clean up await db.delete(maintenance).where(eq(maintenance.id, testMaintenanceId)); testMaintenanceId = 0; }); test("ignores past maintenances", async () => { // First clean up the ongoing maintenance from previous test if it still exists if (testMaintenanceId) { await db .delete(maintenance) .where(eq(maintenance.id, testMaintenanceId)) .catch(() => {}); testMaintenanceId = 0; } // Create a past maintenance const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); const pastMaint = await db .insert(maintenance) .values({ workspaceId: 1, pageId: testPageId, title: "Past Maintenance", message: "Past maintenance", from: twoDaysAgo, to: oneDayAgo, }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); // Status should be operational because the maintenance is in the past expect(data.status).toBe("operational"); // Clean up await db.delete(maintenance).where(eq(maintenance.id, pastMaint.id)); }); test("ignores future maintenances", async () => { // First clean up any ongoing maintenance from previous test if it still exists if (testMaintenanceId) { await db .delete(maintenance) .where(eq(maintenance.id, testMaintenanceId)) .catch(() => {}); testMaintenanceId = 0; } // Create a future maintenance const oneDayFromNow = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); const futureMaint = await db .insert(maintenance) .values({ workspaceId: 1, pageId: testPageId, title: "Future Maintenance", message: "Future maintenance", from: oneDayFromNow, to: twoDaysFromNow, }) .returning() .get(); const res = await app.request(`/public/status/${TEST_PREFIX}-page`); expect(res.status).toBe(200); const data = await res.json(); // Status should be operational because the maintenance is in the future expect(data.status).toBe("operational"); // Clean up await db.delete(maintenance).where(eq(maintenance.id, futureMaint.id)); }); }); describe("Status Route: Cache functionality", () => { test("returns cached status on second request if cache is available", async () => { const slug = `${TEST_PREFIX}-cache-test`; // Create a test page for cache testing const cachePage = await db .insert(page) .values({ workspaceId: 1, title: "Cache Test Page", description: "A test page for cache testing", slug, customDomain: "", accessType: "public", }) .returning() .get(); // First request should hit the database const res1 = await app.request(`/public/status/${slug}`); expect(res1.status).toBe(200); const data1 = await res1.json(); expect(data1.status).toBe("operational"); expect(res1.headers.get("OpenStatus-Cache")).toBeNull(); // Second request may hit the cache if Redis is configured const res2 = await app.request(`/public/status/${slug}`); expect(res2.status).toBe(200); const data2 = await res2.json(); expect(data2.status).toBe("operational"); // Cache header may be "HIT" if Redis is available, or null if not const cacheHeader = res2.headers.get("OpenStatus-Cache"); if (cacheHeader !== null) { expect(cacheHeader).toBe("HIT"); } // Clean up await db.delete(page).where(eq(page.id, cachePage.id)); }); }); ================================================ FILE: apps/server/src/routes/public/status.ts ================================================ import { getLogger } from "@logtape/logtape"; import { Hono } from "hono"; import { endTime, setMetric, startTime } from "hono/timing"; import { db, eq } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; const logger = getLogger("api-server"); import { Status, Tracker } from "@openstatus/tracker"; import { redis } from "@/libs/clients"; // TODO: include ratelimiting export const status = new Hono(); status.get("/:slug", async (c) => { try { const { slug } = c.req.param(); const cache = await redis.get(slug); if (cache) { setMetric(c, "OpenStatus-Cache", "HIT"); return c.json({ status: cache }); } startTime(c, "database"); // Single query with all relations const currentPage = await db.query.page.findFirst({ where: eq(page.slug, slug), with: { pageComponents: { with: { monitor: { with: { incidents: true, }, }, }, }, statusReports: true, maintenances: true, }, }); endTime(c, "database"); if (!currentPage) { return c.json({ status: Status.Unknown }); } if (currentPage.accessType !== "public") { return c.json({ status: Status.Unknown }); } // Extract active monitor components const monitorComponents = currentPage.pageComponents.filter( (c) => c.type === "monitor" && c.monitor && c.monitor.active && !c.monitor.deletedAt, ); // Extract all ongoing incidents from active monitors const ongoingIncidents = monitorComponents.flatMap( (c) => c.monitor?.incidents?.filter((inc) => !inc.resolvedAt) ?? [], ); // Filter for unresolved status reports const unresolvedStatusReports = currentPage.statusReports.filter( (report) => report.status !== "resolved", ); // Filter for ongoing maintenances const now = new Date(); const ongoingMaintenances = currentPage.maintenances.filter( (m) => m.from <= now && m.to >= now, ); // Use the tracker to determine status const tracker = new Tracker({ incidents: ongoingIncidents, statusReports: unresolvedStatusReports, maintenances: ongoingMaintenances, }); const status = tracker.currentStatus; await redis.set(slug, status, { ex: 60 }); // 1m cache return c.json({ status }); } catch (e) { logger.error("Error in public status page", { error: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined, }); return c.json({ status: Status.Unknown }); } }); ================================================ FILE: apps/server/src/routes/public/unsubscribe.ts ================================================ import { getLogger } from "@logtape/logtape"; import { Hono } from "hono"; import { db, eq } from "@openstatus/db"; import { pageSubscriber } from "@openstatus/db/src/schema"; const logger = getLogger("api-server"); /** * RFC 8058 One-Click Unsubscribe Endpoint * * This endpoint handles POST requests from email clients that support one-click unsubscribe. * Email clients send a POST request with form-urlencoded body containing "List-Unsubscribe=One-Click". * * @see https://datatracker.ietf.org/doc/html/rfc8058 */ export const unsubscribe = new Hono(); unsubscribe.post("/:token", async (c) => { try { const { token } = c.req.param(); // Validate token is a valid UUID format const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRegex.test(token)) { return c.json({ error: "Invalid token format" }, 400); } // Find the subscriber by token const subscriber = await db .select() .from(pageSubscriber) .where(eq(pageSubscriber.token, token)) .get(); // Return 404 if subscriber not found if (!subscriber) { return c.json({ error: "Subscriber not found" }, 404); } // Check if subscriber has verified their subscription if (!subscriber.acceptedAt) { return c.json({ error: "Subscription not verified" }, 400); } // Check if already unsubscribed if (subscriber.unsubscribedAt) { // Return 200 OK even if already unsubscribed (idempotent) return c.json({ message: "Already unsubscribed" }, 200); } // Set unsubscribedAt timestamp await db .update(pageSubscriber) .set({ unsubscribedAt: new Date() }) .where(eq(pageSubscriber.id, subscriber.id)); // Return 200 OK on success return c.json({ message: "Successfully unsubscribed" }, 200); } catch (e) { logger.error("Error in one-click unsubscribe", { error: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined, }); return c.json({ error: "Internal server error" }, 500); } }); ================================================ FILE: apps/server/src/routes/rpc/index.ts ================================================ import { universalServerRequestFromFetch, universalServerResponseToFetch, } from "@connectrpc/connect/protocol"; import type { Hono } from "hono"; import { routes } from "./router"; // Re-export for external use export { routes } from "./router"; export { getRpcContext } from "./interceptors"; export type { RpcContext } from "./interceptors"; /** * Mount ConnectRPC routes on a Hono app at /rpc prefix. * * @param app - The Hono app instance */ export function mountRpcRoutes( app: Hono<{ Variables: { event: Record<string, unknown>; }; }>, ) { // Handle all RPC routes at /rpc/* prefix app.all("/rpc/*", async (c) => { const url = new URL(c.req.url); // Remove the /rpc prefix from the path for matching const pathWithoutPrefix = url.pathname.replace(/^\/rpc/, ""); // Find the handler that matches this request const handler = routes.handlers.find( (h) => h.requestPath === pathWithoutPrefix, ); if (!handler) { return c.json({ error: "Not found" }, 404); } // Check if the HTTP method is allowed if (!handler.allowedMethods.includes(c.req.method)) { return c.json({ error: "Method not allowed" }, 405); } // Convert fetch Request to universal request const universalRequest = universalServerRequestFromFetch(c.req.raw, {}); // Call the handler const universalResponse = await handler(universalRequest); // Convert universal response back to fetch Response return universalServerResponseToFetch(universalResponse); }); } ================================================ FILE: apps/server/src/routes/rpc/interceptors/auth.ts ================================================ import { Code, ConnectError, type Interceptor, createContextKey, } from "@connectrpc/connect"; import type { Workspace } from "@openstatus/db/src/schema"; import { nanoid } from "nanoid"; import { lookupWorkspace, validateKey } from "@/libs/middlewares/auth"; /** * RPC context containing workspace and request information. * This is set by the auth interceptor and available to all handlers. */ export interface RpcContext { workspace: Workspace; requestId: string; } /** * Context key for storing RPC context in request context values. */ export const RPC_CONTEXT_KEY = createContextKey<RpcContext | undefined>( undefined, ); /** * Authentication interceptor for ConnectRPC. * Validates the x-openstatus-key header and sets workspace context. * Skips authentication for HealthService endpoints. */ export function authInterceptor(): Interceptor { return (next) => async (req) => { // Skip auth for HealthService if (req.service.typeName === "openstatus.health.v1.HealthService") { return next(req); } const apiKey = req.header.get("x-openstatus-key"); if (!apiKey) { throw new ConnectError( "Missing 'x-openstatus-key' header", Code.Unauthenticated, ); } const { error, result } = await validateKey(apiKey); if (error) { throw new ConnectError(error.message, Code.Unauthenticated); } if (!result.valid || !result.ownerId) { throw new ConnectError("Invalid API Key", Code.Unauthenticated); } const ownerId = Number.parseInt(result.ownerId); if (Number.isNaN(ownerId)) { throw new ConnectError("Invalid API Key format", Code.Unauthenticated); } // lookupWorkspace throws OpenStatusApiError if not found // The error interceptor will convert it to ConnectError const workspace = await lookupWorkspace(ownerId); // Generate request ID if not provided const requestId = req.header.get("x-request-id") ?? nanoid(); // Store context for handlers to access const rpcContext: RpcContext = { workspace, requestId, }; // Set context using ConnectRPC's context values req.contextValues.set(RPC_CONTEXT_KEY, rpcContext); return next(req); }; } /** * Helper to get RPC context from handler context. */ export function getRpcContext(ctx: { values: { get: <T>(key: { id: symbol; defaultValue: T }) => T }; }): RpcContext { const rpcCtx = ctx.values.get(RPC_CONTEXT_KEY); if (!rpcCtx) { throw new ConnectError( "RPC context not found - auth interceptor may not have run", Code.Internal, ); } return rpcCtx; } ================================================ FILE: apps/server/src/routes/rpc/interceptors/error.ts ================================================ import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; import { getLogger } from "@logtape/logtape"; import type { ErrorCode } from "@openstatus/error"; import { OpenStatusApiError } from "@/libs/errors"; import { RPC_CONTEXT_KEY } from "./auth"; const logger = getLogger("api-server"); /** * Mapping from OpenStatus error codes to ConnectRPC codes. */ const ERROR_CODE_MAP: Record<ErrorCode, Code> = { BAD_REQUEST: Code.InvalidArgument, UNAUTHORIZED: Code.Unauthenticated, PAYMENT_REQUIRED: Code.ResourceExhausted, FORBIDDEN: Code.PermissionDenied, NOT_FOUND: Code.NotFound, METHOD_NOT_ALLOWED: Code.Unimplemented, CONFLICT: Code.AlreadyExists, UNPROCESSABLE_ENTITY: Code.InvalidArgument, INTERNAL_SERVER_ERROR: Code.Internal, }; /** * Error mapping interceptor for ConnectRPC. * Converts OpenStatusApiError to ConnectError with appropriate codes. * Logs server errors and passes through client errors. */ export function errorInterceptor(): Interceptor { return (next) => async (req) => { try { return await next(req); } catch (error) { const rpcCtx = req.contextValues.get(RPC_CONTEXT_KEY); // Already a ConnectError, pass through if (error instanceof ConnectError) { throw error; } // Map OpenStatusApiError to ConnectError if (error instanceof OpenStatusApiError) { const code = ERROR_CODE_MAP[error.code] ?? Code.Internal; // Log server errors (5xx equivalent) if (error.status >= 500) { logger.error("RPC server error", { error: { code: error.code, message: error.message, }, requestId: rpcCtx?.requestId, }); } throw new ConnectError(error.message, code); } // Unknown error - log and wrap as Internal logger.error("RPC unexpected error", { error: { name: error instanceof Error ? error.name : "Unknown", message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, requestId: rpcCtx?.requestId, }); throw new ConnectError( error instanceof Error ? error.message : "Internal server error", Code.Internal, ); } }; } ================================================ FILE: apps/server/src/routes/rpc/interceptors/index.ts ================================================ export { authInterceptor, getRpcContext, RPC_CONTEXT_KEY } from "./auth"; export type { RpcContext } from "./auth"; export { loggingInterceptor } from "./logging"; export { errorInterceptor } from "./error"; export { validationInterceptor } from "./validation"; ================================================ FILE: apps/server/src/routes/rpc/interceptors/logging.ts ================================================ import type { Interceptor } from "@connectrpc/connect"; import { getLogger, withContext } from "@logtape/logtape"; import { env } from "@/env"; import { RPC_CONTEXT_KEY } from "./auth"; const logger = getLogger("api-server-otel"); /** * Logging interceptor for ConnectRPC. * Implements wide events pattern - emits ONE canonical log line per RPC request. * All context is collected during execution and emitted at completion. */ export function loggingInterceptor(): Interceptor { return (next) => async (req) => { const rpcCtx = req.contextValues.get(RPC_CONTEXT_KEY); const serviceName = req.service.typeName; const methodName = req.method.name; const startTime = Date.now(); // Initialize wide event - will be emitted once at completion const event: Record<string, unknown> = { timestamp: new Date().toISOString(), request_id: rpcCtx?.requestId ?? "unknown", protocol: "connectrpc", service: serviceName, method: methodName, // Business context // Environment characteristics environment: env.NODE_ENV, region: env.FLY_REGION, }; // Wrap in LogTape context for correlation return withContext( { requestId: rpcCtx?.requestId ?? "unknown", service: serviceName, method: methodName, workspaceId: rpcCtx?.workspace.id, protocol: "connectrpc", }, async () => { try { const response = await next(req); event.duration_ms = Date.now() - startTime; event.outcome = "success"; event.workspace_id = rpcCtx?.workspace.id; event.workspace_plan = rpcCtx?.workspace.plan; return response; } catch (error) { event.duration_ms = Date.now() - startTime; event.outcome = "error"; event.error = { type: error instanceof Error ? error.name : "UnknownError", message: error instanceof Error ? error.message : String(error), }; throw error; } finally { // Emit single canonical log line logger.info("rpc_request", { ...event }); } }, ); }; } ================================================ FILE: apps/server/src/routes/rpc/interceptors/validation.ts ================================================ import type { Interceptor } from "@connectrpc/connect"; import { createValidateInterceptor } from "@connectrpc/validate"; // Methods that skip standard protovalidate (they do manual validation in handlers) // These methods use partial updates where nested message fields are optional const SKIP_VALIDATION_METHODS = new Set([ "UpdateHTTPMonitor", "UpdateTCPMonitor", "UpdateDNSMonitor", ]); /** * Validation interceptor for ConnectRPC using protovalidate. * Validates incoming request messages against their proto constraints. * * Uses @connectrpc/validate which provides a proper interceptor that: * - Validates request messages using protovalidate rules * - Returns InvalidArgument error for validation failures * - Works with all message types defined with buf.validate constraints * * Note: Update methods skip validation because they support partial updates * where nested message fields are optional. Validation for these methods * is done in the service handlers. */ export function validationInterceptor(): Interceptor { const baseInterceptor = createValidateInterceptor(); return (next) => async (req) => { // Skip validation for update methods that support partial updates if (SKIP_VALIDATION_METHODS.has(req.method.name)) { return next(req); } return baseInterceptor(next)(req); }; } ================================================ FILE: apps/server/src/routes/rpc/router.ts ================================================ import { createConnectRouter } from "@connectrpc/connect"; import { HealthService } from "@openstatus/proto/health/v1"; import { MaintenanceService } from "@openstatus/proto/maintenance/v1"; import { MonitorService } from "@openstatus/proto/monitor/v1"; import { NotificationService } from "@openstatus/proto/notification/v1"; import { StatusPageService } from "@openstatus/proto/status_page/v1"; import { StatusReportService } from "@openstatus/proto/status_report/v1"; import { authInterceptor, errorInterceptor, loggingInterceptor, validationInterceptor, } from "./interceptors"; import { healthServiceImpl } from "./services/health"; import { maintenanceServiceImpl } from "./services/maintenance"; import { monitorServiceImpl } from "./services/monitor"; import { notificationServiceImpl } from "./services/notification"; import { statusPageServiceImpl } from "./services/status-page"; import { statusReportServiceImpl } from "./services/status-report"; /** * Create ConnectRPC router with services. * Interceptors are applied in order (outermost to innermost): * 1. errorInterceptor - Catches all errors and maps to ConnectError * 2. loggingInterceptor - Logs requests/responses with duration * 3. authInterceptor - Validates API key and sets workspace context * 4. validationInterceptor - Validates request messages using protovalidate */ export const routes = createConnectRouter({ interceptors: [ errorInterceptor(), loggingInterceptor(), authInterceptor(), validationInterceptor(), ], }) .service(MonitorService, monitorServiceImpl) .service(HealthService, healthServiceImpl) .service(StatusReportService, statusReportServiceImpl) .service(StatusPageService, statusPageServiceImpl) .service(MaintenanceService, maintenanceServiceImpl) .service(NotificationService, notificationServiceImpl); ================================================ FILE: apps/server/src/routes/rpc/services/health/index.ts ================================================ import type { ServiceImpl } from "@connectrpc/connect"; import { CheckResponse_ServingStatus, type HealthService, } from "@openstatus/proto/health/v1"; /** * Health service implementation. * Provides a simple health check endpoint for load balancer probes. */ export const healthServiceImpl: ServiceImpl<typeof HealthService> = { async check(_req) { return { status: CheckResponse_ServingStatus.SERVING, }; }, }; ================================================ FILE: apps/server/src/routes/rpc/services/maintenance/__tests__/maintenance.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, page, pageComponent, pageSubscriber, } from "@openstatus/db/src/schema"; import { app } from "@/index"; const subscriptionSpies = (globalThis as Record<string, unknown>) .__subscriptionSpies as { dispatchStatusReportUpdate: ReturnType<typeof import("bun:test").mock>; dispatchMaintenanceUpdate: ReturnType<typeof import("bun:test").mock>; }; /** * Helper to make ConnectRPC requests using the Connect protocol (JSON). * Connect uses POST with JSON body at /rpc/<service>/<method> */ async function connectRequest( method: string, body: Record<string, unknown> = {}, headers: Record<string, string> = {}, ) { return app.request( `/rpc/openstatus.maintenance.v1.MaintenanceService/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }, ); } const TEST_PREFIX = "rpc-maintenance-test"; let testPageComponentId: number; let testMaintenanceId: number; let testMaintenanceToDeleteId: number; let testMaintenanceToUpdateId: number; let testMaintenanceForNotifyId: number; let testSubscriberId: number; // For mixed-page validation tests let testPage2Id: number; let testPage2ComponentId: number; beforeAll(async () => { // Clean up any existing test data await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-main`)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-to-delete`)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-to-update`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); // Create a test page component (using existing page 1 from seed) const component = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: 1, type: "static", name: `${TEST_PREFIX}-component`, description: "Test component for maintenance tests", order: 100, }) .returning() .get(); testPageComponentId = component.id; // Create a second page and component for mixed-page validation tests const page2 = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page-2`, slug: `${TEST_PREFIX}-page-2-slug`, description: "Second test page for mixed-page tests", customDomain: "", }) .returning() .get(); testPage2Id = page2.id; const component2 = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: testPage2Id, type: "static", name: `${TEST_PREFIX}-component-2`, description: "Test component on page 2", order: 100, }) .returning() .get(); testPage2ComponentId = component2.id; // Create test maintenance const maintenanceRecord = await db .insert(maintenance) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-main`, message: "Test maintenance message", from: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day from now to: new Date(Date.now() + 25 * 60 * 60 * 1000), // 25 hours from now }) .returning() .get(); testMaintenanceId = maintenanceRecord.id; // Create page component association await db.insert(maintenancesToPageComponents).values({ maintenanceId: maintenanceRecord.id, pageComponentId: testPageComponentId, }); // Create maintenance to delete const deleteRecord = await db .insert(maintenance) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-to-delete`, message: "Maintenance to delete", from: new Date(Date.now() + 48 * 60 * 60 * 1000), to: new Date(Date.now() + 49 * 60 * 60 * 1000), }) .returning() .get(); testMaintenanceToDeleteId = deleteRecord.id; // Create maintenance to update const updateRecord = await db .insert(maintenance) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-to-update`, message: "Maintenance to update", from: new Date(Date.now() + 72 * 60 * 60 * 1000), to: new Date(Date.now() + 73 * 60 * 60 * 1000), }) .returning() .get(); testMaintenanceToUpdateId = updateRecord.id; await db.insert(maintenancesToPageComponents).values({ maintenanceId: updateRecord.id, pageComponentId: testPageComponentId, }); // Create maintenance for notify tests const notifyRecord = await db .insert(maintenance) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-for-notify`, message: "Maintenance for notify tests", from: new Date(Date.now() + 96 * 60 * 60 * 1000), to: new Date(Date.now() + 97 * 60 * 60 * 1000), }) .returning() .get(); testMaintenanceForNotifyId = notifyRecord.id; await db.insert(maintenancesToPageComponents).values({ maintenanceId: notifyRecord.id, pageComponentId: testPageComponentId, }); // Create a verified subscriber for notification tests const subscriber = await db .insert(pageSubscriber) .values({ pageId: 1, email: `${TEST_PREFIX}@example.com`, token: `${TEST_PREFIX}-token`, acceptedAt: new Date(), }) .returning() .get(); testSubscriberId = subscriber.id; }); afterAll(async () => { // Clean up subscriber first (only if it was created) if (testSubscriberId) { await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, testSubscriberId)); } // Clean up associations await db .delete(maintenancesToPageComponents) .where(eq(maintenancesToPageComponents.maintenanceId, testMaintenanceId)); await db .delete(maintenancesToPageComponents) .where( eq(maintenancesToPageComponents.maintenanceId, testMaintenanceToUpdateId), ); await db .delete(maintenancesToPageComponents) .where( eq( maintenancesToPageComponents.maintenanceId, testMaintenanceForNotifyId, ), ); // Clean up maintenances await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-main`)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-to-delete`)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-to-update`)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-for-notify`)); // Clean up page component await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); // Clean up second page component and page (for mixed-page tests) await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-2`)); await db.delete(page).where(eq(page.title, `${TEST_PREFIX}-page-2`)); }); describe("MaintenanceService.CreateMaintenance", () => { test("creates a new maintenance", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-created`, message: "Scheduled maintenance for system upgrade.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [String(testPageComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-created`); expect(data.maintenance.message).toBe( "Scheduled maintenance for system upgrade.", ); expect(data.maintenance.pageComponentIds).toContain( String(testPageComponentId), ); // Clean up await db .delete(maintenancesToPageComponents) .where( eq( maintenancesToPageComponents.maintenanceId, Number(data.maintenance.id), ), ); await db .delete(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))); }); test("returns 401 when no auth key provided", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest("CreateMaintenance", { title: "Unauthorized test", message: "Test message", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: ["1"], }); expect(res.status).toBe(401); }); test("returns error for invalid page component ID", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: "Invalid component test", message: "Test message", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when page components are from different pages", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-mixed-pages`, message: "Test message", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [ String(testPageComponentId), String(testPage2ComponentId), ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain( "All page components must belong to the same page", ); }); test("returns error when pageId does not match components page", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-pageid-mismatch`, message: "Test pageId mismatch with components.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", // This doesn't match testPage2ComponentId's page pageComponentIds: [String(testPage2ComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("does not match the page ID"); }); test("creates maintenance when pageId matches component page", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-matching-pageid`, message: "Test with matching pageId and components.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: String(testPage2Id), // Matching the component's page pageComponentIds: [String(testPage2ComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.pageComponentIds).toContain( String(testPage2ComponentId), ); // Verify the pageId was set correctly const createdRecord = await db .select() .from(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))) .get(); expect(createdRecord?.pageId).toBe(testPage2Id); // Clean up await db .delete(maintenancesToPageComponents) .where( eq( maintenancesToPageComponents.maintenanceId, Number(data.maintenance.id), ), ); await db .delete(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))); }); test("creates maintenance with empty pageComponentIds", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-no-components`, message: "Maintenance without components.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-no-components`); const pageComponentIds = data.maintenance.pageComponentIds ?? []; expect(pageComponentIds).toHaveLength(0); // Clean up await db .delete(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))); }); test("creates maintenance with notify=true", async () => { subscriptionSpies.dispatchMaintenanceUpdate.mockClear(); const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-with-notify`, message: "Notifying subscribers about this maintenance.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [String(testPageComponentId)], notify: true, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-with-notify`); // Verify dispatcher was called (dispatchers are mocked in preload.ts) expect(subscriptionSpies.dispatchMaintenanceUpdate).toHaveBeenCalledTimes( 1, ); // Clean up await db .delete(maintenancesToPageComponents) .where( eq( maintenancesToPageComponents.maintenanceId, Number(data.maintenance.id), ), ); await db .delete(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))); }); test("creates maintenance with notify=false (default)", async () => { subscriptionSpies.dispatchMaintenanceUpdate.mockClear(); const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-no-notify`, message: "No notification for this one.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [], notify: false, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-no-notify`); // Verify dispatcher was NOT called expect(subscriptionSpies.dispatchMaintenanceUpdate).not.toHaveBeenCalled(); // Clean up await db .delete(maintenance) .where(eq(maintenance.id, Number(data.maintenance.id))); }); test("returns error for invalid date format", async () => { const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-invalid-date`, message: "Test with invalid date.", from: "not-a-valid-date", to: new Date( Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000, ).toISOString(), pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("from: value does not match regex pattern"); }); test("returns error when from date is after to date", async () => { const fromDate = new Date(Date.now() + 8 * 24 * 60 * 60 * 1000); // 8 days from now const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days from now (before from) const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-invalid-range`, message: "Test with invalid date range.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("Start time (from) must be before end time"); }); test("returns error for non-existent page", async () => { const fromDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const toDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3600000); const res = await connectRequest( "CreateMaintenance", { title: `${TEST_PREFIX}-invalid-page`, message: "Test with non-existent page.", from: fromDate.toISOString(), to: toDate.toISOString(), pageId: "99999", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); const data = await res.json(); expect(data.message).toContain("Page not found"); }); }); describe("MaintenanceService.GetMaintenance", () => { test("returns maintenance with page components", async () => { const res = await connectRequest( "GetMaintenance", { id: String(testMaintenanceId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.id).toBe(String(testMaintenanceId)); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-main`); expect(data.maintenance.pageComponentIds).toContain( String(testPageComponentId), ); expect(data.maintenance).toHaveProperty("createdAt"); expect(data.maintenance).toHaveProperty("updatedAt"); expect(data.maintenance).toHaveProperty("from"); expect(data.maintenance).toHaveProperty("to"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetMaintenance", { id: String(testMaintenanceId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent maintenance", async () => { const res = await connectRequest( "GetMaintenance", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for maintenance in different workspace", async () => { // Create maintenance in workspace 2 const otherRecord = await db .insert(maintenance) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace`, message: "Other workspace maintenance", from: new Date(Date.now() + 24 * 60 * 60 * 1000), to: new Date(Date.now() + 25 * 60 * 60 * 1000), }) .returning() .get(); try { const res = await connectRequest( "GetMaintenance", { id: String(otherRecord.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(maintenance).where(eq(maintenance.id, otherRecord.id)); } }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "GetMaintenance", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "GetMaintenance", { id: " " }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("MaintenanceService.ListMaintenances", () => { test("returns maintenances for authenticated workspace", async () => { const res = await connectRequest( "ListMaintenances", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenances"); expect(Array.isArray(data.maintenances)).toBe(true); expect(data).toHaveProperty("totalSize"); }); test("returns maintenances with correct structure (summary)", async () => { const res = await connectRequest( "ListMaintenances", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const record = data.maintenances?.find( (m: { id: string }) => m.id === String(testMaintenanceId), ); expect(record).toBeDefined(); expect(record.title).toBe(`${TEST_PREFIX}-main`); expect(record.pageComponentIds).toBeDefined(); expect(record.createdAt).toBeDefined(); expect(record.updatedAt).toBeDefined(); expect(record.from).toBeDefined(); expect(record.to).toBeDefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListMaintenances", {}); expect(res.status).toBe(401); }); test("respects limit parameter", async () => { const res = await connectRequest( "ListMaintenances", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.maintenances?.length || 0).toBeLessThanOrEqual(1); }); test("respects offset parameter", async () => { // Get total count first const res1 = await connectRequest( "ListMaintenances", {}, { "x-openstatus-key": "1" }, ); const data1 = await res1.json(); const totalSize = data1.totalSize; if (totalSize > 1) { // Get first page const res2 = await connectRequest( "ListMaintenances", { limit: 1, offset: 0 }, { "x-openstatus-key": "1" }, ); const data2 = await res2.json(); // Get second page const res3 = await connectRequest( "ListMaintenances", { limit: 1, offset: 1 }, { "x-openstatus-key": "1" }, ); const data3 = await res3.json(); // Should have different maintenances if (data2.maintenances?.length > 0 && data3.maintenances?.length > 0) { expect(data2.maintenances[0].id).not.toBe(data3.maintenances[0].id); } } }); test("filters by page_id", async () => { const res = await connectRequest( "ListMaintenances", { pageId: "1" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // All returned maintenances should have pageId 1 for (const record of data.maintenances || []) { expect(record.pageId).toBe("1"); } }); test("only returns maintenances for authenticated workspace", async () => { // Create maintenance in workspace 2 const otherRecord = await db .insert(maintenance) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-list`, message: "Other workspace maintenance", from: new Date(Date.now() + 24 * 60 * 60 * 1000), to: new Date(Date.now() + 25 * 60 * 60 * 1000), }) .returning() .get(); try { const res = await connectRequest( "ListMaintenances", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const recordIds = (data.maintenances || []).map( (m: { id: string }) => m.id, ); expect(recordIds).not.toContain(String(otherRecord.id)); } finally { await db.delete(maintenance).where(eq(maintenance.id, otherRecord.id)); } }); }); describe("MaintenanceService.UpdateMaintenance", () => { test("updates maintenance title", async () => { const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), title: `${TEST_PREFIX}-updated-title`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("maintenance"); expect(data.maintenance.title).toBe(`${TEST_PREFIX}-updated-title`); // Restore original title await db .update(maintenance) .set({ title: `${TEST_PREFIX}-to-update` }) .where(eq(maintenance.id, testMaintenanceToUpdateId)); }); test("updates maintenance message", async () => { const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), message: "Updated maintenance message", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.maintenance.message).toBe("Updated maintenance message"); // Restore original message await db .update(maintenance) .set({ message: "Maintenance to update" }) .where(eq(maintenance.id, testMaintenanceToUpdateId)); }); test("updates page component associations", async () => { // Use existing seeded page component 1 const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), pageComponentIds: ["1"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.maintenance.pageComponentIds).toContain("1"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateMaintenance", { id: String(testMaintenanceToUpdateId), title: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent maintenance", async () => { const res = await connectRequest( "UpdateMaintenance", { id: "99999", title: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "UpdateMaintenance", { id: "", title: "Empty ID update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "UpdateMaintenance", { id: " ", title: "Whitespace ID update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for maintenance in different workspace", async () => { // Create maintenance in workspace 2 const otherRecord = await db .insert(maintenance) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-update`, message: "Other workspace maintenance", from: new Date(Date.now() + 24 * 60 * 60 * 1000), to: new Date(Date.now() + 25 * 60 * 60 * 1000), }) .returning() .get(); try { const res = await connectRequest( "UpdateMaintenance", { id: String(otherRecord.id), title: "Should not update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(maintenance).where(eq(maintenance.id, otherRecord.id)); } }); test("returns error for invalid page component ID on update", async () => { const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), pageComponentIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when updating with components from different pages", async () => { const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), pageComponentIds: [ String(testPageComponentId), String(testPage2ComponentId), ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain( "All page components must belong to the same page", ); }); test("validates date range on update", async () => { // Try to update with from > to const record = await db .select() .from(maintenance) .where(eq(maintenance.id, testMaintenanceToUpdateId)) .get(); const newFrom = new Date(record?.to?.getTime() ?? Date.now() + 86400000); const res = await connectRequest( "UpdateMaintenance", { id: String(testMaintenanceToUpdateId), from: newFrom.toISOString(), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("Start time (from) must be before end time"); }); }); describe("MaintenanceService.DeleteMaintenance", () => { test("successfully deletes existing maintenance", async () => { const res = await connectRequest( "DeleteMaintenance", { id: String(testMaintenanceToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(maintenance) .where(eq(maintenance.id, testMaintenanceToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteMaintenance", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent maintenance", async () => { const res = await connectRequest( "DeleteMaintenance", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "DeleteMaintenance", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "DeleteMaintenance", { id: " " }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for maintenance in different workspace", async () => { // Create maintenance in workspace 2 const otherRecord = await db .insert(maintenance) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-delete`, message: "Other workspace maintenance", from: new Date(Date.now() + 24 * 60 * 60 * 1000), to: new Date(Date.now() + 25 * 60 * 60 * 1000), }) .returning() .get(); try { const res = await connectRequest( "DeleteMaintenance", { id: String(otherRecord.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); // Verify it wasn't deleted const stillExists = await db .select() .from(maintenance) .where(eq(maintenance.id, otherRecord.id)) .get(); expect(stillExists).toBeDefined(); } finally { await db.delete(maintenance).where(eq(maintenance.id, otherRecord.id)); } }); }); ================================================ FILE: apps/server/src/routes/rpc/services/maintenance/converters.ts ================================================ import type { Maintenance, MaintenanceSummary, } from "@openstatus/proto/maintenance/v1"; type DBMaintenance = { id: number; title: string; message: string; from: Date; to: Date; workspaceId: number | null; pageId: number | null; createdAt: Date | null; updatedAt: Date | null; }; /** * Convert a DB maintenance to proto summary format. */ export function dbMaintenanceToProtoSummary( maintenance: DBMaintenance, pageComponentIds: string[], ): MaintenanceSummary { return { $typeName: "openstatus.maintenance.v1.MaintenanceSummary" as const, id: String(maintenance.id), title: maintenance.title, message: maintenance.message, from: maintenance.from.toISOString(), to: maintenance.to.toISOString(), pageId: maintenance.pageId ? String(maintenance.pageId) : "", pageComponentIds, createdAt: maintenance.createdAt?.toISOString() ?? "", updatedAt: maintenance.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB maintenance to full proto format. */ export function dbMaintenanceToProto( maintenance: DBMaintenance, pageComponentIds: string[], ): Maintenance { return { $typeName: "openstatus.maintenance.v1.Maintenance" as const, id: String(maintenance.id), title: maintenance.title, message: maintenance.message, from: maintenance.from.toISOString(), to: maintenance.to.toISOString(), pageId: maintenance.pageId ? String(maintenance.pageId) : "", pageComponentIds, createdAt: maintenance.createdAt?.toISOString() ?? "", updatedAt: maintenance.updatedAt?.toISOString() ?? "", }; } ================================================ FILE: apps/server/src/routes/rpc/services/maintenance/errors.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; /** * Error reasons for structured error handling. */ export const ErrorReason = { MAINTENANCE_NOT_FOUND: "MAINTENANCE_NOT_FOUND", MAINTENANCE_ID_REQUIRED: "MAINTENANCE_ID_REQUIRED", MAINTENANCE_CREATE_FAILED: "MAINTENANCE_CREATE_FAILED", MAINTENANCE_UPDATE_FAILED: "MAINTENANCE_UPDATE_FAILED", PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", PAGE_COMPONENTS_MIXED_PAGES: "PAGE_COMPONENTS_MIXED_PAGES", PAGE_ID_COMPONENT_MISMATCH: "PAGE_ID_COMPONENT_MISMATCH", PAGE_NOT_FOUND: "PAGE_NOT_FOUND", INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", INVALID_DATE_RANGE: "INVALID_DATE_RANGE", } as const; export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; const DOMAIN = "openstatus.dev"; /** * Creates a ConnectError with structured metadata. */ function createError( message: string, code: Code, reason: ErrorReason, metadata?: Record<string, string>, ): ConnectError { const headers = new Headers({ "error-domain": DOMAIN, "error-reason": reason, }); if (metadata) { for (const [key, value] of Object.entries(metadata)) { headers.set(`error-${key}`, value); } } return new ConnectError(message, code, headers); } /** * Creates a "maintenance not found" error. */ export function maintenanceNotFoundError(maintenanceId: string): ConnectError { return createError( "Maintenance not found", Code.NotFound, ErrorReason.MAINTENANCE_NOT_FOUND, { "maintenance-id": maintenanceId }, ); } /** * Creates a "maintenance ID required" error. */ export function maintenanceIdRequiredError(): ConnectError { return createError( "Maintenance ID is required", Code.InvalidArgument, ErrorReason.MAINTENANCE_ID_REQUIRED, ); } /** * Creates a "failed to create maintenance" error. */ export function maintenanceCreateFailedError(): ConnectError { return createError( "Failed to create maintenance", Code.Internal, ErrorReason.MAINTENANCE_CREATE_FAILED, ); } /** * Creates a "failed to update maintenance" error. */ export function maintenanceUpdateFailedError( maintenanceId: string, ): ConnectError { return createError( "Failed to update maintenance", Code.Internal, ErrorReason.MAINTENANCE_UPDATE_FAILED, { "maintenance-id": maintenanceId }, ); } /** * Creates a "page component not found" error. */ export function pageComponentNotFoundError( pageComponentId: string, ): ConnectError { return createError( "Page component not found", Code.NotFound, ErrorReason.PAGE_COMPONENT_NOT_FOUND, { "page-component-id": pageComponentId }, ); } /** * Creates a "page components from mixed pages" error. */ export function pageComponentsMixedPagesError(): ConnectError { return createError( "All page components must belong to the same page", Code.InvalidArgument, ErrorReason.PAGE_COMPONENTS_MIXED_PAGES, ); } /** * Creates a "page not found" error. */ export function pageNotFoundError(pageId: string): ConnectError { return createError( "Page not found", Code.NotFound, ErrorReason.PAGE_NOT_FOUND, { "page-id": pageId, }, ); } /** * Creates an "invalid date format" error. */ export function invalidDateFormatError(dateValue: string): ConnectError { return createError( "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", Code.InvalidArgument, ErrorReason.INVALID_DATE_FORMAT, { "date-value": dateValue }, ); } /** * Creates an "invalid date range" error (from must be before to). */ export function invalidDateRangeError(from: string, to: string): ConnectError { return createError( "Invalid date range. Start time (from) must be before end time (to)", Code.InvalidArgument, ErrorReason.INVALID_DATE_RANGE, { from, to }, ); } /** * Creates a "page ID and component page mismatch" error. */ export function pageIdComponentMismatchError( providedPageId: string, componentPageId: string, ): ConnectError { return createError( `Page ID ${providedPageId} does not match the page ID ${componentPageId} of the provided components`, Code.InvalidArgument, ErrorReason.PAGE_ID_COMPONENT_MISMATCH, { "provided-page-id": providedPageId, "component-page-id": componentPageId, }, ); } ================================================ FILE: apps/server/src/routes/rpc/services/maintenance/index.ts ================================================ import type { ServiceImpl } from "@connectrpc/connect"; import { and, db, desc, eq, inArray, sql } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, page, pageComponent, } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { MaintenanceService } from "@openstatus/proto/maintenance/v1"; import { dispatchMaintenanceUpdate } from "@openstatus/subscriptions"; import { getRpcContext } from "../../interceptors"; import { dbMaintenanceToProto, dbMaintenanceToProtoSummary, } from "./converters"; import { invalidDateFormatError, invalidDateRangeError, maintenanceCreateFailedError, maintenanceIdRequiredError, maintenanceNotFoundError, maintenanceUpdateFailedError, pageComponentNotFoundError, pageComponentsMixedPagesError, pageIdComponentMismatchError, pageNotFoundError, } from "./errors"; // Type that works with both db instance and transaction type DB = typeof db; type Transaction = Parameters<Parameters<DB["transaction"]>[0]>[0]; /** * Helper to get a maintenance by ID with workspace scope. */ async function getMaintenanceById(id: number, workspaceId: number) { return db .select() .from(maintenance) .where( and(eq(maintenance.id, id), eq(maintenance.workspaceId, workspaceId)), ) .get(); } /** * Helper to get page component IDs for a maintenance. */ async function getPageComponentIdsForMaintenance(maintenanceId: number) { const components = await db .select({ pageComponentId: maintenancesToPageComponents.pageComponentId }) .from(maintenancesToPageComponents) .where(eq(maintenancesToPageComponents.maintenanceId, maintenanceId)) .all(); return components.map((c) => String(c.pageComponentId)); } /** * Result of validating page component IDs. */ interface ValidatedPageComponents { componentIds: number[]; pageId: number | null; } /** * Helper to validate page component IDs belong to the workspace and same page. * Accepts an optional transaction to ensure atomicity with subsequent operations. */ export async function validatePageComponentIds( pageComponentIds: string[], workspaceId: number, tx: DB | Transaction = db, ): Promise<ValidatedPageComponents> { if (pageComponentIds.length === 0) { return { componentIds: [], pageId: null }; } const numericIds = pageComponentIds.map((id) => Number(id)); const validComponents = await tx .select({ id: pageComponent.id, pageId: pageComponent.pageId }) .from(pageComponent) .where( and( inArray(pageComponent.id, numericIds), eq(pageComponent.workspaceId, workspaceId), ), ) .all(); const validComponentsMap = new Map( validComponents.map((c) => [c.id, c.pageId]), ); // Check all requested IDs exist for (const id of numericIds) { if (!validComponentsMap.has(id)) { throw pageComponentNotFoundError(String(id)); } } // Validate all components belong to the same page const pageIds = new Set(validComponents.map((c) => c.pageId)); if (pageIds.size > 1) { throw pageComponentsMixedPagesError(); } const pageId = validComponents[0]?.pageId ?? null; return { componentIds: numericIds, pageId }; } /** * Helper to update page component associations for a maintenance. * Accepts an optional transaction to ensure atomicity. */ export async function updatePageComponentAssociations( maintenanceId: number, pageComponentIds: number[], tx: DB | Transaction = db, ) { // Delete existing associations await tx .delete(maintenancesToPageComponents) .where(eq(maintenancesToPageComponents.maintenanceId, maintenanceId)); // Insert new associations if (pageComponentIds.length > 0) { await tx.insert(maintenancesToPageComponents).values( pageComponentIds.map((pageComponentId) => ({ maintenanceId, pageComponentId, })), ); } } /** * Parses and validates a date string. * Throws invalidDateFormatError if the date is invalid. */ function parseDate(dateString: string): Date { const date = new Date(dateString); if (Number.isNaN(date.getTime())) { throw invalidDateFormatError(dateString); } return date; } /** * Validates that from date is before to date. */ function validateDateRange( from: Date, to: Date, fromStr: string, toStr: string, ): void { if (from >= to) { throw invalidDateRangeError(fromStr, toStr); } } /** * Helper to validate page exists in workspace. */ export async function validatePageExists( pageId: number, workspaceId: number, tx: DB | Transaction = db, ): Promise<void> { const pageRecord = await tx .select({ id: page.id }) .from(page) .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) .get(); if (!pageRecord) { throw pageNotFoundError(String(pageId)); } } /** * Helper to send maintenance notifications to page subscribers. * Uses the subscription dispatcher for component-aware filtering. */ export async function sendMaintenanceNotification(params: { maintenanceId: number; limits: Limits; }) { const { maintenanceId, limits } = params; if (!limits["status-subscribers"]) { return; } await dispatchMaintenanceUpdate(maintenanceId); } /** * Maintenance service implementation for ConnectRPC. */ export const maintenanceServiceImpl: ServiceImpl<typeof MaintenanceService> = { async createMaintenance(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Parse and validate dates const fromDate = parseDate(req.from); const toDate = parseDate(req.to); validateDateRange(fromDate, toDate, req.from, req.to); const providedPageId = Number(req.pageId); // Create maintenance and associations in a transaction const newMaintenance = await db.transaction(async (tx) => { // Validate page exists in workspace await validatePageExists(providedPageId, workspaceId, tx); // Validate page component IDs const validatedComponents = await validatePageComponentIds( req.pageComponentIds, workspaceId, tx, ); // Validate that components belong to the same page as provided pageId if ( validatedComponents.pageId !== null && validatedComponents.pageId !== providedPageId ) { throw pageIdComponentMismatchError( req.pageId, String(validatedComponents.pageId), ); } // Create the maintenance const record = await tx .insert(maintenance) .values({ workspaceId, pageId: providedPageId, title: req.title, message: req.message, from: fromDate, to: toDate, }) .returning() .get(); if (!record) { throw maintenanceCreateFailedError(); } // Create page component associations await updatePageComponentAssociations( record.id, validatedComponents.componentIds, tx, ); return record; }); // Send notifications if requested (outside transaction) if (req.notify) { await sendMaintenanceNotification({ maintenanceId: newMaintenance.id, limits: rpcCtx.workspace.limits, }); } return { maintenance: dbMaintenanceToProto(newMaintenance, req.pageComponentIds), }; }, async getMaintenance(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw maintenanceIdRequiredError(); } const record = await getMaintenanceById(Number(req.id), workspaceId); if (!record) { throw maintenanceNotFoundError(req.id); } const pageComponentIds = await getPageComponentIdsForMaintenance(record.id); return { maintenance: dbMaintenanceToProto(record, pageComponentIds), }; }, async listMaintenances(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Build conditions const conditions = [eq(maintenance.workspaceId, workspaceId)]; // Add page_id filter if provided if (req.pageId && req.pageId.trim() !== "") { conditions.push(eq(maintenance.pageId, Number(req.pageId))); } // Get total count const countResult = await db .select({ count: sql<number>`count(*)` }) .from(maintenance) .where(and(...conditions)) .get(); const totalCount = countResult?.count ?? 0; // Get maintenances const records = await db .select() .from(maintenance) .where(and(...conditions)) .orderBy(desc(maintenance.from)) .limit(limit) .offset(offset) .all(); // Get page component IDs for each maintenance const maintenances = await Promise.all( records.map(async (record) => { const pageComponentIds = await getPageComponentIdsForMaintenance( record.id, ); return dbMaintenanceToProtoSummary(record, pageComponentIds); }), ); return { maintenances, totalSize: totalCount, }; }, async updateMaintenance(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw maintenanceIdRequiredError(); } const record = await getMaintenanceById(Number(req.id), workspaceId); if (!record) { throw maintenanceNotFoundError(req.id); } // Parse dates if provided const fromDate = req.from ? parseDate(req.from) : null; const toDate = req.to ? parseDate(req.to) : null; // Validate date range with updated values const effectiveFrom = fromDate ?? record.from; const effectiveTo = toDate ?? record.to; const fromStr = fromDate && req.from ? req.from : record.from.toISOString(); const toStr = toDate && req.to ? req.to : record.to.toISOString(); validateDateRange(effectiveFrom, effectiveTo, fromStr, toStr); // Update maintenance and associations in a transaction const updatedMaintenance = await db.transaction(async (tx) => { // Validate page component IDs const validatedComponents = await validatePageComponentIds( req.pageComponentIds, workspaceId, tx, ); // Determine effective pageId let effectivePageId = record.pageId; if (req.pageId && req.pageId.trim() !== "") { effectivePageId = Number(req.pageId); await validatePageExists(effectivePageId, workspaceId, tx); } // Validate that components belong to the same page if ( validatedComponents.pageId !== null && effectivePageId !== null && validatedComponents.pageId !== effectivePageId ) { throw pageIdComponentMismatchError( String(effectivePageId), String(validatedComponents.pageId), ); } // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (req.title !== undefined && req.title !== "") { updateValues.title = req.title; } if (req.message !== undefined && req.message !== "") { updateValues.message = req.message; } if (fromDate) { updateValues.from = fromDate; } if (toDate) { updateValues.to = toDate; } if (req.pageId && req.pageId.trim() !== "") { updateValues.pageId = effectivePageId; } // Update page component associations await updatePageComponentAssociations( record.id, validatedComponents.componentIds, tx, ); // Update the maintenance const updated = await tx .update(maintenance) .set(updateValues) .where(eq(maintenance.id, record.id)) .returning() .get(); if (!updated) { throw maintenanceUpdateFailedError(req.id); } return updated; }); // Fetch updated page component IDs const pageComponentIds = await getPageComponentIdsForMaintenance( updatedMaintenance.id, ); return { maintenance: dbMaintenanceToProto(updatedMaintenance, pageComponentIds), }; }, async deleteMaintenance(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw maintenanceIdRequiredError(); } const record = await getMaintenanceById(Number(req.id), workspaceId); if (!record) { throw maintenanceNotFoundError(req.id); } // Delete the maintenance (cascade will delete associations) await db.delete(maintenance).where(eq(maintenance.id, record.id)); return { success: true }; }, }; ================================================ FILE: apps/server/src/routes/rpc/services/monitor/__tests__/monitor.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { monitor, pageComponent } from "@openstatus/db/src/schema"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { app } from "@/index"; /** * Helper to make ConnectRPC requests using the Connect protocol (JSON). * Connect uses POST with JSON body at /rpc/<service>/<method> */ async function connectRequest( method: string, body: Record<string, unknown> = {}, headers: Record<string, string> = {}, ) { return app.request(`/rpc/openstatus.monitor.v1.MonitorService/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }); } const TEST_PREFIX = "rpc-monitor-test"; let testHttpMonitorId: number; let testTcpMonitorId: number; let testDnsMonitorId: number; let testMonitorToDeleteId: number; let testMonitorWithStatusId: number; beforeAll(async () => { // Clean up any existing test data await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) .where(eq(monitor.name, `${TEST_PREFIX}-with-status`)); // Create test HTTP monitor const httpMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-http`, url: "https://example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", method: "GET", timeout: 30000, headers: JSON.stringify([{ key: "X-Test", value: "test-value" }]), assertions: JSON.stringify([ { type: "status", compare: "eq", target: 200 }, { type: "textBody", compare: "contains", target: "success" }, { type: "header", compare: "eq", target: "application/json", key: "content-type", }, ]), }) .returning() .get(); testHttpMonitorId = httpMon.id; // Create test TCP monitor const tcpMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-tcp`, url: "tcp://example.com:443", periodicity: "5m", active: true, regions: "ams", jobType: "tcp", timeout: 10000, }) .returning() .get(); testTcpMonitorId = tcpMon.id; // Create test DNS monitor const dnsMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-dns`, url: "example.com", periodicity: "10m", active: true, regions: "ams", jobType: "dns", timeout: 5000, assertions: JSON.stringify([ { type: "dnsRecord", compare: "eq", target: "93.184.216.34", key: "A" }, ]), }) .returning() .get(); testDnsMonitorId = dnsMon.id; // Create monitor to be deleted const deleteMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-to-delete`, url: "https://to-delete.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); testMonitorToDeleteId = deleteMon.id; // Create monitor with status entries for GetMonitorStatus tests const statusMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-with-status`, url: "https://with-status.example.com", periodicity: "1m", active: true, regions: "ams,iad,fra", jobType: "http", }) .returning() .get(); testMonitorWithStatusId = statusMon.id; // Create status entries for the monitor await db.insert(monitorStatusTable).values([ { monitorId: statusMon.id, region: "ams", status: "active" }, { monitorId: statusMon.id, region: "iad", status: "error" }, { monitorId: statusMon.id, region: "fra", status: "degraded" }, // Add a stale region entry that is not in the monitor's configured regions { monitorId: statusMon.id, region: "lhr", status: "active" }, ]); }); afterAll(async () => { // Clean up monitor status entries first (due to foreign key) await db .delete(monitorStatusTable) .where(eq(monitorStatusTable.monitorId, testMonitorWithStatusId)); // Clean up test data await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) .where(eq(monitor.name, `${TEST_PREFIX}-with-status`)); }); describe("MonitorService.ListMonitors", () => { test("returns monitors for authenticated workspace", async () => { const res = await connectRequest( "ListMonitors", {}, { "x-openstatus-key": "1", }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("httpMonitors"); expect(data).toHaveProperty("tcpMonitors"); expect(data).toHaveProperty("dnsMonitors"); expect(Array.isArray(data.httpMonitors)).toBe(true); expect(Array.isArray(data.tcpMonitors)).toBe(true); expect(Array.isArray(data.dnsMonitors)).toBe(true); }); test("returns HTTP monitors with correct structure", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, // Request more to ensure we get our test monitors { "x-openstatus-key": "1", }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 may omit empty arrays const httpMonitors = data.httpMonitors || []; const httpMon = httpMonitors.find( (m: { id: string }) => m.id === String(testHttpMonitorId), ); expect(httpMon).toBeDefined(); expect(httpMon.url).toBe("https://example.com"); expect(httpMon.periodicity).toBe("PERIODICITY_1M"); expect(httpMon.method).toBe("HTTP_METHOD_GET"); expect(httpMon.headers).toBeDefined(); expect(httpMon.statusCodeAssertions).toBeDefined(); expect(httpMon.bodyAssertions).toBeDefined(); expect(httpMon.headerAssertions).toBeDefined(); }); test("returns TCP monitors with correct structure", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, // Request more to ensure we get our test monitors { "x-openstatus-key": "1", }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 may omit empty arrays const tcpMonitors = data.tcpMonitors || []; const tcpMon = tcpMonitors.find( (m: { id: string }) => m.id === String(testTcpMonitorId), ); expect(tcpMon).toBeDefined(); expect(tcpMon.uri).toBe("tcp://example.com:443"); expect(tcpMon.periodicity).toBe("PERIODICITY_5M"); }); test("returns DNS monitors with correct structure", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, // Request more to ensure we get our test monitors { "x-openstatus-key": "1", }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 may omit empty arrays const dnsMonitors = data.dnsMonitors || []; const dnsMon = dnsMonitors.find( (m: { id: string }) => m.id === String(testDnsMonitorId), ); expect(dnsMon).toBeDefined(); expect(dnsMon.uri).toBe("example.com"); expect(dnsMon.periodicity).toBe("PERIODICITY_10M"); expect(dnsMon.recordAssertions).toBeDefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListMonitors", {}); expect(res.status).toBe(401); }); test("respects limit parameter", async () => { const res = await connectRequest( "ListMonitors", { limit: 2 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 may omit empty repeated fields from JSON output const totalMonitors = (data.httpMonitors?.length || 0) + (data.tcpMonitors?.length || 0) + (data.dnsMonitors?.length || 0); // Should return at most 2 monitors total expect(totalMonitors).toBeLessThanOrEqual(2); }); test("returns totalSize for pagination", async () => { const res = await connectRequest( "ListMonitors", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // Should have totalSize indicating total number of monitors expect(data).toHaveProperty("totalSize"); expect(typeof data.totalSize).toBe("number"); }); test("uses offset for pagination", async () => { // First page const res1 = await connectRequest( "ListMonitors", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res1.status).toBe(200); const data1 = await res1.json(); if (data1.totalSize > 1) { // Second page using offset const res2 = await connectRequest( "ListMonitors", { limit: 1, offset: 1 }, { "x-openstatus-key": "1" }, ); expect(res2.status).toBe(200); const data2 = await res2.json(); // Proto3 may omit empty repeated fields from JSON output // The monitors from second page should be different from first page const firstPageIds = [ ...(data1.httpMonitors || []).map((m: { id: string }) => m.id), ...(data1.tcpMonitors || []).map((m: { id: string }) => m.id), ...(data1.dnsMonitors || []).map((m: { id: string }) => m.id), ]; const secondPageIds = [ ...(data2.httpMonitors || []).map((m: { id: string }) => m.id), ...(data2.tcpMonitors || []).map((m: { id: string }) => m.id), ...(data2.dnsMonitors || []).map((m: { id: string }) => m.id), ]; // Should have no overlap const overlap = firstPageIds.filter((id: string) => secondPageIds.includes(id), ); expect(overlap.length).toBe(0); } }); test("only returns monitors for the authenticated workspace", async () => { // Create a monitor for workspace 2 const otherWorkspaceMon = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-other-workspace`, url: "https://other-workspace.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { const res = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1", }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 may omit empty arrays const allMonitorIds = [ ...(data.httpMonitors || []).map((m: { id: string }) => m.id), ...(data.tcpMonitors || []).map((m: { id: string }) => m.id), ...(data.dnsMonitors || []).map((m: { id: string }) => m.id), ]; // Should not contain the other workspace's monitor expect(allMonitorIds).not.toContain(String(otherWorkspaceMon.id)); } finally { await db.delete(monitor).where(eq(monitor.id, otherWorkspaceMon.id)); } }); }); describe("MonitorService.DeleteMonitor", () => { test("successfully deletes existing monitor", async () => { const res = await connectRequest( "DeleteMonitor", { id: String(testMonitorToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify the monitor was soft-deleted const deletedMon = await db .select() .from(monitor) .where(eq(monitor.id, testMonitorToDeleteId)) .get(); expect(deletedMon).toBeDefined(); expect(deletedMon?.deletedAt).not.toBeNull(); expect(deletedMon?.active).toBe(false); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "DeleteMonitor", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteMonitor", { id: "1" }); expect(res.status).toBe(401); }); test("cannot delete monitor from another workspace", async () => { // Create a monitor for workspace 2 const otherWorkspaceMon = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-delete-other-ws`, url: "https://other-ws-delete.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { // Try to delete with workspace 1's key const res = await connectRequest( "DeleteMonitor", { id: String(otherWorkspaceMon.id) }, { "x-openstatus-key": "1" }, ); // Should return 404 (not found in this workspace) expect(res.status).toBe(404); // Verify the monitor still exists const stillExists = await db .select() .from(monitor) .where(eq(monitor.id, otherWorkspaceMon.id)) .get(); expect(stillExists).toBeDefined(); expect(stillExists?.deletedAt).toBeNull(); } finally { await db.delete(monitor).where(eq(monitor.id, otherWorkspaceMon.id)); } }); test("deleting a monitor removes its page components", async () => { // Create a monitor const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-delete-with-component`, url: "https://delete-component.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); // Create a pageComponent referencing that monitor const component = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: 1, type: "monitor", monitorId: mon.id, name: `${TEST_PREFIX}-component`, order: 0, }) .returning() .get(); // Verify the component exists const beforeDelete = await db .select() .from(pageComponent) .where(eq(pageComponent.id, component.id)) .get(); expect(beforeDelete).toBeDefined(); // Delete the monitor const res = await connectRequest( "DeleteMonitor", { id: String(mon.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); // Verify the page component was removed const afterDelete = await db .select() .from(pageComponent) .where(eq(pageComponent.id, component.id)) .get(); expect(afterDelete).toBeUndefined(); }); }); describe("MonitorService.CreateHTTPMonitor", () => { test("successfully creates HTTP monitor", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-create-http", url: "https://create-test.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_POST", timeout: "30000", followRedirects: true, headers: [{ key: "X-Custom", value: "test" }], statusCodeAssertions: [{ target: "200", comparator: 1 }], }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.url).toBe("https://create-test.example.com"); expect(data.monitor.periodicity).toBe("PERIODICITY_5M"); expect(data.monitor.method).toBe("HTTP_METHOD_POST"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("returns error when monitor is missing", async () => { const res = await connectRequest( "CreateHTTPMonitor", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CreateHTTPMonitor", { monitor: { url: "https://test.example.com" }, }); expect(res.status).toBe(401); }); }); describe("MonitorService.CreateTCPMonitor", () => { test("successfully creates TCP monitor", async () => { const res = await connectRequest( "CreateTCPMonitor", { monitor: { name: "test-create-tcp", uri: "tcp://create-tcp-test.example.com:8080", periodicity: "PERIODICITY_10M", timeout: "15000", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.uri).toBe("tcp://create-tcp-test.example.com:8080"); expect(data.monitor.periodicity).toBe("PERIODICITY_10M"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("returns error when monitor is missing", async () => { const res = await connectRequest( "CreateTCPMonitor", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("MonitorService.CreateDNSMonitor", () => { test("successfully creates DNS monitor", async () => { const res = await connectRequest( "CreateDNSMonitor", { monitor: { name: "test-create-dns", uri: "create-dns-test.example.com", periodicity: "PERIODICITY_30M", timeout: "5000", recordAssertions: [{ record: "A", target: "1.2.3.4", comparator: 1 }], }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.uri).toBe("create-dns-test.example.com"); expect(data.monitor.periodicity).toBe("PERIODICITY_30M"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("returns error when monitor is missing", async () => { const res = await connectRequest( "CreateDNSMonitor", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("MonitorService.UpdateHTTPMonitor", () => { test("successfully updates HTTP monitor with partial data", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { name: "updated-http-name", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.name).toBe("updated-http-name"); // Original URL should be preserved expect(data.monitor.url).toBe("https://example.com"); // Restore original name await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { name: `${TEST_PREFIX}-http`, }, }, { "x-openstatus-key": "1" }, ); }); test("successfully updates HTTP monitor URL", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { url: "https://updated-example.com", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor.url).toBe("https://updated-example.com"); // Restore original URL await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { url: "https://example.com", }, }, { "x-openstatus-key": "1" }, ); }); test("successfully updates HTTP method", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { method: "HTTP_METHOD_POST", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor.method).toBe("HTTP_METHOD_POST"); // Restore original method await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": "1" }, ); }); test("returns current monitor when no monitor data provided", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: String(testHttpMonitorId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.id).toBe(String(testHttpMonitorId)); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: "99999", monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when trying to update TCP monitor as HTTP", async () => { const res = await connectRequest( "UpdateHTTPMonitor", { id: String(testTcpMonitorId), monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("type mismatch"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateHTTPMonitor", { id: String(testHttpMonitorId), monitor: { name: "test" }, }); expect(res.status).toBe(401); }); }); describe("MonitorService.UpdateTCPMonitor", () => { test("successfully updates TCP monitor with partial data", async () => { const res = await connectRequest( "UpdateTCPMonitor", { id: String(testTcpMonitorId), monitor: { name: "updated-tcp-name", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.name).toBe("updated-tcp-name"); // Original URI should be preserved expect(data.monitor.uri).toBe("tcp://example.com:443"); // Restore original name await connectRequest( "UpdateTCPMonitor", { id: String(testTcpMonitorId), monitor: { name: `${TEST_PREFIX}-tcp`, }, }, { "x-openstatus-key": "1" }, ); }); test("successfully updates TCP monitor URI", async () => { const res = await connectRequest( "UpdateTCPMonitor", { id: String(testTcpMonitorId), monitor: { uri: "tcp://updated-example.com:8080", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor.uri).toBe("tcp://updated-example.com:8080"); // Restore original URI await connectRequest( "UpdateTCPMonitor", { id: String(testTcpMonitorId), monitor: { uri: "tcp://example.com:443", }, }, { "x-openstatus-key": "1" }, ); }); test("returns current monitor when no monitor data provided", async () => { const res = await connectRequest( "UpdateTCPMonitor", { id: String(testTcpMonitorId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.id).toBe(String(testTcpMonitorId)); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "UpdateTCPMonitor", { id: "99999", monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when trying to update HTTP monitor as TCP", async () => { const res = await connectRequest( "UpdateTCPMonitor", { id: String(testHttpMonitorId), monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("type mismatch"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateTCPMonitor", { id: String(testTcpMonitorId), monitor: { name: "test" }, }); expect(res.status).toBe(401); }); }); describe("MonitorService.UpdateDNSMonitor", () => { test("successfully updates DNS monitor with partial data", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { name: "updated-dns-name", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.name).toBe("updated-dns-name"); // Original URI should be preserved expect(data.monitor.uri).toBe("example.com"); // Restore original name await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { name: `${TEST_PREFIX}-dns`, }, }, { "x-openstatus-key": "1" }, ); }); test("successfully updates DNS monitor URI", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { uri: "updated-example.com", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor.uri).toBe("updated-example.com"); // Restore original URI await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { uri: "example.com", }, }, { "x-openstatus-key": "1" }, ); }); test("successfully updates DNS record assertions", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { recordAssertions: [ { record: "A", target: "1.2.3.4", comparator: 1 }, { record: "AAAA", target: "2001:db8::1", comparator: 1 }, ], }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor.recordAssertions).toHaveLength(2); // Restore original assertions await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { recordAssertions: [ { record: "A", target: "93.184.216.34", comparator: 1 }, ], }, }, { "x-openstatus-key": "1" }, ); }); test("returns current monitor when no monitor data provided", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: String(testDnsMonitorId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.id).toBe(String(testDnsMonitorId)); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: "99999", monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when trying to update HTTP monitor as DNS", async () => { const res = await connectRequest( "UpdateDNSMonitor", { id: String(testHttpMonitorId), monitor: { name: "test" }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("type mismatch"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateDNSMonitor", { id: String(testDnsMonitorId), monitor: { name: "test" }, }); expect(res.status).toBe(401); }); }); describe("MonitorService.TriggerMonitor", () => { test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "TriggerMonitor", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("TriggerMonitor", { id: "1" }); expect(res.status).toBe(401); }); }); describe("MonitorService - Authentication", () => { test("invalid API key returns 401", async () => { const res = await connectRequest( "ListMonitors", {}, { "x-openstatus-key": "invalid-key", }, ); expect(res.status).toBe(401); }); }); describe("MonitorService - Validation", () => { test("returns error when name is missing for HTTP monitor", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { url: "https://test.example.com", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("name"); }); test("returns error when name is empty for HTTP monitor", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "", url: "https://test.example.com", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("name"); }); test("returns error when URL is missing for HTTP monitor", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-monitor", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); // protovalidate uses lowercase "url" in the error message expect(data.message.toLowerCase()).toContain("url"); }); test("returns error when URI is missing for TCP monitor", async () => { const res = await connectRequest( "CreateTCPMonitor", { monitor: { name: "test-tcp-monitor", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); // protovalidate uses lowercase "uri" in the error message expect(data.message.toLowerCase()).toContain("uri"); }); test("returns error when URI is missing for DNS monitor", async () => { const res = await connectRequest( "CreateDNSMonitor", { monitor: { name: "test-dns-monitor", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); // protovalidate uses lowercase "uri" in the error message expect(data.message.toLowerCase()).toContain("uri"); }); test("invalid region strings are filtered out", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-invalid-regions", url: "https://test.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", regions: ["invalid-region", "another-invalid"], }, }, { "x-openstatus-key": "1" }, ); // Invalid region strings are parsed as UNSPECIFIED and filtered out // Monitor is created with empty regions expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); // Proto3 may omit empty arrays expect(data.monitor.regions || []).toEqual([]); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("accepts valid regions", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-valid-regions", url: "https://test-valid-regions.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", regions: ["REGION_FLY_AMS", "REGION_FLY_IAD", "REGION_FLY_SIN"], }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.regions).toEqual([ "REGION_FLY_AMS", "REGION_FLY_IAD", "REGION_FLY_SIN", ]); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); }); describe("MonitorService - Assertions Round-trip", () => { test("HTTP assertions are correctly stored and retrieved", async () => { const createRes = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-assertions-roundtrip", url: "https://test-assertions.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", statusCodeAssertions: [ { target: "200", comparator: 1 }, { target: "201", comparator: 1 }, ], bodyAssertions: [{ target: "success", comparator: 3 }], headerAssertions: [ { key: "content-type", target: "application/json", comparator: 1 }, ], }, }, { "x-openstatus-key": "1" }, ); expect(createRes.status).toBe(200); const createData = await createRes.json(); const monitorId = createData.monitor.id; try { // List monitors to verify assertions are retrieved correctly const listRes = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(listRes.status).toBe(200); const listData = await listRes.json(); // Proto3 may omit empty arrays const httpMonitors = listData.httpMonitors || []; const foundMonitor = httpMonitors.find( (m: { id: string }) => m.id === monitorId, ); expect(foundMonitor).toBeDefined(); expect(foundMonitor.statusCodeAssertions).toHaveLength(2); expect(foundMonitor.bodyAssertions).toHaveLength(1); expect(foundMonitor.headerAssertions).toHaveLength(1); expect(foundMonitor.headerAssertions[0].key).toBe("content-type"); } finally { // Clean up await db.delete(monitor).where(eq(monitor.id, Number(monitorId))); } }); test("DNS record assertions are correctly stored and retrieved", async () => { const createRes = await connectRequest( "CreateDNSMonitor", { monitor: { name: "test-dns-assertions", uri: "test-dns-assertions.example.com", periodicity: "PERIODICITY_5M", recordAssertions: [ { record: "A", target: "93.184.216.34", comparator: 1 }, { record: "AAAA", target: "2606:2800:220:1::", comparator: 1 }, ], }, }, { "x-openstatus-key": "1" }, ); expect(createRes.status).toBe(200); const createData = await createRes.json(); const monitorId = createData.monitor.id; try { const listRes = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(listRes.status).toBe(200); const listData = await listRes.json(); // Proto3 may omit empty arrays const dnsMonitors = listData.dnsMonitors || []; const foundMonitor = dnsMonitors.find( (m: { id: string }) => m.id === monitorId, ); expect(foundMonitor).toBeDefined(); expect(foundMonitor.recordAssertions).toHaveLength(2); expect(foundMonitor.recordAssertions[0].record).toBe("A"); expect(foundMonitor.recordAssertions[1].record).toBe("AAAA"); } finally { await db.delete(monitor).where(eq(monitor.id, Number(monitorId))); } }); }); describe("MonitorService - OpenTelemetry Configuration", () => { test("OpenTelemetry config is correctly stored and retrieved", async () => { const createRes = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-otel-config", url: "https://test-otel.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", openTelemetry: { endpoint: "https://otel-collector.example.com/v1/traces", headers: [ { key: "Authorization", value: "Bearer test-token" }, { key: "X-Custom-Header", value: "custom-value" }, ], }, }, }, { "x-openstatus-key": "1" }, ); expect(createRes.status).toBe(200); const createData = await createRes.json(); const monitorId = createData.monitor.id; try { const listRes = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(listRes.status).toBe(200); const listData = await listRes.json(); // Proto3 may omit empty arrays const httpMonitors = listData.httpMonitors || []; const foundMonitor = httpMonitors.find( (m: { id: string }) => m.id === monitorId, ); expect(foundMonitor).toBeDefined(); expect(foundMonitor.openTelemetry).toBeDefined(); expect(foundMonitor.openTelemetry.endpoint).toBe( "https://otel-collector.example.com/v1/traces", ); expect(foundMonitor.openTelemetry.headers).toHaveLength(2); expect(foundMonitor.openTelemetry.headers[0].key).toBe("Authorization"); } finally { await db.delete(monitor).where(eq(monitor.id, Number(monitorId))); } }); test("Monitor without OpenTelemetry config works correctly", async () => { const createRes = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-no-otel", url: "https://test-no-otel.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": "1" }, ); expect(createRes.status).toBe(200); const createData = await createRes.json(); const monitorId = createData.monitor.id; try { const listRes = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); const listData = await listRes.json(); // Proto3 may omit empty arrays const httpMonitors = listData.httpMonitors || []; const foundMonitor = httpMonitors.find( (m: { id: string }) => m.id === monitorId, ); expect(foundMonitor).toBeDefined(); // OpenTelemetry should be undefined when not configured expect(foundMonitor.openTelemetry).toBeUndefined(); } finally { await db.delete(monitor).where(eq(monitor.id, Number(monitorId))); } }); }); describe("MonitorService - Default Values", () => { test("HTTP monitor uses default values when not specified", async () => { const createRes = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-defaults", url: "https://test-defaults.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": "1" }, ); expect(createRes.status).toBe(200); const createData = await createRes.json(); const monitorId = createData.monitor.id; try { expect(createData.monitor.timeout).toBe("45000"); expect(createData.monitor.retry).toBe("3"); // Server applies business defaults when proto fields are omitted // followRedirects defaults to true (as documented in the proto) expect(createData.monitor.followRedirects).toBe(true); expect(createData.monitor.active ?? false).toBe(false); expect(createData.monitor.public ?? false).toBe(false); expect(createData.monitor.method).toBe("HTTP_METHOD_GET"); } finally { await db.delete(monitor).where(eq(monitor.id, Number(monitorId))); } }); }); describe("MonitorService - Limits", () => { // Workspace 2 has free plan with limited periodicity (10m, 30m, 1h) and max 6 regions const FREE_PLAN_KEY = "2"; test("returns error when periodicity is not allowed by plan", async () => { // Free plan only allows 10m, 30m, 1h - PERIODICITY_30S is not allowed const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-periodicity-limit", url: "https://test-periodicity.example.com", periodicity: "PERIODICITY_30S", method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": FREE_PLAN_KEY }, ); // Should return 403 (PermissionDenied maps to 403 in Connect) expect(res.status).toBe(403); const data = await res.json(); expect(data.message).toContain("periodicity"); }); test("returns error when too many regions specified", async () => { // Free plan has max-regions: 6, try to use 8 regions const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-region-limit", url: "https://test-region-limit.example.com", periodicity: "PERIODICITY_10M", method: "HTTP_METHOD_GET", regions: [ "REGION_FLY_AMS", "REGION_FLY_IAD", "REGION_FLY_SIN", "REGION_FLY_LHR", "REGION_FLY_SYD", "REGION_FLY_NRT", "REGION_FLY_FRA", "REGION_FLY_GRU", ], }, }, { "x-openstatus-key": FREE_PLAN_KEY }, ); // Should return 403 (PermissionDenied maps to 403 in Connect) expect(res.status).toBe(403); const data = await res.json(); expect(data.message).toContain("region"); }); test("allows valid periodicity for plan", async () => { // PERIODICITY_10M is allowed on free plan const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-valid-periodicity", url: "https://test-valid-periodicity.example.com", periodicity: "PERIODICITY_10M", method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": FREE_PLAN_KEY }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("TCP monitor respects periodicity limits", async () => { const res = await connectRequest( "CreateTCPMonitor", { monitor: { name: "test-tcp-periodicity-limit", uri: "tcp://test-periodicity.example.com:443", periodicity: "PERIODICITY_30S", }, }, { "x-openstatus-key": FREE_PLAN_KEY }, ); expect(res.status).toBe(403); const data = await res.json(); expect(data.message).toContain("periodicity"); }); test("DNS monitor respects periodicity limits", async () => { const res = await connectRequest( "CreateDNSMonitor", { monitor: { name: "test-dns-periodicity-limit", uri: "test-periodicity.example.com", periodicity: "PERIODICITY_30S", }, }, { "x-openstatus-key": FREE_PLAN_KEY }, ); expect(res.status).toBe(403); const data = await res.json(); expect(data.message).toContain("periodicity"); }); }); describe("MonitorService - Status Field", () => { test("HTTP monitor includes status field in response", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const httpMonitors = data.httpMonitors || []; const httpMon = httpMonitors.find( (m: { id: string }) => m.id === String(testHttpMonitorId), ); expect(httpMon).toBeDefined(); // Status should be present and be a valid MonitorStatus enum value expect(httpMon.status).toBeDefined(); expect([ "MONITOR_STATUS_ACTIVE", "MONITOR_STATUS_DEGRADED", "MONITOR_STATUS_ERROR", "MONITOR_STATUS_UNSPECIFIED", ]).toContain(httpMon.status); }); test("TCP monitor includes status field in response", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const tcpMonitors = data.tcpMonitors || []; const tcpMon = tcpMonitors.find( (m: { id: string }) => m.id === String(testTcpMonitorId), ); expect(tcpMon).toBeDefined(); // Status should be present and be a valid MonitorStatus enum value expect(tcpMon.status).toBeDefined(); expect([ "MONITOR_STATUS_ACTIVE", "MONITOR_STATUS_DEGRADED", "MONITOR_STATUS_ERROR", "MONITOR_STATUS_UNSPECIFIED", ]).toContain(tcpMon.status); }); test("DNS monitor includes status field in response", async () => { const res = await connectRequest( "ListMonitors", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const dnsMonitors = data.dnsMonitors || []; const dnsMon = dnsMonitors.find( (m: { id: string }) => m.id === String(testDnsMonitorId), ); expect(dnsMon).toBeDefined(); // Status should be present and be a valid MonitorStatus enum value expect(dnsMon.status).toBeDefined(); expect([ "MONITOR_STATUS_ACTIVE", "MONITOR_STATUS_DEGRADED", "MONITOR_STATUS_ERROR", "MONITOR_STATUS_UNSPECIFIED", ]).toContain(dnsMon.status); }); test("newly created HTTP monitor has active status by default", async () => { const res = await connectRequest( "CreateHTTPMonitor", { monitor: { name: "test-status-default", url: "https://test-status.example.com", periodicity: "PERIODICITY_5M", method: "HTTP_METHOD_GET", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); // New monitors default to active status expect(data.monitor.status).toBe("MONITOR_STATUS_ACTIVE"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("newly created TCP monitor has active status by default", async () => { const res = await connectRequest( "CreateTCPMonitor", { monitor: { name: "test-tcp-status-default", uri: "tcp://test-status.example.com:443", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); // New monitors default to active status expect(data.monitor.status).toBe("MONITOR_STATUS_ACTIVE"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); test("newly created DNS monitor has active status by default", async () => { const res = await connectRequest( "CreateDNSMonitor", { monitor: { name: "test-dns-status-default", uri: "test-status.example.com", periodicity: "PERIODICITY_5M", }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); // New monitors default to active status expect(data.monitor.status).toBe("MONITOR_STATUS_ACTIVE"); // Clean up if (data.monitor.id) { await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); } }); }); describe("MonitorService.GetMonitorStatus", () => { test("returns status for all configured regions", async () => { const res = await connectRequest( "GetMonitorStatus", { id: String(testMonitorWithStatusId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe(String(testMonitorWithStatusId)); expect(data.regions).toBeDefined(); expect(Array.isArray(data.regions)).toBe(true); // Should have 3 regions (ams, iad, fra) - not lhr which is stale expect(data.regions).toHaveLength(3); }); test("returns correct status values for each region", async () => { const res = await connectRequest( "GetMonitorStatus", { id: String(testMonitorWithStatusId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const regions = data.regions || []; // Find each region and verify status const amsRegion = regions.find( (r: { region: string }) => r.region === "REGION_FLY_AMS", ); const iadRegion = regions.find( (r: { region: string }) => r.region === "REGION_FLY_IAD", ); const fraRegion = regions.find( (r: { region: string }) => r.region === "REGION_FLY_FRA", ); expect(amsRegion).toBeDefined(); expect(amsRegion.status).toBe("MONITOR_STATUS_ACTIVE"); expect(iadRegion).toBeDefined(); expect(iadRegion.status).toBe("MONITOR_STATUS_ERROR"); expect(fraRegion).toBeDefined(); expect(fraRegion.status).toBe("MONITOR_STATUS_DEGRADED"); }); test("does not return stale regions not in monitor configuration", async () => { const res = await connectRequest( "GetMonitorStatus", { id: String(testMonitorWithStatusId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const regions = data.regions || []; // lhr has a status entry but is not in the monitor's configured regions const lhrRegion = regions.find( (r: { region: string }) => r.region === "REGION_FLY_LHR", ); expect(lhrRegion).toBeUndefined(); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "GetMonitorStatus", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetMonitorStatus", { id: String(testMonitorWithStatusId), }); expect(res.status).toBe(401); }); test("cannot get status from another workspace", async () => { // Create a monitor for workspace 2 const otherWorkspaceMon = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-status-other-ws`, url: "https://other-ws-status.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { // Try to get status with workspace 1's key const res = await connectRequest( "GetMonitorStatus", { id: String(otherWorkspaceMon.id) }, { "x-openstatus-key": "1" }, ); // Should return 404 (not found in this workspace) expect(res.status).toBe(404); } finally { await db.delete(monitor).where(eq(monitor.id, otherWorkspaceMon.id)); } }); test("returns empty regions array when monitor has no status entries", async () => { // Create a monitor without status entries const noStatusMon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-no-status`, url: "https://no-status.example.com", periodicity: "1m", active: true, regions: "ams,iad", jobType: "http", }) .returning() .get(); try { const res = await connectRequest( "GetMonitorStatus", { id: String(noStatusMon.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe(String(noStatusMon.id)); // Proto3 may omit empty arrays expect(data.regions || []).toEqual([]); } finally { await db.delete(monitor).where(eq(monitor.id, noStatusMon.id)); } }); }); describe("MonitorService.GetMonitor", () => { test("returns HTTP monitor with correct structure", async () => { const res = await connectRequest( "GetMonitor", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.http).toBeDefined(); expect(data.monitor.http.id).toBe(String(testHttpMonitorId)); expect(data.monitor.http.url).toBe("https://example.com"); expect(data.monitor.http.method).toBe("HTTP_METHOD_GET"); expect(data.monitor.http.periodicity).toBe("PERIODICITY_1M"); // Should not have tcp or dns expect(data.monitor.tcp).toBeUndefined(); expect(data.monitor.dns).toBeUndefined(); }); test("returns TCP monitor with correct structure", async () => { const res = await connectRequest( "GetMonitor", { id: String(testTcpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.tcp).toBeDefined(); expect(data.monitor.tcp.id).toBe(String(testTcpMonitorId)); expect(data.monitor.tcp.uri).toBe("tcp://example.com:443"); expect(data.monitor.tcp.periodicity).toBe("PERIODICITY_5M"); // Should not have http or dns expect(data.monitor.http).toBeUndefined(); expect(data.monitor.dns).toBeUndefined(); }); test("returns DNS monitor with correct structure", async () => { const res = await connectRequest( "GetMonitor", { id: String(testDnsMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.monitor).toBeDefined(); expect(data.monitor.dns).toBeDefined(); expect(data.monitor.dns.id).toBe(String(testDnsMonitorId)); expect(data.monitor.dns.uri).toBe("example.com"); expect(data.monitor.dns.periodicity).toBe("PERIODICITY_10M"); // Should not have http or tcp expect(data.monitor.http).toBeUndefined(); expect(data.monitor.tcp).toBeUndefined(); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "GetMonitor", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetMonitor", { id: String(testHttpMonitorId), }); expect(res.status).toBe(401); }); test("cannot get monitor from another workspace", async () => { // Create a monitor for workspace 2 const otherWorkspaceMon = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-get-other-ws`, url: "https://other-ws-get.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { // Try to get with workspace 1's key const res = await connectRequest( "GetMonitor", { id: String(otherWorkspaceMon.id) }, { "x-openstatus-key": "1" }, ); // Should return 404 (not found in this workspace) expect(res.status).toBe(404); } finally { await db.delete(monitor).where(eq(monitor.id, otherWorkspaceMon.id)); } }); test("invalid API key returns 401", async () => { const res = await connectRequest( "GetMonitor", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "invalid-key" }, ); expect(res.status).toBe(401); }); test("returns 500 when monitor data fails schema parsing", async () => { // Insert a monitor with invalid data that will fail selectMonitorSchema parsing const corruptedMon = await db .insert(monitor) // @ts-expect-error - intentionally invalid periodicity to test parse failure .values({ workspaceId: 1, name: `${TEST_PREFIX}-corrupted-data`, url: "https://corrupted.example.com", periodicity: "invalid-periodicity", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { const res = await connectRequest( "GetMonitor", { id: String(corruptedMon.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(500); } finally { await db.delete(monitor).where(eq(monitor.id, corruptedMon.id)); } }); }); describe("MonitorService.GetMonitorSummary", () => { test("returns summary for HTTP monitor with correct structure", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe(String(testHttpMonitorId)); // Proto3 JSON omits default values, so we check with defaults // lastPingAt is empty string when no data, may be omitted expect(data.lastPingAt ?? "").toBe(""); // Numeric values default to "0" but may be omitted in proto3 JSON expect(data.totalSuccessful ?? "0").toBe("0"); expect(data.totalDegraded ?? "0").toBe("0"); expect(data.totalFailed ?? "0").toBe("0"); expect(data.p50 ?? "0").toBe("0"); expect(data.p75 ?? "0").toBe("0"); expect(data.p90 ?? "0").toBe("0"); expect(data.p95 ?? "0").toBe("0"); expect(data.p99 ?? "0").toBe("0"); expect(data.timeRange).toBe("TIME_RANGE_1D"); // regions array - check it exists (may be empty or omitted) expect(Array.isArray(data.regions ?? [])).toBe(true); }); test("returns summary for TCP monitor", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testTcpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe(String(testTcpMonitorId)); expect(data).toHaveProperty("timeRange"); }); test("returns summary for DNS monitor", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testDnsMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.id).toBe(String(testDnsMonitorId)); expect(data).toHaveProperty("timeRange"); }); test("defaults to TIME_RANGE_1D when time range not specified", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.timeRange).toBe("TIME_RANGE_1D"); }); test("accepts TIME_RANGE_7D parameter", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId), timeRange: "TIME_RANGE_7D" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.timeRange).toBe("TIME_RANGE_7D"); }); test("accepts TIME_RANGE_14D parameter", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId), timeRange: "TIME_RANGE_14D" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.timeRange).toBe("TIME_RANGE_14D"); }); test("accepts regions filter parameter", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testMonitorWithStatusId), regions: ["REGION_FLY_AMS", "REGION_FLY_IAD"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.regions).toBeDefined(); expect(Array.isArray(data.regions)).toBe(true); // Should return the requested regions expect(data.regions).toContain("REGION_FLY_AMS"); expect(data.regions).toContain("REGION_FLY_IAD"); expect(data.regions).toHaveLength(2); }); test("uses monitor configured regions when no regions filter provided", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testMonitorWithStatusId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.regions).toBeDefined(); // Monitor has regions: "ams,iad,fra" expect(data.regions).toContain("REGION_FLY_AMS"); expect(data.regions).toContain("REGION_FLY_IAD"); expect(data.regions).toContain("REGION_FLY_FRA"); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "GetMonitorSummary", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetMonitorSummary", { id: String(testHttpMonitorId), }); expect(res.status).toBe(401); }); test("cannot get summary from another workspace", async () => { // Create a monitor for workspace 2 const otherWorkspaceMon = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-summary-other-ws`, url: "https://other-ws-summary.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", }) .returning() .get(); try { // Try to get summary with workspace 1's key const res = await connectRequest( "GetMonitorSummary", { id: String(otherWorkspaceMon.id) }, { "x-openstatus-key": "1" }, ); // Should return 404 (not found in this workspace) expect(res.status).toBe(404); } finally { await db.delete(monitor).where(eq(monitor.id, otherWorkspaceMon.id)); } }); test("returns zero values when no metrics data available", async () => { // In test environment, Tinybird returns empty data (NoopTinybird) const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 JSON omits default values, so we check with defaults expect(data.totalSuccessful ?? "0").toBe("0"); expect(data.totalDegraded ?? "0").toBe("0"); expect(data.totalFailed ?? "0").toBe("0"); expect(data.p50 ?? "0").toBe("0"); expect(data.p75 ?? "0").toBe("0"); expect(data.p90 ?? "0").toBe("0"); expect(data.p95 ?? "0").toBe("0"); expect(data.p99 ?? "0").toBe("0"); expect(data.lastPingAt ?? "").toBe(""); }); test("invalid API key returns 401", async () => { const res = await connectRequest( "GetMonitorSummary", { id: String(testHttpMonitorId) }, { "x-openstatus-key": "invalid-key" }, ); expect(res.status).toBe(401); }); }); ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/assertions.ts ================================================ import { getLogger } from "@logtape/logtape"; import { type Assertion, deserialize, headerAssertion, recordAssertion, statusAssertion, textBodyAssertion, } from "@openstatus/assertions"; import type { BodyAssertion, HeaderAssertion, RecordAssertion, StatusCodeAssertion, } from "@openstatus/proto/monitor/v1"; import { compareToNumberComparator, compareToRecordComparator, compareToStringComparator, numberComparatorToString, recordComparatorToString, stringComparatorToString, } from "./comparators"; const logger = getLogger("api-server"); // ============================================================ // DB to Proto (for reads) // ============================================================ export interface HttpAssertions { statusCodeAssertions: StatusCodeAssertion[]; bodyAssertions: BodyAssertion[]; headerAssertions: HeaderAssertion[]; } /** * Parse database assertions JSON for HTTP monitors using @openstatus/assertions package. */ export function parseHttpAssertions( assertionsJson: string | null, ): HttpAssertions { const result: HttpAssertions = { statusCodeAssertions: [], bodyAssertions: [], headerAssertions: [], }; if (!assertionsJson) { return result; } try { const assertions = deserialize(assertionsJson); for (const a of assertions) { const schema = a.schema; switch (schema.type) { case "status": { const parsed = statusAssertion.parse(schema); result.statusCodeAssertions.push({ $typeName: "openstatus.monitor.v1.StatusCodeAssertion", target: BigInt(parsed.target), comparator: compareToNumberComparator(parsed.compare), }); break; } case "textBody": case "jsonBody": { const parsed = textBodyAssertion.parse(schema); result.bodyAssertions.push({ $typeName: "openstatus.monitor.v1.BodyAssertion", target: parsed.target, comparator: compareToStringComparator(parsed.compare), }); break; } case "header": { const parsed = headerAssertion.parse(schema); result.headerAssertions.push({ $typeName: "openstatus.monitor.v1.HeaderAssertion", target: parsed.target, comparator: compareToStringComparator(parsed.compare), key: parsed.key, }); break; } } } } catch (error) { logger.error("Failed to parse HTTP assertions JSON", { error: error instanceof Error ? error.message : String(error), assertions_json: assertionsJson, }); } return result; } /** * Parse database assertions JSON for DNS monitors using @openstatus/assertions package. */ export function parseDnsAssertions( assertionsJson: string | null, ): RecordAssertion[] { if (!assertionsJson) { return []; } try { // Normalize legacy DNS format before deserializing const assertions = deserialize(assertionsJson); return assertions .filter( (a): a is Assertion & { schema: { type: "dnsRecord" } } => a.schema.type === "dnsRecord", ) .map((a) => { const parsed = recordAssertion.parse(a.schema); return { $typeName: "openstatus.monitor.v1.RecordAssertion" as const, record: parsed.key, target: parsed.target, comparator: compareToRecordComparator(parsed.compare), }; }); } catch (error) { logger.error("Failed to parse DNS assertions JSON", { error: error instanceof Error ? error.message : String(error), assertions_json: assertionsJson, }); return []; } } // ============================================================ // Proto to DB (for writes) // ============================================================ /** * Convert HTTP monitor proto assertions to database JSON string. * Uses @openstatus/assertions package format. */ export function httpAssertionsToDbJson( statusCodeAssertions: StatusCodeAssertion[], bodyAssertions: BodyAssertion[], headerAssertions: HeaderAssertion[], ): string | undefined { const schemas: Array<Record<string, unknown>> = []; for (const s of statusCodeAssertions) { schemas.push({ version: "v1", type: "status", compare: numberComparatorToString(s.comparator), target: Number(s.target), }); } for (const b of bodyAssertions) { schemas.push({ version: "v1", type: "textBody", compare: stringComparatorToString(b.comparator), target: b.target, }); } for (const h of headerAssertions) { schemas.push({ version: "v1", type: "header", compare: stringComparatorToString(h.comparator), target: h.target, key: h.key, }); } return schemas.length > 0 ? JSON.stringify(schemas) : undefined; } /** * Convert DNS monitor proto assertions to database JSON string. * Uses @openstatus/assertions package format with dnsRecord type. */ export function dnsAssertionsToDbJson( recordAssertions: RecordAssertion[], ): string | undefined { if (recordAssertions.length === 0) { return undefined; } const schemas = recordAssertions.map((a) => ({ version: "v1", type: "dnsRecord", compare: recordComparatorToString(a.comparator), target: a.target, key: a.record, })); return JSON.stringify(schemas); } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/comparators.ts ================================================ import { NumberComparator, RecordComparator, StringComparator, } from "@openstatus/proto/monitor/v1"; // ============================================================ // DB to Proto (for reads) // ============================================================ const DB_TO_NUMBER_COMPARATOR: Record<string, NumberComparator> = { eq: NumberComparator.EQUAL, not_eq: NumberComparator.NOT_EQUAL, gt: NumberComparator.GREATER_THAN, gte: NumberComparator.GREATER_THAN_OR_EQUAL, lt: NumberComparator.LESS_THAN, lte: NumberComparator.LESS_THAN_OR_EQUAL, }; const DB_TO_STRING_COMPARATOR: Record<string, StringComparator> = { eq: StringComparator.EQUAL, not_eq: StringComparator.NOT_EQUAL, contains: StringComparator.CONTAINS, not_contains: StringComparator.NOT_CONTAINS, empty: StringComparator.EMPTY, not_empty: StringComparator.NOT_EMPTY, gt: StringComparator.GREATER_THAN, gte: StringComparator.GREATER_THAN_OR_EQUAL, lt: StringComparator.LESS_THAN, lte: StringComparator.LESS_THAN_OR_EQUAL, }; const DB_TO_RECORD_COMPARATOR: Record<string, RecordComparator> = { eq: RecordComparator.EQUAL, not_eq: RecordComparator.NOT_EQUAL, contains: RecordComparator.CONTAINS, not_contains: RecordComparator.NOT_CONTAINS, }; export function compareToNumberComparator(compare: string): NumberComparator { return DB_TO_NUMBER_COMPARATOR[compare] ?? NumberComparator.UNSPECIFIED; } export function compareToStringComparator(compare: string): StringComparator { return DB_TO_STRING_COMPARATOR[compare] ?? StringComparator.UNSPECIFIED; } export function compareToRecordComparator(compare: string): RecordComparator { return DB_TO_RECORD_COMPARATOR[compare] ?? RecordComparator.UNSPECIFIED; } // ============================================================ // Proto to DB (for writes) // ============================================================ const NUMBER_COMPARATOR_TO_DB: Record<NumberComparator, string> = { [NumberComparator.EQUAL]: "eq", [NumberComparator.NOT_EQUAL]: "not_eq", [NumberComparator.GREATER_THAN]: "gt", [NumberComparator.GREATER_THAN_OR_EQUAL]: "gte", [NumberComparator.LESS_THAN]: "lt", [NumberComparator.LESS_THAN_OR_EQUAL]: "lte", [NumberComparator.UNSPECIFIED]: "eq", }; const STRING_COMPARATOR_TO_DB: Record<StringComparator, string> = { [StringComparator.EQUAL]: "eq", [StringComparator.NOT_EQUAL]: "not_eq", [StringComparator.CONTAINS]: "contains", [StringComparator.NOT_CONTAINS]: "not_contains", [StringComparator.EMPTY]: "empty", [StringComparator.NOT_EMPTY]: "not_empty", [StringComparator.GREATER_THAN]: "gt", [StringComparator.GREATER_THAN_OR_EQUAL]: "gte", [StringComparator.LESS_THAN]: "lt", [StringComparator.LESS_THAN_OR_EQUAL]: "lte", [StringComparator.UNSPECIFIED]: "eq", }; const RECORD_COMPARATOR_TO_DB: Record<RecordComparator, string> = { [RecordComparator.EQUAL]: "eq", [RecordComparator.NOT_EQUAL]: "not_eq", [RecordComparator.CONTAINS]: "contains", [RecordComparator.NOT_CONTAINS]: "not_contains", [RecordComparator.UNSPECIFIED]: "eq", }; export function numberComparatorToString(comp: NumberComparator): string { return NUMBER_COMPARATOR_TO_DB[comp] ?? "eq"; } export function stringComparatorToString(comp: StringComparator): string { return STRING_COMPARATOR_TO_DB[comp] ?? "eq"; } export function recordComparatorToString(comp: RecordComparator): string { return RECORD_COMPARATOR_TO_DB[comp] ?? "eq"; } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/defaults.ts ================================================ /** * Default values for monitor fields. */ export const MONITOR_DEFAULTS = { timeout: 45000, retry: 3, followRedirects: true, active: false, public: false, description: "", } as const; ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/enums.ts ================================================ import { HTTPMethod, MonitorStatus, Periodicity, TimeRange, } from "@openstatus/proto/monitor/v1"; import type { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants"; import type { z } from "zod"; // ============================================================ // Periodicity Conversions // ============================================================ const DB_TO_PERIODICITY: Record<string, Periodicity> = { "30s": Periodicity.PERIODICITY_30S, "1m": Periodicity.PERIODICITY_1M, "5m": Periodicity.PERIODICITY_5M, "10m": Periodicity.PERIODICITY_10M, "30m": Periodicity.PERIODICITY_30M, "1h": Periodicity.PERIODICITY_1H, }; const PERIODICITY_TO_DB: Record< Periodicity, z.infer<typeof monitorPeriodicitySchema> > = { [Periodicity.PERIODICITY_30S]: "30s", [Periodicity.PERIODICITY_1M]: "1m", [Periodicity.PERIODICITY_5M]: "5m", [Periodicity.PERIODICITY_10M]: "10m", [Periodicity.PERIODICITY_30M]: "30m", [Periodicity.PERIODICITY_1H]: "1h", [Periodicity.PERIODICITY_UNSPECIFIED]: "1m", }; export function stringToPeriodicity(value: string): Periodicity { return DB_TO_PERIODICITY[value] ?? Periodicity.PERIODICITY_UNSPECIFIED; } export function periodicityToString(value: Periodicity) { return PERIODICITY_TO_DB[value] ?? "1m"; } // ============================================================ // HTTP Method Conversions // ============================================================ const DB_TO_HTTP_METHOD: Record<string, HTTPMethod> = { GET: HTTPMethod.HTTP_METHOD_GET, POST: HTTPMethod.HTTP_METHOD_POST, HEAD: HTTPMethod.HTTP_METHOD_HEAD, PUT: HTTPMethod.HTTP_METHOD_PUT, PATCH: HTTPMethod.HTTP_METHOD_PATCH, DELETE: HTTPMethod.HTTP_METHOD_DELETE, TRACE: HTTPMethod.HTTP_METHOD_TRACE, CONNECT: HTTPMethod.HTTP_METHOD_CONNECT, OPTIONS: HTTPMethod.HTTP_METHOD_OPTIONS, }; const HTTP_METHOD_TO_DB: Record<HTTPMethod, string> = { [HTTPMethod.HTTP_METHOD_GET]: "GET", [HTTPMethod.HTTP_METHOD_POST]: "POST", [HTTPMethod.HTTP_METHOD_HEAD]: "HEAD", [HTTPMethod.HTTP_METHOD_PUT]: "PUT", [HTTPMethod.HTTP_METHOD_PATCH]: "PATCH", [HTTPMethod.HTTP_METHOD_DELETE]: "DELETE", [HTTPMethod.HTTP_METHOD_TRACE]: "TRACE", [HTTPMethod.HTTP_METHOD_CONNECT]: "CONNECT", [HTTPMethod.HTTP_METHOD_OPTIONS]: "OPTIONS", [HTTPMethod.HTTP_METHOD_UNSPECIFIED]: "GET", }; export function stringToHttpMethod(value: string | undefined): HTTPMethod { return ( DB_TO_HTTP_METHOD[value?.toUpperCase() ?? ""] ?? HTTPMethod.HTTP_METHOD_UNSPECIFIED ); } export function httpMethodToString(value: HTTPMethod): string { return HTTP_METHOD_TO_DB[value] ?? "GET"; } // ============================================================ // Monitor Status Conversions // ============================================================ const DB_TO_MONITOR_STATUS: Record<string, MonitorStatus> = { active: MonitorStatus.ACTIVE, degraded: MonitorStatus.DEGRADED, error: MonitorStatus.ERROR, }; export function stringToMonitorStatus(value: string): MonitorStatus { return DB_TO_MONITOR_STATUS[value] ?? MonitorStatus.UNSPECIFIED; } // ============================================================ // Time Range Conversions // ============================================================ export type TimeRangeKey = "1d" | "7d" | "14d"; const TIME_RANGE_TO_KEY: Record<TimeRange, TimeRangeKey> = { [TimeRange.TIME_RANGE_1D]: "1d", [TimeRange.TIME_RANGE_7D]: "7d", [TimeRange.TIME_RANGE_14D]: "14d", [TimeRange.TIME_RANGE_UNSPECIFIED]: "1d", }; export function timeRangeToKey(value: TimeRange): TimeRangeKey { return TIME_RANGE_TO_KEY[value] ?? "1d"; } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/headers.ts ================================================ import type { Headers, OpenTelemetryConfig, } from "@openstatus/proto/monitor/v1"; // ============================================================ // DB to Proto (for reads) // ============================================================ /** * Convert headers array to proto Headers array. */ export function toProtoHeaders( headers: Array<{ key: string; value: string }> | null | undefined, ): Headers[] { if (!headers || headers.length === 0) { return []; } return headers.map((h) => ({ $typeName: "openstatus.monitor.v1.Headers" as const, key: h.key, value: h.value, })); } /** * Parse OpenTelemetry configuration from database fields. */ export function parseOpenTelemetry( endpoint: string | null, headers: Array<{ key: string; value: string }> | null | undefined, ): OpenTelemetryConfig | undefined { if (!endpoint) { return undefined; } return { $typeName: "openstatus.monitor.v1.OpenTelemetryConfig", endpoint, headers: toProtoHeaders(headers), }; } // ============================================================ // Proto to DB (for writes) // ============================================================ /** * Convert proto Headers array to database JSON string. */ export function headersToDbJson(headers: Headers[]): string | undefined { if (headers.length === 0) { return undefined; } return JSON.stringify(headers.map((h) => ({ key: h.key, value: h.value }))); } /** * Convert OpenTelemetry config to database fields. */ export function openTelemetryToDb(config: OpenTelemetryConfig | undefined): { otelEndpoint: string | undefined; otelHeaders: string | undefined; } { if (!config || !config.endpoint) { return { otelEndpoint: undefined, otelHeaders: undefined, }; } return { otelEndpoint: config.endpoint, otelHeaders: headersToDbJson(config.headers), }; } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/index.ts ================================================ // Barrel file for converter modules // Assertions export { parseHttpAssertions, parseDnsAssertions, httpAssertionsToDbJson, dnsAssertionsToDbJson, type HttpAssertions, } from "./assertions"; // Comparators export { compareToNumberComparator, compareToStringComparator, compareToRecordComparator, numberComparatorToString, stringComparatorToString, recordComparatorToString, } from "./comparators"; // Defaults export { MONITOR_DEFAULTS } from "./defaults"; // Enums export { stringToPeriodicity, periodicityToString, stringToHttpMethod, httpMethodToString, stringToMonitorStatus, timeRangeToKey, type TimeRangeKey, } from "./enums"; // Headers export { toProtoHeaders, parseOpenTelemetry, headersToDbJson, openTelemetryToDb, } from "./headers"; // Monitors export { dbMonitorToHttpProto, dbMonitorToTcpProto, dbMonitorToDnsProto, } from "./monitors"; // Regions export { stringToRegion, regionToString, stringsToRegions, regionsToStrings, regionsToDbString, validateRegions, } from "./regions"; ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/monitors.ts ================================================ import type { Monitor } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, HTTPMonitor, TCPMonitor, } from "@openstatus/proto/monitor/v1"; import { parseDnsAssertions, parseHttpAssertions } from "./assertions"; import { MONITOR_DEFAULTS } from "./defaults"; import { stringToHttpMethod, stringToMonitorStatus, stringToPeriodicity, } from "./enums"; import { parseOpenTelemetry, toProtoHeaders } from "./headers"; import { stringsToRegions } from "./regions"; /** * Transform database HTTP monitor to proto HTTPMonitor. */ export function dbMonitorToHttpProto(dbMon: Monitor): HTTPMonitor { const assertions = parseHttpAssertions(dbMon.assertions); return { $typeName: "openstatus.monitor.v1.HTTPMonitor", id: String(dbMon.id), name: dbMon.name, url: dbMon.url, periodicity: stringToPeriodicity(dbMon.periodicity), method: stringToHttpMethod(dbMon.method), body: dbMon.body ?? "", timeout: BigInt(dbMon.timeout), degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), followRedirects: dbMon.followRedirects ?? MONITOR_DEFAULTS.followRedirects, headers: toProtoHeaders(dbMon.headers), statusCodeAssertions: assertions.statusCodeAssertions, bodyAssertions: assertions.bodyAssertions, headerAssertions: assertions.headerAssertions, description: dbMon.description, active: dbMon.active ?? MONITOR_DEFAULTS.active, public: dbMon.public ?? MONITOR_DEFAULTS.public, regions: stringsToRegions(dbMon.regions), openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), status: stringToMonitorStatus(dbMon.status), }; } /** * Transform database TCP monitor to proto TCPMonitor. */ export function dbMonitorToTcpProto(dbMon: Monitor): TCPMonitor { return { $typeName: "openstatus.monitor.v1.TCPMonitor", id: String(dbMon.id), name: dbMon.name, uri: dbMon.url, periodicity: stringToPeriodicity(dbMon.periodicity), timeout: BigInt(dbMon.timeout), degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), description: dbMon.description, active: dbMon.active ?? MONITOR_DEFAULTS.active, public: dbMon.public ?? MONITOR_DEFAULTS.public, regions: stringsToRegions(dbMon.regions), openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), status: stringToMonitorStatus(dbMon.status), }; } /** * Transform database DNS monitor to proto DNSMonitor. */ export function dbMonitorToDnsProto(dbMon: Monitor): DNSMonitor { return { $typeName: "openstatus.monitor.v1.DNSMonitor", id: String(dbMon.id), name: dbMon.name, uri: dbMon.url, periodicity: stringToPeriodicity(dbMon.periodicity), timeout: BigInt(dbMon.timeout), degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), recordAssertions: parseDnsAssertions(dbMon.assertions), description: dbMon.description, active: dbMon.active ?? MONITOR_DEFAULTS.active, public: dbMon.public ?? MONITOR_DEFAULTS.public, regions: stringsToRegions(dbMon.regions), openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), status: stringToMonitorStatus(dbMon.status), }; } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/converters/regions.ts ================================================ import { Region } from "@openstatus/proto/monitor/v1"; import { AVAILABLE_REGIONS } from "@openstatus/regions"; /** * Mapping from database region strings to proto Region enum. */ const DB_TO_REGION: Record<string, Region> = { // Fly.io regions ams: Region.FLY_AMS, arn: Region.FLY_ARN, bom: Region.FLY_BOM, cdg: Region.FLY_CDG, dfw: Region.FLY_DFW, ewr: Region.FLY_EWR, fra: Region.FLY_FRA, gru: Region.FLY_GRU, iad: Region.FLY_IAD, jnb: Region.FLY_JNB, lax: Region.FLY_LAX, lhr: Region.FLY_LHR, nrt: Region.FLY_NRT, ord: Region.FLY_ORD, sjc: Region.FLY_SJC, sin: Region.FLY_SIN, syd: Region.FLY_SYD, yyz: Region.FLY_YYZ, // Koyeb regions koyeb_fra: Region.KOYEB_FRA, koyeb_par: Region.KOYEB_PAR, koyeb_sfo: Region.KOYEB_SFO, koyeb_sin: Region.KOYEB_SIN, koyeb_tyo: Region.KOYEB_TYO, koyeb_was: Region.KOYEB_WAS, // Railway regions "railway_us-west2": Region.RAILWAY_US_WEST2, "railway_us-east4": Region.RAILWAY_US_EAST4, "railway_europe-west4": Region.RAILWAY_EUROPE_WEST4, "railway_asia-southeast1": Region.RAILWAY_ASIA_SOUTHEAST1, }; /** * Mapping from proto Region enum to database strings. */ const REGION_TO_DB: Record<Region, string> = { // Fly.io regions [Region.FLY_AMS]: "ams", [Region.FLY_ARN]: "arn", [Region.FLY_BOM]: "bom", [Region.FLY_CDG]: "cdg", [Region.FLY_DFW]: "dfw", [Region.FLY_EWR]: "ewr", [Region.FLY_FRA]: "fra", [Region.FLY_GRU]: "gru", [Region.FLY_IAD]: "iad", [Region.FLY_JNB]: "jnb", [Region.FLY_LAX]: "lax", [Region.FLY_LHR]: "lhr", [Region.FLY_NRT]: "nrt", [Region.FLY_ORD]: "ord", [Region.FLY_SJC]: "sjc", [Region.FLY_SIN]: "sin", [Region.FLY_SYD]: "syd", [Region.FLY_YYZ]: "yyz", // Koyeb regions [Region.KOYEB_FRA]: "koyeb_fra", [Region.KOYEB_PAR]: "koyeb_par", [Region.KOYEB_SFO]: "koyeb_sfo", [Region.KOYEB_SIN]: "koyeb_sin", [Region.KOYEB_TYO]: "koyeb_tyo", [Region.KOYEB_WAS]: "koyeb_was", // Railway regions [Region.RAILWAY_US_WEST2]: "railway_us-west2", [Region.RAILWAY_US_EAST4]: "railway_us-east4", [Region.RAILWAY_EUROPE_WEST4]: "railway_europe-west4", [Region.RAILWAY_ASIA_SOUTHEAST1]: "railway_asia-southeast1", // Unspecified [Region.UNSPECIFIED]: "", }; /** * Convert database region string to proto Region enum. */ export function stringToRegion(value: string): Region { return DB_TO_REGION[value.toLowerCase()] ?? Region.UNSPECIFIED; } /** * Convert proto Region enum to database string. */ export function regionToString(value: Region): string { return REGION_TO_DB[value] ?? ""; } /** * Convert database regions array to proto Region enum array. */ export function stringsToRegions(values: string[]): Region[] { return values.map(stringToRegion).filter((r) => r !== Region.UNSPECIFIED); } /** * Convert proto Region enum array to database strings. */ export function regionsToStrings(values: Region[]): string[] { return values.map(regionToString).filter((r) => r !== ""); } /** * Convert regions array to database string format (comma-separated). */ export function regionsToDbString(regions: string[]): string { return regions.join(","); } /** * Validate that all regions are valid available regions. * Returns an array of invalid region codes, or empty array if all valid. */ export function validateRegions(regions: string[]): string[] { const availableSet = new Set(AVAILABLE_REGIONS); return regions.filter( (r) => !availableSet.has(r as (typeof AVAILABLE_REGIONS)[number]), ); } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/errors.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; /** * Error reasons for structured error handling. */ export const ErrorReason = { MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", MONITOR_REQUIRED: "MONITOR_REQUIRED", MONITOR_ID_REQUIRED: "MONITOR_ID_REQUIRED", MONITOR_CREATE_FAILED: "MONITOR_CREATE_FAILED", MONITOR_UPDATE_FAILED: "MONITOR_UPDATE_FAILED", MONITOR_PARSE_FAILED: "MONITOR_PARSE_FAILED", MONITOR_RUN_CREATE_FAILED: "MONITOR_RUN_CREATE_FAILED", MONITOR_INVALID_DATA: "MONITOR_INVALID_DATA", MONITOR_TYPE_MISMATCH: "MONITOR_TYPE_MISMATCH", RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", } as const; export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; const DOMAIN = "openstatus.dev"; /** * Creates a ConnectError with structured metadata. * * This provides machine-parseable error information via metadata headers * while maintaining human-readable error messages. */ function createError( message: string, code: Code, reason: ErrorReason, metadata?: Record<string, string>, ): ConnectError { const headers = new Headers({ "error-domain": DOMAIN, "error-reason": reason, }); if (metadata) { for (const [key, value] of Object.entries(metadata)) { headers.set(`error-${key}`, value); } } return new ConnectError(message, code, headers); } /** * Creates a "monitor not found" error with the monitor ID in metadata. */ export function monitorNotFoundError(monitorId: string): ConnectError { return createError( "Monitor not found", Code.NotFound, ErrorReason.MONITOR_NOT_FOUND, { "monitor-id": monitorId }, ); } /** * Creates a "monitor required" error. */ export function monitorRequiredError(): ConnectError { return createError( "Monitor is required", Code.InvalidArgument, ErrorReason.MONITOR_REQUIRED, ); } /** * Creates a "monitor ID required" error. */ export function monitorIdRequiredError(): ConnectError { return createError( "Monitor ID is required", Code.InvalidArgument, ErrorReason.MONITOR_ID_REQUIRED, ); } /** * Creates a "failed to create monitor" error. */ export function monitorCreateFailedError(): ConnectError { return createError( "Failed to create monitor", Code.Internal, ErrorReason.MONITOR_CREATE_FAILED, ); } /** * Creates a "failed to update monitor" error. */ export function monitorUpdateFailedError(monitorId: string): ConnectError { return createError( "Failed to update monitor", Code.Internal, ErrorReason.MONITOR_UPDATE_FAILED, { "monitor-id": monitorId }, ); } /** * Creates a "monitor type mismatch" error when trying to update with wrong type. */ export function monitorTypeMismatchError( monitorId: string, expectedType: string, actualType: string, ): ConnectError { return createError( `Monitor type mismatch: expected ${expectedType}, got ${actualType}`, Code.InvalidArgument, ErrorReason.MONITOR_TYPE_MISMATCH, { "monitor-id": monitorId, "expected-type": expectedType, "actual-type": actualType, }, ); } /** * Creates a "failed to parse monitor data" error. */ export function monitorParseFailedError(monitorId?: string): ConnectError { return createError( "Failed to parse monitor data", Code.Internal, ErrorReason.MONITOR_PARSE_FAILED, monitorId ? { "monitor-id": monitorId } : undefined, ); } /** * Creates a "failed to create monitor run" error. */ export function monitorRunCreateFailedError(monitorId: string): ConnectError { return createError( "Failed to create monitor run", Code.Internal, ErrorReason.MONITOR_RUN_CREATE_FAILED, { "monitor-id": monitorId }, ); } /** * Creates an "invalid monitor data" error for corrupted data. */ export function monitorInvalidDataError(monitorId: string): ConnectError { return createError( "Invalid monitor data, please contact support", Code.Internal, ErrorReason.MONITOR_INVALID_DATA, { "monitor-id": monitorId }, ); } /** * Creates a rate limit exceeded error. */ export function rateLimitExceededError( limit: number, current: number, ): ConnectError { return createError( "Upgrade for more checks", Code.ResourceExhausted, ErrorReason.RATE_LIMIT_EXCEEDED, { limit: String(limit), current: String(current) }, ); } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/index.ts ================================================ import { env } from "@/env"; import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; import { tb } from "@/libs/clients"; import type { ServiceImpl } from "@connectrpc/connect"; import { and, db, eq, gte, inArray, isNull, sql } from "@openstatus/db"; import { monitor, monitorRun, monitorTagsToMonitors, notificationsToMonitors, pageComponent, } from "@openstatus/db/src/schema"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, GetMonitorResponse, GetMonitorSummaryResponse, HTTPMonitor, MonitorConfig, MonitorService, RegionStatus, TCPMonitor, } from "@openstatus/proto/monitor/v1"; import { TimeRange } from "@openstatus/proto/monitor/v1"; import { getRpcContext } from "../../interceptors"; import { MONITOR_DEFAULTS, type TimeRangeKey, dbMonitorToDnsProto, dbMonitorToHttpProto, dbMonitorToTcpProto, dnsAssertionsToDbJson, headersToDbJson, httpAssertionsToDbJson, httpMethodToString, regionsToStrings, stringToMonitorStatus, stringToRegion, stringsToRegions, timeRangeToKey, } from "./converters"; import { monitorCreateFailedError, monitorIdRequiredError, monitorInvalidDataError, monitorNotFoundError, monitorParseFailedError, monitorRequiredError, monitorRunCreateFailedError, monitorTypeMismatchError, monitorUpdateFailedError, rateLimitExceededError, } from "./errors"; import { checkMonitorLimits } from "./limits"; import { getCommonDbValues, getCommonDbValuesForUpdate, toValidMethod, validateCommonMonitorFields, } from "./validators"; /** * Helper to get a monitor by ID with workspace scope. */ async function getMonitorById(id: number, workspaceId: number) { return db .select() .from(monitor) .where( and( eq(monitor.id, id), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); } type DBMonitor = NonNullable<Awaited<ReturnType<typeof getMonitorById>>>; /** * Helper to validate and get a monitor for update operations. * Validates ID, fetches the monitor, and verifies the job type. */ async function validateAndGetMonitor( id: string | undefined, workspaceId: number, expectedJobType: "http" | "tcp" | "dns", ): Promise<DBMonitor> { if (!id || id.trim() === "") { throw monitorIdRequiredError(); } const dbMon = await getMonitorById(Number(id), workspaceId); if (!dbMon) { throw monitorNotFoundError(id); } if (dbMon.jobType !== expectedJobType) { throw monitorTypeMismatchError(id, expectedJobType, dbMon.jobType); } return dbMon; } type ParsedMonitor = ReturnType<typeof selectMonitorSchema.parse>; /** * Helper to perform update and return the updated monitor. */ async function performUpdateAndReturn<T>( monitorId: number, requestId: string, updateValues: Record<string, unknown>, converter: (data: ParsedMonitor) => T, ): Promise<{ monitor: T }> { const updatedMonitor = await db .update(monitor) .set(updateValues) .where(eq(monitor.id, monitorId)) .returning() .get(); if (!updatedMonitor) { throw monitorUpdateFailedError(requestId); } const parsed = selectMonitorSchema.safeParse(updatedMonitor); if (!parsed.success) { throw monitorParseFailedError(requestId); } return { monitor: converter(parsed.data) }; } /** * Monitor service implementation for ConnectRPC. */ export const monitorServiceImpl: ServiceImpl<typeof MonitorService> = { async createHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; if (!req.monitor) { throw monitorRequiredError(); } const mon = req.monitor; // Validate required fields (proto validation handles name, url, periodicity) validateCommonMonitorFields(mon); // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); // Get common DB values const commonValues = getCommonDbValues(mon); // Convert headers and assertions to DB format const headers = headersToDbJson(mon.headers); const assertions = httpAssertionsToDbJson( mon.statusCodeAssertions, mon.bodyAssertions, mon.headerAssertions, ); // Insert into database const newMonitor = await db .insert(monitor) .values({ workspaceId, jobType: "http", url: mon.url, method: toValidMethod(httpMethodToString(mon.method)), body: mon.body || undefined, headers, assertions, followRedirects: mon.followRedirects ?? MONITOR_DEFAULTS.followRedirects, ...commonValues, }) .returning() .get(); if (!newMonitor) { throw monitorCreateFailedError(); } // Parse through schema to transform fields const parsed = selectMonitorSchema.safeParse(newMonitor); if (!parsed.success) { throw monitorParseFailedError(); } return { monitor: dbMonitorToHttpProto(parsed.data), }; }, async createTCPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; if (!req.monitor) { throw monitorRequiredError(); } const mon = req.monitor; // Validate required fields (proto validation handles name, uri, periodicity) validateCommonMonitorFields(mon); // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); // Get common DB values const commonValues = getCommonDbValues(mon); // Insert into database const newMonitor = await db .insert(monitor) .values({ workspaceId, jobType: "tcp", url: mon.uri, ...commonValues, }) .returning() .get(); if (!newMonitor) { throw monitorCreateFailedError(); } // Parse through schema to transform fields const parsed = selectMonitorSchema.safeParse(newMonitor); if (!parsed.success) { throw monitorParseFailedError(); } return { monitor: dbMonitorToTcpProto(parsed.data), }; }, async createDNSMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; if (!req.monitor) { throw monitorRequiredError(); } const mon = req.monitor; // Validate required fields (proto validation handles name, uri, periodicity) validateCommonMonitorFields(mon); // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); // Get common DB values const commonValues = getCommonDbValues(mon); // Convert assertions to DB format const assertions = dnsAssertionsToDbJson(mon.recordAssertions); // Insert into database const newMonitor = await db .insert(monitor) .values({ workspaceId, jobType: "dns", url: mon.uri, assertions, ...commonValues, }) .returning() .get(); if (!newMonitor) { throw monitorCreateFailedError(); } // Parse through schema to transform fields const parsed = selectMonitorSchema.safeParse(newMonitor); if (!parsed.success) { throw monitorParseFailedError(); } return { monitor: dbMonitorToDnsProto(parsed.data), }; }, async updateHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; const dbMon = await validateAndGetMonitor(req.id, workspaceId, "http"); // If no monitor data provided, return current monitor if (!req.monitor) { const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } return { monitor: dbMonitorToHttpProto(parsed.data) }; } const mon = req.monitor; // Validate regions if provided validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { await checkMonitorLimits( workspaceId, limits, mon.periodicity || undefined, mon.regions && mon.regions.length > 0 ? mon.regions : undefined, ); } // Build update values - only include fields that are provided const updateValues: Record<string, unknown> = getCommonDbValuesForUpdate(mon); // Handle HTTP-specific fields if (mon.url !== undefined && mon.url !== "") { updateValues.url = mon.url; } if (mon.method !== undefined && mon.method !== 0) { updateValues.method = toValidMethod(httpMethodToString(mon.method)); } if (mon.body !== undefined) { updateValues.body = mon.body || undefined; } if (mon.followRedirects !== undefined) { updateValues.followRedirects = mon.followRedirects; } if (mon.headers !== undefined) { updateValues.headers = headersToDbJson(mon.headers); } // Handle assertions - update if any assertion type is provided if ( mon.statusCodeAssertions !== undefined || mon.bodyAssertions !== undefined || mon.headerAssertions !== undefined ) { updateValues.assertions = httpAssertionsToDbJson( mon.statusCodeAssertions ?? [], mon.bodyAssertions ?? [], mon.headerAssertions ?? [], ); } return performUpdateAndReturn( dbMon.id, req.id, updateValues, dbMonitorToHttpProto, ); }, async updateTCPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; const dbMon = await validateAndGetMonitor(req.id, workspaceId, "tcp"); // If no monitor data provided, return current monitor if (!req.monitor) { const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } return { monitor: dbMonitorToTcpProto(parsed.data) }; } const mon = req.monitor; // Validate regions if provided validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { await checkMonitorLimits( workspaceId, limits, mon.periodicity || undefined, mon.regions && mon.regions.length > 0 ? mon.regions : undefined, ); } // Build update values - only include fields that are provided const updateValues: Record<string, unknown> = getCommonDbValuesForUpdate(mon); // Handle TCP-specific fields if (mon.uri !== undefined && mon.uri !== "") { updateValues.url = mon.uri; } return performUpdateAndReturn( dbMon.id, req.id, updateValues, dbMonitorToTcpProto, ); }, async updateDNSMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; const dbMon = await validateAndGetMonitor(req.id, workspaceId, "dns"); // If no monitor data provided, return current monitor if (!req.monitor) { const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } return { monitor: dbMonitorToDnsProto(parsed.data) }; } const mon = req.monitor; // Validate regions if provided validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { await checkMonitorLimits( workspaceId, limits, mon.periodicity || undefined, mon.regions && mon.regions.length > 0 ? mon.regions : undefined, ); } // Build update values - only include fields that are provided const updateValues: Record<string, unknown> = getCommonDbValuesForUpdate(mon); // Handle DNS-specific fields if (mon.uri !== undefined && mon.uri !== "") { updateValues.url = mon.uri; } // Handle DNS assertions if (mon.recordAssertions !== undefined) { updateValues.assertions = dnsAssertionsToDbJson(mon.recordAssertions); } return performUpdateAndReturn( dbMon.id, req.id, updateValues, dbMonitorToDnsProto, ); }, async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; // Check rate limits const lastMonth = new Date().setMonth(new Date().getMonth() - 1); const countResult = await db .select({ count: sql<number>`count(*)` }) .from(monitorRun) .where( and( eq(monitorRun.workspaceId, workspaceId), gte(monitorRun.createdAt, new Date(lastMonth)), ), ) .get(); const count = countResult?.count ?? 0; if (count >= limits["synthetic-checks"]) { throw rateLimitExceededError(limits["synthetic-checks"], count); } // Get the monitor const dbMon = await getMonitorById(Number(req.id), workspaceId); if (!dbMon) { throw monitorNotFoundError(req.id); } // Validate monitor data const validateMonitor = selectMonitorSchema.safeParse(dbMon); if (!validateMonitor.success) { throw monitorInvalidDataError(req.id); } const row = validateMonitor.data; // Get monitor status for each region const monitorStatuses = await db .select() .from(monitorStatusTable) .where(eq(monitorStatusTable.monitorId, dbMon.id)) .all(); // Create a monitor run record const timestamp = Date.now(); const newRun = await db .insert(monitorRun) .values({ monitorId: row.id, workspaceId: row.workspaceId, runnedAt: new Date(timestamp), }) .returning() .get(); if (!newRun) { throw monitorRunCreateFailedError(req.id); } // Trigger checks for each region in parallel await Promise.all( validateMonitor.data.regions.map((region) => { const statusEntry = monitorStatuses.find((m) => region === m.region); const status = statusEntry?.status || "active"; const payload = getCheckerPayload(row, status); const url = getCheckerUrl(row); return fetch(url, { headers: { "Content-Type": "application/json", "fly-prefer-region": region, Authorization: `Basic ${env.CRON_SECRET}`, }, method: "POST", body: JSON.stringify(payload), }); }), ); return { success: true }; }, async deleteMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const dbMon = await getMonitorById(Number(req.id), workspaceId); if (!dbMon) { throw monitorNotFoundError(req.id); } // Soft delete and clean up related rows atomically await db.transaction(async (tx) => { await tx .update(monitor) .set({ active: false, deletedAt: new Date(), }) .where(eq(monitor.id, dbMon.id)); await tx .delete(monitorTagsToMonitors) .where(eq(monitorTagsToMonitors.monitorId, dbMon.id)); await tx .delete(notificationsToMonitors) .where(eq(notificationsToMonitors.monitorId, dbMon.id)); await tx .delete(pageComponent) .where(eq(pageComponent.monitorId, dbMon.id)); }); return { success: true }; }, async listMonitors(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Build query conditions const conditions = [ eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ]; // Get total count const countResult = await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where(and(...conditions)) .get(); const totalCount = countResult?.count ?? 0; // Get monitors const monitors = await db .select() .from(monitor) .where(and(...conditions)) .limit(limit) .offset(offset) .all(); // Group monitors by type const httpMonitors: HTTPMonitor[] = []; const tcpMonitors: TCPMonitor[] = []; const dnsMonitors: DNSMonitor[] = []; for (const m of monitors) { // Parse through schema to transform fields const parsed = selectMonitorSchema.safeParse(m); if (!parsed.success) { continue; // Skip invalid monitors } switch (parsed.data.jobType) { case "http": httpMonitors.push(dbMonitorToHttpProto(parsed.data)); break; case "tcp": tcpMonitors.push(dbMonitorToTcpProto(parsed.data)); break; case "dns": dnsMonitors.push(dbMonitorToDnsProto(parsed.data)); break; } } return { httpMonitors, tcpMonitors, dnsMonitors, totalSize: totalCount, }; }, async getMonitorStatus(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Get the monitor const dbMon = await getMonitorById(Number(req.id), workspaceId); if (!dbMon) { throw monitorNotFoundError(req.id); } // Parse monitor to get configured regions const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } // Get monitor status only for configured regions const monitorStatuses = await db .select() .from(monitorStatusTable) .where( and( eq(monitorStatusTable.monitorId, dbMon.id), inArray(monitorStatusTable.region, parsed.data.regions), ), ) .all(); // Map to proto format const regions: RegionStatus[] = monitorStatuses.map((s) => ({ $typeName: "openstatus.monitor.v1.RegionStatus" as const, region: stringToRegion(s.region), status: stringToMonitorStatus(s.status), })); return { id: String(dbMon.id), regions, }; }, async getMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Get the monitor const dbMon = await getMonitorById(Number(req.id), workspaceId); if (!dbMon) { throw monitorNotFoundError(req.id); } // Parse monitor data const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } const monitorData = parsed.data; // Convert to appropriate proto type based on jobType let monitorConfig: MonitorConfig; switch (monitorData.jobType) { case "http": monitorConfig = { $typeName: "openstatus.monitor.v1.MonitorConfig", config: { case: "http", value: dbMonitorToHttpProto(monitorData) }, }; break; case "tcp": monitorConfig = { $typeName: "openstatus.monitor.v1.MonitorConfig", config: { case: "tcp", value: dbMonitorToTcpProto(monitorData) }, }; break; case "dns": monitorConfig = { $typeName: "openstatus.monitor.v1.MonitorConfig", config: { case: "dns", value: dbMonitorToDnsProto(monitorData) }, }; break; default: { const _exhaustive: never = monitorData.jobType; throw monitorTypeMismatchError( req.id, "http, tcp, or dns", monitorData.jobType, ); } } return { $typeName: "openstatus.monitor.v1.GetMonitorResponse", monitor: monitorConfig, } satisfies GetMonitorResponse; }, async getMonitorSummary(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Get the monitor const dbMon = await getMonitorById(Number(req.id), workspaceId); if (!dbMon) { throw monitorNotFoundError(req.id); } // Parse monitor data const parsed = selectMonitorSchema.safeParse(dbMon); if (!parsed.success) { throw monitorParseFailedError(req.id); } const monitorData = parsed.data; const timeRangeKey = timeRangeToKey(req.timeRange); const effectiveTimeRange = req.timeRange === TimeRange.TIME_RANGE_UNSPECIFIED ? TimeRange.TIME_RANGE_1D : req.timeRange; // Get regions to filter by (use request regions or monitor's configured regions) const regionStrings = req.regions.length > 0 ? regionsToStrings(req.regions) : monitorData.regions; // Build Tinybird query parameters const queryParams = { monitorId: req.id, regions: regionStrings.length > 0 ? regionStrings : undefined, }; // Call appropriate Tinybird method based on monitor type and time range const metricsResult = await getMetricsByTypeAndRange( monitorData.jobType, timeRangeKey, queryParams, ); if (!metricsResult || metricsResult.data.length === 0) { // Return empty response if no data return { $typeName: "openstatus.monitor.v1.GetMonitorSummaryResponse" as const, id: req.id, lastPingAt: "", totalSuccessful: BigInt(0), totalDegraded: BigInt(0), totalFailed: BigInt(0), p50: BigInt(0), p75: BigInt(0), p90: BigInt(0), p95: BigInt(0), p99: BigInt(0), timeRange: effectiveTimeRange, regions: stringsToRegions(regionStrings), } satisfies GetMonitorSummaryResponse; } const metrics = metricsResult.data[0]; // Format last timestamp to RFC 3339 const lastPingAt = metrics.lastTimestamp ? new Date(metrics.lastTimestamp).toISOString() : ""; return { $typeName: "openstatus.monitor.v1.GetMonitorSummaryResponse" as const, id: req.id, lastPingAt, totalSuccessful: BigInt(metrics.success ?? 0), totalDegraded: BigInt(metrics.degraded ?? 0), totalFailed: BigInt(metrics.error ?? 0), p50: BigInt(Math.round(metrics.p50Latency ?? 0)), p75: BigInt(Math.round(metrics.p75Latency ?? 0)), p90: BigInt(Math.round(metrics.p90Latency ?? 0)), p95: BigInt(Math.round(metrics.p95Latency ?? 0)), p99: BigInt(Math.round(metrics.p99Latency ?? 0)), timeRange: effectiveTimeRange, regions: stringsToRegions(regionStrings), } satisfies GetMonitorSummaryResponse; }, }; /** * Get metrics from Tinybird based on monitor type and time range. */ async function getMetricsByTypeAndRange( jobType: string, timeRange: TimeRangeKey, params: { monitorId: string; regions?: string[] }, ) { switch (jobType) { case "http": switch (timeRange) { case "1d": return tb.httpMetricsDaily(params); case "7d": return tb.httpMetricsWeekly(params); case "14d": return tb.httpMetricsBiweekly(params); } break; case "tcp": switch (timeRange) { case "1d": return tb.tcpMetricsDaily(params); case "7d": return tb.tcpMetricsWeekly(params); case "14d": return tb.tcpMetricsBiweekly(params); } break; case "dns": switch (timeRange) { case "1d": return tb.dnsMetricsDaily(params); case "7d": return tb.dnsMetricsWeekly(params); case "14d": return tb.dnsMetricsBiweekly(params); } break; } return null; } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/limits.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import { z } from "zod"; import { periodicityToString, regionsToStrings } from "./converters"; /** * Check workspace limits for creating a new monitor. * Throws ConnectError with PermissionDenied if any limit is exceeded. */ export async function checkMonitorLimits( workspaceId: number, limits: Limits, periodicity: Periodicity | undefined, regions: Region[] | undefined, ): Promise<void> { // Check monitor count limit const countResult = await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where(and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt))) .get(); const count = countResult?.count ?? 0; if (count >= limits.monitors) { throw new ConnectError("Upgrade for more monitors", Code.PermissionDenied); } // Check periodicity limit if (periodicity) { const periodicityStr = periodicityToString(periodicity); if (!limits.periodicity.includes(periodicityStr)) { throw new ConnectError( "Upgrade for more periodicity options", Code.PermissionDenied, ); } } // Check regions limits if (regions && regions.length > 0) { const regionStrings = z .array(monitorRegionSchema) .parse(regionsToStrings(regions)); // Check max regions limit if (regionStrings.length > limits["max-regions"]) { throw new ConnectError("Upgrade for more regions", Code.PermissionDenied); } // Check if each region is allowed for (const region of regionStrings) { if (!limits.regions.includes(region)) { throw new ConnectError( `Region '${region}' is not available on your plan. Upgrade for more regions`, Code.PermissionDenied, ); } } } } ================================================ FILE: apps/server/src/routes/rpc/services/monitor/validators.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; import type { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import { MONITOR_DEFAULTS, openTelemetryToDb, periodicityToString, regionsToDbString, regionsToStrings, validateRegions, } from "./converters"; type MonitorPeriodicity = (typeof monitorPeriodicity)[number]; type MonitorMethod = (typeof monitorMethods)[number]; /** * Validate and convert periodicity string to enum type. */ export function toValidPeriodicity( value: string | undefined, ): MonitorPeriodicity { const valid = monitorPeriodicity as readonly string[]; if (value && valid.includes(value)) { return value as MonitorPeriodicity; } return "1m"; } /** * Validate and convert method string to enum type. */ export function toValidMethod(value: string | undefined): MonitorMethod { const upper = value?.toUpperCase(); const valid = monitorMethods as readonly string[]; if (upper && valid.includes(upper)) { return upper as MonitorMethod; } return "GET"; } /** * Validate required monitor fields common to all monitor types. * Note: name, url/uri, and periodicity are validated by protovalidate interceptor. * Throws ConnectError if validation fails. */ export function validateCommonMonitorFields(mon: { regions?: Region[]; }): void { if (mon.regions && mon.regions.length > 0) { const regionStrings = regionsToStrings(mon.regions); const invalidRegions = validateRegions(regionStrings); if (invalidRegions.length > 0) { throw new ConnectError( `Invalid regions: ${invalidRegions.join(", ")}`, Code.InvalidArgument, ); } } } /** * Extract common database values for all monitor types. */ export function getCommonDbValues(mon: { name: string; periodicity?: Periodicity; timeout?: bigint; degradedAt?: bigint; active?: boolean; description?: string; public?: boolean; regions?: Region[]; retry?: bigint; openTelemetry?: Parameters<typeof openTelemetryToDb>[0]; }) { const otelConfig = openTelemetryToDb(mon.openTelemetry); const periodicityStr = mon.periodicity ? periodicityToString(mon.periodicity) : undefined; const regionStrings = mon.regions ? regionsToStrings(mon.regions) : []; return { name: mon.name, periodicity: toValidPeriodicity(periodicityStr), timeout: mon.timeout ? Number(mon.timeout) : MONITOR_DEFAULTS.timeout, degradedAfter: mon.degradedAt ? Number(mon.degradedAt) : undefined, active: mon.active ?? MONITOR_DEFAULTS.active, description: mon.description || MONITOR_DEFAULTS.description, public: mon.public ?? MONITOR_DEFAULTS.public, regions: regionsToDbString(regionStrings), retry: mon.retry ? Number(mon.retry) : MONITOR_DEFAULTS.retry, otelEndpoint: otelConfig.otelEndpoint, otelHeaders: otelConfig.otelHeaders, }; } /** * Extract common database values for update operations. * Only includes fields that are explicitly provided (not undefined). * This enables partial updates where only specified fields are changed. */ export function getCommonDbValuesForUpdate(mon: { name?: string; periodicity?: Periodicity; timeout?: bigint; degradedAt?: bigint; active?: boolean; description?: string; public?: boolean; regions?: Region[]; retry?: bigint; openTelemetry?: Parameters<typeof openTelemetryToDb>[0]; }) { const result: Record<string, unknown> = {}; if (mon.name !== undefined && mon.name !== "") { result.name = mon.name; } if (mon.periodicity !== undefined && mon.periodicity !== 0) { const periodicityStr = periodicityToString(mon.periodicity); result.periodicity = toValidPeriodicity(periodicityStr); } if (mon.timeout !== undefined && mon.timeout !== BigInt(0)) { result.timeout = Number(mon.timeout); } if (mon.degradedAt !== undefined) { result.degradedAfter = Number(mon.degradedAt); } if (mon.active !== undefined) { result.active = mon.active; } if (mon.description !== undefined) { result.description = mon.description; } if (mon.public !== undefined) { result.public = mon.public; } if (mon.regions !== undefined && mon.regions.length > 0) { const regionStrings = regionsToStrings(mon.regions); result.regions = regionsToDbString(regionStrings); } if (mon.retry !== undefined && mon.retry !== BigInt(0)) { result.retry = Number(mon.retry); } if (mon.openTelemetry !== undefined) { const otelConfig = openTelemetryToDb(mon.openTelemetry); result.otelEndpoint = otelConfig.otelEndpoint; result.otelHeaders = otelConfig.otelHeaders; } return result; } ================================================ FILE: apps/server/src/routes/rpc/services/notification/__tests__/notification.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { notification, notificationsToMonitors, } from "@openstatus/db/src/schema"; import { app } from "@/index"; /** * Helper to make ConnectRPC requests using the Connect protocol (JSON). * Connect uses POST with JSON body at /rpc/<service>/<method> */ async function connectRequest( method: string, body: Record<string, unknown> = {}, headers: Record<string, string> = {}, ) { return app.request( `/rpc/openstatus.notification.v1.NotificationService/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }, ); } const TEST_PREFIX = "rpc-notification-test"; let testNotificationId: number; let testNotificationToDeleteId: number; let testNotificationToUpdateId: number; beforeAll(async () => { // Clean up any existing test data await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-main`)); await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-to-delete`)); await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-to-update`)); // Create test notification (email type) const notificationRecord = await db .insert(notification) .values({ workspaceId: 1, name: `${TEST_PREFIX}-main`, provider: "email", data: JSON.stringify({ email: "test@example.com" }), }) .returning() .get(); testNotificationId = notificationRecord.id; // Create monitor association await db.insert(notificationsToMonitors).values({ notificationId: notificationRecord.id, monitorId: 1, // Use seeded monitor }); // Create notification to delete const deleteRecord = await db .insert(notification) .values({ workspaceId: 1, name: `${TEST_PREFIX}-to-delete`, provider: "discord", data: JSON.stringify({ discord: "https://discord.com/api/webhooks/test", }), }) .returning() .get(); testNotificationToDeleteId = deleteRecord.id; // Create notification to update const updateRecord = await db .insert(notification) .values({ workspaceId: 1, name: `${TEST_PREFIX}-to-update`, provider: "slack", data: JSON.stringify({ slack: "https://hooks.slack.com/services/test" }), }) .returning() .get(); testNotificationToUpdateId = updateRecord.id; await db.insert(notificationsToMonitors).values({ notificationId: updateRecord.id, monitorId: 1, }); }); afterAll(async () => { // Clean up associations await db .delete(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, testNotificationId)); await db .delete(notificationsToMonitors) .where( eq(notificationsToMonitors.notificationId, testNotificationToUpdateId), ); // Clean up notifications await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-main`)); await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-to-delete`)); await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-to-update`)); }); describe("NotificationService.CreateNotification", () => { test("creates a new email notification", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-created-email`, provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "created@example.com", }, }, monitorIds: ["1"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notification"); expect(data.notification.name).toBe(`${TEST_PREFIX}-created-email`); expect(data.notification.provider).toBe("NOTIFICATION_PROVIDER_EMAIL"); expect(data.notification.monitorIds).toContain("1"); // Clean up await db .delete(notificationsToMonitors) .where( eq( notificationsToMonitors.notificationId, Number(data.notification.id), ), ); await db .delete(notification) .where(eq(notification.id, Number(data.notification.id))); }); test("creates a new discord notification", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-created-discord`, provider: "NOTIFICATION_PROVIDER_DISCORD", data: { discord: { webhookUrl: "https://discord.com/api/webhooks/123/abc", }, }, monitorIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notification"); expect(data.notification.name).toBe(`${TEST_PREFIX}-created-discord`); expect(data.notification.provider).toBe("NOTIFICATION_PROVIDER_DISCORD"); // Clean up await db .delete(notification) .where(eq(notification.id, Number(data.notification.id))); }); test("creates a new slack notification", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-created-slack`, provider: "NOTIFICATION_PROVIDER_SLACK", data: { slack: { webhookUrl: "https://hooks.slack.com/services/T00/B00/XXX", }, }, monitorIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notification"); expect(data.notification.provider).toBe("NOTIFICATION_PROVIDER_SLACK"); // Clean up await db .delete(notification) .where(eq(notification.id, Number(data.notification.id))); }); test("creates notification with multiple monitors", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-multi-monitor`, provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "multi@example.com", }, }, monitorIds: ["1"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.notification.monitorIds).toContain("1"); // Clean up await db .delete(notificationsToMonitors) .where( eq( notificationsToMonitors.notificationId, Number(data.notification.id), ), ); await db .delete(notification) .where(eq(notification.id, Number(data.notification.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CreateNotification", { name: "Unauthorized test", provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "test@example.com", }, }, monitorIds: [], }); expect(res.status).toBe(401); }); test("returns error for invalid monitor ID", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-invalid-monitor`, provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "test@example.com", }, }, monitorIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error for unspecified provider", async () => { const res = await connectRequest( "CreateNotification", { name: `${TEST_PREFIX}-no-provider`, provider: "NOTIFICATION_PROVIDER_UNSPECIFIED", data: {}, monitorIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("NotificationService.GetNotification", () => { test("returns notification with monitor IDs", async () => { const res = await connectRequest( "GetNotification", { id: String(testNotificationId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notification"); expect(data.notification.id).toBe(String(testNotificationId)); expect(data.notification.name).toBe(`${TEST_PREFIX}-main`); expect(data.notification.provider).toBe("NOTIFICATION_PROVIDER_EMAIL"); expect(data.notification.monitorIds).toContain("1"); expect(data.notification).toHaveProperty("createdAt"); expect(data.notification).toHaveProperty("updatedAt"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetNotification", { id: String(testNotificationId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent notification", async () => { const res = await connectRequest( "GetNotification", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for notification in different workspace", async () => { // Create notification in workspace 2 const otherRecord = await db .insert(notification) .values({ workspaceId: 2, name: `${TEST_PREFIX}-other-workspace`, provider: "email", data: JSON.stringify({ email: "other@example.com" }), }) .returning() .get(); try { const res = await connectRequest( "GetNotification", { id: String(otherRecord.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(notification).where(eq(notification.id, otherRecord.id)); } }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "GetNotification", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("NotificationService.ListNotifications", () => { test("returns notifications for authenticated workspace", async () => { const res = await connectRequest( "ListNotifications", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notifications"); expect(Array.isArray(data.notifications)).toBe(true); expect(data).toHaveProperty("totalSize"); }); test("returns notifications with correct summary structure", async () => { const res = await connectRequest( "ListNotifications", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const record = data.notifications?.find( (n: { id: string }) => n.id === String(testNotificationId), ); expect(record).toBeDefined(); expect(record.name).toBe(`${TEST_PREFIX}-main`); expect(record.provider).toBe("NOTIFICATION_PROVIDER_EMAIL"); expect(record).toHaveProperty("monitorCount"); expect(record).toHaveProperty("createdAt"); expect(record).toHaveProperty("updatedAt"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListNotifications", {}); expect(res.status).toBe(401); }); test("respects limit parameter", async () => { const res = await connectRequest( "ListNotifications", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.notifications?.length || 0).toBeLessThanOrEqual(1); }); test("respects offset parameter", async () => { // Get total count first const res1 = await connectRequest( "ListNotifications", {}, { "x-openstatus-key": "1" }, ); const data1 = await res1.json(); const totalSize = data1.totalSize; if (totalSize > 1) { // Get first page const res2 = await connectRequest( "ListNotifications", { limit: 1, offset: 0 }, { "x-openstatus-key": "1" }, ); const data2 = await res2.json(); // Get second page const res3 = await connectRequest( "ListNotifications", { limit: 1, offset: 1 }, { "x-openstatus-key": "1" }, ); const data3 = await res3.json(); // Should have different notifications if (data2.notifications?.length > 0 && data3.notifications?.length > 0) { expect(data2.notifications[0].id).not.toBe(data3.notifications[0].id); } } }); test("only returns notifications for authenticated workspace", async () => { // Create notification in workspace 2 const otherRecord = await db .insert(notification) .values({ workspaceId: 2, name: `${TEST_PREFIX}-other-workspace-list`, provider: "email", data: JSON.stringify({ email: "other@example.com" }), }) .returning() .get(); try { const res = await connectRequest( "ListNotifications", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const recordIds = (data.notifications || []).map( (n: { id: string }) => n.id, ); expect(recordIds).not.toContain(String(otherRecord.id)); } finally { await db.delete(notification).where(eq(notification.id, otherRecord.id)); } }); }); describe("NotificationService.UpdateNotification", () => { test("updates notification name", async () => { const res = await connectRequest( "UpdateNotification", { id: String(testNotificationToUpdateId), name: `${TEST_PREFIX}-updated-name`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("notification"); expect(data.notification.name).toBe(`${TEST_PREFIX}-updated-name`); // Restore original name await db .update(notification) .set({ name: `${TEST_PREFIX}-to-update` }) .where(eq(notification.id, testNotificationToUpdateId)); }); test("updates monitor associations", async () => { const res = await connectRequest( "UpdateNotification", { id: String(testNotificationToUpdateId), monitorIds: ["1"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.notification.monitorIds).toContain("1"); }); test("clears monitor associations with empty array", async () => { const res = await connectRequest( "UpdateNotification", { id: String(testNotificationToUpdateId), monitorIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const monitorIds = data.notification.monitorIds ?? []; expect(monitorIds).toHaveLength(0); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateNotification", { id: String(testNotificationToUpdateId), name: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent notification", async () => { const res = await connectRequest( "UpdateNotification", { id: "99999", name: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "UpdateNotification", { id: "", name: "Empty ID update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for notification in different workspace", async () => { // Create notification in workspace 2 const otherRecord = await db .insert(notification) .values({ workspaceId: 2, name: `${TEST_PREFIX}-other-workspace-update`, provider: "email", data: JSON.stringify({ email: "other@example.com" }), }) .returning() .get(); try { const res = await connectRequest( "UpdateNotification", { id: String(otherRecord.id), name: "Should not update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(notification).where(eq(notification.id, otherRecord.id)); } }); test("returns error for invalid monitor ID on update", async () => { const res = await connectRequest( "UpdateNotification", { id: String(testNotificationToUpdateId), monitorIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); describe("NotificationService.DeleteNotification", () => { test("successfully deletes existing notification", async () => { const res = await connectRequest( "DeleteNotification", { id: String(testNotificationToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(notification) .where(eq(notification.id, testNotificationToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteNotification", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent notification", async () => { const res = await connectRequest( "DeleteNotification", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "DeleteNotification", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for notification in different workspace", async () => { // Create notification in workspace 2 const otherRecord = await db .insert(notification) .values({ workspaceId: 2, name: `${TEST_PREFIX}-other-workspace-delete`, provider: "email", data: JSON.stringify({ email: "other@example.com" }), }) .returning() .get(); try { const res = await connectRequest( "DeleteNotification", { id: String(otherRecord.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); // Verify it wasn't deleted const stillExists = await db .select() .from(notification) .where(eq(notification.id, otherRecord.id)) .get(); expect(stillExists).toBeDefined(); } finally { await db.delete(notification).where(eq(notification.id, otherRecord.id)); } }); }); describe("NotificationService.CheckNotificationLimit", () => { test("returns limit information for authenticated workspace", async () => { const res = await connectRequest( "CheckNotificationLimit", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // Proto3 JSON omits fields with default values (false, 0) // So we check that the response is valid and contains expected types // when the values are non-default const limitReached = data.limitReached ?? false; const currentCount = data.currentCount ?? 0; const maxCount = data.maxCount ?? 0; expect(typeof limitReached).toBe("boolean"); expect(typeof currentCount).toBe("number"); expect(typeof maxCount).toBe("number"); expect(currentCount).toBeGreaterThanOrEqual(0); expect(maxCount).toBeGreaterThanOrEqual(0); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CheckNotificationLimit", {}); expect(res.status).toBe(401); }); }); describe("NotificationService.SendTestNotification", () => { // Note: These tests verify error handling since we can't actually send // real notifications in tests without mocking external services test("returns error for unsupported email provider", async () => { const res = await connectRequest( "SendTestNotification", { provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "test@example.com", }, }, }, { "x-openstatus-key": "1" }, ); // Email doesn't support test notifications expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("not supported"); }); test("returns error for unsupported SMS provider", async () => { const res = await connectRequest( "SendTestNotification", { provider: "NOTIFICATION_PROVIDER_SMS", data: { sms: { phoneNumber: "+1234567890", }, }, }, { "x-openstatus-key": "1" }, ); // SMS doesn't support test notifications expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("not supported"); }); test("returns error when no data provided", async () => { const res = await connectRequest( "SendTestNotification", { provider: "NOTIFICATION_PROVIDER_DISCORD", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when data doesn't match provider", async () => { const res = await connectRequest( "SendTestNotification", { provider: "NOTIFICATION_PROVIDER_DISCORD", data: { email: { email: "test@example.com", }, }, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("Expected discord data"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("SendTestNotification", { provider: "NOTIFICATION_PROVIDER_EMAIL", data: { email: { email: "test@example.com", }, }, }); expect(res.status).toBe(401); }); test("returns error for unspecified provider", async () => { const res = await connectRequest( "SendTestNotification", { provider: "NOTIFICATION_PROVIDER_UNSPECIFIED", data: {}, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); ================================================ FILE: apps/server/src/routes/rpc/services/notification/converters.ts ================================================ import { create } from "@bufbuild/protobuf"; import type { NotificationProvider as DBNotificationProvider } from "@openstatus/db/src/schema"; import type { Notification, NotificationData, NotificationSummary, } from "@openstatus/proto/notification/v1"; import { NotificationDataSchema, NotificationProvider, NotificationSchema, NotificationSummarySchema, OpsgenieRegion, } from "@openstatus/proto/notification/v1"; type DBNotification = { id: number; name: string; provider: DBNotificationProvider; data: string | null; workspaceId: number | null; createdAt: Date | null; updatedAt: Date | null; }; /** * Maps DB provider string to proto NotificationProvider enum. */ export function dbProviderToProto( provider: DBNotificationProvider, ): NotificationProvider { const mapping: Record<DBNotificationProvider, NotificationProvider> = { discord: NotificationProvider.DISCORD, email: NotificationProvider.EMAIL, "google-chat": NotificationProvider.GOOGLE_CHAT, "grafana-oncall": NotificationProvider.GRAFANA_ONCALL, ntfy: NotificationProvider.NTFY, pagerduty: NotificationProvider.PAGERDUTY, opsgenie: NotificationProvider.OPSGENIE, slack: NotificationProvider.SLACK, sms: NotificationProvider.SMS, telegram: NotificationProvider.TELEGRAM, webhook: NotificationProvider.WEBHOOK, whatsapp: NotificationProvider.WHATSAPP, }; return mapping[provider] ?? NotificationProvider.UNSPECIFIED; } /** * Maps NotificationProvider to expected NotificationData case. */ export function getExpectedDataCase( provider: NotificationProvider, ): string | undefined { const mapping: Record<number, string> = { [NotificationProvider.DISCORD]: "discord", [NotificationProvider.EMAIL]: "email", [NotificationProvider.GOOGLE_CHAT]: "googleChat", [NotificationProvider.GRAFANA_ONCALL]: "grafanaOncall", [NotificationProvider.NTFY]: "ntfy", [NotificationProvider.PAGERDUTY]: "pagerduty", [NotificationProvider.OPSGENIE]: "opsgenie", [NotificationProvider.SLACK]: "slack", [NotificationProvider.SMS]: "sms", [NotificationProvider.TELEGRAM]: "telegram", [NotificationProvider.WEBHOOK]: "webhook", [NotificationProvider.WHATSAPP]: "whatsapp", }; return mapping[provider]; } /** * Validates that the notification data matches the provider. * Returns an error message if validation fails, undefined if valid. */ export function validateProviderDataConsistency( provider: NotificationProvider, data: NotificationData | undefined, ): string | undefined { if (provider === NotificationProvider.UNSPECIFIED) { return "Provider must be specified"; } const expectedCase = getExpectedDataCase(provider); if (!expectedCase) { return `Unknown provider: ${provider}`; } if (!data || data.data.case === undefined) { return `Provider ${NotificationProvider[provider]} requires ${expectedCase} data, but no data was provided`; } const actualCase = data.data.case; if (actualCase !== expectedCase) { return `Provider ${NotificationProvider[provider]} requires ${expectedCase} data, got ${actualCase}`; } return undefined; } /** * Maps proto NotificationProvider enum to DB provider string. */ export function protoProviderToDb( provider: NotificationProvider, ): DBNotificationProvider { const mapping: Record<number, DBNotificationProvider> = { [NotificationProvider.DISCORD]: "discord", [NotificationProvider.EMAIL]: "email", [NotificationProvider.GOOGLE_CHAT]: "google-chat", [NotificationProvider.GRAFANA_ONCALL]: "grafana-oncall", [NotificationProvider.NTFY]: "ntfy", [NotificationProvider.PAGERDUTY]: "pagerduty", [NotificationProvider.OPSGENIE]: "opsgenie", [NotificationProvider.SLACK]: "slack", [NotificationProvider.SMS]: "sms", [NotificationProvider.TELEGRAM]: "telegram", [NotificationProvider.WEBHOOK]: "webhook", [NotificationProvider.WHATSAPP]: "whatsapp", }; return mapping[provider] ?? "email"; } /** * Parses DB JSON data string to proto NotificationData. */ export function dbDataToProto( provider: DBNotificationProvider, dataStr: string | null, ): NotificationData | undefined { if (!dataStr) { return undefined; } try { const data = JSON.parse(dataStr); const protoData = create(NotificationDataSchema); switch (provider) { case "discord": if (data.discord) { protoData.data = { case: "discord", value: { $typeName: "openstatus.notification.v1.DiscordData", webhookUrl: data.discord, }, }; } break; case "email": if (data.email) { protoData.data = { case: "email", value: { $typeName: "openstatus.notification.v1.EmailData", email: data.email, }, }; } break; case "google-chat": if (data["google-chat"]) { protoData.data = { case: "googleChat", value: { $typeName: "openstatus.notification.v1.GoogleChatData", webhookUrl: data["google-chat"], }, }; } break; case "grafana-oncall": if (data["grafana-oncall"]) { protoData.data = { case: "grafanaOncall", value: { $typeName: "openstatus.notification.v1.GrafanaOncallData", webhookUrl: data["grafana-oncall"].webhookUrl, }, }; } break; case "ntfy": if (data.ntfy) { protoData.data = { case: "ntfy", value: { $typeName: "openstatus.notification.v1.NtfyData", topic: data.ntfy.topic || "", serverUrl: data.ntfy.serverUrl || "https://ntfy.sh", token: data.ntfy.token, }, }; } break; case "pagerduty": if (data.pagerduty) { protoData.data = { case: "pagerduty", value: { $typeName: "openstatus.notification.v1.PagerDutyData", integrationKey: data.pagerduty, }, }; } break; case "opsgenie": if (data.opsgenie) { protoData.data = { case: "opsgenie", value: { $typeName: "openstatus.notification.v1.OpsgenieData", apiKey: data.opsgenie.apiKey, region: data.opsgenie.region === "eu" ? OpsgenieRegion.EU : OpsgenieRegion.US, }, }; } break; case "slack": if (data.slack) { protoData.data = { case: "slack", value: { $typeName: "openstatus.notification.v1.SlackData", webhookUrl: data.slack, }, }; } break; case "sms": if (data.sms) { protoData.data = { case: "sms", value: { $typeName: "openstatus.notification.v1.SmsData", phoneNumber: data.sms, }, }; } break; case "telegram": if (data.telegram) { protoData.data = { case: "telegram", value: { $typeName: "openstatus.notification.v1.TelegramData", chatId: data.telegram.chatId, }, }; } break; case "webhook": if (data.webhook) { protoData.data = { case: "webhook", value: { $typeName: "openstatus.notification.v1.WebhookData", endpoint: data.webhook.endpoint, headers: data.webhook.headers?.map( (h: { key: string; value: string }) => ({ $typeName: "openstatus.notification.v1.WebhookHeader", key: h.key, value: h.value, }), ) ?? [], }, }; } break; case "whatsapp": if (data.whatsapp) { protoData.data = { case: "whatsapp", value: { $typeName: "openstatus.notification.v1.WhatsappData", phoneNumber: data.whatsapp, }, }; } break; } return protoData; } catch (error) { console.error("Failed to parse notification data:", { provider, error: error instanceof Error ? error.message : "Unknown error", }); return undefined; } } /** * Converts proto NotificationData to DB JSON string format. */ export function protoDataToDb( _provider: NotificationProvider, data: NotificationData | undefined, ): string { if (!data || data.data.case === undefined) { return "{}"; } switch (data.data.case) { case "discord": return JSON.stringify({ discord: data.data.value.webhookUrl }); case "email": return JSON.stringify({ email: data.data.value.email }); case "googleChat": return JSON.stringify({ "google-chat": data.data.value.webhookUrl }); case "grafanaOncall": return JSON.stringify({ "grafana-oncall": { webhookUrl: data.data.value.webhookUrl }, }); case "ntfy": return JSON.stringify({ ntfy: { topic: data.data.value.topic, serverUrl: data.data.value.serverUrl, token: data.data.value.token, }, }); case "pagerduty": return JSON.stringify({ pagerduty: data.data.value.integrationKey }); case "opsgenie": return JSON.stringify({ opsgenie: { apiKey: data.data.value.apiKey, region: data.data.value.region === OpsgenieRegion.EU ? "eu" : "us", }, }); case "slack": return JSON.stringify({ slack: data.data.value.webhookUrl }); case "sms": return JSON.stringify({ sms: data.data.value.phoneNumber }); case "telegram": return JSON.stringify({ telegram: { chatId: data.data.value.chatId }, }); case "webhook": return JSON.stringify({ webhook: { endpoint: data.data.value.endpoint, headers: data.data.value.headers?.map((h) => ({ key: h.key, value: h.value, })), }, }); case "whatsapp": return JSON.stringify({ whatsapp: data.data.value.phoneNumber }); default: return "{}"; } } /** * Converts a DB notification to proto Notification format. */ export function dbNotificationToProto( notification: DBNotification, monitorIds: string[], ): Notification { return create(NotificationSchema, { id: String(notification.id), name: notification.name, provider: dbProviderToProto(notification.provider), data: dbDataToProto(notification.provider, notification.data), monitorIds, createdAt: notification.createdAt?.toISOString() ?? "", updatedAt: notification.updatedAt?.toISOString() ?? "", }); } /** * Converts a DB notification to proto NotificationSummary format. */ export function dbNotificationToProtoSummary( notification: DBNotification, monitorCount: number, ): NotificationSummary { return create(NotificationSummarySchema, { id: String(notification.id), name: notification.name, provider: dbProviderToProto(notification.provider), monitorCount, createdAt: notification.createdAt?.toISOString() ?? "", updatedAt: notification.updatedAt?.toISOString() ?? "", }); } ================================================ FILE: apps/server/src/routes/rpc/services/notification/errors.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; /** * Error reasons for structured error handling. */ export const ErrorReason = { NOTIFICATION_NOT_FOUND: "NOTIFICATION_NOT_FOUND", NOTIFICATION_ID_REQUIRED: "NOTIFICATION_ID_REQUIRED", NOTIFICATION_CREATE_FAILED: "NOTIFICATION_CREATE_FAILED", NOTIFICATION_UPDATE_FAILED: "NOTIFICATION_UPDATE_FAILED", NOTIFICATION_LIMIT_REACHED: "NOTIFICATION_LIMIT_REACHED", PROVIDER_NOT_ALLOWED: "PROVIDER_NOT_ALLOWED", PROVIDER_NOT_SUPPORTED: "PROVIDER_NOT_SUPPORTED", INVALID_NOTIFICATION_DATA: "INVALID_NOTIFICATION_DATA", MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", TEST_NOTIFICATION_FAILED: "TEST_NOTIFICATION_FAILED", } as const; export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; const DOMAIN = "openstatus.dev"; /** * Creates a ConnectError with structured metadata. */ function createError( message: string, code: Code, reason: ErrorReason, metadata?: Record<string, string>, ): ConnectError { const headers = new Headers({ "error-domain": DOMAIN, "error-reason": reason, }); if (metadata) { for (const [key, value] of Object.entries(metadata)) { headers.set(`error-${key}`, value); } } return new ConnectError(message, code, headers); } /** * Creates a "notification not found" error. */ export function notificationNotFoundError( notificationId: string, ): ConnectError { return createError( "Notification not found", Code.NotFound, ErrorReason.NOTIFICATION_NOT_FOUND, { "notification-id": notificationId }, ); } /** * Creates a "notification ID required" error. */ export function notificationIdRequiredError(): ConnectError { return createError( "Notification ID is required", Code.InvalidArgument, ErrorReason.NOTIFICATION_ID_REQUIRED, ); } /** * Creates a "failed to create notification" error. */ export function notificationCreateFailedError(): ConnectError { return createError( "Failed to create notification", Code.Internal, ErrorReason.NOTIFICATION_CREATE_FAILED, ); } /** * Creates a "failed to update notification" error. */ export function notificationUpdateFailedError( notificationId: string, ): ConnectError { return createError( "Failed to update notification", Code.Internal, ErrorReason.NOTIFICATION_UPDATE_FAILED, { "notification-id": notificationId }, ); } /** * Creates a "notification limit reached" error. */ export function notificationLimitReachedError(): ConnectError { return createError( "You have reached your notification channel limit. Upgrade to add more.", Code.ResourceExhausted, ErrorReason.NOTIFICATION_LIMIT_REACHED, ); } /** * Creates a "provider not allowed" error for limited providers. */ export function providerNotAllowedError(provider: string): ConnectError { return createError( `The ${provider} provider requires an upgraded plan.`, Code.PermissionDenied, ErrorReason.PROVIDER_NOT_ALLOWED, { provider }, ); } /** * Creates a "provider not supported" error. */ export function providerNotSupportedError(provider: string): ConnectError { return createError( `The provider ${provider} is not supported for test notifications.`, Code.InvalidArgument, ErrorReason.PROVIDER_NOT_SUPPORTED, { provider }, ); } /** * Creates an "invalid notification data" error. */ export function invalidNotificationDataError(details: string): ConnectError { return createError( `Invalid notification data: ${details}`, Code.InvalidArgument, ErrorReason.INVALID_NOTIFICATION_DATA, { details }, ); } /** * Creates a "monitor not found" error. */ export function monitorNotFoundError(monitorId: string): ConnectError { return createError( "Monitor not found or not accessible", Code.NotFound, ErrorReason.MONITOR_NOT_FOUND, { "monitor-id": monitorId }, ); } /** * Creates a "test notification failed" error. */ export function testNotificationFailedError(message: string): ConnectError { return createError( `Test notification failed: ${message}`, Code.Internal, ErrorReason.TEST_NOTIFICATION_FAILED, { message }, ); } ================================================ FILE: apps/server/src/routes/rpc/services/notification/index.ts ================================================ import type { ServiceImpl } from "@connectrpc/connect"; import { and, count, db, desc, eq, inArray, sql } from "@openstatus/db"; import { monitor, notification, notificationsToMonitors, } from "@openstatus/db/src/schema"; import { NotificationProvider, type NotificationService, } from "@openstatus/proto/notification/v1"; import { getRpcContext } from "../../interceptors"; import { dbNotificationToProto, dbNotificationToProtoSummary, dbProviderToProto, protoDataToDb, protoProviderToDb, validateProviderDataConsistency, } from "./converters"; import { invalidNotificationDataError, monitorNotFoundError, notificationCreateFailedError, notificationIdRequiredError, notificationNotFoundError, notificationUpdateFailedError, } from "./errors"; import { checkNotificationLimit, checkProviderAllowed, getNotificationLimitInfo, } from "./limits"; import { sendTestNotification } from "./test-providers"; // Type that works with both db instance and transaction type DB = typeof db; type Transaction = Parameters<Parameters<DB["transaction"]>[0]>[0]; /** * Helper to get a notification by ID with workspace scope. */ async function getNotificationById(id: number, workspaceId: number) { return db .select() .from(notification) .where( and(eq(notification.id, id), eq(notification.workspaceId, workspaceId)), ) .get(); } /** * Helper to get monitor IDs for a notification. */ async function getMonitorIdsForNotification( notificationId: number, ): Promise<string[]> { const monitors = await db .select({ monitorId: notificationsToMonitors.monitorId }) .from(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, notificationId)) .all(); return monitors.map((m) => String(m.monitorId)); } /** * Helper to get monitor count for a notification. */ async function getMonitorCountForNotification( notificationId: number, ): Promise<number> { const result = await db .select({ count: count() }) .from(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, notificationId)) .get(); return result?.count ?? 0; } /** * Validates that all monitor IDs belong to the workspace. * Throws monitorNotFoundError if any monitor is not found. */ async function validateMonitorIds( monitorIds: string[], workspaceId: number, tx: DB | Transaction = db, ): Promise<number[]> { if (monitorIds.length === 0) { return []; } const numericIds = monitorIds.map((id) => Number(id)); const validMonitors = await tx .select({ id: monitor.id }) .from(monitor) .where( and( inArray(monitor.id, numericIds), eq(monitor.workspaceId, workspaceId), ), ) .all(); const validIds = new Set(validMonitors.map((m) => m.id)); for (const id of numericIds) { if (!validIds.has(id)) { throw monitorNotFoundError(String(id)); } } return numericIds; } /** * Helper to update monitor associations for a notification. */ async function updateMonitorAssociations( notificationId: number, monitorIds: number[], tx: DB | Transaction = db, ) { // Delete existing associations await tx .delete(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, notificationId)); // Insert new associations if (monitorIds.length > 0) { await tx.insert(notificationsToMonitors).values( monitorIds.map((monitorId) => ({ notificationId, monitorId, })), ); } } /** * Notification service implementation for ConnectRPC. */ export const notificationServiceImpl: ServiceImpl<typeof NotificationService> = { async createNotification(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; // Check notification limit await checkNotificationLimit(workspaceId, limits); // Check if provider is allowed for this plan checkProviderAllowed(req.provider, limits); // Validate provider-data consistency const validationError = validateProviderDataConsistency( req.provider, req.data, ); if (validationError) { throw invalidNotificationDataError(validationError); } // Create notification in a transaction const newNotification = await db.transaction(async (tx) => { // Validate monitor IDs const validMonitorIds = await validateMonitorIds( req.monitorIds, workspaceId, tx, ); // Convert proto data to DB format const dataStr = protoDataToDb(req.provider, req.data); // Create the notification const record = await tx .insert(notification) .values({ name: req.name, provider: protoProviderToDb(req.provider), data: dataStr, workspaceId, }) .returning() .get(); if (!record) { throw notificationCreateFailedError(); } // Create monitor associations await updateMonitorAssociations(record.id, validMonitorIds, tx); return record; }); return { notification: dbNotificationToProto(newNotification, req.monitorIds), }; }, async getNotification(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw notificationIdRequiredError(); } const record = await getNotificationById(Number(req.id), workspaceId); if (!record) { throw notificationNotFoundError(req.id); } const monitorIds = await getMonitorIdsForNotification(record.id); return { notification: dbNotificationToProto(record, monitorIds), }; }, async listNotifications(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Get total count const countResult = await db .select({ count: sql<number>`count(*)` }) .from(notification) .where(eq(notification.workspaceId, workspaceId)) .get(); const totalCount = countResult?.count ?? 0; // Get notifications const records = await db .select() .from(notification) .where(eq(notification.workspaceId, workspaceId)) .orderBy(desc(notification.createdAt)) .limit(limit) .offset(offset) .all(); // Get monitor counts for each notification const notifications = await Promise.all( records.map(async (record) => { const monitorCount = await getMonitorCountForNotification(record.id); return dbNotificationToProtoSummary(record, monitorCount); }), ); return { notifications, totalSize: totalCount, }; }, async updateNotification(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw notificationIdRequiredError(); } const record = await getNotificationById(Number(req.id), workspaceId); if (!record) { throw notificationNotFoundError(req.id); } // Validate provider-data consistency if data is being updated if (req.data !== undefined) { const existingProvider = dbProviderToProto(record.provider); const validationError = validateProviderDataConsistency( existingProvider, req.data, ); if (validationError) { throw invalidNotificationDataError(validationError); } } // Update notification in a transaction const updatedNotification = await db.transaction(async (tx) => { // Validate monitor IDs const validMonitorIds = await validateMonitorIds( req.monitorIds, workspaceId, tx, ); // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (req.name !== undefined && req.name !== "") { updateValues.name = req.name; } if (req.data !== undefined) { // Use the existing provider since we can't change provider on update updateValues.data = protoDataToDb( // Provider can't change, so we'll use the current data's case req.data.data.case !== undefined ? (() => { // Map case to NotificationProvider const caseToProvider: Record<string, number> = { discord: NotificationProvider.DISCORD, email: NotificationProvider.EMAIL, googleChat: NotificationProvider.GOOGLE_CHAT, grafanaOncall: NotificationProvider.GRAFANA_ONCALL, ntfy: NotificationProvider.NTFY, pagerduty: NotificationProvider.PAGERDUTY, opsgenie: NotificationProvider.OPSGENIE, slack: NotificationProvider.SLACK, sms: NotificationProvider.SMS, telegram: NotificationProvider.TELEGRAM, webhook: NotificationProvider.WEBHOOK, whatsapp: NotificationProvider.WHATSAPP, }; return ( caseToProvider[req.data.data.case] ?? NotificationProvider.UNSPECIFIED ); })() : 0, req.data, ); } // Update monitor associations await updateMonitorAssociations(record.id, validMonitorIds, tx); // Update the notification const updated = await tx .update(notification) .set(updateValues) .where(eq(notification.id, record.id)) .returning() .get(); if (!updated) { throw notificationUpdateFailedError(req.id); } return updated; }); // Fetch updated monitor IDs const monitorIds = await getMonitorIdsForNotification( updatedNotification.id, ); return { notification: dbNotificationToProto(updatedNotification, monitorIds), }; }, async deleteNotification(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw notificationIdRequiredError(); } const record = await getNotificationById(Number(req.id), workspaceId); if (!record) { throw notificationNotFoundError(req.id); } // Delete the notification (cascade will delete associations) await db.delete(notification).where(eq(notification.id, record.id)); return { success: true }; }, async sendTestNotification(req, _ctx) { const result = await sendTestNotification(req.provider, req.data); return result; }, async checkNotificationLimit(_req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; const info = await getNotificationLimitInfo(workspaceId, limits); return info; }, }; ================================================ FILE: apps/server/src/routes/rpc/services/notification/limits.ts ================================================ import { count, db, eq } from "@openstatus/db"; import { notification } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import { NotificationProvider } from "@openstatus/proto/notification/v1"; import { notificationLimitReachedError, providerNotAllowedError, } from "./errors"; /** * Providers that require a paid plan. */ const LIMITED_PROVIDERS = new Set([ NotificationProvider.SMS, NotificationProvider.PAGERDUTY, NotificationProvider.OPSGENIE, NotificationProvider.GRAFANA_ONCALL, NotificationProvider.WHATSAPP, ]); /** * Maps proto provider to the limit key in workspace limits. */ function providerToLimitKey( provider: NotificationProvider, ): keyof Limits | null { switch (provider) { case NotificationProvider.SMS: return "sms"; case NotificationProvider.PAGERDUTY: return "pagerduty"; case NotificationProvider.OPSGENIE: return "opsgenie"; case NotificationProvider.GRAFANA_ONCALL: return "grafana-oncall"; case NotificationProvider.WHATSAPP: return "whatsapp"; default: return null; } } /** * Maps proto provider to display name for error messages. */ function providerToDisplayName(provider: NotificationProvider): string { switch (provider) { case NotificationProvider.DISCORD: return "Discord"; case NotificationProvider.EMAIL: return "Email"; case NotificationProvider.GOOGLE_CHAT: return "Google Chat"; case NotificationProvider.GRAFANA_ONCALL: return "Grafana OnCall"; case NotificationProvider.NTFY: return "Ntfy"; case NotificationProvider.PAGERDUTY: return "PagerDuty"; case NotificationProvider.OPSGENIE: return "Opsgenie"; case NotificationProvider.SLACK: return "Slack"; case NotificationProvider.SMS: return "SMS"; case NotificationProvider.TELEGRAM: return "Telegram"; case NotificationProvider.WEBHOOK: return "Webhook"; case NotificationProvider.WHATSAPP: return "WhatsApp"; default: return "Unknown"; } } /** * Checks if the workspace has reached its notification limit. * Throws notificationLimitReachedError if limit is reached. */ export async function checkNotificationLimit( workspaceId: number, limits: Limits, ): Promise<void> { const maxCount = limits["notification-channels"]; const result = await db .select({ count: count() }) .from(notification) .where(eq(notification.workspaceId, workspaceId)) .get(); const currentCount = result?.count ?? 0; if (currentCount >= maxCount) { throw notificationLimitReachedError(); } } /** * Checks if the provider is allowed for the workspace's plan. * Throws providerNotAllowedError if not allowed. */ export function checkProviderAllowed( provider: NotificationProvider, limits: Limits, ): void { if (!LIMITED_PROVIDERS.has(provider)) { return; // Provider is free for all plans } const limitKey = providerToLimitKey(provider); if (!limitKey) { return; // No limit key means it's free } const isAllowed = limits[limitKey]; if (!isAllowed) { throw providerNotAllowedError(providerToDisplayName(provider)); } } /** * Gets the current notification count and limit for a workspace. */ export async function getNotificationLimitInfo( workspaceId: number, limits: Limits, ): Promise<{ currentCount: number; maxCount: number; limitReached: boolean }> { const maxCount = limits["notification-channels"]; const result = await db .select({ count: count() }) .from(notification) .where(eq(notification.workspaceId, workspaceId)) .get(); const currentCount = result?.count ?? 0; return { currentCount, maxCount, limitReached: currentCount >= maxCount, }; } ================================================ FILE: apps/server/src/routes/rpc/services/notification/test-providers.ts ================================================ import { sendTestDiscordMessage } from "@openstatus/notification-discord"; import { sendTest as sendGoogleChatTest } from "@openstatus/notification-google-chat"; import { sendTest as sendGrafanaTest } from "@openstatus/notification-grafana-oncall"; import { sendTest as sendNtfyTest } from "@openstatus/notification-ntfy"; import { sendTest as sendOpsgenieTest } from "@openstatus/notification-opsgenie"; import { sendTest as sendPagerDutyTest } from "@openstatus/notification-pagerduty"; import { sendTestSlackMessage } from "@openstatus/notification-slack"; import { sendTest as sendTelegramTest } from "@openstatus/notification-telegram"; import { sendTest as sendWhatsAppTest } from "@openstatus/notification-twillio-whatsapp"; import { sendTest as sendWebhookTest } from "@openstatus/notification-webhook"; import { type NotificationData, NotificationProvider, OpsgenieRegion, } from "@openstatus/proto/notification/v1"; import { invalidNotificationDataError, providerNotSupportedError, testNotificationFailedError, } from "./errors"; /** * Sends a test notification using the specified provider and data. * Throws an error if the provider is not supported or if sending fails. */ export async function sendTestNotification( provider: NotificationProvider, data: NotificationData | undefined, ): Promise<{ success: boolean; errorMessage?: string }> { if (!data || data.data.case === undefined) { throw invalidNotificationDataError("No provider data specified"); } try { switch (provider) { case NotificationProvider.TELEGRAM: { if (data.data.case !== "telegram") { throw invalidNotificationDataError( "Expected telegram data for Telegram provider", ); } await sendTelegramTest({ chatId: data.data.value.chatId }); return { success: true }; } case NotificationProvider.WHATSAPP: { if (data.data.case !== "whatsapp") { throw invalidNotificationDataError( "Expected whatsapp data for WhatsApp provider", ); } await sendWhatsAppTest({ phoneNumber: data.data.value.phoneNumber }); return { success: true }; } case NotificationProvider.GOOGLE_CHAT: { if (data.data.case !== "googleChat") { throw invalidNotificationDataError( "Expected google_chat data for Google Chat provider", ); } await sendGoogleChatTest(data.data.value.webhookUrl); return { success: true }; } case NotificationProvider.GRAFANA_ONCALL: { if (data.data.case !== "grafanaOncall") { throw invalidNotificationDataError( "Expected grafana_oncall data for Grafana OnCall provider", ); } await sendGrafanaTest({ webhookUrl: data.data.value.webhookUrl }); return { success: true }; } case NotificationProvider.DISCORD: { if (data.data.case !== "discord") { throw invalidNotificationDataError( "Expected discord data for Discord provider", ); } await sendTestDiscordMessage(data.data.value.webhookUrl); return { success: true }; } case NotificationProvider.SLACK: { if (data.data.case !== "slack") { throw invalidNotificationDataError( "Expected slack data for Slack provider", ); } await sendTestSlackMessage(data.data.value.webhookUrl); return { success: true }; } case NotificationProvider.NTFY: { if (data.data.case !== "ntfy") { throw invalidNotificationDataError( "Expected ntfy data for Ntfy provider", ); } await sendNtfyTest({ topic: data.data.value.topic, serverUrl: data.data.value.serverUrl || undefined, token: data.data.value.token, }); return { success: true }; } case NotificationProvider.PAGERDUTY: { if (data.data.case !== "pagerduty") { throw invalidNotificationDataError( "Expected pagerduty data for PagerDuty provider", ); } await sendPagerDutyTest({ integrationKey: data.data.value.integrationKey, }); return { success: true }; } case NotificationProvider.OPSGENIE: { if (data.data.case !== "opsgenie") { throw invalidNotificationDataError( "Expected opsgenie data for Opsgenie provider", ); } await sendOpsgenieTest({ apiKey: data.data.value.apiKey, region: data.data.value.region === OpsgenieRegion.EU ? "eu" : "us", }); return { success: true }; } case NotificationProvider.WEBHOOK: { if (data.data.case !== "webhook") { throw invalidNotificationDataError( "Expected webhook data for Webhook provider", ); } await sendWebhookTest({ url: data.data.value.endpoint, headers: data.data.value.headers?.map((h) => ({ key: h.key, value: h.value, })), }); return { success: true }; } // Providers that don't support test notifications yet case NotificationProvider.EMAIL: case NotificationProvider.SMS: throw providerNotSupportedError(NotificationProvider[provider]); default: throw providerNotSupportedError("Unknown"); } } catch (error) { if (error instanceof Error && "code" in error) { // Re-throw ConnectErrors as-is throw error; } // Wrap other errors const message = error instanceof Error ? error.message : "Unknown error"; throw testNotificationFailedError(message); } } ================================================ FILE: apps/server/src/routes/rpc/services/status-page/__tests__/status-page.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { monitor, page, pageComponent, pageComponentGroup, pageSubscriber, statusReport, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import { app } from "@/index"; /** * Helper to make ConnectRPC requests using the Connect protocol (JSON). * Connect uses POST with JSON body at /rpc/<service>/<method> */ async function connectRequest( method: string, body: Record<string, unknown> = {}, headers: Record<string, string> = {}, ) { return app.request( `/rpc/openstatus.status_page.v1.StatusPageService/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }, ); } const TEST_PREFIX = "rpc-status-page-test"; let testPageId: number; let testPageSlug: string; let testPageToDeleteId: number; let testPageToUpdateId: number; let testComponentId: number; let testComponentToDeleteId: number; let testComponentToUpdateId: number; let testGroupId: number; let testGroupToDeleteId: number; let testGroupToUpdateId: number; let testMonitorId: number; let testSubscriberId: number; beforeAll(async () => { // Clean up any existing test data await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, `${TEST_PREFIX}@example.com`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-delete`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-update`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-delete`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-update`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-delete`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-update`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); // Create a test monitor for component tests const testMonitor = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor`, url: "https://example.com", periodicity: "1m", active: true, jobType: "http", }) .returning() .get(); testMonitorId = testMonitor.id; // Create a test page (published and public for testing public access) const testPage = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page`, slug: `${TEST_PREFIX}-slug`, description: "Test page for status page tests", customDomain: "", published: true, accessType: "public", }) .returning() .get(); testPageId = testPage.id; testPageSlug = testPage.slug; // Create page to delete const pageToDelete = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page-to-delete`, slug: `${TEST_PREFIX}-slug-to-delete`, description: "Test page to delete", customDomain: "", }) .returning() .get(); testPageToDeleteId = pageToDelete.id; // Create page to update const pageToUpdate = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page-to-update`, slug: `${TEST_PREFIX}-slug-to-update`, description: "Test page to update", customDomain: "", }) .returning() .get(); testPageToUpdateId = pageToUpdate.id; // Create a test component group const testGroup = await db .insert(pageComponentGroup) .values({ workspaceId: 1, pageId: testPageId, name: `${TEST_PREFIX}-group`, }) .returning() .get(); testGroupId = testGroup.id; // Create group to delete const groupToDelete = await db .insert(pageComponentGroup) .values({ workspaceId: 1, pageId: testPageId, name: `${TEST_PREFIX}-group-to-delete`, }) .returning() .get(); testGroupToDeleteId = groupToDelete.id; // Create group to update const groupToUpdate = await db .insert(pageComponentGroup) .values({ workspaceId: 1, pageId: testPageId, name: `${TEST_PREFIX}-group-to-update`, }) .returning() .get(); testGroupToUpdateId = groupToUpdate.id; // Create a test component const testComponent = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: testPageId, type: "static", name: `${TEST_PREFIX}-component`, description: "Test component", order: 100, }) .returning() .get(); testComponentId = testComponent.id; // Create component to delete const componentToDelete = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: testPageId, type: "static", name: `${TEST_PREFIX}-component-to-delete`, description: "Test component to delete", order: 101, }) .returning() .get(); testComponentToDeleteId = componentToDelete.id; // Create component to update const componentToUpdate = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: testPageId, type: "static", name: `${TEST_PREFIX}-component-to-update`, description: "Test component to update", order: 102, }) .returning() .get(); testComponentToUpdateId = componentToUpdate.id; // Create a test subscriber const testSubscriber = await db .insert(pageSubscriber) .values({ pageId: testPageId, email: `${TEST_PREFIX}@example.com`, token: `${TEST_PREFIX}-token`, acceptedAt: new Date(), }) .returning() .get(); testSubscriberId = testSubscriber.id; }); afterAll(async () => { // Clean up test data in proper order await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, `${TEST_PREFIX}@example.com`)); await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, `${TEST_PREFIX}-subscribe@example.com`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-delete`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-to-update`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-delete`)); await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-group-to-update`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-delete`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-update`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-created-slug`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); }); // ========================================================================== // Page CRUD // ========================================================================== describe("StatusPageService.CreateStatusPage", () => { test("creates a new status page", async () => { const res = await connectRequest( "CreateStatusPage", { title: `${TEST_PREFIX}-created`, description: "A new test page", slug: `${TEST_PREFIX}-created-slug`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPage"); expect(data.statusPage.title).toBe(`${TEST_PREFIX}-created`); expect(data.statusPage.description).toBe("A new test page"); expect(data.statusPage.slug).toBe(`${TEST_PREFIX}-created-slug`); // Clean up await db.delete(page).where(eq(page.id, Number(data.statusPage.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CreateStatusPage", { title: "Unauthorized test", slug: "unauthorized-slug", }); expect(res.status).toBe(401); }); test("returns error when slug already exists", async () => { const res = await connectRequest( "CreateStatusPage", { title: "Duplicate slug test", slug: testPageSlug, // Already exists }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(409); // AlreadyExists }); test("returns 403 when status page limit is exceeded", async () => { // Workspace 2 is on free plan with status-pages limit of 1 // First, create a page for workspace 2 to hit the limit const firstPage = await db .insert(page) .values({ workspaceId: 2, title: `${TEST_PREFIX}-limit-test`, slug: `${TEST_PREFIX}-limit-test-slug`, description: "First page for limit test", customDomain: "", }) .returning() .get(); try { // Try to create a second page - should fail with PermissionDenied const res = await connectRequest( "CreateStatusPage", { title: `${TEST_PREFIX}-limit-exceeded`, description: "Should fail due to limit", slug: `${TEST_PREFIX}-limit-exceeded-slug`, }, { "x-openstatus-key": "2" }, ); expect(res.status).toBe(403); // PermissionDenied const data = await res.json(); expect(data.message).toContain("Upgrade for more status pages"); } finally { // Clean up await db.delete(page).where(eq(page.id, firstPage.id)); } }); }); describe("StatusPageService.GetStatusPage", () => { test("returns status page by ID", async () => { const res = await connectRequest( "GetStatusPage", { id: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPage"); expect(data.statusPage.id).toBe(String(testPageId)); expect(data.statusPage.slug).toBe(testPageSlug); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetStatusPage", { id: String(testPageId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status page", async () => { const res = await connectRequest( "GetStatusPage", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty", async () => { const res = await connectRequest( "GetStatusPage", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for status page in different workspace", async () => { // Create page in workspace 2 const otherPage = await db .insert(page) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace`, slug: `${TEST_PREFIX}-other-workspace-slug`, description: "Other workspace page", customDomain: "", }) .returning() .get(); try { const res = await connectRequest( "GetStatusPage", { id: String(otherPage.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(page).where(eq(page.id, otherPage.id)); } }); }); describe("StatusPageService.ListStatusPages", () => { test("returns status pages for authenticated workspace", async () => { const res = await connectRequest( "ListStatusPages", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPages"); expect(Array.isArray(data.statusPages)).toBe(true); expect(data).toHaveProperty("totalSize"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListStatusPages", {}); expect(res.status).toBe(401); }); test("respects limit parameter", async () => { const res = await connectRequest( "ListStatusPages", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusPages?.length || 0).toBeLessThanOrEqual(1); }); test("respects offset parameter", async () => { // Get first page const res1 = await connectRequest( "ListStatusPages", { limit: 1, offset: 0 }, { "x-openstatus-key": "1" }, ); const data1 = await res1.json(); // Get second page const res2 = await connectRequest( "ListStatusPages", { limit: 1, offset: 1 }, { "x-openstatus-key": "1" }, ); const data2 = await res2.json(); // Should have different pages if multiple exist if (data1.statusPages?.length > 0 && data2.statusPages?.length > 0) { expect(data1.statusPages[0].id).not.toBe(data2.statusPages[0].id); } }); }); describe("StatusPageService.UpdateStatusPage", () => { test("updates status page title", async () => { const res = await connectRequest( "UpdateStatusPage", { id: String(testPageToUpdateId), title: `${TEST_PREFIX}-updated-title`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPage"); expect(data.statusPage.title).toBe(`${TEST_PREFIX}-updated-title`); // Restore original title await db .update(page) .set({ title: `${TEST_PREFIX}-page-to-update` }) .where(eq(page.id, testPageToUpdateId)); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateStatusPage", { id: String(testPageToUpdateId), title: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status page", async () => { const res = await connectRequest( "UpdateStatusPage", { id: "99999", title: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when slug conflicts with another page", async () => { const res = await connectRequest( "UpdateStatusPage", { id: String(testPageToUpdateId), slug: testPageSlug, // Already exists on another page }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(409); }); }); describe("StatusPageService.DeleteStatusPage", () => { test("successfully deletes existing status page", async () => { const res = await connectRequest( "DeleteStatusPage", { id: String(testPageToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(page) .where(eq(page.id, testPageToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteStatusPage", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status page", async () => { const res = await connectRequest( "DeleteStatusPage", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); // ========================================================================== // Component Management // ========================================================================== describe("StatusPageService.AddMonitorComponent", () => { test("adds monitor component to page", async () => { const res = await connectRequest( "AddMonitorComponent", { pageId: String(testPageId), monitorId: String(testMonitorId), name: `${TEST_PREFIX}-monitor-component`, order: 200, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("component"); expect(data.component.name).toBe(`${TEST_PREFIX}-monitor-component`); expect(data.component.type).toBe("PAGE_COMPONENT_TYPE_MONITOR"); expect(data.component.monitorId).toBe(String(testMonitorId)); // Clean up await db .delete(pageComponent) .where(eq(pageComponent.id, Number(data.component.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("AddMonitorComponent", { pageId: String(testPageId), monitorId: String(testMonitorId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "AddMonitorComponent", { pageId: "99999", monitorId: String(testMonitorId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for non-existent monitor", async () => { const res = await connectRequest( "AddMonitorComponent", { pageId: String(testPageId), monitorId: "99999", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("adds component with group", async () => { const res = await connectRequest( "AddMonitorComponent", { pageId: String(testPageId), monitorId: String(testMonitorId), name: `${TEST_PREFIX}-monitor-component-grouped`, groupId: String(testGroupId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.component.groupId).toBe(String(testGroupId)); // Clean up await db .delete(pageComponent) .where(eq(pageComponent.id, Number(data.component.id))); }); test("returns 403 when page component limit is exceeded", async () => { // Workspace 2 is on free plan with page-components limit of 3 // Create a page for workspace 2 const limitTestPage = await db .insert(page) .values({ workspaceId: 2, title: `${TEST_PREFIX}-component-limit-test`, slug: `${TEST_PREFIX}-component-limit-test-slug`, description: "Page for component limit test", customDomain: "", }) .returning() .get(); // Create a monitor for workspace 2 const limitTestMonitor = await db .insert(monitor) .values({ workspaceId: 2, name: `${TEST_PREFIX}-limit-monitor`, url: "https://example.com", periodicity: "1m", active: true, jobType: "http", }) .returning() .get(); // Create 3 components to hit the limit const createdComponentIds: number[] = []; for (let i = 0; i < 3; i++) { const component = await db .insert(pageComponent) .values({ workspaceId: 2, pageId: limitTestPage.id, type: "static", name: `${TEST_PREFIX}-limit-component-${i}`, order: i, }) .returning() .get(); createdComponentIds.push(component.id); } try { // Try to add a 4th component - should fail with PermissionDenied const res = await connectRequest( "AddMonitorComponent", { pageId: String(limitTestPage.id), monitorId: String(limitTestMonitor.id), name: `${TEST_PREFIX}-limit-exceeded-component`, }, { "x-openstatus-key": "2" }, ); expect(res.status).toBe(403); // PermissionDenied const data = await res.json(); expect(data.message).toContain("Upgrade for more page components"); } finally { // Clean up for (const id of createdComponentIds) { await db.delete(pageComponent).where(eq(pageComponent.id, id)); } await db.delete(monitor).where(eq(monitor.id, limitTestMonitor.id)); await db.delete(page).where(eq(page.id, limitTestPage.id)); } }); }); describe("StatusPageService.AddStaticComponent", () => { test("adds static component to page", async () => { const res = await connectRequest( "AddStaticComponent", { pageId: String(testPageId), name: `${TEST_PREFIX}-static-component`, description: "Static service", order: 300, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("component"); expect(data.component.name).toBe(`${TEST_PREFIX}-static-component`); expect(data.component.type).toBe("PAGE_COMPONENT_TYPE_STATIC"); expect(data.component.description).toBe("Static service"); // Clean up await db .delete(pageComponent) .where(eq(pageComponent.id, Number(data.component.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("AddStaticComponent", { pageId: String(testPageId), name: "Unauthorized component", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "AddStaticComponent", { pageId: "99999", name: "Component for non-existent page", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 403 when page component limit is exceeded", async () => { // Workspace 2 is on free plan with page-components limit of 3 // Create a page for workspace 2 const limitTestPage = await db .insert(page) .values({ workspaceId: 2, title: `${TEST_PREFIX}-static-limit-test`, slug: `${TEST_PREFIX}-static-limit-test-slug`, description: "Page for static component limit test", customDomain: "", }) .returning() .get(); // Create 3 components to hit the limit const createdComponentIds: number[] = []; for (let i = 0; i < 3; i++) { const component = await db .insert(pageComponent) .values({ workspaceId: 2, pageId: limitTestPage.id, type: "static", name: `${TEST_PREFIX}-static-limit-component-${i}`, order: i, }) .returning() .get(); createdComponentIds.push(component.id); } try { // Try to add a 4th component - should fail with PermissionDenied const res = await connectRequest( "AddStaticComponent", { pageId: String(limitTestPage.id), name: `${TEST_PREFIX}-static-limit-exceeded`, description: "Should fail due to limit", }, { "x-openstatus-key": "2" }, ); expect(res.status).toBe(403); // PermissionDenied const data = await res.json(); expect(data.message).toContain("Upgrade for more page components"); } finally { // Clean up for (const id of createdComponentIds) { await db.delete(pageComponent).where(eq(pageComponent.id, id)); } await db.delete(page).where(eq(page.id, limitTestPage.id)); } }); }); describe("StatusPageService.RemoveComponent", () => { test("successfully removes component", async () => { const res = await connectRequest( "RemoveComponent", { id: String(testComponentToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(pageComponent) .where(eq(pageComponent.id, testComponentToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("RemoveComponent", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent component", async () => { const res = await connectRequest( "RemoveComponent", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); describe("StatusPageService.UpdateComponent", () => { test("updates component name", async () => { const res = await connectRequest( "UpdateComponent", { id: String(testComponentToUpdateId), name: `${TEST_PREFIX}-component-updated`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("component"); expect(data.component.name).toBe(`${TEST_PREFIX}-component-updated`); // Restore original name await db .update(pageComponent) .set({ name: `${TEST_PREFIX}-component-to-update` }) .where(eq(pageComponent.id, testComponentToUpdateId)); }); test("updates component group", async () => { const res = await connectRequest( "UpdateComponent", { id: String(testComponentToUpdateId), groupId: String(testGroupId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.component.groupId).toBe(String(testGroupId)); // Remove from group await db .update(pageComponent) .set({ groupId: null }) .where(eq(pageComponent.id, testComponentToUpdateId)); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateComponent", { id: String(testComponentToUpdateId), name: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent component", async () => { const res = await connectRequest( "UpdateComponent", { id: "99999", name: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for non-existent group", async () => { const res = await connectRequest( "UpdateComponent", { id: String(testComponentToUpdateId), groupId: "99999", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); // ========================================================================== // Component Groups // ========================================================================== describe("StatusPageService.CreateComponentGroup", () => { test("creates a new component group", async () => { const res = await connectRequest( "CreateComponentGroup", { pageId: String(testPageId), name: `${TEST_PREFIX}-new-group`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("group"); expect(data.group.name).toBe(`${TEST_PREFIX}-new-group`); expect(data.group.pageId).toBe(String(testPageId)); // Clean up await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.id, Number(data.group.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CreateComponentGroup", { pageId: String(testPageId), name: "Unauthorized group", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "CreateComponentGroup", { pageId: "99999", name: "Group for non-existent page", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); describe("StatusPageService.DeleteComponentGroup", () => { test("successfully deletes component group", async () => { const res = await connectRequest( "DeleteComponentGroup", { id: String(testGroupToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(pageComponentGroup) .where(eq(pageComponentGroup.id, testGroupToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteComponentGroup", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent group", async () => { const res = await connectRequest( "DeleteComponentGroup", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); describe("StatusPageService.UpdateComponentGroup", () => { test("updates component group name", async () => { const res = await connectRequest( "UpdateComponentGroup", { id: String(testGroupToUpdateId), name: `${TEST_PREFIX}-group-updated`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("group"); expect(data.group.name).toBe(`${TEST_PREFIX}-group-updated`); // Restore original name await db .update(pageComponentGroup) .set({ name: `${TEST_PREFIX}-group-to-update` }) .where(eq(pageComponentGroup.id, testGroupToUpdateId)); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateComponentGroup", { id: String(testGroupToUpdateId), name: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent group", async () => { const res = await connectRequest( "UpdateComponentGroup", { id: "99999", name: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); // ========================================================================== // Subscribers // ========================================================================== describe("StatusPageService.SubscribeToPage", () => { test("subscribes new user to page", async () => { const res = await connectRequest( "SubscribeToPage", { pageId: String(testPageId), email: `${TEST_PREFIX}-subscribe@example.com`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("subscriber"); expect(data.subscriber.email).toBe(`${TEST_PREFIX}-subscribe@example.com`); expect(data.subscriber.pageId).toBe(String(testPageId)); // Clean up await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, Number(data.subscriber.id))); }); test("returns existing subscriber when already subscribed", async () => { const res = await connectRequest( "SubscribeToPage", { pageId: String(testPageId), email: `${TEST_PREFIX}@example.com`, // Already exists }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("subscriber"); expect(data.subscriber.id).toBe(String(testSubscriberId)); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("SubscribeToPage", { pageId: String(testPageId), email: "unauthorized@example.com", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "SubscribeToPage", { pageId: "99999", email: "test@example.com", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); }); describe("StatusPageService.UnsubscribeFromPage", () => { test("unsubscribes by email", async () => { // First subscribe a new user const subscribeRes = await connectRequest( "SubscribeToPage", { pageId: String(testPageId), email: `${TEST_PREFIX}-unsub-email@example.com`, }, { "x-openstatus-key": "1" }, ); const subscribeData = await subscribeRes.json(); // Then unsubscribe const res = await connectRequest( "UnsubscribeFromPage", { pageId: String(testPageId), email: `${TEST_PREFIX}-unsub-email@example.com`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify unsubscribedAt is set const subscriber = await db .select() .from(pageSubscriber) .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))) .get(); expect(subscriber?.unsubscribedAt).not.toBeNull(); // Clean up await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))); }); test("unsubscribes by id", async () => { // First subscribe a new user const subscribeRes = await connectRequest( "SubscribeToPage", { pageId: String(testPageId), email: `${TEST_PREFIX}-unsub-id@example.com`, }, { "x-openstatus-key": "1" }, ); const subscribeData = await subscribeRes.json(); // Then unsubscribe by id const res = await connectRequest( "UnsubscribeFromPage", { pageId: String(testPageId), id: subscribeData.subscriber.id, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Clean up await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, Number(subscribeData.subscriber.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UnsubscribeFromPage", { pageId: String(testPageId), email: "test@example.com", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent subscriber", async () => { const res = await connectRequest( "UnsubscribeFromPage", { pageId: String(testPageId), email: "nonexistent@example.com", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when no identifier provided", async () => { const res = await connectRequest( "UnsubscribeFromPage", { pageId: String(testPageId), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("StatusPageService.ListSubscribers", () => { test("returns subscribers for page", async () => { const res = await connectRequest( "ListSubscribers", { pageId: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("subscribers"); expect(Array.isArray(data.subscribers)).toBe(true); expect(data).toHaveProperty("totalSize"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListSubscribers", { pageId: String(testPageId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "ListSubscribers", { pageId: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("filters out unsubscribed by default", async () => { // Create an unsubscribed subscriber const unsubscriber = await db .insert(pageSubscriber) .values({ pageId: testPageId, email: `${TEST_PREFIX}-unsubbed@example.com`, token: `${TEST_PREFIX}-unsubbed-token`, unsubscribedAt: new Date(), }) .returning() .get(); try { const res = await connectRequest( "ListSubscribers", { pageId: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const subscriberEmails = (data.subscribers || []).map( (s: { email: string }) => s.email, ); expect(subscriberEmails).not.toContain( `${TEST_PREFIX}-unsubbed@example.com`, ); } finally { await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, unsubscriber.id)); } }); test("includes unsubscribed when flag is true", async () => { // Create an unsubscribed subscriber const unsubscriber = await db .insert(pageSubscriber) .values({ pageId: testPageId, email: `${TEST_PREFIX}-unsubbed2@example.com`, token: `${TEST_PREFIX}-unsubbed2-token`, unsubscribedAt: new Date(), }) .returning() .get(); try { const res = await connectRequest( "ListSubscribers", { pageId: String(testPageId), includeUnsubscribed: true }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const subscriberEmails = (data.subscribers || []).map( (s: { email: string }) => s.email, ); expect(subscriberEmails).toContain( `${TEST_PREFIX}-unsubbed2@example.com`, ); } finally { await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, unsubscriber.id)); } }); }); // ========================================================================== // Full Content & Status // ========================================================================== describe("StatusPageService.GetStatusPageContent", () => { test("returns full content by ID", async () => { const res = await connectRequest( "GetStatusPageContent", { id: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPage"); expect(data).toHaveProperty("components"); expect(data).toHaveProperty("groups"); // statusReports may be undefined/empty if there are no active reports expect(data.statusPage.id).toBe(String(testPageId)); }); test("returns full content by slug", async () => { const res = await connectRequest( "GetStatusPageContent", { slug: testPageSlug }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusPage"); expect(data.statusPage.slug).toBe(testPageSlug); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetStatusPageContent", { id: String(testPageId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "GetStatusPageContent", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for unpublished page accessed by slug", async () => { // Create an unpublished page const unpublishedPage = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-unpublished`, slug: `${TEST_PREFIX}-unpublished-slug`, description: "Unpublished page", customDomain: "", published: false, accessType: "public", }) .returning() .get(); try { const res = await connectRequest( "GetStatusPageContent", { slug: unpublishedPage.slug }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(page).where(eq(page.id, unpublishedPage.id)); } }); test("returns 403 for password-protected page accessed by slug", async () => { // Create a password-protected page const protectedPage = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-protected`, slug: `${TEST_PREFIX}-protected-slug`, description: "Password protected page", customDomain: "", published: true, accessType: "password", }) .returning() .get(); try { const res = await connectRequest( "GetStatusPageContent", { slug: protectedPage.slug }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(403); } finally { await db.delete(page).where(eq(page.id, protectedPage.id)); } }); test("allows workspace owner to access unpublished page by ID", async () => { // Create an unpublished page const unpublishedPage = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-unpublished-by-id`, slug: `${TEST_PREFIX}-unpublished-by-id-slug`, description: "Unpublished page accessible by ID", customDomain: "", published: false, accessType: "public", }) .returning() .get(); try { const res = await connectRequest( "GetStatusPageContent", { id: String(unpublishedPage.id) }, { "x-openstatus-key": "1" }, ); // Workspace owner can access their own unpublished pages by ID expect(res.status).toBe(200); } finally { await db.delete(page).where(eq(page.id, unpublishedPage.id)); } }); test("includes active status reports", async () => { // Create an active status report for the test page const report = await db .insert(statusReport) .values({ workspaceId: 1, pageId: testPageId, title: `${TEST_PREFIX}-active-report`, status: "investigating", }) .returning() .get(); await db.insert(statusReportsToPageComponents).values({ statusReportId: report.id, pageComponentId: testComponentId, }); try { const res = await connectRequest( "GetStatusPageContent", { id: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusReports.length).toBeGreaterThan(0); const testReport = data.statusReports.find( (r: { title: string }) => r.title === `${TEST_PREFIX}-active-report`, ); expect(testReport).toBeDefined(); } finally { await db .delete(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, report.id)); await db.delete(statusReport).where(eq(statusReport.id, report.id)); } }); }); describe("StatusPageService.GetOverallStatus", () => { test("returns overall status by ID", async () => { const res = await connectRequest( "GetOverallStatus", { id: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("overallStatus"); expect(data).toHaveProperty("componentStatuses"); }); test("returns overall status by slug", async () => { const res = await connectRequest( "GetOverallStatus", { slug: testPageSlug }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("overallStatus"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetOverallStatus", { id: String(testPageId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent page", async () => { const res = await connectRequest( "GetOverallStatus", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns degraded status when there are active incidents", async () => { // Create an active status report for the test page const report = await db .insert(statusReport) .values({ workspaceId: 1, pageId: testPageId, title: `${TEST_PREFIX}-incident-report`, status: "investigating", }) .returning() .get(); await db.insert(statusReportsToPageComponents).values({ statusReportId: report.id, pageComponentId: testComponentId, }); try { const res = await connectRequest( "GetOverallStatus", { id: String(testPageId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.overallStatus).toBe("OVERALL_STATUS_DEGRADED"); } finally { await db .delete(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, report.id)); await db.delete(statusReport).where(eq(statusReport.id, report.id)); } }); }); ================================================ FILE: apps/server/src/routes/rpc/services/status-page/converters.ts ================================================ import type { PageComponent, PageComponentGroup, PageSubscriber, StatusPage, StatusPageSummary, } from "@openstatus/proto/status_page/v1"; import { OverallStatus, PageAccessType, PageComponentType, PageTheme, } from "@openstatus/proto/status_page/v1"; /** * Database types */ type DBPage = { id: number; title: string; description: string; slug: string; customDomain: string; published: boolean | null; forceTheme: "system" | "light" | "dark"; accessType: "public" | "password" | "email-domain" | null; homepageUrl: string | null; contactUrl: string | null; icon: string | null; createdAt: Date | null; updatedAt: Date | null; }; type DBPageComponent = { id: number; pageId: number; name: string; description: string | null; type: "static" | "monitor"; monitorId: number | null; order: number | null; groupId: number | null; groupOrder: number | null; createdAt: Date | null; updatedAt: Date | null; }; type DBPageComponentGroup = { id: number; pageId: number; name: string; createdAt: Date | null; updatedAt: Date | null; }; type DBPageSubscriber = { id: number; pageId: number; email: string; acceptedAt: Date | null; unsubscribedAt: Date | null; createdAt: Date | null; updatedAt: Date | null; }; /** * Convert DB access type string to proto enum. */ export function dbAccessTypeToProto( accessType: "public" | "password" | "email-domain" | null, ): PageAccessType { switch (accessType) { case "public": return PageAccessType.PUBLIC; case "password": return PageAccessType.PASSWORD_PROTECTED; case "email-domain": return PageAccessType.AUTHENTICATED; default: return PageAccessType.PUBLIC; } } /** * Convert proto access type enum to DB string. */ export function protoAccessTypeToDb( accessType: PageAccessType, ): "public" | "password" | "email-domain" { switch (accessType) { case PageAccessType.PUBLIC: return "public"; case PageAccessType.PASSWORD_PROTECTED: return "password"; case PageAccessType.AUTHENTICATED: return "email-domain"; default: return "public"; } } /** * Convert DB theme string to proto enum. */ export function dbThemeToProto(theme: "system" | "light" | "dark"): PageTheme { switch (theme) { case "system": return PageTheme.SYSTEM; case "light": return PageTheme.LIGHT; case "dark": return PageTheme.DARK; default: return PageTheme.SYSTEM; } } /** * Convert proto theme enum to DB string. */ export function protoThemeToDb(theme: PageTheme): "system" | "light" | "dark" { switch (theme) { case PageTheme.SYSTEM: return "system"; case PageTheme.LIGHT: return "light"; case PageTheme.DARK: return "dark"; default: return "system"; } } /** * Convert DB component type string to proto enum. */ export function dbComponentTypeToProto( type: "static" | "monitor", ): PageComponentType { switch (type) { case "monitor": return PageComponentType.MONITOR; case "static": return PageComponentType.STATIC; default: return PageComponentType.UNSPECIFIED; } } /** * Convert proto component type enum to DB string. */ export function protoComponentTypeToDb( type: PageComponentType, ): "static" | "monitor" { switch (type) { case PageComponentType.MONITOR: return "monitor"; case PageComponentType.STATIC: return "static"; default: return "static"; } } /** * Convert a DB status page to full proto format. */ export function dbPageToProto(page: DBPage): StatusPage { return { $typeName: "openstatus.status_page.v1.StatusPage" as const, id: String(page.id), title: page.title, description: page.description, slug: page.slug, customDomain: page.customDomain ?? "", published: page.published ?? false, accessType: dbAccessTypeToProto(page.accessType), theme: dbThemeToProto(page.forceTheme), homepageUrl: page.homepageUrl ?? "", contactUrl: page.contactUrl ?? "", icon: page.icon ?? "", createdAt: page.createdAt?.toISOString() ?? "", updatedAt: page.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB status page to summary proto format. */ export function dbPageToProtoSummary(page: DBPage): StatusPageSummary { return { $typeName: "openstatus.status_page.v1.StatusPageSummary" as const, id: String(page.id), title: page.title, slug: page.slug, published: page.published ?? false, createdAt: page.createdAt?.toISOString() ?? "", updatedAt: page.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB page component to proto format. */ export function dbComponentToProto(component: DBPageComponent): PageComponent { return { $typeName: "openstatus.status_page.v1.PageComponent" as const, id: String(component.id), pageId: String(component.pageId), name: component.name, description: component.description ?? "", type: dbComponentTypeToProto(component.type), monitorId: component.monitorId != null ? String(component.monitorId) : "", order: component.order ?? 0, groupId: component.groupId != null ? String(component.groupId) : "", groupOrder: component.groupOrder ?? 0, createdAt: component.createdAt?.toISOString() ?? "", updatedAt: component.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB component group to proto format. */ export function dbGroupToProto( group: DBPageComponentGroup, ): PageComponentGroup { return { $typeName: "openstatus.status_page.v1.PageComponentGroup" as const, id: String(group.id), pageId: String(group.pageId), name: group.name, createdAt: group.createdAt?.toISOString() ?? "", updatedAt: group.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB subscriber to proto format. */ export function dbSubscriberToProto( subscriber: DBPageSubscriber, ): PageSubscriber { return { $typeName: "openstatus.status_page.v1.PageSubscriber" as const, id: String(subscriber.id), pageId: String(subscriber.pageId), email: subscriber.email, acceptedAt: subscriber.acceptedAt?.toISOString() ?? "", unsubscribedAt: subscriber.unsubscribedAt?.toISOString() ?? "", createdAt: subscriber.createdAt?.toISOString() ?? "", updatedAt: subscriber.updatedAt?.toISOString() ?? "", }; } /** * Get overall status based on component statuses. * This is a placeholder - the actual implementation would look at * monitor statuses, active incidents, and maintenance windows. */ export function getOverallStatusValue(): OverallStatus { // Default to operational - in a real implementation this would // aggregate status from monitors and incidents return OverallStatus.OPERATIONAL; } ================================================ FILE: apps/server/src/routes/rpc/services/status-page/errors.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; /** * Error reasons for structured error handling. */ export const ErrorReason = { STATUS_PAGE_NOT_FOUND: "STATUS_PAGE_NOT_FOUND", STATUS_PAGE_ID_REQUIRED: "STATUS_PAGE_ID_REQUIRED", STATUS_PAGE_CREATE_FAILED: "STATUS_PAGE_CREATE_FAILED", STATUS_PAGE_UPDATE_FAILED: "STATUS_PAGE_UPDATE_FAILED", STATUS_PAGE_NOT_PUBLISHED: "STATUS_PAGE_NOT_PUBLISHED", STATUS_PAGE_ACCESS_DENIED: "STATUS_PAGE_ACCESS_DENIED", SLUG_ALREADY_EXISTS: "SLUG_ALREADY_EXISTS", PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", PAGE_COMPONENT_CREATE_FAILED: "PAGE_COMPONENT_CREATE_FAILED", PAGE_COMPONENT_UPDATE_FAILED: "PAGE_COMPONENT_UPDATE_FAILED", COMPONENT_GROUP_NOT_FOUND: "COMPONENT_GROUP_NOT_FOUND", COMPONENT_GROUP_CREATE_FAILED: "COMPONENT_GROUP_CREATE_FAILED", COMPONENT_GROUP_UPDATE_FAILED: "COMPONENT_GROUP_UPDATE_FAILED", MONITOR_NOT_FOUND: "MONITOR_NOT_FOUND", SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND", SUBSCRIBER_CREATE_FAILED: "SUBSCRIBER_CREATE_FAILED", IDENTIFIER_REQUIRED: "IDENTIFIER_REQUIRED", } as const; export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; const DOMAIN = "openstatus.dev"; /** * Creates a ConnectError with structured metadata. */ function createError( message: string, code: Code, reason: ErrorReason, metadata?: Record<string, string>, ): ConnectError { const headers = new Headers({ "error-domain": DOMAIN, "error-reason": reason, }); if (metadata) { for (const [key, value] of Object.entries(metadata)) { headers.set(`error-${key}`, value); } } return new ConnectError(message, code, headers); } /** * Creates a "status page not found" error. */ export function statusPageNotFoundError(pageId: string): ConnectError { return createError( "Status page not found", Code.NotFound, ErrorReason.STATUS_PAGE_NOT_FOUND, { "page-id": pageId }, ); } /** * Creates a "status page ID required" error. */ export function statusPageIdRequiredError(): ConnectError { return createError( "Status page ID is required", Code.InvalidArgument, ErrorReason.STATUS_PAGE_ID_REQUIRED, ); } /** * Creates a "failed to create status page" error. */ export function statusPageCreateFailedError(): ConnectError { return createError( "Failed to create status page", Code.Internal, ErrorReason.STATUS_PAGE_CREATE_FAILED, ); } /** * Creates a "failed to update status page" error. */ export function statusPageUpdateFailedError(pageId: string): ConnectError { return createError( "Failed to update status page", Code.Internal, ErrorReason.STATUS_PAGE_UPDATE_FAILED, { "page-id": pageId }, ); } /** * Creates a "slug already exists" error. */ export function slugAlreadyExistsError(slug: string): ConnectError { return createError( "A status page with this slug already exists", Code.AlreadyExists, ErrorReason.SLUG_ALREADY_EXISTS, { slug }, ); } /** * Creates a "status page not published" error. * Used when trying to access an unpublished page via public slug. */ export function statusPageNotPublishedError(slug: string): ConnectError { return createError( "Status page is not published", Code.NotFound, ErrorReason.STATUS_PAGE_NOT_PUBLISHED, { slug }, ); } /** * Creates a "status page access denied" error. * Used when trying to access a protected page without proper authentication. */ export function statusPageAccessDeniedError( slug: string, accessType: string, ): ConnectError { return createError( `Status page requires ${accessType} access`, Code.PermissionDenied, ErrorReason.STATUS_PAGE_ACCESS_DENIED, { slug, "access-type": accessType }, ); } /** * Creates a "page component not found" error. */ export function pageComponentNotFoundError(componentId: string): ConnectError { return createError( "Page component not found", Code.NotFound, ErrorReason.PAGE_COMPONENT_NOT_FOUND, { "component-id": componentId }, ); } /** * Creates a "failed to create page component" error. */ export function pageComponentCreateFailedError(): ConnectError { return createError( "Failed to create page component", Code.Internal, ErrorReason.PAGE_COMPONENT_CREATE_FAILED, ); } /** * Creates a "failed to update page component" error. */ export function pageComponentUpdateFailedError( componentId: string, ): ConnectError { return createError( "Failed to update page component", Code.Internal, ErrorReason.PAGE_COMPONENT_UPDATE_FAILED, { "component-id": componentId }, ); } /** * Creates a "component group not found" error. */ export function componentGroupNotFoundError(groupId: string): ConnectError { return createError( "Component group not found", Code.NotFound, ErrorReason.COMPONENT_GROUP_NOT_FOUND, { "group-id": groupId }, ); } /** * Creates a "failed to create component group" error. */ export function componentGroupCreateFailedError(): ConnectError { return createError( "Failed to create component group", Code.Internal, ErrorReason.COMPONENT_GROUP_CREATE_FAILED, ); } /** * Creates a "failed to update component group" error. */ export function componentGroupUpdateFailedError(groupId: string): ConnectError { return createError( "Failed to update component group", Code.Internal, ErrorReason.COMPONENT_GROUP_UPDATE_FAILED, { "group-id": groupId }, ); } /** * Creates a "monitor not found" error. */ export function monitorNotFoundError(monitorId: string): ConnectError { return createError( "Monitor not found", Code.NotFound, ErrorReason.MONITOR_NOT_FOUND, { "monitor-id": monitorId }, ); } /** * Creates a "subscriber not found" error. */ export function subscriberNotFoundError(identifier: string): ConnectError { return createError( "Subscriber not found", Code.NotFound, ErrorReason.SUBSCRIBER_NOT_FOUND, { identifier }, ); } /** * Creates a "failed to create subscriber" error. */ export function subscriberCreateFailedError(): ConnectError { return createError( "Failed to create subscriber", Code.Internal, ErrorReason.SUBSCRIBER_CREATE_FAILED, ); } /** * Creates an "identifier required" error. */ export function identifierRequiredError(): ConnectError { return createError( "Either email or token is required to identify the subscriber", Code.InvalidArgument, ErrorReason.IDENTIFIER_REQUIRED, ); } ================================================ FILE: apps/server/src/routes/rpc/services/status-page/index.ts ================================================ import type { ServiceImpl } from "@connectrpc/connect"; import { and, count, db, desc, eq, gte, inArray, isNull, lte, } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, monitor, page, pageComponent, pageComponentGroup, pageSubscriber, statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import type { StatusPageService } from "@openstatus/proto/status_page/v1"; import { OverallStatus } from "@openstatus/proto/status_page/v1"; import { nanoid } from "nanoid"; import { getRpcContext } from "../../interceptors"; import { dbComponentToProto, dbGroupToProto, dbPageToProto, dbPageToProtoSummary, dbSubscriberToProto, } from "./converters"; import { componentGroupCreateFailedError, componentGroupNotFoundError, componentGroupUpdateFailedError, identifierRequiredError, monitorNotFoundError, pageComponentCreateFailedError, pageComponentNotFoundError, pageComponentUpdateFailedError, slugAlreadyExistsError, statusPageAccessDeniedError, statusPageCreateFailedError, statusPageIdRequiredError, statusPageNotFoundError, statusPageNotPublishedError, statusPageUpdateFailedError, subscriberCreateFailedError, subscriberNotFoundError, } from "./errors"; import { checkPageComponentLimits, checkStatusPageLimits } from "./limits"; /** * Helper to get a status page by ID with workspace scope. */ async function getPageById(id: number, workspaceId: number) { return db .select() .from(page) .where(and(eq(page.id, id), eq(page.workspaceId, workspaceId))) .get(); } /** * Helper to get a status page by slug. * Normalizes the slug to lowercase before querying. */ async function getPageBySlug(slug: string) { const normalizedSlug = slug.toLowerCase(); return db.select().from(page).where(eq(page.slug, normalizedSlug)).get(); } /** * Validates public access to a status page. * Checks that the page is published and has public access type. * Throws appropriate errors if access is denied. */ function validatePublicAccess( pageData: { published: boolean | null; accessType: string | null }, slug: string, ): void { // Check if page is published if (!pageData.published) { throw statusPageNotPublishedError(slug); } // Check access type - only public pages are accessible without authentication if (pageData.accessType && pageData.accessType !== "public") { throw statusPageAccessDeniedError(slug, pageData.accessType); } } /** * Helper to get a component by ID with workspace scope. */ async function getComponentById(id: number, workspaceId: number) { return db .select() .from(pageComponent) .where( and(eq(pageComponent.id, id), eq(pageComponent.workspaceId, workspaceId)), ) .get(); } /** * Helper to get a component group by ID with workspace scope. */ async function getGroupById(id: number, workspaceId: number) { return db .select() .from(pageComponentGroup) .where( and( eq(pageComponentGroup.id, id), eq(pageComponentGroup.workspaceId, workspaceId), ), ) .get(); } /** * Helper to get a monitor by ID with workspace scope. */ async function getMonitorById(id: number, workspaceId: number) { return db .select() .from(monitor) .where(and(eq(monitor.id, id), eq(monitor.workspaceId, workspaceId))) .get(); } /** * Status page service implementation for ConnectRPC. */ export const statusPageServiceImpl: ServiceImpl<typeof StatusPageService> = { // ========================================================================== // Page CRUD // ========================================================================== async createStatusPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; // Check workspace limits for status pages await checkStatusPageLimits(workspaceId, limits); // Check if slug already exists const existingPage = await getPageBySlug(req.slug); if (existingPage) { throw slugAlreadyExistsError(req.slug); } // Create the status page const newPage = await db .insert(page) .values({ workspaceId, title: req.title, description: req.description ?? "", slug: req.slug, customDomain: "", published: false, homepageUrl: req.homepageUrl ?? null, contactUrl: req.contactUrl ?? null, }) .returning() .get(); if (!newPage) { throw statusPageCreateFailedError(); } return { statusPage: dbPageToProto(newPage), }; }, async getStatusPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw statusPageIdRequiredError(); } const pageData = await getPageById(Number(id), workspaceId); if (!pageData) { throw statusPageNotFoundError(id); } return { statusPage: dbPageToProto(pageData), }; }, async listStatusPages(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Get total count const countResult = await db .select({ count: count() }) .from(page) .where(eq(page.workspaceId, workspaceId)) .get(); const totalCount = countResult?.count ?? 0; // Get pages const pages = await db .select() .from(page) .where(eq(page.workspaceId, workspaceId)) .orderBy(desc(page.createdAt)) .limit(limit) .offset(offset) .all(); return { statusPages: pages.map(dbPageToProtoSummary), totalSize: totalCount, }; }, async updateStatusPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw statusPageIdRequiredError(); } const pageData = await getPageById(Number(id), workspaceId); if (!pageData) { throw statusPageNotFoundError(id); } // Check if new slug conflicts with another page if (req.slug && req.slug !== pageData.slug) { const existingPage = await getPageBySlug(req.slug); if (existingPage && existingPage.id !== pageData.id) { throw slugAlreadyExistsError(req.slug); } } // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (req.title !== undefined && req.title !== "") { updateValues.title = req.title; } if (req.description !== undefined) { updateValues.description = req.description; } if (req.slug !== undefined && req.slug !== "") { updateValues.slug = req.slug; } if (req.homepageUrl !== undefined) { updateValues.homepageUrl = req.homepageUrl || null; } if (req.contactUrl !== undefined) { updateValues.contactUrl = req.contactUrl || null; } const updatedPage = await db .update(page) .set(updateValues) .where(eq(page.id, pageData.id)) .returning() .get(); if (!updatedPage) { throw statusPageUpdateFailedError(req.id); } return { statusPage: dbPageToProto(updatedPage), }; }, async deleteStatusPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw statusPageIdRequiredError(); } const pageData = await getPageById(Number(id), workspaceId); if (!pageData) { throw statusPageNotFoundError(id); } // Delete the page (cascade will delete components, groups, subscribers) await db.delete(page).where(eq(page.id, pageData.id)); return { success: true }; }, // ========================================================================== // Component Management // ========================================================================== async addMonitorComponent(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } // Check workspace limits for page components await checkPageComponentLimits(pageData.id, limits); // Verify monitor exists and belongs to workspace const monitorData = await getMonitorById( Number(req.monitorId), workspaceId, ); if (!monitorData) { throw monitorNotFoundError(req.monitorId); } // Validate group exists if provided if (req.groupId) { const group = await getGroupById(Number(req.groupId), workspaceId); if (!group) { throw componentGroupNotFoundError(req.groupId); } } // Create the component const newComponent = await db .insert(pageComponent) .values({ workspaceId, pageId: pageData.id, type: "monitor", monitorId: monitorData.id, name: req.name ?? monitorData.name, description: req.description ?? null, order: req.order ?? 0, groupId: req.groupId ? Number(req.groupId) : null, }) .returning() .get(); if (!newComponent) { throw pageComponentCreateFailedError(); } return { component: dbComponentToProto(newComponent), }; }, async addStaticComponent(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } // Check workspace limits for page components await checkPageComponentLimits(pageData.id, limits); // Validate group exists if provided if (req.groupId) { const group = await getGroupById(Number(req.groupId), workspaceId); if (!group) { throw componentGroupNotFoundError(req.groupId); } } // Create the component const newComponent = await db .insert(pageComponent) .values({ workspaceId, pageId: pageData.id, type: "static", monitorId: null, name: req.name, description: req.description ?? null, order: req.order ?? 0, groupId: req.groupId ? Number(req.groupId) : null, }) .returning() .get(); if (!newComponent) { throw pageComponentCreateFailedError(); } return { component: dbComponentToProto(newComponent), }; }, async removeComponent(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw pageComponentNotFoundError(req.id); } const component = await getComponentById(Number(id), workspaceId); if (!component) { throw pageComponentNotFoundError(id); } // Delete the component await db.delete(pageComponent).where(eq(pageComponent.id, component.id)); return { success: true }; }, async updateComponent(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw pageComponentNotFoundError(req.id); } const component = await getComponentById(Number(id), workspaceId); if (!component) { throw pageComponentNotFoundError(id); } // Validate group exists if provided if (req.groupId !== undefined && req.groupId !== "") { const group = await getGroupById(Number(req.groupId), workspaceId); if (!group) { throw componentGroupNotFoundError(req.groupId); } } // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (req.name !== undefined && req.name !== "") { updateValues.name = req.name; } if (req.description !== undefined) { updateValues.description = req.description || null; } if (req.order !== undefined) { updateValues.order = req.order; } if (req.groupId !== undefined) { // Empty string means remove from group updateValues.groupId = req.groupId === "" ? null : Number(req.groupId); } if (req.groupOrder !== undefined) { updateValues.groupOrder = req.groupOrder; } const updatedComponent = await db .update(pageComponent) .set(updateValues) .where(eq(pageComponent.id, component.id)) .returning() .get(); if (!updatedComponent) { throw pageComponentUpdateFailedError(req.id); } return { component: dbComponentToProto(updatedComponent), }; }, // ========================================================================== // Component Groups // ========================================================================== async createComponentGroup(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } // Create the group const newGroup = await db .insert(pageComponentGroup) .values({ workspaceId, pageId: pageData.id, name: req.name, }) .returning() .get(); if (!newGroup) { throw componentGroupCreateFailedError(); } return { group: dbGroupToProto(newGroup), }; }, async deleteComponentGroup(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw componentGroupNotFoundError(req.id); } const group = await getGroupById(Number(id), workspaceId); if (!group) { throw componentGroupNotFoundError(id); } // Delete the group (components will have groupId set to null due to FK constraint) await db .delete(pageComponentGroup) .where(eq(pageComponentGroup.id, group.id)); return { success: true }; }, async updateComponentGroup(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const id = req.id?.trim(); if (!id) { throw componentGroupNotFoundError(req.id); } const group = await getGroupById(Number(id), workspaceId); if (!group) { throw componentGroupNotFoundError(id); } // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (req.name !== undefined && req.name !== "") { updateValues.name = req.name; } const updatedGroup = await db .update(pageComponentGroup) .set(updateValues) .where(eq(pageComponentGroup.id, group.id)) .returning() .get(); if (!updatedGroup) { throw componentGroupUpdateFailedError(req.id); } return { group: dbGroupToProto(updatedGroup), }; }, // ========================================================================== // Subscribers // ========================================================================== async subscribeToPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } // Check if already subscribed const existingSubscriber = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, pageData.id), eq(pageSubscriber.email, req.email), ), ) .get(); if (existingSubscriber) { // If unsubscribed, resubscribe within a transaction to ensure atomicity if (existingSubscriber.unsubscribedAt) { const updatedSubscriber = await db.transaction(async (tx) => { const result = await tx .update(pageSubscriber) .set({ unsubscribedAt: null, updatedAt: new Date(), token: nanoid(), }) .where(eq(pageSubscriber.id, existingSubscriber.id)) .returning() .get(); if (!result) { throw subscriberCreateFailedError(); } return result; }); return { subscriber: dbSubscriberToProto(updatedSubscriber), }; } // Already subscribed, return existing return { subscriber: dbSubscriberToProto(existingSubscriber), }; } // Create new subscriber const newSubscriber = await db .insert(pageSubscriber) .values({ pageId: pageData.id, email: req.email, token: nanoid(), }) .returning() .get(); if (!newSubscriber) { throw subscriberCreateFailedError(); } return { subscriber: dbSubscriberToProto(newSubscriber), }; }, async unsubscribeFromPage(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } // Find subscriber based on identifier type if (req.identifier.case === "email") { const subscriber = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, pageData.id), eq(pageSubscriber.email, req.identifier.value), ), ) .get(); if (!subscriber) { throw subscriberNotFoundError(req.identifier.value); } await db .update(pageSubscriber) .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) .where(eq(pageSubscriber.id, subscriber.id)); return { success: true }; } if (req.identifier.case === "id") { const subscriber = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, pageData.id), eq(pageSubscriber.id, Number(req.identifier.value)), ), ) .get(); if (!subscriber) { throw subscriberNotFoundError(req.identifier.value); } await db .update(pageSubscriber) .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) .where(eq(pageSubscriber.id, subscriber.id)); return { success: true }; } throw identifierRequiredError(); }, async listSubscribers(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Verify page exists and belongs to workspace const pageData = await getPageById(Number(req.pageId), workspaceId); if (!pageData) { throw statusPageNotFoundError(req.pageId); } const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Build conditions const conditions = [eq(pageSubscriber.pageId, pageData.id)]; if (!req.includeUnsubscribed) { conditions.push(isNull(pageSubscriber.unsubscribedAt)); } // Get total count const countResult = await db .select({ count: count() }) .from(pageSubscriber) .where(and(...conditions)) .get(); const totalCount = countResult?.count ?? 0; // Get subscribers const subscribers = await db .select() .from(pageSubscriber) .where(and(...conditions)) .orderBy(desc(pageSubscriber.createdAt)) .limit(limit) .offset(offset) .all(); return { subscribers: subscribers.map(dbSubscriberToProto), totalSize: totalCount, }; }, // ========================================================================== // Full Content & Status // ========================================================================== async getStatusPageContent(req, ctx) { // Note: This endpoint may be used publicly, so we need to handle // the case where we look up by slug without workspace scope type PageData = Awaited<ReturnType<typeof getPageById>>; let pageData: PageData; let identifierValue: string; let isPublicAccess = false; if (req.identifier.case === "id") { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; identifierValue = req.identifier.value; pageData = await getPageById(Number(identifierValue), workspaceId); } else if (req.identifier.case === "slug") { identifierValue = req.identifier.value; pageData = await getPageBySlug(identifierValue); isPublicAccess = true; } else { throw statusPageIdRequiredError(); } if (!pageData) { throw statusPageNotFoundError(identifierValue); } // Access control differs based on how the page is accessed: // - By slug (public): Validates page is published and publicly accessible // - By ID (workspace): Allows workspace members to preview unpublished pages if (isPublicAccess) { validatePublicAccess(pageData, identifierValue); } // Get components const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, pageData.id)) .orderBy(pageComponent.order) .all(); // Get groups const groups = await db .select() .from(pageComponentGroup) .where(eq(pageComponentGroup.pageId, pageData.id)) .all(); // Get active status reports (not resolved) const activeReports = await db .select() .from(statusReport) .where( and( eq(statusReport.pageId, pageData.id), inArray(statusReport.status, [ "investigating", "identified", "monitoring", ]), ), ) .orderBy(desc(statusReport.createdAt)) .all(); // Get status report updates for active reports const reportIds = activeReports.map((r) => r.id); const reportUpdates = reportIds.length > 0 ? await db .select() .from(statusReportUpdate) .where(inArray(statusReportUpdate.statusReportId, reportIds)) .orderBy(desc(statusReportUpdate.date)) .all() : []; // Get page component IDs for each report const reportComponents = reportIds.length > 0 ? await db .select() .from(statusReportsToPageComponents) .where( inArray(statusReportsToPageComponents.statusReportId, reportIds), ) .all() : []; // Import the converter from status-report service const { dbStatusToProto } = await import("../status-report/converters"); // Convert reports to proto format const statusReports = activeReports.map((report) => { const updates = reportUpdates.filter( (u) => u.statusReportId === report.id, ); const componentIds = reportComponents .filter((rc) => rc.statusReportId === report.id) .map((rc) => String(rc.pageComponentId)); return { $typeName: "openstatus.status_report.v1.StatusReport" as const, id: String(report.id), status: dbStatusToProto(report.status), title: report.title, pageComponentIds: componentIds, updates: updates.map((u) => ({ $typeName: "openstatus.status_report.v1.StatusReportUpdate" as const, id: String(u.id), status: dbStatusToProto(u.status), date: u.date.toISOString(), message: u.message, createdAt: u.createdAt?.toISOString() ?? "", })), createdAt: report.createdAt?.toISOString() ?? "", updatedAt: report.updatedAt?.toISOString() ?? "", }; }); // Get maintenances for the page (upcoming and recent) const pageMaintenances = await db .select() .from(maintenance) .where(eq(maintenance.pageId, pageData.id)) .orderBy(desc(maintenance.from)) .all(); // Get component associations for maintenances const maintenanceIds = pageMaintenances.map((m) => m.id); const maintenanceComponents = maintenanceIds.length > 0 ? await db .select() .from(maintenancesToPageComponents) .where( inArray( maintenancesToPageComponents.maintenanceId, maintenanceIds, ), ) .all() : []; // Convert maintenances to proto format const maintenancesProto = pageMaintenances.map((m) => { const componentIds = maintenanceComponents .filter((mc) => mc.maintenanceId === m.id) .map((mc) => String(mc.pageComponentId)); return { $typeName: "openstatus.maintenance.v1.MaintenanceSummary" as const, id: String(m.id), title: m.title, message: m.message, from: m.from.toISOString(), to: m.to.toISOString(), pageId: String(pageData.id), pageComponentIds: componentIds, createdAt: m.createdAt?.toISOString() ?? "", updatedAt: m.updatedAt?.toISOString() ?? "", }; }); return { statusPage: dbPageToProto(pageData), components: components.map(dbComponentToProto), groups: groups.map(dbGroupToProto), statusReports, maintenances: maintenancesProto, }; }, async getOverallStatus(req, ctx) { type PageData = Awaited<ReturnType<typeof getPageById>>; let pageData: PageData; let identifierValue: string; let isPublicAccess = false; if (req.identifier.case === "id") { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; identifierValue = req.identifier.value; pageData = await getPageById(Number(identifierValue), workspaceId); } else if (req.identifier.case === "slug") { identifierValue = req.identifier.value; pageData = await getPageBySlug(identifierValue); isPublicAccess = true; } else { throw statusPageIdRequiredError(); } if (!pageData) { throw statusPageNotFoundError(identifierValue); } // Access control differs based on how the page is accessed: // - By slug (public): Validates page is published and publicly accessible // - By ID (workspace): Allows workspace members to preview unpublished pages if (isPublicAccess) { validatePublicAccess(pageData, identifierValue); } // Get components const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, pageData.id)) .all(); const componentIds = components.map((c) => c.id); const now = new Date(); // Check for active status reports (degraded state) let hasActiveStatusReport = false; const componentReportStatus = new Map<number, boolean>(); if (componentIds.length > 0) { const activeReports = await db .select({ componentId: statusReportsToPageComponents.pageComponentId, }) .from(statusReportsToPageComponents) .innerJoin( statusReport, eq(statusReportsToPageComponents.statusReportId, statusReport.id), ) .where( and( inArray( statusReportsToPageComponents.pageComponentId, componentIds, ), inArray(statusReport.status, [ "investigating", "identified", "monitoring", ]), ), ) .all(); hasActiveStatusReport = activeReports.length > 0; // Track which components have active reports for (const report of activeReports) { componentReportStatus.set(report.componentId, true); } } // Check for active maintenances (info state - current time between from and to) let hasActiveMaintenance = false; const componentMaintenanceStatus = new Map<number, boolean>(); const activeMaintenances = await db .select() .from(maintenance) .where( and( eq(maintenance.pageId, pageData.id), lte(maintenance.from, now), gte(maintenance.to, now), ), ) .all(); hasActiveMaintenance = activeMaintenances.length > 0; // Get component associations for active maintenances if (activeMaintenances.length > 0) { const maintenanceIds = activeMaintenances.map((m) => m.id); const maintenanceComponentAssocs = await db .select() .from(maintenancesToPageComponents) .where( inArray(maintenancesToPageComponents.maintenanceId, maintenanceIds), ) .all(); // Track which components are under maintenance for (const assoc of maintenanceComponentAssocs) { componentMaintenanceStatus.set(assoc.pageComponentId, true); } } // Determine overall status based on priority: degraded > maintenance > operational // Note: In the existing codebase, status reports indicate "degraded" state // and maintenances indicate "info/maintenance" state const overallStatus = hasActiveStatusReport ? OverallStatus.DEGRADED : hasActiveMaintenance ? OverallStatus.MAINTENANCE : OverallStatus.OPERATIONAL; // Build component statuses based on their individual state const componentStatuses = components.map((c) => { const hasReport = componentReportStatus.get(c.id) ?? false; const hasMaintenance = componentMaintenanceStatus.get(c.id) ?? false; const status = hasReport ? OverallStatus.DEGRADED : hasMaintenance ? OverallStatus.MAINTENANCE : OverallStatus.OPERATIONAL; return { $typeName: "openstatus.status_page.v1.ComponentStatus" as const, componentId: String(c.id), status, }; }); return { overallStatus, componentStatuses, }; }, }; ================================================ FILE: apps/server/src/routes/rpc/services/status-page/limits.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; import { count, db, eq } from "@openstatus/db"; import { page, pageComponent } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; /** * Check workspace limits for creating a new status page. * Throws ConnectError with PermissionDenied if limit is exceeded. */ export async function checkStatusPageLimits( workspaceId: number, limits: Limits, ): Promise<void> { // Check status page count limit const countResult = await db .select({ count: count() }) .from(page) .where(eq(page.workspaceId, workspaceId)) .get(); const currentCount = countResult?.count ?? 0; if (currentCount >= limits["status-pages"]) { throw new ConnectError( "Upgrade for more status pages", Code.PermissionDenied, ); } } /** * Check if custom domain feature is available on the workspace plan. * Throws ConnectError with PermissionDenied if not available. */ export function checkCustomDomainLimit(limits: Limits): void { if (!limits["custom-domain"]) { throw new ConnectError("Upgrade for custom domains", Code.PermissionDenied); } } /** * Check if password protection feature is available on the workspace plan. * Throws ConnectError with PermissionDenied if not available. */ export function checkPasswordProtectionLimit(limits: Limits): void { if (!limits["password-protection"]) { throw new ConnectError( "Upgrade for password protection", Code.PermissionDenied, ); } } /** * Check if email domain protection feature is available on the workspace plan. * Throws ConnectError with PermissionDenied if not available. */ export function checkEmailDomainProtectionLimit(limits: Limits): void { if (!limits["email-domain-protection"]) { throw new ConnectError( "Upgrade for email domain protection", Code.PermissionDenied, ); } } /** * Check workspace limits for creating a new page component. * Throws ConnectError with PermissionDenied if limit is exceeded. */ export async function checkPageComponentLimits( pageId: number, limits: Limits, ): Promise<void> { const countResult = await db .select({ count: count() }) .from(pageComponent) .where(eq(pageComponent.pageId, pageId)) .get(); const currentCount = countResult?.count ?? 0; if (currentCount >= limits["page-components"]) { throw new ConnectError( "Upgrade for more page components", Code.PermissionDenied, ); } } ================================================ FILE: apps/server/src/routes/rpc/services/status-report/__tests__/status-report.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { page, pageComponent, pageSubscriber, statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; import { app } from "@/index"; import { protoStatusToDb } from "../converters"; const subscriptionSpies = (globalThis as Record<string, unknown>) .__subscriptionSpies as { dispatchStatusReportUpdate: ReturnType<typeof import("bun:test").mock>; dispatchMaintenanceUpdate: ReturnType<typeof import("bun:test").mock>; }; /** * Helper to make ConnectRPC requests using the Connect protocol (JSON). * Connect uses POST with JSON body at /rpc/<service>/<method> */ async function connectRequest( method: string, body: Record<string, unknown> = {}, headers: Record<string, string> = {}, ) { return app.request( `/rpc/openstatus.status_report.v1.StatusReportService/${method}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }, ); } const TEST_PREFIX = "rpc-status-report-test"; let testPageComponentId: number; let testStatusReportId: number; let testStatusReportToDeleteId: number; let testStatusReportToUpdateId: number; let testStatusReportForNotifyId: number; let testSubscriberId: number; // For mixed-page validation tests let testPage2Id: number; let testPage2ComponentId: number; beforeAll(async () => { // Clean up any existing test data await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-main`)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-to-delete`)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-to-update`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); // Create a test page component (using existing page 1 from seed) const component = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: 1, type: "static", name: `${TEST_PREFIX}-component`, description: "Test component for status report tests", order: 100, }) .returning() .get(); testPageComponentId = component.id; // Create a second page and component for mixed-page validation tests const page2 = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page-2`, slug: `${TEST_PREFIX}-page-2-slug`, description: "Second test page for mixed-page tests", customDomain: "", }) .returning() .get(); testPage2Id = page2.id; const component2 = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: testPage2Id, type: "static", name: `${TEST_PREFIX}-component-2`, description: "Test component on page 2", order: 100, }) .returning() .get(); testPage2ComponentId = component2.id; // Create test status report const report = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-main`, status: "investigating", }) .returning() .get(); testStatusReportId = report.id; // Create page component association await db.insert(statusReportsToPageComponents).values({ statusReportId: report.id, pageComponentId: testPageComponentId, }); // Create status report updates await db.insert(statusReportUpdate).values([ { statusReportId: report.id, status: "investigating", date: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago message: "We are investigating the issue.", }, { statusReportId: report.id, status: "identified", date: new Date(Date.now() - 30 * 60 * 1000), // 30 min ago message: "We have identified the root cause.", }, ]); // Create status report to delete const deleteReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-to-delete`, status: "investigating", }) .returning() .get(); testStatusReportToDeleteId = deleteReport.id; // Create status report to update const updateReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-to-update`, status: "investigating", }) .returning() .get(); testStatusReportToUpdateId = updateReport.id; await db.insert(statusReportsToPageComponents).values({ statusReportId: updateReport.id, pageComponentId: testPageComponentId, }); // Create status report for notify tests const notifyReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-for-notify`, status: "investigating", }) .returning() .get(); testStatusReportForNotifyId = notifyReport.id; await db.insert(statusReportsToPageComponents).values({ statusReportId: notifyReport.id, pageComponentId: testPageComponentId, }); // Create a verified subscriber for notification tests const subscriber = await db .insert(pageSubscriber) .values({ pageId: 1, email: `${TEST_PREFIX}@example.com`, token: `${TEST_PREFIX}-token`, acceptedAt: new Date(), }) .returning() .get(); testSubscriberId = subscriber.id; }); afterAll(async () => { // Clean up subscriber first (only if it was created) if (testSubscriberId) { await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, testSubscriberId)); } // Clean up status report updates first (due to foreign key) await db .delete(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, testStatusReportId)); await db .delete(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, testStatusReportToUpdateId)); await db .delete(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, testStatusReportForNotifyId)); // Clean up associations await db .delete(statusReportsToPageComponents) .where( eq(statusReportsToPageComponents.statusReportId, testStatusReportId), ); await db .delete(statusReportsToPageComponents) .where( eq( statusReportsToPageComponents.statusReportId, testStatusReportToUpdateId, ), ); await db .delete(statusReportsToPageComponents) .where( eq( statusReportsToPageComponents.statusReportId, testStatusReportForNotifyId, ), ); // Clean up status reports await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-main`)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-to-delete`)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-to-update`)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-for-notify`)); // Clean up page component await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); // Clean up second page component and page (for mixed-page tests) await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component-2`)); await db.delete(page).where(eq(page.title, `${TEST_PREFIX}-page-2`)); }); describe("StatusReportService.CreateStatusReport", () => { test("creates a new status report with initial update", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-created`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "We are looking into this issue.", date: new Date().toISOString(), pageId: "1", pageComponentIds: [String(testPageComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-created`); expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); expect(data.statusReport.pageComponentIds).toContain( String(testPageComponentId), ); expect(data.statusReport.updates).toHaveLength(1); expect(data.statusReport.updates[0].message).toBe( "We are looking into this issue.", ); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReportsToPageComponents) .where( eq( statusReportsToPageComponents.statusReportId, Number(data.statusReport.id), ), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("CreateStatusReport", { title: "Unauthorized test", status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test message", date: new Date().toISOString(), pageId: "1", pageComponentIds: ["1"], }); expect(res.status).toBe(401); }); test("returns error for invalid page component ID", async () => { const res = await connectRequest( "CreateStatusReport", { title: "Invalid component test", status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test message", date: new Date().toISOString(), pageId: "1", pageComponentIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when page components are from different pages", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-mixed-pages`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test message", date: new Date().toISOString(), pageId: "1", pageComponentIds: [ String(testPageComponentId), String(testPage2ComponentId), ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain( "All page components must belong to the same page", ); }); test("returns error when pageId does not match components page", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-pageid-mismatch`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test pageId mismatch with components.", date: new Date().toISOString(), pageId: "1", // This doesn't match testPage2ComponentId's page pageComponentIds: [String(testPage2ComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("does not match the page ID"); }); test("creates status report when pageId matches component page", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-matching-pageid`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test with matching pageId and components.", date: new Date().toISOString(), pageId: String(testPage2Id), // Matching the component's page pageComponentIds: [String(testPage2ComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.pageComponentIds).toContain( String(testPage2ComponentId), ); // Verify the pageId was set correctly const createdReport = await db .select() .from(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))) .get(); expect(createdReport?.pageId).toBe(testPage2Id); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReportsToPageComponents) .where( eq( statusReportsToPageComponents.statusReportId, Number(data.statusReport.id), ), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("preserves pageId when no components provided", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-null-pageid`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test pageId preserved when no components.", date: new Date().toISOString(), pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); // Verify the pageId is preserved from the request const createdReport = await db .select() .from(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))) .get(); expect(createdReport?.pageId).toBe(1); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("creates status report with empty pageComponentIds", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-no-components`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Report without components.", date: new Date().toISOString(), pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-no-components`); // Empty array may be serialized as undefined or empty array in proto const pageComponentIds = data.statusReport.pageComponentIds ?? []; expect(pageComponentIds).toHaveLength(0); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("creates status report with notify=true", async () => { subscriptionSpies.dispatchStatusReportUpdate.mockClear(); const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-with-notify`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Notifying subscribers about this issue.", date: new Date().toISOString(), pageId: "1", pageComponentIds: [String(testPageComponentId)], notify: true, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-with-notify`); expect(data.statusReport.updates).toHaveLength(1); // Verify dispatcher was called (dispatchers are mocked in preload.ts) expect(subscriptionSpies.dispatchStatusReportUpdate).toHaveBeenCalledTimes( 1, ); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReportsToPageComponents) .where( eq( statusReportsToPageComponents.statusReportId, Number(data.statusReport.id), ), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("creates status report with notify=false (default)", async () => { subscriptionSpies.dispatchStatusReportUpdate.mockClear(); const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-no-notify`, status: "STATUS_REPORT_STATUS_IDENTIFIED", message: "No notification for this one.", date: new Date().toISOString(), pageId: "1", pageComponentIds: [], notify: false, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-no-notify`); // Verify dispatcher was NOT called expect(subscriptionSpies.dispatchStatusReportUpdate).not.toHaveBeenCalled(); // Clean up await db .delete(statusReportUpdate) .where( eq(statusReportUpdate.statusReportId, Number(data.statusReport.id)), ); await db .delete(statusReport) .where(eq(statusReport.id, Number(data.statusReport.id))); }); test("returns error for invalid date format", async () => { const res = await connectRequest( "CreateStatusReport", { title: `${TEST_PREFIX}-invalid-date`, status: "STATUS_REPORT_STATUS_INVESTIGATING", message: "Test with invalid date.", date: "not-a-valid-date", pageId: "1", pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("date: value does not match regex pattern"); }); }); describe("StatusReportService.GetStatusReport", () => { test("returns status report with updates", async () => { const res = await connectRequest( "GetStatusReport", { id: String(testStatusReportId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.id).toBe(String(testStatusReportId)); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-main`); expect(data.statusReport.pageComponentIds).toContain( String(testPageComponentId), ); expect(data.statusReport.updates).toHaveLength(2); expect(data.statusReport).toHaveProperty("createdAt"); expect(data.statusReport).toHaveProperty("updatedAt"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("GetStatusReport", { id: String(testStatusReportId), }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status report", async () => { const res = await connectRequest( "GetStatusReport", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns 404 for status report in different workspace", async () => { // Create status report in workspace 2 const otherReport = await db .insert(statusReport) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace`, status: "investigating", }) .returning() .get(); try { const res = await connectRequest( "GetStatusReport", { id: String(otherReport.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); } }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "GetStatusReport", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "GetStatusReport", { id: " " }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); }); describe("StatusReportService.ListStatusReports", () => { test("returns status reports for authenticated workspace", async () => { const res = await connectRequest( "ListStatusReports", {}, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReports"); expect(Array.isArray(data.statusReports)).toBe(true); expect(data).toHaveProperty("totalSize"); }); test("returns status reports with correct structure (summary only)", async () => { const res = await connectRequest( "ListStatusReports", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const report = data.statusReports?.find( (r: { id: string }) => r.id === String(testStatusReportId), ); expect(report).toBeDefined(); expect(report.title).toBe(`${TEST_PREFIX}-main`); expect(report.pageComponentIds).toBeDefined(); expect(report.createdAt).toBeDefined(); expect(report.updatedAt).toBeDefined(); // Summary should NOT include updates expect(report.updates).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListStatusReports", {}); expect(res.status).toBe(401); }); test("respects limit parameter", async () => { const res = await connectRequest( "ListStatusReports", { limit: 1 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusReports?.length || 0).toBeLessThanOrEqual(1); }); test("respects offset parameter", async () => { // Get total count first const res1 = await connectRequest( "ListStatusReports", {}, { "x-openstatus-key": "1" }, ); const data1 = await res1.json(); const totalSize = data1.totalSize; if (totalSize > 1) { // Get first page const res2 = await connectRequest( "ListStatusReports", { limit: 1, offset: 0 }, { "x-openstatus-key": "1" }, ); const data2 = await res2.json(); // Get second page const res3 = await connectRequest( "ListStatusReports", { limit: 1, offset: 1 }, { "x-openstatus-key": "1" }, ); const data3 = await res3.json(); // Should have different reports if (data2.statusReports?.length > 0 && data3.statusReports?.length > 0) { expect(data2.statusReports[0].id).not.toBe(data3.statusReports[0].id); } } }); test("filters by status", async () => { const res = await connectRequest( "ListStatusReports", { statuses: ["STATUS_REPORT_STATUS_INVESTIGATING"] }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // All returned reports should have investigating status for (const report of data.statusReports || []) { expect(report.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); } }); test("only returns reports for authenticated workspace", async () => { // Create status report in workspace 2 const otherReport = await db .insert(statusReport) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-list`, status: "investigating", }) .returning() .get(); try { const res = await connectRequest( "ListStatusReports", { limit: 100 }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); const reportIds = (data.statusReports || []).map( (r: { id: string }) => r.id, ); expect(reportIds).not.toContain(String(otherReport.id)); } finally { await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); } }); test("filters by multiple statuses", async () => { // Create reports with different statuses const monitoringReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-monitoring-filter`, status: "monitoring", }) .returning() .get(); const resolvedReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-resolved-filter`, status: "resolved", }) .returning() .get(); try { const res = await connectRequest( "ListStatusReports", { statuses: [ "STATUS_REPORT_STATUS_MONITORING", "STATUS_REPORT_STATUS_RESOLVED", ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // All returned reports should have monitoring or resolved status for (const report of data.statusReports || []) { expect([ "STATUS_REPORT_STATUS_MONITORING", "STATUS_REPORT_STATUS_RESOLVED", ]).toContain(report.status); } } finally { await db .delete(statusReport) .where(eq(statusReport.id, monitoringReport.id)); await db .delete(statusReport) .where(eq(statusReport.id, resolvedReport.id)); } }); test("returns all statuses when statuses filter is empty", async () => { const res = await connectRequest( "ListStatusReports", { statuses: [] }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReports"); expect(data).toHaveProperty("totalSize"); }); test("ignores UNSPECIFIED status in filter", async () => { const res = await connectRequest( "ListStatusReports", { statuses: [ "STATUS_REPORT_STATUS_UNSPECIFIED", "STATUS_REPORT_STATUS_INVESTIGATING", ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); // Should only return investigating status (UNSPECIFIED is ignored) for (const report of data.statusReports || []) { expect(report.status).toBe("STATUS_REPORT_STATUS_INVESTIGATING"); } }); }); describe("StatusReportService.UpdateStatusReport", () => { test("updates status report title", async () => { const res = await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), title: `${TEST_PREFIX}-updated-title`, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.title).toBe(`${TEST_PREFIX}-updated-title`); // Restore original title await db .update(statusReport) .set({ title: `${TEST_PREFIX}-to-update` }) .where(eq(statusReport.id, testStatusReportToUpdateId)); }); test("updates page component associations", async () => { // Use existing seeded page component 1 const res = await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), pageComponentIds: ["1"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusReport.pageComponentIds).toContain("1"); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("UpdateStatusReport", { id: String(testStatusReportToUpdateId), title: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status report", async () => { const res = await connectRequest( "UpdateStatusReport", { id: "99999", title: "Non-existent update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "UpdateStatusReport", { id: "", title: "Empty ID update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "UpdateStatusReport", { id: " ", title: "Whitespace ID update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for status report in different workspace", async () => { // Create status report in workspace 2 const otherReport = await db .insert(statusReport) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-update`, status: "investigating", }) .returning() .get(); try { const res = await connectRequest( "UpdateStatusReport", { id: String(otherReport.id), title: "Should not update" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); } }); test("returns error for invalid page component ID on update", async () => { const res = await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), pageComponentIds: ["99999"], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when updating with components from different pages", async () => { const res = await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), pageComponentIds: [ String(testPageComponentId), String(testPage2ComponentId), ], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain( "All page components must belong to the same page", ); }); test("updates pageId when changing components to different page", async () => { // First verify the current pageId const beforeReport = await db .select() .from(statusReport) .where(eq(statusReport.id, testStatusReportToUpdateId)) .get(); expect(beforeReport?.pageId).toBe(1); // Update to use component from page 2 const res = await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), pageComponentIds: [String(testPage2ComponentId)], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); // Verify the pageId was updated to page 2 const afterReport = await db .select() .from(statusReport) .where(eq(statusReport.id, testStatusReportToUpdateId)) .get(); expect(afterReport?.pageId).toBe(testPage2Id); // Restore original component association await connectRequest( "UpdateStatusReport", { id: String(testStatusReportToUpdateId), pageComponentIds: [String(testPageComponentId)], }, { "x-openstatus-key": "1" }, ); }); test("clears pageId when removing all components", async () => { // Create a temporary status report for this test const tempReport = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-clear-pageid`, status: "investigating", }) .returning() .get(); await db.insert(statusReportsToPageComponents).values({ statusReportId: tempReport.id, pageComponentId: testPageComponentId, }); try { // Clear all components const res = await connectRequest( "UpdateStatusReport", { id: String(tempReport.id), pageComponentIds: [], }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); // Verify the pageId is now null const afterReport = await db .select() .from(statusReport) .where(eq(statusReport.id, tempReport.id)) .get(); expect(afterReport?.pageId).toBeNull(); } finally { // Clean up await db .delete(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, tempReport.id)); await db.delete(statusReport).where(eq(statusReport.id, tempReport.id)); } }); }); describe("StatusReportService.DeleteStatusReport", () => { test("successfully deletes existing status report", async () => { const res = await connectRequest( "DeleteStatusReport", { id: String(testStatusReportToDeleteId) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); // Verify it's deleted const deleted = await db .select() .from(statusReport) .where(eq(statusReport.id, testStatusReportToDeleteId)) .get(); expect(deleted).toBeUndefined(); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("DeleteStatusReport", { id: "1" }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status report", async () => { const res = await connectRequest( "DeleteStatusReport", { id: "99999" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when ID is empty string", async () => { const res = await connectRequest( "DeleteStatusReport", { id: "" }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when ID is whitespace only", async () => { const res = await connectRequest( "DeleteStatusReport", { id: " " }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for status report in different workspace", async () => { // Create status report in workspace 2 const otherReport = await db .insert(statusReport) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-delete`, status: "investigating", }) .returning() .get(); try { const res = await connectRequest( "DeleteStatusReport", { id: String(otherReport.id) }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); // Verify it wasn't deleted const stillExists = await db .select() .from(statusReport) .where(eq(statusReport.id, otherReport.id)) .get(); expect(stillExists).toBeDefined(); } finally { await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); } }); }); describe("StatusReportService.AddStatusReportUpdate", () => { test("adds update to existing status report", async () => { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportId), status: "STATUS_REPORT_STATUS_MONITORING", message: "We are monitoring the fix.", date: new Date().toISOString(), }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_MONITORING"); // Should now have 3 updates (2 initial + 1 new) expect(data.statusReport.updates.length).toBeGreaterThanOrEqual(3); const newUpdate = data.statusReport.updates.find( (u: { message: string }) => u.message === "We are monitoring the fix.", ); expect(newUpdate).toBeDefined(); expect(newUpdate.status).toBe("STATUS_REPORT_STATUS_MONITORING"); }); test("uses current time when date is not provided", async () => { // Allow 2 second tolerance for timing differences const beforeTime = new Date(Date.now() - 2000); const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportId), status: "STATUS_REPORT_STATUS_RESOLVED", message: "Issue has been resolved.", }, { "x-openstatus-key": "1" }, ); const afterTime = new Date(Date.now() + 2000); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_RESOLVED"); const newUpdate = data.statusReport.updates.find( (u: { message: string }) => u.message === "Issue has been resolved.", ); expect(newUpdate).toBeDefined(); const updateDate = new Date(newUpdate.date); expect(updateDate.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime()); expect(updateDate.getTime()).toBeLessThanOrEqual(afterTime.getTime()); }); test("returns 401 when no auth key provided", async () => { const res = await connectRequest("AddStatusReportUpdate", { statusReportId: String(testStatusReportId), status: "STATUS_REPORT_STATUS_RESOLVED", message: "Unauthorized update", }); expect(res.status).toBe(401); }); test("returns 404 for non-existent status report", async () => { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: "99999", status: "STATUS_REPORT_STATUS_RESOLVED", message: "Non-existent report", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); }); test("returns error when statusReportId is empty string", async () => { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: "", status: "STATUS_REPORT_STATUS_RESOLVED", message: "Empty ID update", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns error when statusReportId is whitespace only", async () => { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: " ", status: "STATUS_REPORT_STATUS_RESOLVED", message: "Whitespace ID update", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); }); test("returns 404 for status report in different workspace", async () => { // Create status report in workspace 2 const otherReport = await db .insert(statusReport) .values({ workspaceId: 2, title: `${TEST_PREFIX}-other-workspace-add-update`, status: "investigating", }) .returning() .get(); try { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(otherReport.id), status: "STATUS_REPORT_STATUS_RESOLVED", message: "Should not add to other workspace", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(404); } finally { await db.delete(statusReport).where(eq(statusReport.id, otherReport.id)); } }); test("adds update with notify=true", async () => { subscriptionSpies.dispatchStatusReportUpdate.mockClear(); const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportForNotifyId), status: "STATUS_REPORT_STATUS_IDENTIFIED", message: "We identified the issue and are notifying subscribers.", date: new Date().toISOString(), notify: true, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_IDENTIFIED"); const newUpdate = data.statusReport.updates.find( (u: { message: string }) => u.message === "We identified the issue and are notifying subscribers.", ); expect(newUpdate).toBeDefined(); // Verify dispatcher was called (dispatchers are mocked in preload.ts) expect(subscriptionSpies.dispatchStatusReportUpdate).toHaveBeenCalledTimes( 1, ); }); test("adds update with notify=false", async () => { subscriptionSpies.dispatchStatusReportUpdate.mockClear(); const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportForNotifyId), status: "STATUS_REPORT_STATUS_MONITORING", message: "Monitoring without notification.", date: new Date().toISOString(), notify: false, }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty("statusReport"); expect(data.statusReport.status).toBe("STATUS_REPORT_STATUS_MONITORING"); // Verify dispatcher was NOT called expect(subscriptionSpies.dispatchStatusReportUpdate).not.toHaveBeenCalled(); }); test("updates status report status when adding update", async () => { // First verify the current status const getRes = await connectRequest( "GetStatusReport", { id: String(testStatusReportForNotifyId) }, { "x-openstatus-key": "1" }, ); const getData = await getRes.json(); const initialStatus = getData.statusReport.status; // Add update with different status const newStatus = initialStatus === "STATUS_REPORT_STATUS_RESOLVED" ? "STATUS_REPORT_STATUS_INVESTIGATING" : "STATUS_REPORT_STATUS_RESOLVED"; const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportForNotifyId), status: newStatus, message: "Status change test.", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(200); const data = await res.json(); expect(data.statusReport.status).toBe(newStatus); }); test("returns error for invalid date format", async () => { const res = await connectRequest( "AddStatusReportUpdate", { statusReportId: String(testStatusReportId), status: "STATUS_REPORT_STATUS_MONITORING", message: "Test with invalid date.", date: "not-a-valid-date", }, { "x-openstatus-key": "1" }, ); expect(res.status).toBe(400); const data = await res.json(); expect(data.message).toContain("date: value does not match regex pattern"); }); }); describe("protoStatusToDb converter", () => { test("converts valid statuses correctly", () => { expect(protoStatusToDb(StatusReportStatus.INVESTIGATING)).toBe( "investigating", ); expect(protoStatusToDb(StatusReportStatus.IDENTIFIED)).toBe("identified"); expect(protoStatusToDb(StatusReportStatus.MONITORING)).toBe("monitoring"); expect(protoStatusToDb(StatusReportStatus.RESOLVED)).toBe("resolved"); }); test("throws error for UNSPECIFIED status", () => { expect(() => protoStatusToDb(StatusReportStatus.UNSPECIFIED)).toThrow( "Invalid status value", ); }); test("throws error for unknown status values", () => { // Simulate a new status value being added to the proto const unknownStatus = 999 as StatusReportStatus; expect(() => protoStatusToDb(unknownStatus)).toThrow( "Invalid status value", ); }); }); ================================================ FILE: apps/server/src/routes/rpc/services/status-report/converters.ts ================================================ import type { StatusReport, StatusReportSummary, StatusReportUpdate, } from "@openstatus/proto/status_report/v1"; import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; import { invalidStatusError } from "./errors"; type DBStatusReport = { id: number; status: "investigating" | "identified" | "monitoring" | "resolved"; title: string; workspaceId: number | null; pageId: number | null; createdAt: Date | null; updatedAt: Date | null; }; type DBStatusReportUpdate = { id: number; status: "investigating" | "identified" | "monitoring" | "resolved"; date: Date; message: string; statusReportId: number; createdAt: Date | null; updatedAt: Date | null; }; /** * Convert DB status string to proto enum. */ export function dbStatusToProto( status: "investigating" | "identified" | "monitoring" | "resolved", ): StatusReportStatus { switch (status) { case "investigating": return StatusReportStatus.INVESTIGATING; case "identified": return StatusReportStatus.IDENTIFIED; case "monitoring": return StatusReportStatus.MONITORING; case "resolved": return StatusReportStatus.RESOLVED; default: return StatusReportStatus.UNSPECIFIED; } } /** * Convert proto enum to DB status string. */ export function protoStatusToDb( status: StatusReportStatus, ): "investigating" | "identified" | "monitoring" | "resolved" { switch (status) { case StatusReportStatus.INVESTIGATING: return "investigating"; case StatusReportStatus.IDENTIFIED: return "identified"; case StatusReportStatus.MONITORING: return "monitoring"; case StatusReportStatus.RESOLVED: return "resolved"; case StatusReportStatus.UNSPECIFIED: throw invalidStatusError(status); default: throw invalidStatusError(status); } } /** * Convert a DB status report update to proto format. */ export function dbUpdateToProto( update: DBStatusReportUpdate, ): StatusReportUpdate { return { $typeName: "openstatus.status_report.v1.StatusReportUpdate" as const, id: String(update.id), status: dbStatusToProto(update.status), date: update.date.toISOString(), message: update.message, createdAt: update.createdAt?.toISOString() ?? "", }; } /** * Convert a DB status report to proto summary format (metadata only). */ export function dbReportToProtoSummary( report: DBStatusReport, pageComponentIds: string[], ): StatusReportSummary { return { $typeName: "openstatus.status_report.v1.StatusReportSummary" as const, id: String(report.id), status: dbStatusToProto(report.status), title: report.title, pageComponentIds, createdAt: report.createdAt?.toISOString() ?? "", updatedAt: report.updatedAt?.toISOString() ?? "", }; } /** * Convert a DB status report to full proto format (with updates). */ export function dbReportToProto( report: DBStatusReport, pageComponentIds: string[], updates: DBStatusReportUpdate[], ): StatusReport { return { $typeName: "openstatus.status_report.v1.StatusReport" as const, id: String(report.id), status: dbStatusToProto(report.status), title: report.title, pageComponentIds, updates: updates.map(dbUpdateToProto), createdAt: report.createdAt?.toISOString() ?? "", updatedAt: report.updatedAt?.toISOString() ?? "", }; } ================================================ FILE: apps/server/src/routes/rpc/services/status-report/errors.ts ================================================ import { Code, ConnectError } from "@connectrpc/connect"; /** * Error reasons for structured error handling. */ export const ErrorReason = { STATUS_REPORT_NOT_FOUND: "STATUS_REPORT_NOT_FOUND", STATUS_REPORT_ID_REQUIRED: "STATUS_REPORT_ID_REQUIRED", STATUS_REPORT_CREATE_FAILED: "STATUS_REPORT_CREATE_FAILED", STATUS_REPORT_UPDATE_FAILED: "STATUS_REPORT_UPDATE_FAILED", PAGE_COMPONENT_NOT_FOUND: "PAGE_COMPONENT_NOT_FOUND", PAGE_COMPONENTS_MIXED_PAGES: "PAGE_COMPONENTS_MIXED_PAGES", PAGE_ID_COMPONENT_MISMATCH: "PAGE_ID_COMPONENT_MISMATCH", INVALID_DATE_FORMAT: "INVALID_DATE_FORMAT", INVALID_STATUS: "INVALID_STATUS", } as const; export type ErrorReason = (typeof ErrorReason)[keyof typeof ErrorReason]; const DOMAIN = "openstatus.dev"; /** * Creates a ConnectError with structured metadata. */ function createError( message: string, code: Code, reason: ErrorReason, metadata?: Record<string, string>, ): ConnectError { const headers = new Headers({ "error-domain": DOMAIN, "error-reason": reason, }); if (metadata) { for (const [key, value] of Object.entries(metadata)) { headers.set(`error-${key}`, value); } } return new ConnectError(message, code, headers); } /** * Creates a "status report not found" error. */ export function statusReportNotFoundError( statusReportId: string, ): ConnectError { return createError( "Status report not found", Code.NotFound, ErrorReason.STATUS_REPORT_NOT_FOUND, { "status-report-id": statusReportId }, ); } /** * Creates a "status report ID required" error. */ export function statusReportIdRequiredError(): ConnectError { return createError( "Status report ID is required", Code.InvalidArgument, ErrorReason.STATUS_REPORT_ID_REQUIRED, ); } /** * Creates a "failed to create status report" error. */ export function statusReportCreateFailedError(): ConnectError { return createError( "Failed to create status report", Code.Internal, ErrorReason.STATUS_REPORT_CREATE_FAILED, ); } /** * Creates a "failed to update status report" error. */ export function statusReportUpdateFailedError( statusReportId: string, ): ConnectError { return createError( "Failed to update status report", Code.Internal, ErrorReason.STATUS_REPORT_UPDATE_FAILED, { "status-report-id": statusReportId }, ); } /** * Creates a "page component not found" error. */ export function pageComponentNotFoundError( pageComponentId: string, ): ConnectError { return createError( "Page component not found", Code.NotFound, ErrorReason.PAGE_COMPONENT_NOT_FOUND, { "page-component-id": pageComponentId }, ); } /** * Creates a "page components from mixed pages" error. */ export function pageComponentsMixedPagesError(): ConnectError { return createError( "All page components must belong to the same page", Code.InvalidArgument, ErrorReason.PAGE_COMPONENTS_MIXED_PAGES, ); } /** * Creates an "invalid date format" error. */ export function invalidDateFormatError(dateValue: string): ConnectError { return createError( "Invalid date format. Expected RFC 3339 format (e.g., 2024-01-15T10:30:00Z)", Code.InvalidArgument, ErrorReason.INVALID_DATE_FORMAT, { "date-value": dateValue }, ); } /** * Creates an "invalid status" error. */ export function invalidStatusError(statusValue: number): ConnectError { return createError( `Invalid status value: ${statusValue}. Expected INVESTIGATING, IDENTIFIED, MONITORING, or RESOLVED`, Code.InvalidArgument, ErrorReason.INVALID_STATUS, { "status-value": String(statusValue) }, ); } /** * Creates a "page ID and component page mismatch" error. */ export function pageIdComponentMismatchError( providedPageId: string, componentPageId: string, ): ConnectError { return createError( `Page ID ${providedPageId} does not match the page ID ${componentPageId} of the provided components`, Code.InvalidArgument, ErrorReason.PAGE_ID_COMPONENT_MISMATCH, { "provided-page-id": providedPageId, "component-page-id": componentPageId, }, ); } ================================================ FILE: apps/server/src/routes/rpc/services/status-report/index.ts ================================================ import type { ServiceImpl } from "@connectrpc/connect"; import { and, db, desc, eq, inArray, sql } from "@openstatus/db"; // Type that works with both db instance and transaction type DB = typeof db; type Transaction = Parameters<Parameters<DB["transaction"]>[0]>[0]; import { pageComponent, statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import type { StatusReportService } from "@openstatus/proto/status_report/v1"; import { StatusReportStatus } from "@openstatus/proto/status_report/v1"; import { dispatchStatusReportUpdate } from "@openstatus/subscriptions"; import { getRpcContext } from "../../interceptors"; import { dbReportToProto, dbReportToProtoSummary, protoStatusToDb, } from "./converters"; import { invalidDateFormatError, pageComponentNotFoundError, pageComponentsMixedPagesError, pageIdComponentMismatchError, statusReportCreateFailedError, statusReportIdRequiredError, statusReportNotFoundError, statusReportUpdateFailedError, } from "./errors"; /** * Helper to send status report notifications to page subscribers. * Uses the subscription dispatcher for component-aware filtering. */ export async function sendStatusReportNotification(params: { statusReportUpdateId: number; limits: Limits; }) { const { statusReportUpdateId, limits } = params; if (!limits["status-subscribers"]) { return; } await dispatchStatusReportUpdate(statusReportUpdateId); } /** * Helper to get a status report by ID with workspace scope. */ export async function getStatusReportById(id: number, workspaceId: number) { return db .select() .from(statusReport) .where( and(eq(statusReport.id, id), eq(statusReport.workspaceId, workspaceId)), ) .get(); } /** * Helper to get page component IDs for a status report. */ export async function getPageComponentIdsForReport(statusReportId: number) { const components = await db .select({ pageComponentId: statusReportsToPageComponents.pageComponentId }) .from(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) .all(); return components.map((c) => String(c.pageComponentId)); } /** * Helper to get updates for a status report, ordered by date descending. */ async function getUpdatesForReport(statusReportId: number) { return db .select() .from(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, statusReportId)) .orderBy(desc(statusReportUpdate.date)) .all(); } /** * Result of validating page component IDs. */ interface ValidatedPageComponents { componentIds: number[]; pageId: number | null; } /** * Helper to validate page component IDs belong to the workspace and same page. * Accepts an optional transaction to ensure atomicity with subsequent operations. */ export async function validatePageComponentIds( pageComponentIds: string[], workspaceId: number, tx: DB | Transaction = db, ): Promise<ValidatedPageComponents> { if (pageComponentIds.length === 0) { return { componentIds: [], pageId: null }; } const numericIds = pageComponentIds.map((id) => Number(id)); const validComponents = await tx .select({ id: pageComponent.id, pageId: pageComponent.pageId }) .from(pageComponent) .where( and( inArray(pageComponent.id, numericIds), eq(pageComponent.workspaceId, workspaceId), ), ) .all(); const validComponentsMap = new Map( validComponents.map((c) => [c.id, c.pageId]), ); // Check all requested IDs exist for (const id of numericIds) { if (!validComponentsMap.has(id)) { throw pageComponentNotFoundError(String(id)); } } // Validate all components belong to the same page const pageIds = new Set(validComponents.map((c) => c.pageId)); if (pageIds.size > 1) { throw pageComponentsMixedPagesError(); } const pageId = validComponents[0]?.pageId ?? null; return { componentIds: numericIds, pageId }; } /** * Helper to update page component associations for a status report. * Accepts an optional transaction to ensure atomicity. */ export async function updatePageComponentAssociations( statusReportId: number, pageComponentIds: number[], tx: DB | Transaction = db, ) { // Delete existing associations await tx .delete(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)); // Insert new associations if (pageComponentIds.length > 0) { await tx.insert(statusReportsToPageComponents).values( pageComponentIds.map((pageComponentId) => ({ statusReportId, pageComponentId, })), ); } } /** * Parses and validates a date string. * Throws invalidDateFormatError if the date is invalid. */ function parseDate(dateString: string): Date { const date = new Date(dateString); if (Number.isNaN(date.getTime())) { throw invalidDateFormatError(dateString); } return date; } /** * Status report service implementation for ConnectRPC. */ export const statusReportServiceImpl: ServiceImpl<typeof StatusReportService> = { async createStatusReport(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; // Parse and validate the date before the transaction const date = parseDate(req.date); // Create status report, associations, and initial update in a transaction const { report: newReport, newUpdate } = await db.transaction( async (tx) => { // Validate page component IDs inside transaction to prevent TOCTOU race condition const validatedComponents = await validatePageComponentIds( req.pageComponentIds, workspaceId, tx, ); // Validate that provided pageId matches the components' page const derivedPageId = validatedComponents.pageId; const providedPageId = req.pageId?.trim(); if ( derivedPageId !== null && providedPageId && providedPageId !== "" && Number(providedPageId) !== derivedPageId ) { throw pageIdComponentMismatchError( providedPageId, String(derivedPageId), ); } // Use the derived pageId from components, or parse the provided one const pageId = derivedPageId ?? (providedPageId ? Number(providedPageId) : null); // Create the status report const report = await tx .insert(statusReport) .values({ workspaceId, pageId, title: req.title, status: protoStatusToDb(req.status), }) .returning() .get(); if (!report) { throw statusReportCreateFailedError(); } // Create page component associations await updatePageComponentAssociations( report.id, validatedComponents.componentIds, tx, ); // Create the initial update const newUpdate = await tx .insert(statusReportUpdate) .values({ statusReportId: report.id, status: protoStatusToDb(req.status), date, message: req.message, }) .returning() .get(); if (!newUpdate) { throw statusReportCreateFailedError(); } return { report, newUpdate, pageId }; }, ); // Send notifications if requested (outside transaction) if (req.notify) { await sendStatusReportNotification({ statusReportUpdateId: newUpdate.id, limits: rpcCtx.workspace.limits, }); } // Fetch the updates for the response const updates = await getUpdatesForReport(newReport.id); return { statusReport: dbReportToProto(newReport, req.pageComponentIds, updates), }; }, async getStatusReport(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw statusReportIdRequiredError(); } const report = await getStatusReportById(Number(req.id), workspaceId); if (!report) { throw statusReportNotFoundError(req.id); } const pageComponentIds = await getPageComponentIdsForReport(report.id); const updates = await getUpdatesForReport(report.id); return { statusReport: dbReportToProto(report, pageComponentIds, updates), }; }, async listStatusReports(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; const limit = Math.min(Math.max(req.limit ?? 50, 1), 100); const offset = req.offset ?? 0; // Build conditions const conditions = [eq(statusReport.workspaceId, workspaceId)]; // Add status filter if provided if (req.statuses.length > 0) { const dbStatuses = req.statuses .filter((s) => s !== StatusReportStatus.UNSPECIFIED) .map(protoStatusToDb); if (dbStatuses.length > 0) { conditions.push(inArray(statusReport.status, dbStatuses)); } } // Get total count const countResult = await db .select({ count: sql<number>`count(*)` }) .from(statusReport) .where(and(...conditions)) .get(); const totalCount = countResult?.count ?? 0; // Get status reports const reports = await db .select() .from(statusReport) .where(and(...conditions)) .orderBy(desc(statusReport.createdAt)) .limit(limit) .offset(offset) .all(); // Get page component IDs for each report const statusReports = await Promise.all( reports.map(async (report) => { const pageComponentIds = await getPageComponentIdsForReport( report.id, ); return dbReportToProtoSummary(report, pageComponentIds); }), ); return { statusReports, totalSize: totalCount, }; }, async updateStatusReport(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw statusReportIdRequiredError(); } const report = await getStatusReportById(Number(req.id), workspaceId); if (!report) { throw statusReportNotFoundError(req.id); } // Update report, associations in a transaction const updatedReport = await db.transaction(async (tx) => { // Validate page component IDs inside transaction to prevent TOCTOU race condition // Allows empty array to clear associations; ensures all components belong to same page const validatedComponents = await validatePageComponentIds( req.pageComponentIds, workspaceId, tx, ); // Build update values const updateValues: Record<string, unknown> = { updatedAt: new Date(), // Set pageId from validated components (null if no components) pageId: validatedComponents.pageId, }; if (req.title !== undefined && req.title !== "") { updateValues.title = req.title; } // Always update page component associations (empty array clears all) await updatePageComponentAssociations( report.id, validatedComponents.componentIds, tx, ); // Update the report const updated = await tx .update(statusReport) .set(updateValues) .where(eq(statusReport.id, report.id)) .returning() .get(); if (!updated) { throw statusReportUpdateFailedError(req.id); } return updated; }); // Fetch updated data const pageComponentIds = await getPageComponentIdsForReport( updatedReport.id, ); const updates = await getUpdatesForReport(updatedReport.id); return { statusReport: dbReportToProto(updatedReport, pageComponentIds, updates), }; }, async deleteStatusReport(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.id || req.id.trim() === "") { throw statusReportIdRequiredError(); } const report = await getStatusReportById(Number(req.id), workspaceId); if (!report) { throw statusReportNotFoundError(req.id); } // Delete the status report (cascade will delete updates and associations) await db.delete(statusReport).where(eq(statusReport.id, report.id)); return { success: true }; }, async addStatusReportUpdate(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; if (!req.statusReportId || req.statusReportId.trim() === "") { throw statusReportIdRequiredError(); } const report = await getStatusReportById( Number(req.statusReportId), workspaceId, ); if (!report) { throw statusReportNotFoundError(req.statusReportId); } // Parse and validate the date or use current time const date = req.date ? parseDate(req.date) : new Date(); // Create update and update status report in a transaction const { updatedReport, newUpdate } = await db.transaction(async (tx) => { // Create the update const newUpdate = await tx .insert(statusReportUpdate) .values({ statusReportId: report.id, status: protoStatusToDb(req.status), date, message: req.message, }) .returning() .get(); if (!newUpdate) { throw statusReportUpdateFailedError(req.statusReportId); } // Update the status report's status and updatedAt const updated = await tx .update(statusReport) .set({ status: protoStatusToDb(req.status), updatedAt: new Date(), }) .where(eq(statusReport.id, report.id)) .returning() .get(); if (!updated) { throw statusReportUpdateFailedError(req.statusReportId); } return { updatedReport: updated, newUpdate }; }); // Send notifications if requested (outside transaction) if (req.notify && updatedReport.pageId) { await sendStatusReportNotification({ statusReportUpdateId: newUpdate.id, limits: rpcCtx.workspace.limits, }); } // Fetch all updates const pageComponentIds = await getPageComponentIdsForReport( updatedReport.id, ); const updates = await getUpdatesForReport(updatedReport.id); return { statusReport: dbReportToProto(updatedReport, pageComponentIds, updates), }; }, }; ================================================ FILE: apps/server/src/routes/slack/agent.test.ts ================================================ import { describe, expect, test } from "bun:test"; import { buildSystemPrompt } from "./agent"; describe("buildSystemPrompt", () => { test("includes the current date and time in ISO 8601 format", () => { const before = new Date(); const prompt = buildSystemPrompt("My Workspace"); const after = new Date(); const match = prompt.match( /The current date and time is: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z) \(UTC\)/, ); expect(match).not.toBeNull(); // biome-ignore lint/style/noNonNullAssertion: <explanation> const promptDate = new Date(match![1]); expect(promptDate.getTime()).toBeGreaterThanOrEqual(before.getTime()); expect(promptDate.getTime()).toBeLessThanOrEqual(after.getTime()); }); test("includes the workspace name", () => { const prompt = buildSystemPrompt("Acme Corp"); expect(prompt).toContain('workspace "Acme Corp"'); }); }); ================================================ FILE: apps/server/src/routes/slack/agent.ts ================================================ import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; import { generateText, stepCountIs } from "ai"; import type { ModelMessage } from "ai"; import { createTools } from "./tools"; interface SlackThreadMessage { user?: string; bot_id?: string; text?: string; } interface AgentResult { text: string; toolResults: Array<{ toolName: string; result: unknown }>; } export function buildSystemPrompt(workspaceName: string): string { const now = new Date().toISOString(); return `You are the OpenStatus assistant for workspace "${workspaceName}". The current date and time is: ${now} (UTC). You help teams create and manage status reports and maintenance windows through Slack. IMPORTANT: You have NO knowledge of this workspace's data. NEVER guess or make up IDs (page IDs, component IDs, report IDs). You MUST call the appropriate tool first to get real data. - Questions about pages or components -> call listStatusPages FIRST - Questions about reports -> call listStatusReports FIRST - Questions about maintenances -> call listMaintenances FIRST - Creating a report -> you MUST call listStatusPages first to get the real pageId, then call createStatusReport with that pageId - Scheduling maintenance -> you MUST call listStatusPages first to get the real pageId, then call createMaintenance with that pageId - NEVER pass a pageId you did not receive from listStatusPages. Guessing a pageId WILL cause an error. Capabilities: - Create status reports on status pages (createStatusReport) - Publish progress updates to existing reports (addStatusReportUpdate) - Edit report metadata like title or components (updateStatusReport) - List active status reports and status pages - Schedule maintenance windows (createMaintenance) - List upcoming maintenance windows (listMaintenances) Lifecycle: createStatusReport once -> addStatusReportUpdate repeatedly -> resolved. - "provide an update", "we found the cause", "resolve it" -> addStatusReportUpdate - "rename the report", "add a component" -> updateStatusReport (metadata only) Guidelines: - If multiple status pages exist, ask which one to use. If only one, use it automatically. - Infer the status from conversation context: "we have an incident" -> investigating "we found the root cause" -> identified "we're watching it" -> monitoring "it's fixed" -> resolved - Draft professional status page updates. Don't repeat the user verbatim. - When tagged in a thread, synthesize the full thread into a status report draft. - Status progression: investigating -> identified -> monitoring -> resolved - Be concise. Use Slack mrkdwn formatting (*bold*, _italic_). - For any mutation, always call the tool so the user sees a confirmation. Maintenance scheduling: - Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps. - If the user doesn't specify a timezone, default to UTC and mention that in your response. - The "from" time must be before the "to" time. - Write a professional maintenance message describing what will happen during the window.`; } function convertThreadToMessages( thread: SlackThreadMessage[], botUserId: string, ): ModelMessage[] { const messages: ModelMessage[] = []; for (const msg of thread) { if (!msg.text) continue; if (msg.bot_id || msg.user === botUserId) { messages.push({ role: "assistant", content: msg.text }); } else { messages.push({ role: "user", content: msg.text }); } } // The API requires the first message to have role "user". // Drop any leading assistant messages (e.g. bot confirmations from a prior turn). while (messages.length > 0 && messages[0].role !== "user") { messages.shift(); } return messages; } export async function runAgent( workspace: Workspace, thread: SlackThreadMessage[], botUserId: string, userText?: string, ): Promise<AgentResult> { const tools = createTools(workspace); let messages = convertThreadToMessages(thread, botUserId); if (messages.length === 0 && userText) { messages = [{ role: "user" as const, content: userText }]; } if (messages.length === 0) { return { text: "I couldn't read your message. Please try again.", toolResults: [], }; } const result = await generateText({ model: "anthropic/claude-sonnet-4.5", system: buildSystemPrompt(workspace.name ?? "Unknown"), messages, tools, stopWhen: stepCountIs(5), }); const toolResults: AgentResult["toolResults"] = []; for (const step of result.steps) { for (const tc of step.toolResults) { toolResults.push({ toolName: tc.toolName, result: tc.output }); } } return { text: result.text, toolResults }; } ================================================ FILE: apps/server/src/routes/slack/blocks.test.ts ================================================ import { describe, expect, test } from "bun:test"; import { buildConfirmationBlocks } from "./blocks"; import type { PendingAction } from "./confirmation-store"; describe("buildConfirmationBlocks", () => { test("createStatusReport includes 3 buttons", () => { const action: PendingAction["action"] = { type: "createStatusReport", params: { title: "API Outage", status: "investigating", message: "API is returning 500 errors", pageId: 1, }, }; const blocks = buildConfirmationBlocks("abc123", action); const section = blocks.find((b) => b.type === "section"); expect(section).toBeDefined(); expect((section as { text: { text: string } }).text.text).toContain( "API Outage", ); expect((section as { text: { text: string } }).text.text).toContain( "Investigating", ); const actions = blocks.find((b) => b.type === "actions") as { elements: { action_id: string }[]; }; expect(actions.elements).toHaveLength(3); expect(actions.elements[0].action_id).toBe("approve_abc123"); expect(actions.elements[1].action_id).toBe("approve_notify_abc123"); expect(actions.elements[2].action_id).toBe("cancel_abc123"); }); test("createStatusReport shows components when provided", () => { const action: PendingAction["action"] = { type: "createStatusReport", params: { title: "Test", status: "investigating", message: "msg", pageId: 1, pageComponentIds: ["comp-1", "comp-2"], }, }; const blocks = buildConfirmationBlocks("id1", action); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("comp-1, comp-2"); }); test("addStatusReportUpdate includes 3 buttons", () => { const action: PendingAction["action"] = { type: "addStatusReportUpdate", params: { statusReportId: 42, status: "identified", message: "Root cause found", }, }; const blocks = buildConfirmationBlocks("abc", action); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("42"); expect(section.text.text).toContain("Identified"); const actions = blocks.find((b) => b.type === "actions") as { elements: { action_id: string }[]; }; expect(actions.elements).toHaveLength(3); }); test("updateStatusReport includes 2 buttons (no notify)", () => { const action: PendingAction["action"] = { type: "updateStatusReport", params: { statusReportId: 10, title: "Updated Title", }, }; const blocks = buildConfirmationBlocks("xyz", action); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("Updated Title"); const actions = blocks.find((b) => b.type === "actions") as { elements: { action_id: string }[]; }; expect(actions.elements).toHaveLength(2); expect(actions.elements[0].action_id).toBe("approve_xyz"); expect(actions.elements[1].action_id).toBe("cancel_xyz"); }); test("resolveStatusReport includes 3 buttons", () => { const action: PendingAction["action"] = { type: "resolveStatusReport", params: { statusReportId: 5, message: "Issue has been resolved", }, }; const blocks = buildConfirmationBlocks("res1", action); const section = blocks.find((b) => b.type === "section") as { text: { text: string }; }; expect(section.text.text).toContain("5"); expect(section.text.text).toContain("Issue has been resolved"); const actions = blocks.find((b) => b.type === "actions") as { elements: { action_id: string }[]; }; expect(actions.elements).toHaveLength(3); }); test("all blocks include a divider", () => { const action: PendingAction["action"] = { type: "createStatusReport", params: { title: "T", status: "investigating", message: "m", pageId: 1, }, }; const blocks = buildConfirmationBlocks("d1", action); expect(blocks.some((b) => b.type === "divider")).toBe(true); }); }); ================================================ FILE: apps/server/src/routes/slack/blocks.ts ================================================ import type { PendingAction } from "./confirmation-store"; interface TextObject { type: "plain_text" | "mrkdwn"; text: string; emoji?: boolean; } interface SectionBlock { type: "section"; text: TextObject; } interface ActionsBlock { type: "actions"; elements: ButtonElement[]; } interface DividerBlock { type: "divider"; } interface ButtonElement { type: "button"; text: TextObject; action_id: string; value?: string; style?: "primary" | "danger"; } type Block = SectionBlock | ActionsBlock | DividerBlock; export function buildConfirmationBlocks( actionId: string, action: PendingAction["action"], ): Block[] { const blocks: Block[] = []; switch (action.type) { case "createStatusReport": { const { title, status, message, pageId, pageComponentIds } = action.params; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Create Status Report*\n\n*Title:* ${title}\n*Status:* ${capitalize(status)}\n*Page ID:* ${pageId}${ pageComponentIds?.length ? `\n*Components:* ${pageComponentIds.join(", ")}` : "" }\n*Message:* ${message}`, }, }); blocks.push({ type: "divider" }); blocks.push({ type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: `approve_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Approve & Notify", emoji: true, }, action_id: `approve_notify_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: `cancel_${actionId}`, style: "danger", }, ], }); break; } case "addStatusReportUpdate": { const { statusReportId, status, message } = action.params; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Add Status Report Update*\n\n*Report ID:* ${statusReportId}\n*New Status:* ${capitalize(status)}\n*Message:* ${message}`, }, }); blocks.push({ type: "divider" }); blocks.push({ type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: `approve_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Approve & Notify", emoji: true, }, action_id: `approve_notify_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: `cancel_${actionId}`, style: "danger", }, ], }); break; } case "updateStatusReport": { const { statusReportId, title, pageComponentIds } = action.params; let text = `*Update Status Report*\n\n*Report ID:* ${statusReportId}`; if (title) text += `\n*New Title:* ${title}`; if (pageComponentIds?.length) text += `\n*Components:* ${pageComponentIds.join(", ")}`; blocks.push({ type: "section", text: { type: "mrkdwn", text } }); blocks.push({ type: "divider" }); blocks.push({ type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: `approve_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: `cancel_${actionId}`, style: "danger", }, ], }); break; } case "resolveStatusReport": { const { statusReportId, message } = action.params; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Resolve Status Report*\n\n*Report ID:* ${statusReportId}\n*Message:* ${message}`, }, }); blocks.push({ type: "divider" }); blocks.push({ type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: `approve_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Approve & Notify", emoji: true, }, action_id: `approve_notify_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: `cancel_${actionId}`, style: "danger", }, ], }); break; } case "createMaintenance": { const { title, message, from, to, pageComponentIds } = action.params; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*Schedule Maintenance*\n\n*Title:* ${title}\n*From:* ${formatDate(from)}\n*To:* ${formatDate(to)}${ pageComponentIds?.length ? `\n*Components:* ${pageComponentIds.join(", ")}` : "" }\n*Message:* ${message}`, }, }); blocks.push({ type: "divider" }); blocks.push({ type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: `approve_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Approve & Notify", emoji: true, }, action_id: `approve_notify_${actionId}`, style: "primary", }, { type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: `cancel_${actionId}`, style: "danger", }, ], }); break; } } return blocks; } function formatDate(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleString("en-US", { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short", }); } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } ================================================ FILE: apps/server/src/routes/slack/confirmation-store.test.ts ================================================ import { beforeEach, describe, expect, test } from "bun:test"; import { consume, findByThread, get, replace, store, } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>; function makePendingInput(): Omit<PendingAction, "id" | "createdAt"> { return { workspaceId: 1, limits: {}, botToken: "xoxb-test-token", channelId: "C123", threadTs: "1234567890.123456", messageTs: "1234567890.654321", userId: "U123", action: { type: "createStatusReport", params: { title: "Test Incident", status: "investigating", message: "We are investigating", pageId: 1, }, }, }; } describe("confirmation-store", () => { beforeEach(() => { redisStore.clear(); }); describe("store", () => { test("returns an action id", async () => { const id = await store(makePendingInput()); expect(typeof id).toBe("string"); expect(id.length).toBeGreaterThan(0); }); test("saves action and thread index to redis", async () => { const input = makePendingInput(); const id = await store(input); const actionKey = `slack:action:${id}`; const threadKey = `slack:thread:${input.threadTs}`; expect(redisStore.has(actionKey)).toBe(true); expect(redisStore.has(threadKey)).toBe(true); const stored = JSON.parse(redisStore.get(actionKey) as string); expect(stored.id).toBe(id); expect(stored.workspaceId).toBe(1); expect(stored.action.type).toBe("createStatusReport"); expect(redisStore.get(threadKey)).toBe(id); }); }); describe("get", () => { test("returns stored action without deleting it", async () => { const input = makePendingInput(); const id = await store(input); const result = await get(id); expect(result).toBeDefined(); expect(result?.id).toBe(id); expect(result?.action.type).toBe("createStatusReport"); // Keys should still exist expect(redisStore.has(`slack:action:${id}`)).toBe(true); expect(redisStore.has(`slack:thread:${input.threadTs}`)).toBe(true); }); test("returns undefined for unknown id", async () => { const result = await get("nonexistent"); expect(result).toBeUndefined(); }); test("returns undefined for invalid data in redis", async () => { redisStore.set("slack:action:bad", JSON.stringify({ invalid: true })); const result = await get("bad"); expect(result).toBeUndefined(); }); }); describe("consume", () => { test("returns stored action and deletes it", async () => { const input = makePendingInput(); const id = await store(input); const result = await consume(id); expect(result).toBeDefined(); expect(result?.id).toBe(id); expect(result?.action.type).toBe("createStatusReport"); expect(redisStore.has(`slack:action:${id}`)).toBe(false); expect(redisStore.has(`slack:thread:${input.threadTs}`)).toBe(false); }); test("returns undefined for unknown id", async () => { const result = await consume("nonexistent"); expect(result).toBeUndefined(); }); test("returns undefined for invalid data in redis", async () => { redisStore.set("slack:action:bad", JSON.stringify({ invalid: true })); const result = await consume("bad"); expect(result).toBeUndefined(); }); }); describe("findByThread", () => { test("finds action by thread timestamp", async () => { const input = makePendingInput(); const id = await store(input); const result = await findByThread(input.threadTs); expect(result).toBeDefined(); expect(result?.id).toBe(id); }); test("returns undefined for unknown thread", async () => { const result = await findByThread("unknown.thread"); expect(result).toBeUndefined(); }); test("cleans up orphaned thread index", async () => { redisStore.set("slack:thread:orphan.ts", "missing-id"); const result = await findByThread("orphan.ts"); expect(result).toBeUndefined(); expect(redisStore.has("slack:thread:orphan.ts")).toBe(false); }); }); describe("replace", () => { test("replaces the action on an existing pending", async () => { const input = makePendingInput(); const id = await store(input); const newAction: PendingAction["action"] = { type: "addStatusReportUpdate", params: { statusReportId: 42, status: "identified", message: "Root cause found", }, }; await replace(id, newAction); const result = await consume(id); expect(result).toBeDefined(); expect(result?.action.type).toBe("addStatusReportUpdate"); if (result?.action.type === "addStatusReportUpdate") { expect(result?.action.params.statusReportId).toBe(42); } }); test("does nothing for unknown id", async () => { await replace("nonexistent", { type: "resolveStatusReport", params: { statusReportId: 1, message: "fixed" }, }); expect(redisStore.size).toBe(0); }); }); describe("zod validation", () => { test("validates all action types", async () => { const actions: PendingAction["action"][] = [ { type: "createStatusReport", params: { title: "Test", status: "investigating", message: "msg", pageId: 1, }, }, { type: "addStatusReportUpdate", params: { statusReportId: 1, status: "identified", message: "update", }, }, { type: "updateStatusReport", params: { statusReportId: 1, title: "New Title" }, }, { type: "resolveStatusReport", params: { statusReportId: 1, message: "resolved" }, }, ]; for (const action of actions) { const input = { ...makePendingInput(), action }; const id = await store(input); const result = await consume(id); expect(result).toBeDefined(); expect(result?.action.type).toBe(action.type); } }); test("rejects invalid status values", async () => { const raw = JSON.stringify({ id: "test", workspaceId: 1, limits: makePendingInput().limits, botToken: "tok", channelId: "C1", threadTs: "1.1", messageTs: "1.2", userId: "U1", createdAt: Date.now(), action: { type: "createStatusReport", params: { title: "T", status: "invalid_status", message: "m", pageId: 1, }, }, }); redisStore.set("slack:action:bad-status", raw); const result = await consume("bad-status"); expect(result).toBeUndefined(); }); }); }); ================================================ FILE: apps/server/src/routes/slack/confirmation-store.ts ================================================ import { redis } from "@/libs/clients"; import { limitsSchema } from "@openstatus/db/src/schema/plan/schema"; import { nanoid } from "nanoid"; import { z } from "zod"; const statusEnum = z.enum([ "investigating", "identified", "monitoring", "resolved", ]); const createStatusReportActionSchema = z.object({ type: z.literal("createStatusReport"), params: z.object({ title: z.string(), status: statusEnum, message: z.string(), pageId: z.number(), pageComponentIds: z.array(z.string()).optional(), }), }); const addStatusReportUpdateActionSchema = z.object({ type: z.literal("addStatusReportUpdate"), params: z.object({ statusReportId: z.number(), status: statusEnum, message: z.string(), }), }); const updateStatusReportActionSchema = z.object({ type: z.literal("updateStatusReport"), params: z.object({ statusReportId: z.number(), title: z.string().optional(), pageComponentIds: z.array(z.string()).optional(), }), }); const resolveStatusReportActionSchema = z.object({ type: z.literal("resolveStatusReport"), params: z.object({ statusReportId: z.number(), message: z.string(), }), }); const createMaintenanceActionSchema = z.object({ type: z.literal("createMaintenance"), params: z.object({ title: z.string(), message: z.string(), from: z.string(), to: z.string(), pageId: z.number(), pageComponentIds: z.array(z.string()).optional(), }), }); const actionSchema = z.discriminatedUnion("type", [ createStatusReportActionSchema, addStatusReportUpdateActionSchema, updateStatusReportActionSchema, resolveStatusReportActionSchema, createMaintenanceActionSchema, ]); const pendingActionSchema = z.object({ id: z.string(), workspaceId: z.number(), limits: limitsSchema, botToken: z.string(), channelId: z.string(), threadTs: z.string(), messageTs: z.string(), userId: z.string(), createdAt: z.number(), action: actionSchema, }); export type PendingAction = z.infer<typeof pendingActionSchema>; const TTL_SECONDS = 5 * 60; const ACTION_PREFIX = "slack:action:"; const THREAD_PREFIX = "slack:thread:"; function parse(raw: unknown): PendingAction | undefined { const data = typeof raw === "string" ? JSON.parse(raw) : raw; const result = pendingActionSchema.safeParse(data); if (!result.success) { console.error("[slack confirmation-store] invalid data:", result.error); return undefined; } return result.data; } export async function store( action: Omit<PendingAction, "id" | "createdAt">, ): Promise<string> { const id = nanoid(); const pending: PendingAction = { ...action, id, createdAt: Date.now() }; await Promise.all([ redis.set(`${ACTION_PREFIX}${id}`, JSON.stringify(pending), { ex: TTL_SECONDS, }), redis.set(`${THREAD_PREFIX}${action.threadTs}`, id, { ex: TTL_SECONDS, }), ]); return id; } export async function get( actionId: string, ): Promise<PendingAction | undefined> { const raw = await redis.get<string>(`${ACTION_PREFIX}${actionId}`); if (!raw) return undefined; return parse(raw); } export async function consume( actionId: string, ): Promise<PendingAction | undefined> { // Atomic read+delete to prevent double execution from concurrent requests const raw = await redis.getdel<string>(`${ACTION_PREFIX}${actionId}`); if (!raw) return undefined; const action = parse(raw); if (!action) return undefined; // Clean up the thread mapping (best-effort, not critical for atomicity) await redis.del(`${THREAD_PREFIX}${action.threadTs}`); return action; } export async function findByThread( threadTs: string, ): Promise<PendingAction | undefined> { const actionId = await redis.get<string>(`${THREAD_PREFIX}${threadTs}`); if (!actionId) return undefined; const raw = await redis.get<string>(`${ACTION_PREFIX}${actionId}`); if (!raw) { await redis.del(`${THREAD_PREFIX}${threadTs}`); return undefined; } return parse(raw); } export async function replace( actionId: string, newAction: PendingAction["action"], ): Promise<void> { const raw = await redis.get<string>(`${ACTION_PREFIX}${actionId}`); if (!raw) return; const existing = parse(raw); if (!existing) return; existing.action = newAction; existing.createdAt = Date.now(); await Promise.all([ redis.set(`${ACTION_PREFIX}${actionId}`, JSON.stringify(existing), { ex: TTL_SECONDS, }), redis.expire(`${THREAD_PREFIX}${existing.threadTs}`, TTL_SECONDS), ]); } ================================================ FILE: apps/server/src/routes/slack/handler.test.ts ================================================ import { beforeEach, describe, expect, mock, test } from "bun:test"; import crypto from "node:crypto"; import { Hono } from "hono"; const SIGNING_SECRET = "test-signing-secret"; process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; process.env.AI_GATEWAY_API_KEY = "test-key"; mock.module("./workspace-resolver", () => ({ resolveWorkspace: (teamId: string) => { if (teamId === "T_KNOWN") { return Promise.resolve({ workspace: { id: 1, name: "Test Workspace", slug: "test", plan: "free", limits: {}, }, botToken: "xoxb-test", botUserId: "UBOT", }); } return Promise.resolve(null); }, })); let slackMessages: Array<{ method: string; args: Record<string, unknown> }> = []; let postMessageOverride: | ((args: Record<string, unknown>) => Promise<{ ts: string }>) | null = null; let updateOverride: | ((args: Record<string, unknown>) => Promise<{ ts: string }>) | null = null; mock.module("@slack/web-api", () => ({ WebClient: class { chat = { postMessage: (args: Record<string, unknown>) => { if (postMessageOverride) return postMessageOverride(args); slackMessages.push({ method: "postMessage", args }); return Promise.resolve({ ts: "msg.ts" }); }, update: (args: Record<string, unknown>) => { if (updateOverride) return updateOverride(args); slackMessages.push({ method: "update", args }); return Promise.resolve({ ts: "msg.ts" }); }, }; conversations = { replies: () => Promise.resolve({ messages: [{ user: "U1", text: "test message", ts: "1.1" }], }), }; }, })); let runAgentOverride: | (() => Promise<{ text: string; toolResults: never[] }>) | null = null; mock.module("./agent", () => ({ runAgent: () => { if (runAgentOverride) return runAgentOverride(); return Promise.resolve({ text: "Here is my response", toolResults: [], }); }, })); const { handleSlackEvent } = await import("./handler"); const { verifySlackSignature } = await import("./verify"); function createTestApp() { const app = new Hono<{ Variables: { slackBody: unknown } }>(); app.post("/slack/events", verifySlackSignature, handleSlackEvent); return app; } function signAndPost( app: ReturnType<typeof createTestApp>, body: Record<string, unknown>, ) { const rawBody = JSON.stringify(body); const timestamp = Math.floor(Date.now() / 1000); const basestring = `v0:${timestamp}:${rawBody}`; const sig = crypto .createHmac("sha256", SIGNING_SECRET) .update(basestring) .digest("hex"); return app.request("/slack/events", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": `v0=${sig}`, }, body: rawBody, }); } describe("handleSlackEvent", () => { const app = createTestApp(); beforeEach(() => { slackMessages = []; postMessageOverride = null; updateOverride = null; runAgentOverride = null; }); test("responds to url_verification challenge", async () => { const res = await signAndPost(app, { type: "url_verification", challenge: "test-challenge-123", }); expect(res.status).toBe(200); const json = (await res.json()) as { challenge: string }; expect(json.challenge).toBe("test-challenge-123"); }); test("returns ok for non-event_callback types", async () => { const res = await signAndPost(app, { type: "app_rate_limited", }); expect(res.status).toBe(200); const json = (await res.json()) as { ok: boolean }; expect(json.ok).toBe(true); }); test("returns ok for event_callback", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_${Date.now()}_1`, event: { type: "app_mention", text: "<@UBOT> create an incident", user: "U1", channel: "C1", ts: "100.1", }, }); expect(res.status).toBe(200); const json = (await res.json()) as { ok: boolean }; expect(json.ok).toBe(true); }); test("handles app_uninstalled event", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_uninstall_${Date.now()}`, event: { type: "app_uninstalled", }, }); expect(res.status).toBe(200); }); test("handles tokens_revoked event", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_revoked_${Date.now()}`, event: { type: "tokens_revoked", }, }); expect(res.status).toBe(200); }); test("deduplicates events with same event_id", async () => { const eventId = `evt_dedup_${Date.now()}`; const body = { type: "event_callback", team_id: "T_KNOWN", event_id: eventId, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.1`, }, }; await signAndPost(app, body); await new Promise((r) => setTimeout(r, 50)); slackMessages = []; await signAndPost(app, body); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores events from unknown teams", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_UNKNOWN", event_id: `evt_unknown_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.2`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores message events from bots", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_bot_${Date.now()}`, event: { type: "message", text: "bot message", bot_id: "B123", channel: "C1", ts: `${Date.now()}.3`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores channel message without bot mention", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nomention_${Date.now()}`, event: { type: "message", text: "just a regular message", user: "U1", channel: "C1", channel_type: "channel", ts: `${Date.now()}.4`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("processes DM messages without bot mention", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_dm_${Date.now()}`, event: { type: "message", text: "hello in DM", user: "U1", channel: "D1", channel_type: "im", ts: `${Date.now()}.5`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); // DM should trigger a response (postMessage for "Thinking...") expect(slackMessages.length).toBeGreaterThan(0); }); test("ignores events without channel", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nochan_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", ts: `${Date.now()}.6`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores events without timestamp", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_nots_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores events without team_id", async () => { const res = await signAndPost(app, { type: "event_callback", event_id: `evt_noteam_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.7`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores unsupported event types", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_unsupported_${Date.now()}`, event: { type: "channel_created", }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores channel_join system messages", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_join_${Date.now()}`, event: { type: "message", subtype: "channel_join", text: "<@U1> has joined the channel", user: "U1", channel: "C1", ts: `${Date.now()}.10`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores channel_leave system messages", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_leave_${Date.now()}`, event: { type: "message", subtype: "channel_leave", text: "<@U1> has left the channel", user: "U1", channel: "C1", ts: `${Date.now()}.11`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("ignores events with no event payload", async () => { const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_noevent_${Date.now()}`, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 50)); expect(slackMessages.length).toBe(0); }); test("falls back to top-level message on cannot_reply_to_message", async () => { let callCount = 0; postMessageOverride = (args: Record<string, unknown>) => { callCount++; if (callCount === 1) { const err = new Error("An API error occurred: cannot_reply_to_message"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "cannot_reply_to_message" }, }); return Promise.reject(err); } slackMessages.push({ method: "postMessage", args }); return Promise.resolve({ ts: "fallback.ts" }); }; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_cantreply_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.20`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); const fallbackPost = slackMessages.find( (m) => m.method === "postMessage" && !m.args.thread_ts, ); expect(fallbackPost).toBeDefined(); }); test("returns early on non-recoverable postMessage error", async () => { postMessageOverride = () => { const err = new Error("An API error occurred: channel_not_found"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "channel_not_found" }, }); return Promise.reject(err); }; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_channotfound_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.21`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); const updateMessages = slackMessages.filter((m) => m.method === "update"); expect(updateMessages.length).toBe(0); }); test("shows error message when runAgent throws", async () => { runAgentOverride = () => Promise.reject(new Error("agent exploded")); const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_agenterr_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.30`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); const errorUpdate = slackMessages.find( (m) => m.method === "update" && typeof m.args.text === "string" && m.args.text.includes("Something went wrong"), ); expect(errorUpdate).toBeDefined(); }); test("does not throw when both runAgent and error update fail", async () => { runAgentOverride = () => Promise.reject(new Error("agent exploded")); updateOverride = () => { const err = new Error("An API error occurred: channel_not_found"); Object.assign(err, { code: "slack_webapi_platform_error", data: { ok: false, error: "channel_not_found" }, }); return Promise.reject(err); }; const res = await signAndPost(app, { type: "event_callback", team_id: "T_KNOWN", event_id: `evt_doublefail_${Date.now()}`, event: { type: "app_mention", text: "<@UBOT> hello", user: "U1", channel: "C1", ts: `${Date.now()}.31`, }, }); expect(res.status).toBe(200); await new Promise((r) => setTimeout(r, 100)); // No unhandled rejection — the .catch() in the error handler swallows it }); }); ================================================ FILE: apps/server/src/routes/slack/handler.ts ================================================ import { getLogger } from "@logtape/logtape"; import { and, db, eq } from "@openstatus/db"; import { integration } from "@openstatus/db/src/schema"; import { WebClient } from "@slack/web-api"; import type { Context } from "hono"; import { z } from "zod"; import { runAgent } from "./agent"; import { buildConfirmationBlocks } from "./blocks"; import { findByThread, replace, store } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; import { resolveWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); const processedEvents = new Map<string, number>(); function dedup(eventId: string): boolean { const now = Date.now(); for (const [id, ts] of processedEvents) { if (now - ts > 300_000) processedEvents.delete(id); } if (processedEvents.has(eventId)) return true; processedEvents.set(eventId, now); return false; } const slackEventSchema = z.object({ type: z.string(), event: z .object({ type: z.string(), subtype: z.string().optional(), text: z.string().optional(), user: z.string().optional(), channel: z.string().optional(), channel_type: z.string().optional(), ts: z.string().optional(), thread_ts: z.string().optional(), bot_id: z.string().optional(), }) .optional(), event_id: z.string().optional(), team_id: z.string().optional(), challenge: z.string().optional(), }); type SlackEvent = z.infer<typeof slackEventSchema>; const threadMessageSchema = z.object({ user: z.string().optional(), bot_id: z.string().optional(), text: z.string().optional(), ts: z.string().optional(), }); type ThreadMessage = z.infer<typeof threadMessageSchema>; const slackPlatformErrorSchema = z.object({ code: z.literal("slack_webapi_platform_error"), data: z.object({ error: z.string(), }), }); function isSlackPlatformError(err: unknown, errorCode: string): boolean { const parsed = slackPlatformErrorSchema.safeParse(err); return parsed.success && parsed.data.data.error === errorCode; } export async function handleSlackEvent(c: Context) { const body = c.get("slackBody") as SlackEvent; if (body.type === "url_verification") { return c.json({ challenge: body.challenge }); } if (body.type !== "event_callback") { return c.json({ ok: true }); } if (body.event_id && dedup(body.event_id)) { return c.json({ ok: true }); } const promise = processEvent(body); promise.catch((err) => logger.error("slack event processing error", { error: err, teamId: body.team_id, eventId: body.event_id, }), ); return c.json({ ok: true }); } async function processEvent(body: SlackEvent) { const event = body.event; if (!event) return; if (event.type === "app_uninstalled" || event.type === "tokens_revoked") { const teamId = body.team_id; if (teamId) { await db .delete(integration) .where( and( eq(integration.name, "slack-agent"), eq(integration.externalId, teamId), ), ); logger.info("slack integration cleaned up", { teamId }); } return; } if (event.type !== "app_mention" && event.type !== "message") return; if (event.type === "message" && event.bot_id) return; const ignoredSubtypes = [ "channel_join", "channel_leave", "channel_topic", "channel_purpose", "channel_name", ]; if (event.subtype && ignoredSubtypes.includes(event.subtype)) return; const teamId = body.team_id; if (!teamId || !event.channel || !event.ts) return; const resolved = await resolveWorkspace(teamId); if (!resolved) { logger.warn("slack integration not found", { teamId }); return; } const slack = new WebClient(resolved.botToken); const botUserId = resolved.botUserId; const threadTs = event.thread_ts ?? event.ts; if (event.type === "message" && event.channel_type !== "im") { if (!event.text?.includes(`<@${botUserId}>`)) return; } logger.info("slack event received", { teamId, channel: event.channel, eventType: event.type, threadTs, user: event.user, }); let thinkingTs: string | undefined; try { const thinkingMsg = await slack.chat.postMessage({ channel: event.channel, thread_ts: threadTs, text: ":hourglass_flowing_sand: Thinking...", }); thinkingTs = thinkingMsg.ts; } catch (err) { if (isSlackPlatformError(err, "cannot_reply_to_message")) { logger.warn("slack cannot reply to message, falling back to top-level", { channel: event.channel, teamId, threadTs, }); try { const fallbackMsg = await slack.chat.postMessage({ channel: event.channel, text: ":hourglass_flowing_sand: Thinking...", }); thinkingTs = fallbackMsg.ts; } catch (fallbackErr) { logger.error("slack failed to post fallback thinking message", { error: fallbackErr, channel: event.channel, teamId, }); return; } } else { logger.error("slack failed to post thinking message", { error: err, channel: event.channel, teamId, threadTs, }); return; } } if (!thinkingTs) { logger.error("slack thinking message returned no ts", { channel: event.channel, teamId, }); return; } try { let thread: ThreadMessage[] = []; if (event.thread_ts) { const replies = await slack.conversations.replies({ channel: event.channel, ts: event.thread_ts, limit: 100, }); thread = ((replies.messages ?? []) as ThreadMessage[]).filter( (msg) => msg.ts !== thinkingTs, ); } else { thread = [{ user: event.user, text: event.text, ts: event.ts }]; } logger.info("slack agent invoked", { teamId, channel: event.channel, threadTs, messageCount: thread.length, }); const result = await runAgent( resolved.workspace, thread, botUserId, event.text, ); logger.info("slack agent completed", { teamId, channel: event.channel, threadTs, toolCalls: result.toolResults.map((tr) => tr.toolName), }); const confirmationResult = result.toolResults.find( (tr) => tr.result && typeof tr.result === "object" && "needsConfirmation" in tr.result && (tr.result as { needsConfirmation: boolean }).needsConfirmation, ); if (confirmationResult) { logger.info("slack confirmation requested", { teamId, channel: event.channel, threadTs, toolName: confirmationResult.toolName, }); await handleConfirmation( slack, event.channel, threadTs, thinkingTs, event.user ?? "", resolved.workspace, resolved.botToken, confirmationResult, ); } else { await slack.chat.update({ channel: event.channel, ts: thinkingTs, text: result.text || "Done!", }); logger.info("slack response sent", { teamId, channel: event.channel, threadTs, }); } } catch (err) { logger.error("slack agent error", { error: err, channel: event.channel, teamId, threadTs, }); if (thinkingTs) { await slack.chat .update({ channel: event.channel, ts: thinkingTs, text: ":x: Something went wrong. Please try again.", }) .catch((updateErr: unknown) => { logger.error("slack failed to update error message", { error: updateErr, channel: event.channel, thinkingTs, }); }); } } } async function handleConfirmation( slack: WebClient, channel: string, threadTs: string, thinkingTs: string, userId: string, workspace: { id: number; limits: PendingAction["limits"] }, botToken: string, confirmationResult: { toolName: string; result: unknown }, ) { const { params } = confirmationResult.result as { needsConfirmation: boolean; params: Record<string, unknown>; }; const actionType = confirmationResult.toolName as PendingAction["action"]["type"]; const action = { type: actionType, params } as PendingAction["action"]; const existing = await findByThread(threadTs); if (existing) { await replace(existing.id, action); const blocks = buildConfirmationBlocks(existing.id, action); await slack.chat.update({ channel, ts: thinkingTs, text: getConfirmationText(action), blocks, }); await slack.chat.update({ channel, ts: existing.messageTs, text: getConfirmationText(action), blocks, }); } else { const actionId = await store({ workspaceId: workspace.id, limits: workspace.limits, botToken, channelId: channel, threadTs, messageTs: thinkingTs, userId, action, }); const blocks = buildConfirmationBlocks(actionId, action); await slack.chat.update({ channel, ts: thinkingTs, text: getConfirmationText(action), blocks, }); } } function getConfirmationText(action: PendingAction["action"]): string { switch (action.type) { case "createStatusReport": return `Create Status Report: ${action.params.title}`; case "addStatusReportUpdate": return `Add Status Report Update (${action.params.status})`; case "updateStatusReport": return `Update Status Report${action.params.title ? `: ${action.params.title}` : ""}`; case "resolveStatusReport": return "Resolve Status Report"; case "createMaintenance": return `Schedule Maintenance: ${action.params.title}`; } } ================================================ FILE: apps/server/src/routes/slack/index.test.ts ================================================ import { beforeEach, describe, expect, test } from "bun:test"; import crypto from "node:crypto"; import { Hono } from "hono"; const SIGNING_SECRET = "test-signing-secret"; function signRequest(body: string, timestamp: number): string { const basestring = `v0:${timestamp}:${body}`; const hmac = crypto .createHmac("sha256", SIGNING_SECRET) .update(basestring) .digest("hex"); return `v0=${hmac}`; } function makeInstallToken(workspaceId: number): string { const payload = JSON.stringify({ workspaceId, ts: Date.now() }); const sig = crypto .createHmac("sha256", SIGNING_SECRET) .update(payload) .digest("hex"); return Buffer.from(`${payload}.${sig}`).toString("base64url"); } describe("slack route middleware", () => { beforeEach(() => { process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; process.env.AI_GATEWAY_API_KEY = "test-key"; process.env.SLACK_CLIENT_ID = "test-client-id"; }); test("returns 503 when SLACK_SIGNING_SECRET is missing", async () => { process.env.SLACK_SIGNING_SECRET = ""; const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const res = await app.request("/slack/install?token=invalid"); expect(res.status).toBe(503); const json = (await res.json()) as { error: string }; expect(json.error).toBe("Slack agent not configured"); }); test("returns 503 when AI_GATEWAY_API_KEY is missing", async () => { process.env.AI_GATEWAY_API_KEY = ""; const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const res = await app.request("/slack/install?token=invalid"); expect(res.status).toBe(503); }); test("GET /install is accessible with valid token", async () => { const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const token = makeInstallToken(1); const res = await app.request(`/slack/install?token=${token}`); // Should redirect (302) to Slack OAuth, not 404 expect(res.status).toBe(302); }); test("GET /install rejects invalid token", async () => { const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const res = await app.request("/slack/install?token=invalid"); expect(res.status).toBe(403); }); test("POST /events requires signature verification", async () => { const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); // POST without signature headers should fail const res = await app.request("/slack/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "url_verification", challenge: "test" }), }); expect(res.status).toBe(401); }); test("POST /events accepts valid signed request", async () => { const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const body = JSON.stringify({ type: "url_verification", challenge: "test-challenge", }); const timestamp = Math.floor(Date.now() / 1000); const signature = signRequest(body, timestamp); const res = await app.request("/slack/events", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { challenge: string }; expect(json.challenge).toBe("test-challenge"); }); test("POST /interactions requires signature verification", async () => { const { slackRoute } = await import("./index"); const app = new Hono(); app.route("/slack", slackRoute); const res = await app.request("/slack/interactions", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "payload={}", }); expect(res.status).toBe(401); }); }); ================================================ FILE: apps/server/src/routes/slack/index.ts ================================================ import { env } from "@/env"; import { Hono } from "hono"; import { handleSlackEvent } from "./handler"; import { handleSlackInteraction } from "./interactions"; import { handleSlackInstall, handleSlackOAuthCallback } from "./oauth"; import { verifySlackSignature } from "./verify"; type SlackEnv = { Variables: { slackBody: unknown; event: Record<string, unknown>; }; }; const slack = new Hono<SlackEnv>(); slack.use("*", async (c, next) => { if (!env.SLACK_SIGNING_SECRET || !env.AI_GATEWAY_API_KEY) { return c.json({ error: "Slack agent not configured" }, 503); } await next(); }); slack.get("/install", handleSlackInstall); slack.get("/oauth/callback", handleSlackOAuthCallback); slack.post("/events", verifySlackSignature, handleSlackEvent); slack.post("/interactions", verifySlackSignature, handleSlackInteraction); export { slack as slackRoute }; ================================================ FILE: apps/server/src/routes/slack/interactions.test.ts ================================================ import { beforeEach, describe, expect, mock, test } from "bun:test"; import crypto from "node:crypto"; import { Hono } from "hono"; const SIGNING_SECRET = "test-signing-secret"; process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; process.env.AI_GATEWAY_API_KEY = "test-key"; const redisStore = (globalThis as Record<string, unknown>) .__testRedisStore as Map<string, string>; const pendingData = { id: "pending-123", workspaceId: 1, limits: {}, botToken: "xoxb-test", channelId: "C1", threadTs: "1.1", messageTs: "1.2", userId: "U_OWNER", createdAt: Date.now(), action: { type: "createStatusReport" as const, params: { title: "Test Incident", status: "investigating" as const, message: "Investigating the issue", pageId: 1, }, }, }; mock.module("./workspace-resolver", () => ({ resolveWorkspace: (teamId: string) => { if (teamId === "T_KNOWN") { return Promise.resolve({ botToken: "xoxb-fallback" }); } return Promise.resolve(null); }, })); let slackCalls: Array<{ method: string; args: Record<string, unknown> }> = []; mock.module("@slack/web-api", () => ({ WebClient: class { chat = { update: (args: Record<string, unknown>) => { slackCalls.push({ method: "update", args }); return Promise.resolve(); }, postEphemeral: (args: Record<string, unknown>) => { slackCalls.push({ method: "postEphemeral", args }); return Promise.resolve(); }, }; }, })); const { handleSlackInteraction } = await import("./interactions"); const { verifySlackSignature } = await import("./verify"); function createTestApp() { const app = new Hono<{ Variables: { slackBody: unknown } }>(); app.post("/slack/interactions", verifySlackSignature, handleSlackInteraction); return app; } function seedPendingAction() { redisStore.set(`slack:action:${pendingData.id}`, JSON.stringify(pendingData)); redisStore.set(`slack:thread:${pendingData.threadTs}`, pendingData.id); } function signAndPost( app: ReturnType<typeof createTestApp>, payload: Record<string, unknown>, ) { const payloadStr = JSON.stringify(payload); const body = `payload=${encodeURIComponent(payloadStr)}`; const timestamp = Math.floor(Date.now() / 1000); const basestring = `v0:${timestamp}:${body}`; const sig = crypto .createHmac("sha256", SIGNING_SECRET) .update(basestring) .digest("hex"); return app.request("/slack/interactions", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": `v0=${sig}`, }, body, }); } describe("handleSlackInteraction", () => { const app = createTestApp(); beforeEach(() => { slackCalls = []; redisStore.clear(); }); test("returns ok for non-block_actions", async () => { const res = await signAndPost(app, { type: "message_action", actions: [], }); expect(res.status).toBe(200); expect(slackCalls).toHaveLength(0); }); test("returns ok for unknown action_id prefix", async () => { const res = await signAndPost(app, { type: "block_actions", user: { id: "U1" }, channel: { id: "C1" }, message: { ts: "1.1" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "unknown_action" }], }); expect(res.status).toBe(200); expect(slackCalls).toHaveLength(0); }); test("cancel updates message to cancelled", async () => { seedPendingAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "cancel_pending-123" }], }); expect(res.status).toBe(200); const cancelCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Cancelled"), ); expect(cancelCall).toBeDefined(); }); test("rejects action from wrong user", async () => { seedPendingAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OTHER" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_pending-123" }], }); expect(res.status).toBe(200); const ephemeral = slackCalls.find((c) => c.method === "postEphemeral"); expect(ephemeral).toBeDefined(); expect(ephemeral?.args.text as string).toContain("Only the person"); // Pending action should NOT be consumed — still available for the real owner expect(redisStore.has(`slack:action:${pendingData.id}`)).toBe(true); }); test("shows expired message when pending action not found", async () => { const res = await signAndPost(app, { type: "block_actions", user: { id: "U1" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_unknown-id" }], }); expect(res.status).toBe(200); const expiredCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("expired"), ); expect(expiredCall).toBeDefined(); }); test("returns ok when no bot token available", async () => { const res = await signAndPost(app, { type: "block_actions", user: { id: "U1" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_UNKNOWN" }, actions: [{ action_id: "approve_some-id" }], }); expect(res.status).toBe(200); expect(slackCalls).toHaveLength(0); }); test("falls back to workspace resolver when pending has no botToken", async () => { const noTokenPending = { ...pendingData, id: "pending-notoken", botToken: "", createdAt: Date.now(), }; redisStore.set( `slack:action:${noTokenPending.id}`, JSON.stringify(noTokenPending), ); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "cancel_pending-notoken" }], }); expect(res.status).toBe(200); // Should still work via workspace resolver fallback const cancelCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Cancelled"), ); expect(cancelCall).toBeDefined(); }); test("returns ok with empty actions array", async () => { const res = await signAndPost(app, { type: "block_actions", user: { id: "U1" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [], }); expect(res.status).toBe(200); expect(slackCalls).toHaveLength(0); }); test("parses approve_notify prefix correctly", async () => { seedPendingAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_notify_pending-123" }], }); // approve_notify should extract pending ID as "pending-123" // Since the action exists and user matches, it tries to execute expect(res.status).toBe(200); }); test("returns ok when no team id and no pending", async () => { const res = await signAndPost(app, { type: "block_actions", user: { id: "U1" }, channel: { id: "C1" }, message: { ts: "1.2" }, actions: [{ action_id: "approve_orphan-id" }], }); expect(res.status).toBe(200); expect(slackCalls).toHaveLength(0); }); test("cancel consumes pending action from redis", async () => { seedPendingAction(); await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "1.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "cancel_pending-123" }], }); // After cancel, the pending action should be consumed from redis expect(redisStore.has(`slack:action:${pendingData.id}`)).toBe(false); }); }); describe("createMaintenance execution", () => { const app = createTestApp(); beforeEach(() => { slackCalls = []; redisStore.clear(); }); function seedMaintenanceAction(overrides: Record<string, unknown> = {}) { const data = { id: "maint-001", workspaceId: 1, limits: {}, botToken: "xoxb-test", channelId: "C1", threadTs: "2.1", messageTs: "2.2", userId: "U_OWNER", createdAt: Date.now(), action: { type: "createMaintenance" as const, params: { title: "DB Maintenance", message: "Scheduled database upgrade.", from: new Date(Date.now() + 86400000).toISOString(), to: new Date(Date.now() + 86400000 + 3600000).toISOString(), pageId: 1, ...overrides, }, }, }; redisStore.set(`slack:action:${data.id}`, JSON.stringify(data)); redisStore.set(`slack:thread:${data.threadTs}`, data.id); return data; } test("approve creates maintenance and shows success", async () => { seedMaintenanceAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); expect(res.status).toBe(200); const successCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes( "Maintenance *DB Maintenance* scheduled", ), ); expect(successCall).toBeDefined(); expect(successCall?.args.text as string).not.toContain( "subscribers notified", ); }); test("approve_notify creates maintenance and notifies", async () => { seedMaintenanceAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_notify_maint-001" }], }); expect(res.status).toBe(200); const successCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("subscribers notified"), ); expect(successCall).toBeDefined(); }); test("cancel does not create maintenance", async () => { seedMaintenanceAction(); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "cancel_maint-001" }], }); expect(res.status).toBe(200); const cancelCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Cancelled"), ); expect(cancelCall).toBeDefined(); }); test("shows error when AI hallucinates page id", async () => { seedMaintenanceAction({ pageId: 99999 }); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); expect(res.status).toBe(200); const errorCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Something went wrong"), ); expect(errorCall).toBeDefined(); }); test("shows error when from is after to", async () => { const now = Date.now(); seedMaintenanceAction({ from: new Date(now + 7200000).toISOString(), to: new Date(now + 3600000).toISOString(), }); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); expect(res.status).toBe(200); const errorCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Something went wrong"), ); expect(errorCall).toBeDefined(); }); test("creates maintenance with page components", async () => { seedMaintenanceAction({ pageComponentIds: ["1", "2"] }); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); expect(res.status).toBe(200); const successCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes( "Maintenance *DB Maintenance* scheduled", ), ); expect(successCall).toBeDefined(); }); test("shows error for invalid page component ids", async () => { seedMaintenanceAction({ pageComponentIds: ["99999"] }); const res = await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); expect(res.status).toBe(200); const errorCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Something went wrong"), ); expect(errorCall).toBeDefined(); }); test("does not leak internal error details to user", async () => { const data = { id: "maint-001", workspaceId: 2, limits: {}, botToken: "xoxb-test", channelId: "C1", threadTs: "2.1", messageTs: "2.2", userId: "U_OWNER", createdAt: Date.now(), action: { type: "createMaintenance" as const, params: { title: "DB Maintenance", message: "Upgrade.", from: new Date(Date.now() + 86400000).toISOString(), to: new Date(Date.now() + 86400000 + 3600000).toISOString(), pageId: 99999, }, }, }; redisStore.set(`slack:action:${data.id}`, JSON.stringify(data)); redisStore.set(`slack:thread:${data.threadTs}`, data.id); await signAndPost(app, { type: "block_actions", user: { id: "U_OWNER" }, channel: { id: "C1" }, message: { ts: "2.2" }, team: { id: "T_KNOWN" }, actions: [{ action_id: "approve_maint-001" }], }); const errorCall = slackCalls.find( (c) => c.method === "update" && (c.args.text as string).includes("Something went wrong"), ); expect(errorCall).toBeDefined(); const text = errorCall?.args.text as string; expect(text).not.toContain("Page 99999"); expect(text).not.toContain("ConnectError"); expect(text).not.toContain("select"); expect(text).not.toContain("query"); }); }); ================================================ FILE: apps/server/src/routes/slack/interactions.ts ================================================ import { and, db, eq, inArray } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, page, pageComponent, statusReport, statusReportUpdate, } from "@openstatus/db/src/schema"; import { WebClient } from "@slack/web-api"; import type { Context } from "hono"; import { sendMaintenanceNotification } from "../rpc/services/maintenance"; import { getStatusReportById, sendStatusReportNotification, updatePageComponentAssociations, validatePageComponentIds, } from "../rpc/services/status-report"; import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; import { resolveWorkspace } from "./workspace-resolver"; interface SlackInteractionPayload { type: string; user: { id: string }; channel: { id: string }; message: { ts: string }; team?: { id: string }; actions: Array<{ action_id: string; value?: string }>; } export async function handleSlackInteraction(c: Context) { const payload = c.get("slackBody") as SlackInteractionPayload; if (payload.type !== "block_actions" || !payload.actions?.length) { return c.json({ ok: true }); } const actionId = payload.actions[0].action_id; const channelId = payload.channel.id; const messageTs = payload.message.ts; const userId = payload.user.id; const teamId = payload.team?.id; let type: "approve" | "approve_notify" | "cancel"; let pendingId: string; if (actionId.startsWith("approve_notify_")) { type = "approve_notify"; pendingId = actionId.replace("approve_notify_", ""); } else if (actionId.startsWith("approve_")) { type = "approve"; pendingId = actionId.replace("approve_", ""); } else if (actionId.startsWith("cancel_")) { type = "cancel"; pendingId = actionId.replace("cancel_", ""); } else { return c.json({ ok: true }); } // Non-atomic read for botToken resolution and authorization checks const pending = await get(pendingId); let botToken: string | undefined = pending?.botToken; if (!botToken && teamId) { const resolved = await resolveWorkspace(teamId); botToken = resolved?.botToken; } if (!botToken) { return c.json({ ok: true }); } const slack = new WebClient(botToken); if (!pending) { await slack.chat.update({ channel: channelId, ts: messageTs, text: ":x: This action has expired. Please try again.", blocks: [], }); return c.json({ ok: true }); } if (pending.userId !== userId) { await slack.chat.postEphemeral({ channel: channelId, user: userId, text: "Only the person who initiated this action can approve or cancel it.", }); return c.json({ ok: true }); } // Atomic consume — prevents double execution from concurrent requests (e.g. double-click). // If another request already consumed this action, consume() returns undefined. const consumed = await consume(pendingId); if (!consumed) { return c.json({ ok: true }); } if (type === "cancel") { await slack.chat.update({ channel: channelId, ts: messageTs, text: ":no_entry_sign: Cancelled.", blocks: [], }); return c.json({ ok: true }); } const notify = type === "approve_notify"; try { await executeAction(consumed, notify, slack, channelId, messageTs); } catch (err) { console.error("[slack] action execution error:", err); await slack.chat.update({ channel: channelId, ts: messageTs, text: ":x: Something went wrong. Please try again.", blocks: [], }); } return c.json({ ok: true }); } async function getPageUrl(pageId: number): Promise<string | null> { const statusPage = await db .select({ slug: page.slug, customDomain: page.customDomain }) .from(page) .where(eq(page.id, pageId)) .get(); if (!statusPage) return null; return statusPage.customDomain ? `https://${statusPage.customDomain}` : `https://${statusPage.slug}.openstatus.dev`; } async function getReportUrl(pageId: number, reportId: number): Promise<string> { const statusPage = await db .select({ slug: page.slug, customDomain: page.customDomain }) .from(page) .where(eq(page.id, pageId)) .get(); const baseUrl = statusPage?.customDomain ? `https://${statusPage.customDomain}` : `https://${statusPage?.slug}.openstatus.dev`; return `${baseUrl}/events/report/${reportId}`; } async function executeAction( pending: PendingAction, notify: boolean, slack: WebClient, channelId: string, messageTs: string, ) { const { action, workspaceId, limits } = pending; switch (action.type) { case "createStatusReport": { const { title, status, message, pageId, pageComponentIds } = action.params; const result = await db.transaction(async (tx) => { const validated = pageComponentIds?.length ? await validatePageComponentIds(pageComponentIds, workspaceId, tx) : { componentIds: [], pageId: null }; // Validate that provided pageId matches the components' page if ( validated.pageId !== null && pageId != null && pageId !== validated.pageId ) { throw new Error( `pageId ${pageId} does not match the page (${validated.pageId}) that the selected components belong to`, ); } // Prefer the validated pageId derived from components const resolvedPageId = validated.pageId ?? pageId; const report = await tx .insert(statusReport) .values({ workspaceId, pageId: resolvedPageId, title, status, }) .returning() .get(); if (validated.componentIds.length > 0) { await updatePageComponentAssociations( report.id, validated.componentIds, tx, ); } const newUpdate = await tx .insert(statusReportUpdate) .values({ statusReportId: report.id, status, date: new Date(), message, }) .returning() .get(); return { report, updateId: newUpdate.id }; }); if (!result || !result.report.pageId) { throw new Error("Failed to create status report"); } if (notify) { await sendStatusReportNotification({ statusReportUpdateId: result.updateId, limits, }); } const reportUrl = await getReportUrl( result.report.pageId, result.report.id, ); await slack.chat.update({ channel: channelId, ts: messageTs, text: `:white_check_mark: Status report *${title}* created${notify ? " and subscribers notified" : ""}.\n<${reportUrl}|View on status page>`, blocks: [], }); break; } case "addStatusReportUpdate": { const { statusReportId, status, message } = action.params; const report = await getStatusReportById(statusReportId, workspaceId); if (!report) { throw new Error("Status report not found"); } const updateId = await db.transaction(async (tx) => { const newUpdate = await tx .insert(statusReportUpdate) .values({ statusReportId: report.id, status, date: new Date(), message, }) .returning() .get(); await tx .update(statusReport) .set({ status, updatedAt: new Date() }) .where(eq(statusReport.id, report.id)); return newUpdate.id; }); if (notify && report.pageId) { await sendStatusReportNotification({ statusReportUpdateId: updateId, limits, }); } const updateReportUrl = report.pageId ? await getReportUrl(report.pageId, report.id) : null; await slack.chat.update({ channel: channelId, ts: messageTs, text: `:white_check_mark: Update added to *${report.title}* (${status})${notify ? " and subscribers notified" : ""}.\n>${message}${updateReportUrl ? `\n<${updateReportUrl}|View on status page>` : ""}`, blocks: [], }); break; } case "updateStatusReport": { const { statusReportId, title, pageComponentIds } = action.params; const report = await getStatusReportById(statusReportId, workspaceId); if (!report) { throw new Error("Status report not found"); } await db.transaction(async (tx) => { if (pageComponentIds) { const validated = await validatePageComponentIds( pageComponentIds, workspaceId, tx, ); await updatePageComponentAssociations( report.id, validated.componentIds, tx, ); } const updateValues: Record<string, unknown> = { updatedAt: new Date(), }; if (title) updateValues.title = title; await tx .update(statusReport) .set(updateValues) .where(eq(statusReport.id, report.id)); }); await slack.chat.update({ channel: channelId, ts: messageTs, text: `:white_check_mark: Status report *${title ?? report.title}* updated.`, blocks: [], }); break; } case "resolveStatusReport": { const { statusReportId, message } = action.params; const report = await getStatusReportById(statusReportId, workspaceId); if (!report) { throw new Error("Status report not found"); } const resolveUpdateId = await db.transaction(async (tx) => { const newUpdate = await tx .insert(statusReportUpdate) .values({ statusReportId: report.id, status: "resolved", date: new Date(), message, }) .returning() .get(); await tx .update(statusReport) .set({ status: "resolved", updatedAt: new Date() }) .where(eq(statusReport.id, report.id)); return newUpdate.id; }); if (notify && report.pageId) { await sendStatusReportNotification({ statusReportUpdateId: resolveUpdateId, limits, }); } const resolveReportUrl = report.pageId ? await getReportUrl(report.pageId, report.id) : null; await slack.chat.update({ channel: channelId, ts: messageTs, text: `:white_check_mark: *${report.title}* resolved${notify ? " and subscribers notified" : ""}.${message ? `\n>${message}` : ""}${resolveReportUrl ? `\n<${resolveReportUrl}|View on status page>` : ""}`, blocks: [], }); break; } case "createMaintenance": { const { title, message, from, to, pageId: maintenancePageId, pageComponentIds: maintenanceComponentIds, } = action.params; const fromDate = new Date(from); const toDate = new Date(to); if (fromDate >= toDate) { throw new Error("Start time must be before end time"); } const newMaintenance = await db.transaction(async (tx) => { const pageRecord = await tx .select({ id: page.id }) .from(page) .where( and( eq(page.id, maintenancePageId), eq(page.workspaceId, workspaceId), ), ) .get(); if (!pageRecord) { throw new Error("Page not found in this workspace"); } const resolvedPageId = pageRecord.id; let componentIds: number[] = []; if (maintenanceComponentIds?.length) { const numericIds = maintenanceComponentIds.map((id) => Number(id)); const validComponents = await tx .select({ id: pageComponent.id, pageId: pageComponent.pageId }) .from(pageComponent) .where( and( inArray(pageComponent.id, numericIds), eq(pageComponent.workspaceId, workspaceId), ), ) .all(); if (validComponents.length !== numericIds.length) { throw new Error("One or more page components not found"); } const componentPageIds = new Set( validComponents.map((c) => c.pageId), ); if (componentPageIds.size > 1) { throw new Error("All components must belong to the same page"); } const componentPageId = validComponents[0]?.pageId; if (componentPageId !== null && componentPageId !== resolvedPageId) { throw new Error( "Selected components do not belong to the target status page", ); } componentIds = numericIds; } const record = await tx .insert(maintenance) .values({ workspaceId, pageId: resolvedPageId, title, message, from: fromDate, to: toDate, }) .returning() .get(); if (componentIds.length > 0) { await tx.insert(maintenancesToPageComponents).values( componentIds.map((pageComponentId) => ({ maintenanceId: record.id, pageComponentId, })), ); } return record; }); if (notify) { await sendMaintenanceNotification({ maintenanceId: newMaintenance.id, limits, }); } const maintenancePageUrl = newMaintenance.pageId ? await getPageUrl(newMaintenance.pageId) : null; await slack.chat.update({ channel: channelId, ts: messageTs, text: `:white_check_mark: Maintenance *${title}* scheduled${notify ? " and subscribers notified" : ""}.${maintenancePageUrl ? `\n<${maintenancePageUrl}|View status page>` : ""}`, blocks: [], }); break; } } } ================================================ FILE: apps/server/src/routes/slack/oauth.test.ts ================================================ import { describe, expect, test } from "bun:test"; import crypto from "node:crypto"; import { Hono } from "hono"; import { handleSlackInstall, handleSlackOAuthCallback } from "./oauth"; const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET ?? "test-signing-secret"; process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; process.env.SLACK_CLIENT_ID = "test-client-id"; process.env.SLACK_CLIENT_SECRET = "test-client-secret"; process.env.NODE_ENV = "development"; function createTestApp() { const app = new Hono(); app.get("/slack/install", handleSlackInstall); app.get("/slack/oauth/callback", handleSlackOAuthCallback); return app; } function signToken(data: { workspaceId: number; ts: number }): string { const payload = JSON.stringify(data); const signature = crypto .createHmac("sha256", SIGNING_SECRET) .update(payload) .digest("hex"); return Buffer.from(`${payload}.${signature}`).toString("base64url"); } function encodeState(state: { workspaceId: number; ts: number }): string { return signToken(state); } function makeInstallToken(workspaceId: number): string { return signToken({ workspaceId, ts: Date.now() }); } describe("handleSlackInstall", () => { const app = createTestApp(); test("redirects to Slack OAuth URL with correct params", async () => { const token = makeInstallToken(1); const res = await app.request(`/slack/install?token=${token}`); expect(res.status).toBe(302); const location = res.headers.get("location"); expect(location).toBeDefined(); expect(location).toContain("https://slack.com/oauth/v2/authorize"); expect(location).toContain("client_id=test-client-id"); expect(location).toContain("scope="); expect(location).toContain("state="); expect(location).toContain("redirect_uri="); }); test("returns 400 when token is missing", async () => { const res = await app.request("/slack/install"); expect(res.status).toBe(400); const json = (await res.json()) as { error: string }; expect(json.error).toBe("token is required"); }); test("returns 403 for invalid token", async () => { const res = await app.request("/slack/install?token=invalid-token"); expect(res.status).toBe(403); const json = (await res.json()) as { error: string }; expect(json.error).toBe("Invalid or expired token"); }); test("returns 403 for expired token", async () => { const expired = signToken({ workspaceId: 1, ts: Date.now() - 10 * 60 * 1000, }); const res = await app.request(`/slack/install?token=${expired}`); expect(res.status).toBe(403); const json = (await res.json()) as { error: string }; expect(json.error).toBe("Invalid or expired token"); }); test("includes all required bot scopes", async () => { const token = makeInstallToken(1); const res = await app.request(`/slack/install?token=${token}`); const location = res.headers.get("location"); expect(location).toBeDefined(); const url = new URL(location as string); const scope = url.searchParams.get("scope"); const expectedScopes = [ "app_mentions:read", "channels:history", "chat:write", "groups:history", "groups:read", "groups:write", "im:history", "im:read", "im:write", "mpim:history", ]; for (const s of expectedScopes) { expect(scope).toContain(s); } }); test("state contains signed workspaceId", async () => { const token = makeInstallToken(42); const res = await app.request(`/slack/install?token=${token}`); const location = res.headers.get("location"); expect(location).toBeDefined(); const url = new URL(location as string); const state = url.searchParams.get("state"); expect(state).toBeDefined(); const decoded = Buffer.from(state as string, "base64url").toString(); const dotIdx = decoded.lastIndexOf("."); const payload = JSON.parse(decoded.slice(0, dotIdx)); expect(payload.workspaceId).toBe(42); expect(payload.ts).toBeGreaterThan(0); }); }); describe("handleSlackOAuthCallback", () => { const app = createTestApp(); test("redirects to error page on Slack error", async () => { const res = await app.request("/slack/oauth/callback?error=access_denied"); expect(res.status).toBe(302); const location = res.headers.get("location"); expect(location).toContain("slack=error"); }); test("returns 400 when code is missing", async () => { const state = encodeState({ workspaceId: 1, ts: Date.now() }); const res = await app.request(`/slack/oauth/callback?state=${state}`); expect(res.status).toBe(400); }); test("returns 400 when state is missing", async () => { const res = await app.request("/slack/oauth/callback?code=test-code"); expect(res.status).toBe(400); }); test("returns 400 for expired state", async () => { const expiredState = encodeState({ workspaceId: 1, ts: Date.now() - 15 * 60 * 1000, }); const res = await app.request( `/slack/oauth/callback?code=test-code&state=${expiredState}`, ); expect(res.status).toBe(400); const json = (await res.json()) as { error: string }; expect(json.error).toBe("Invalid or expired state"); }); test("returns 400 for tampered state", async () => { const payload = JSON.stringify({ workspaceId: 1, ts: Date.now() }); const tamperedState = Buffer.from(`${payload}.invalidsignature`).toString( "base64url", ); const res = await app.request( `/slack/oauth/callback?code=test-code&state=${tamperedState}`, ); expect(res.status).toBe(400); const json = (await res.json()) as { error: string }; expect(json.error).toBe("Invalid or expired state"); }); test("returns 400 for invalid base64 state", async () => { const res = await app.request( "/slack/oauth/callback?code=test-code&state=not-valid-base64!!!", ); expect(res.status).toBe(400); }); test("returns 400 for state without dot separator", async () => { const noDotState = Buffer.from("nodothere").toString("base64url"); const res = await app.request( `/slack/oauth/callback?code=test-code&state=${noDotState}`, ); expect(res.status).toBe(400); }); test("returns 400 for state with valid signature but invalid JSON", async () => { const payload = "not-json"; const signature = crypto .createHmac("sha256", SIGNING_SECRET) .update(payload) .digest("hex"); const state = Buffer.from(`${payload}.${signature}`).toString("base64url"); const res = await app.request( `/slack/oauth/callback?code=test-code&state=${state}`, ); expect(res.status).toBe(400); }); test("returns 400 when both code and state are missing", async () => { const res = await app.request("/slack/oauth/callback"); expect(res.status).toBe(400); }); test("accepts state within 10 minute window", async () => { const validState = encodeState({ workspaceId: 1, ts: Date.now() - 9 * 60 * 1000, }); // This will proceed to the token exchange which will fail (no mock for fetch) // but it won't fail on state validation const res = await app.request( `/slack/oauth/callback?code=test-code&state=${validState}`, ); // Will get a redirect to error page because token exchange fails, // but NOT a 400 for invalid state expect(res.status).not.toBe(400); }); }); ================================================ FILE: apps/server/src/routes/slack/oauth.ts ================================================ import crypto from "node:crypto"; import { env } from "@/env"; import { and, db, eq } from "@openstatus/db"; import { integration } from "@openstatus/db/src/schema"; import type { Context } from "hono"; const SLACK_OAUTH_URL = "https://slack.com/oauth/v2/authorize"; const SLACK_TOKEN_URL = "https://slack.com/api/oauth.v2.access"; const BOT_SCOPES = [ "app_mentions:read", "channels:history", "chat:write", "groups:history", "groups:read", "groups:write", "im:history", "im:read", "im:write", "mpim:history", ].join(","); interface OAuthState { workspaceId: number; ts: number; } interface SlackOAuthResponse { ok: boolean; error?: string; access_token: string; token_type: string; scope: string; bot_user_id: string; app_id: string; team: { id: string; name: string }; authed_user: { id: string }; enterprise?: { id: string; name: string } | null; } export async function handleSlackInstall(c: Context) { const token = c.req.query("token"); if (!token) { return c.json({ error: "token is required" }, 400); } const installPayload = verifyInstallToken(token); if (!installPayload) { return c.json({ error: "Invalid or expired token" }, 403); } if (!env.SLACK_CLIENT_ID) { return c.json({ error: "Slack OAuth not configured" }, 503); } const state = encodeState({ workspaceId: installPayload.workspaceId, ts: Date.now(), }); const params = new URLSearchParams({ client_id: env.SLACK_CLIENT_ID, scope: BOT_SCOPES, redirect_uri: getRedirectUri(c), state, }); return c.redirect(`${SLACK_OAUTH_URL}?${params.toString()}`); } export async function handleSlackOAuthCallback(c: Context) { const code = c.req.query("code"); const stateParam = c.req.query("state"); const error = c.req.query("error"); if (error) { return c.redirect(`${getDashboardUrl()}/settings/integrations?slack=error`); } if (!code || !stateParam) { return c.json({ error: "Missing code or state" }, 400); } const state = decodeState(stateParam); if (!state || Date.now() - state.ts > 10 * 60 * 1000) { return c.json({ error: "Invalid or expired state" }, 400); } if (!env.SLACK_CLIENT_ID || !env.SLACK_CLIENT_SECRET) { return c.json({ error: "Slack OAuth not configured" }, 503); } const tokenRes = await fetch(SLACK_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: env.SLACK_CLIENT_ID, client_secret: env.SLACK_CLIENT_SECRET, code, redirect_uri: getRedirectUri(c), }), }); const tokenData = (await tokenRes.json()) as SlackOAuthResponse; if (!tokenData.ok) { console.error("[slack oauth] token exchange failed:", tokenData.error); return c.redirect(`${getDashboardUrl()}/settings/integrations?slack=error`); } const credential = { botToken: tokenData.access_token, botUserId: tokenData.bot_user_id, }; const data = { teamId: tokenData.team.id, teamName: tokenData.team.name, appId: tokenData.app_id, scopes: tokenData.scope, installedBy: tokenData.authed_user.id, }; const existing = await db .select() .from(integration) .where( and( eq(integration.name, "slack-agent"), eq(integration.workspaceId, state.workspaceId), ), ) .get(); if (existing) { await db .update(integration) .set({ externalId: tokenData.team.id, credential, data, updatedAt: new Date(), }) .where(eq(integration.id, existing.id)); } else { await db.insert(integration).values({ name: "slack-agent", workspaceId: state.workspaceId, externalId: tokenData.team.id, credential, data, }); } return c.redirect(`${getDashboardUrl()}/settings/integrations?slack=success`); } function getRedirectUri(c: Context): string { if (env.SLACK_REDIRECT_URI) return env.SLACK_REDIRECT_URI; const url = new URL(c.req.url); return `${url.origin}/slack/oauth/callback`; } function getDashboardUrl(): string { return env.NODE_ENV === "production" ? "https://app.openstatus.dev" : "http://localhost:3000"; } function encodeState(state: OAuthState): string { const payload = JSON.stringify(state); const signature = computeHmac(payload); return Buffer.from(`${payload}.${signature}`).toString("base64url"); } function decodeState(encoded: string): OAuthState | null { try { const decoded = Buffer.from(encoded, "base64url").toString(); const dotIdx = decoded.lastIndexOf("."); if (dotIdx === -1) return null; const payload = decoded.slice(0, dotIdx); const signature = decoded.slice(dotIdx + 1); if (!verifyHmac(payload, signature)) return null; return JSON.parse(payload) as OAuthState; } catch { return null; } } function computeHmac(payload: string): string { const secret = env.SLACK_SIGNING_SECRET; if (!secret) throw new Error("Slack signing secret not configured"); return crypto.createHmac("sha256", secret).update(payload).digest("hex"); } function verifyHmac(payload: string, signature: string): boolean { const expected = computeHmac(payload); if (expected.length !== signature.length) return false; return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } const INSTALL_TOKEN_TTL_MS = 5 * 60 * 1000; function verifyInstallToken(token: string): { workspaceId: number } | null { try { const decoded = Buffer.from(token, "base64url").toString(); const dotIdx = decoded.lastIndexOf("."); if (dotIdx === -1) return null; const payload = decoded.slice(0, dotIdx); const signature = decoded.slice(dotIdx + 1); if (!verifyHmac(payload, signature)) return null; const data = JSON.parse(payload) as { workspaceId: number; ts: number }; if (Date.now() - data.ts > INSTALL_TOKEN_TTL_MS) return null; return { workspaceId: data.workspaceId }; } catch { return null; } } ================================================ FILE: apps/server/src/routes/slack/tools/add-status-report-update.ts ================================================ import { tool } from "ai"; import { z } from "zod"; export function createAddStatusReportUpdateTool() { return tool({ description: "Add a progress update to an existing status report. This creates a new update entry and changes the report's status. Use for progress updates and resolving incidents.", inputSchema: z.object({ statusReportId: z.number().describe("ID of the status report to update"), status: z .enum(["investigating", "identified", "monitoring", "resolved"]) .describe("New status for the report"), message: z .string() .describe("Professional update message for the public status page"), }), execute: async (input) => { return { needsConfirmation: true as const, params: input }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/create-maintenance.ts ================================================ import { tool } from "ai"; import { z } from "zod"; export function createCreateMaintenanceTool() { return tool({ description: "Schedule a maintenance window on a status page. IMPORTANT: You MUST call listStatusPages first to get the real pageId — never guess or make up a pageId. Parse natural language dates into ISO 8601 format (e.g. 'next Friday 2-3 PM' -> proper ISO strings). The maintenance will be shown to the user for confirmation before publishing.", inputSchema: z.object({ title: z.string().describe("Short title for the maintenance window"), message: z .string() .describe( "Professional maintenance message for the public status page", ), from: z .string() .describe("Start time in ISO 8601 format (e.g. 2025-03-14T14:00:00Z)"), to: z .string() .describe("End time in ISO 8601 format (e.g. 2025-03-14T15:00:00Z)"), pageId: z .number() .describe( "ID of the status page — MUST come from listStatusPages, never guess this value", ), pageComponentIds: z .array(z.string()) .optional() .describe("IDs of affected page components (optional)"), }), execute: async (input) => { return { needsConfirmation: true as const, params: input }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/create-status-report.ts ================================================ import { tool } from "ai"; import { z } from "zod"; export function createCreateStatusReportTool() { return tool({ description: "Create a new status report. IMPORTANT: You MUST call listStatusPages first to get the real pageId — never guess or make up a pageId. Draft the title and message based on the conversation. The report will be shown to the user for confirmation before publishing.", inputSchema: z.object({ title: z.string().describe("Short title for the status report"), status: z .enum(["investigating", "identified", "monitoring", "resolved"]) .describe("Current status of the incident"), message: z .string() .describe( "Professional status update message for the public status page", ), pageId: z .number() .describe( "ID of the status page — MUST come from listStatusPages, never guess this value", ), pageComponentIds: z .array(z.string()) .optional() .describe("IDs of affected page components (optional)"), }), execute: async (input) => { return { needsConfirmation: true as const, params: input }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/index.ts ================================================ import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; import { createAddStatusReportUpdateTool } from "./add-status-report-update"; import { createCreateMaintenanceTool } from "./create-maintenance"; import { createCreateStatusReportTool } from "./create-status-report"; import { createListMaintenancesTool } from "./list-maintenances"; import { createListStatusPagesTool } from "./list-status-pages"; import { createListStatusReportsTool } from "./list-status-reports"; import { createResolveStatusReportTool } from "./resolve-status-report"; import { createUpdateStatusReportTool } from "./update-status-report"; export function createTools(workspace: Workspace) { return { listStatusPages: createListStatusPagesTool(workspace.id), listStatusReports: createListStatusReportsTool(workspace.id), createStatusReport: createCreateStatusReportTool(), addStatusReportUpdate: createAddStatusReportUpdateTool(), updateStatusReport: createUpdateStatusReportTool(), resolveStatusReport: createResolveStatusReportTool(), listMaintenances: createListMaintenancesTool(workspace.id), createMaintenance: createCreateMaintenanceTool(), }; } ================================================ FILE: apps/server/src/routes/slack/tools/list-maintenances.ts ================================================ import { and, asc, db, desc, eq, gt } from "@openstatus/db"; import { maintenance } from "@openstatus/db/src/schema"; import { tool } from "ai"; import { z } from "zod"; export function createListMaintenancesTool(workspaceId: number) { return tool({ description: "List maintenance windows for this workspace. By default returns only upcoming maintenances. Use this to check existing scheduled maintenance.", inputSchema: z.object({ filter: z .enum(["upcoming", "all"]) .optional() .describe( "Filter: 'upcoming' for future maintenances (default), 'all' for everything", ), }), execute: async ({ filter = "upcoming" }) => { const conditions = [eq(maintenance.workspaceId, workspaceId)]; if (filter === "upcoming") { conditions.push(gt(maintenance.from, new Date())); } const records = await db .select({ id: maintenance.id, title: maintenance.title, message: maintenance.message, from: maintenance.from, to: maintenance.to, pageId: maintenance.pageId, }) .from(maintenance) .where(and(...conditions)) .orderBy( filter === "upcoming" ? asc(maintenance.from) : desc(maintenance.from), ) .limit(20) .all(); return { maintenances: records.map((r) => ({ id: r.id, title: r.title, message: r.message, from: r.from.toISOString(), to: r.to.toISOString(), pageId: r.pageId, })), }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/list-status-pages.ts ================================================ import { db, eq } from "@openstatus/db"; import { page, pageComponent } from "@openstatus/db/src/schema"; import { tool } from "ai"; import { z } from "zod"; export function createListStatusPagesTool(workspaceId: number) { return tool({ description: "List all status pages for this workspace, including their components. Use this to find which page and components to use when creating a status report.", inputSchema: z.object({}), execute: async () => { const pages = await db .select({ id: page.id, title: page.title, slug: page.slug, }) .from(page) .where(eq(page.workspaceId, workspaceId)) .all(); const result = await Promise.all( pages.map(async (p) => { const components = await db .select({ id: pageComponent.id, name: pageComponent.name, }) .from(pageComponent) .where(eq(pageComponent.pageId, p.id)) .all(); return { id: p.id, title: p.title, slug: p.slug, components: components.map((c) => ({ id: String(c.id), name: c.name, })), }; }), ); return { pages: result }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/list-status-reports.ts ================================================ import { and, db, desc, eq, ne } from "@openstatus/db"; import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; import { tool } from "ai"; import { z } from "zod"; export function createListStatusReportsTool(workspaceId: number) { return tool({ description: "List status reports for this workspace. By default returns only active (non-resolved) reports. Use this to find existing reports when adding updates or editing.", inputSchema: z.object({ filter: z .enum(["active", "all"]) .optional() .describe( "Filter: 'active' for non-resolved reports (default), 'all' for everything", ), }), execute: async ({ filter = "active" }) => { const conditions = [eq(statusReport.workspaceId, workspaceId)]; if (filter === "active") { conditions.push(ne(statusReport.status, "resolved")); } const reports = await db .select() .from(statusReport) .where(and(...conditions)) .orderBy(desc(statusReport.updatedAt)) .limit(20) .all(); const result = await Promise.all( reports.map(async (r) => { const latestUpdate = await db .select({ message: statusReportUpdate.message, status: statusReportUpdate.status, date: statusReportUpdate.date, }) .from(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, r.id)) .orderBy(desc(statusReportUpdate.date)) .limit(1) .get(); return { id: r.id, title: r.title, status: r.status, pageId: r.pageId, latestUpdate: latestUpdate ? { message: latestUpdate.message, status: latestUpdate.status, date: latestUpdate.date?.toISOString() ?? null, } : null, }; }), ); return { reports: result }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/resolve-status-report.ts ================================================ import { tool } from "ai"; import { z } from "zod"; export function createResolveStatusReportTool() { return tool({ description: "Resolve an active status report. This marks the incident as resolved and adds a final update message to the public status page.", inputSchema: z.object({ statusReportId: z.number().describe("ID of the status report to resolve"), message: z .string() .describe( "Resolution message explaining what was fixed, for the public status page", ), }), execute: async (input) => { return { needsConfirmation: true as const, params: input }; }, }); } ================================================ FILE: apps/server/src/routes/slack/tools/tools.test.ts ================================================ import { describe, expect, test } from "bun:test"; import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; import { createAddStatusReportUpdateTool } from "./add-status-report-update"; import { createCreateStatusReportTool } from "./create-status-report"; import { createTools } from "./index"; import { createUpdateStatusReportTool } from "./update-status-report"; const mockWorkspace = { id: 1, name: "Test", slug: "test", plan: "free", limits: {}, } as Workspace; describe("createTools", () => { test("returns all expected tool keys", () => { const tools = createTools(mockWorkspace); expect(Object.keys(tools).sort()).toEqual([ "addStatusReportUpdate", "createMaintenance", "createStatusReport", "listMaintenances", "listStatusPages", "listStatusReports", "resolveStatusReport", "updateStatusReport", ]); }); }); describe("createCreateStatusReportTool", () => { const tool = createCreateStatusReportTool(); test("returns needsConfirmation with params", async () => { const input = { title: "API Outage", status: "investigating" as const, message: "We are investigating the issue", pageId: 1, }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result).toEqual({ needsConfirmation: true, params: input }); }); test("includes optional pageComponentIds", async () => { const input = { title: "Outage", status: "investigating" as const, message: "msg", pageId: 1, pageComponentIds: ["comp-1", "comp-2"], }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result.params.pageComponentIds).toEqual(["comp-1", "comp-2"]); }); }); describe("createAddStatusReportUpdateTool", () => { const tool = createAddStatusReportUpdateTool(); test("returns needsConfirmation with params", async () => { const input = { statusReportId: 42, status: "identified" as const, message: "Root cause identified", }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result).toEqual({ needsConfirmation: true, params: input }); }); test("works with resolved status", async () => { const input = { statusReportId: 42, status: "resolved" as const, message: "Issue has been fixed", }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result.params.status).toBe("resolved"); }); }); describe("createUpdateStatusReportTool", () => { const tool = createUpdateStatusReportTool(); test("returns needsConfirmation with title update", async () => { const input = { statusReportId: 10, title: "Updated Title", }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result).toEqual({ needsConfirmation: true, params: input }); }); test("works with only pageComponentIds", async () => { const input = { statusReportId: 10, pageComponentIds: ["comp-1"], }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result.params.pageComponentIds).toEqual(["comp-1"]); }); test("works with both title and components", async () => { const input = { statusReportId: 10, title: "New Title", pageComponentIds: ["comp-1", "comp-2"], }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result.params.title).toBe("New Title"); expect(result.params.pageComponentIds).toEqual(["comp-1", "comp-2"]); }); test("works with only statusReportId", async () => { const input = { statusReportId: 10 }; const result = await tool.execute(input, { toolCallId: "test", messages: [], }); expect(result.params.statusReportId).toBe(10); }); }); ================================================ FILE: apps/server/src/routes/slack/tools/update-status-report.ts ================================================ import { tool } from "ai"; import { z } from "zod"; export function createUpdateStatusReportTool() { return tool({ description: "Edit a status report's metadata (title, components). This does NOT add a new update message — use addStatusReportUpdate for that. No subscriber notifications are sent.", inputSchema: z.object({ statusReportId: z.number().describe("ID of the status report to edit"), title: z.string().optional().describe("New title for the report"), pageComponentIds: z .array(z.string()) .optional() .describe("Updated list of affected component IDs"), }), execute: async (input) => { return { needsConfirmation: true as const, params: input }; }, }); } ================================================ FILE: apps/server/src/routes/slack/verify.test.ts ================================================ import { describe, expect, test } from "bun:test"; import crypto from "node:crypto"; import { Hono } from "hono"; import { verifySlackSignature } from "./verify"; const SIGNING_SECRET = "test-signing-secret"; process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET; function signRequest(body: string, timestamp: number): string { const basestring = `v0:${timestamp}:${body}`; const hmac = crypto .createHmac("sha256", SIGNING_SECRET) .update(basestring) .digest("hex"); return `v0=${hmac}`; } function createTestApp() { const app = new Hono<{ Variables: { slackBody: unknown } }>(); app.post("/test", verifySlackSignature, (c) => { return c.json({ body: c.get("slackBody") }); }); return app; } describe("verifySlackSignature", () => { const app = createTestApp(); test("accepts valid JSON signature", async () => { const body = JSON.stringify({ type: "event_callback", event: {} }); const timestamp = Math.floor(Date.now() / 1000); const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { body: { type: string } }; expect(json.body.type).toBe("event_callback"); }); test("accepts valid form-urlencoded payload", async () => { const payload = JSON.stringify({ type: "block_actions", user: { id: "U1" }, }); const body = `payload=${encodeURIComponent(payload)}`; const timestamp = Math.floor(Date.now() / 1000); const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); const json = (await res.json()) as { body: { type: string } }; expect(json.body.type).toBe("block_actions"); }); test("rejects missing headers", async () => { const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }); expect(res.status).toBe(401); }); test("rejects old timestamp", async () => { const body = "{}"; const timestamp = Math.floor(Date.now() / 1000) - 600; const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(401); }); test("rejects invalid signature", async () => { const body = "{}"; const timestamp = Math.floor(Date.now() / 1000); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": "v0=invalidsignature000000000000000000000000000000000000000000000000", }, body, }); expect(res.status).toBe(401); }); test("rejects signature with wrong length", async () => { const body = "{}"; const timestamp = Math.floor(Date.now() / 1000); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": "v0=short", }, body, }); expect(res.status).toBe(401); }); test("rejects missing timestamp header only", async () => { const body = "{}"; const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-signature": "v0=abc", }, body, }); expect(res.status).toBe(401); }); test("rejects missing signature header only", async () => { const body = "{}"; const timestamp = Math.floor(Date.now() / 1000); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), }, body, }); expect(res.status).toBe(401); }); test("rejects future timestamp beyond 5 minutes", async () => { const body = "{}"; const timestamp = Math.floor(Date.now() / 1000) + 600; const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(401); }); test("accepts timestamp within 5 minute window", async () => { const body = JSON.stringify({ type: "test" }); const timestamp = Math.floor(Date.now() / 1000) - 200; const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); }); test("rejects signature computed with wrong body", async () => { const body = JSON.stringify({ type: "real_body" }); const timestamp = Math.floor(Date.now() / 1000); const wrongSignature = signRequest("different body", timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": wrongSignature, }, body, }); expect(res.status).toBe(401); }); test("handles form-urlencoded without payload param", async () => { const body = "key=value"; const timestamp = Math.floor(Date.now() / 1000); const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); }); test("handles large JSON payload", async () => { const largePayload = { type: "test", data: "x".repeat(10000) }; const body = JSON.stringify(largePayload); const timestamp = Math.floor(Date.now() / 1000); const signature = signRequest(body, timestamp); const res = await app.request("/test", { method: "POST", headers: { "Content-Type": "application/json", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature, }, body, }); expect(res.status).toBe(200); }); }); ================================================ FILE: apps/server/src/routes/slack/verify.ts ================================================ import { env } from "@/env"; import { createMiddleware } from "hono/factory"; export const verifySlackSignature = createMiddleware<{ Variables: { slackBody: unknown }; }>(async (c, next) => { const signingSecret = env.SLACK_SIGNING_SECRET; if (!signingSecret) { return c.json({ error: "Slack not configured" }, 503); } const timestamp = c.req.header("x-slack-request-timestamp"); const signature = c.req.header("x-slack-signature"); if (!timestamp || !signature) { return c.json({ error: "Missing Slack headers" }, 401); } const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(timestamp)) > 300) { return c.json({ error: "Request too old" }, 401); } const rawBody = await c.req.text(); const encoder = new TextEncoder(); const basestring = `v0:${timestamp}:${rawBody}`; const key = await crypto.subtle.importKey( "raw", encoder.encode(signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(basestring)); const computed = `v0=${Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, "0")) .join("")}`; if (computed.length !== signature.length) { return c.json({ error: "Invalid signature" }, 401); } const a = encoder.encode(computed); const b = encoder.encode(signature); let mismatch = 0; for (let i = 0; i < a.length; i++) { mismatch |= a[i] ^ b[i]; } if (mismatch !== 0) { return c.json({ error: "Invalid signature" }, 401); } const contentType = c.req.header("content-type") ?? ""; if (contentType.includes("application/json")) { c.set("slackBody", JSON.parse(rawBody)); } else if (contentType.includes("application/x-www-form-urlencoded")) { const params = new URLSearchParams(rawBody); const payload = params.get("payload"); c.set("slackBody", payload ? JSON.parse(payload) : {}); } await next(); }); ================================================ FILE: apps/server/src/routes/slack/workspace-resolver.ts ================================================ import { and, db, eq } from "@openstatus/db"; import { integration, selectWorkspaceSchema, workspace, } from "@openstatus/db/src/schema"; import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; export interface SlackWorkspace { workspace: Workspace; botToken: string; botUserId: string; } interface IntegrationCredential { botToken: string; botUserId: string; } export async function resolveWorkspace( teamId: string, ): Promise<SlackWorkspace | null> { const row = await db .select({ workspaceId: integration.workspaceId, credential: integration.credential, }) .from(integration) .where( and( eq(integration.name, "slack-agent"), eq(integration.externalId, teamId), ), ) .get(); if (!row?.workspaceId) return null; const credential = row.credential as IntegrationCredential | null; if (!credential?.botToken) return null; const ws = await db .select() .from(workspace) .where(eq(workspace.id, row.workspaceId)) .get(); if (!ws) return null; const parsed = selectWorkspaceSchema.safeParse(ws); if (!parsed.success) return null; return { workspace: parsed.data, botToken: credential.botToken, botUserId: credential.botUserId ?? "", }; } ================================================ FILE: apps/server/src/routes/v1/check/http/post.test.ts ================================================ import { expect, test } from "bun:test"; import { afterEach, mock } from "bun:test"; import { app } from "@/index"; const mockFetch = mock(); global.fetch = mockFetch as unknown as typeof fetch; mock.module("node-fetch", () => mockFetch); afterEach(() => { mockFetch.mockReset(); }); test("Create a single check ", async () => { const data = { url: "https://www.openstatus.dev", regions: ["ams"], method: "POST", body: '{"hello":"world"}', headers: [{ key: "key", value: "value" }], }; mockFetch.mockReturnValue( Promise.resolve( new Response( '{"status":200,"latency":100,"body":"Hello World","headers":{"Content-Type":"application/json"},"timestamp":1234567890,"timing":{"dnsStart":1,"dnsDone":2,"connectStart":3,"connectDone":4,"tlsHandshakeStart":5,"tlsHandshakeDone":6,"firstByteStart":7,"firstByteDone":8,"transferStart":9,"transferDone":10},"region":"ams"}', { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/check/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify(data), }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ id: expect.any(Number), raw: [ { connectDone: 4, connectStart: 3, dnsDone: 2, dnsStart: 1, firstByteDone: 8, firstByteStart: 7, tlsHandshakeDone: 6, tlsHandshakeStart: 5, transferDone: 10, transferStart: 9, }, ], response: { body: "Hello World", headers: { "Content-Type": "application/json", }, latency: 100, region: "ams", status: 200, timestamp: 1234567890, timing: { connectDone: 4, connectStart: 3, dnsDone: 2, dnsStart: 1, firstByteDone: 8, firstByteStart: 7, tlsHandshakeDone: 6, tlsHandshakeStart: 5, transferDone: 10, transferStart: 9, }, }, }); }); test("Create a multiple check", async () => { const data = { url: "https://www.openstatus.dev", regions: ["ams", "gru"], method: "POST", body: '{"hello":"world"}', headers: [{ key: "key", value: "value" }], }; const amsResponse = { status: 200, latency: 100, body: "Hello from ams", headers: { "Content-Type": "application/json" }, timestamp: 1234567890, timing: { dnsStart: 1, dnsDone: 2, connectStart: 3, connectDone: 4, tlsHandshakeStart: 5, tlsHandshakeDone: 6, firstByteStart: 7, firstByteDone: 8, transferStart: 9, transferDone: 10, }, region: "ams", }; const gruResponse = { status: 200, latency: 150, body: "Hello from gru", headers: { "Content-Type": "application/json" }, timestamp: 1234567891, timing: { dnsStart: 11, dnsDone: 12, connectStart: 13, connectDone: 14, tlsHandshakeStart: 15, tlsHandshakeDone: 16, firstByteStart: 17, firstByteDone: 18, transferStart: 19, transferDone: 20, }, region: "gru", }; mockFetch .mockResolvedValueOnce( new Response(JSON.stringify(amsResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) .mockResolvedValueOnce( new Response(JSON.stringify(gruResponse), { status: 200, headers: { "content-type": "application/json" }, }), ); const res = await app.request("/v1/check/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify(data), }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ id: expect.any(Number), raw: [ { connectDone: 4, connectStart: 3, dnsDone: 2, dnsStart: 1, firstByteDone: 8, firstByteStart: 7, tlsHandshakeDone: 6, tlsHandshakeStart: 5, transferDone: 10, transferStart: 9, }, { connectDone: 14, connectStart: 13, dnsDone: 12, dnsStart: 11, firstByteDone: 18, firstByteStart: 17, tlsHandshakeDone: 16, tlsHandshakeStart: 15, transferDone: 20, transferStart: 19, }, ], response: { body: "Hello from gru", headers: { "Content-Type": "application/json", }, latency: 150, region: "gru", status: 200, timestamp: 1234567891, timing: { connectDone: 14, connectStart: 13, dnsDone: 12, dnsStart: 11, firstByteDone: 18, firstByteStart: 17, tlsHandshakeDone: 16, tlsHandshakeStart: 15, transferDone: 20, transferStart: 19, }, }, }); }); ================================================ FILE: apps/server/src/routes/v1/check/http/post.ts ================================================ import { createRoute, type z } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import { env } from "@/env"; import { openApiErrorResponses } from "@/libs/errors"; import { db } from "@openstatus/db"; import { check } from "@openstatus/db/src/schema/check"; import percentile from "percentile"; import type { checkApi } from "../index"; const logger = getLogger("api-server"); import { AggregatedResponseSchema, AggregatedResult, CheckPostResponseSchema, CheckSchema, ResponseSchema, } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["check"], summary: "Run a single check", path: "/http", request: { body: { description: "The run request to create", content: { "application/json": { schema: CheckSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: CheckPostResponseSchema, }, }, description: "Return a run result", }, ...openApiErrorResponses, }, }); export function registerHTTPPostCheck(api: typeof checkApi) { return api.openapi(postRoute, async (c) => { const data = c.req.valid("json"); const workspaceId = c.get("workspace").id; const input = c.req.valid("json"); const { headers, regions, runCount, aggregated, ...rest } = data; const newCheck = await db .insert(check) .values({ workspaceId: workspaceId, regions: regions.join(","), countRequests: runCount, ...rest, }) .returning() .get(); const result = []; for (let count = 0; count < input.runCount; count++) { const currentFetch = []; for (const region of input.regions) { const r = fetch(`https://openstatus-checker.fly.dev/ping/${region}`, { headers: { Authorization: `Basic ${env.CRON_SECRET}`, "Content-Type": "application/json", "fly-prefer-region": region, }, method: "POST", body: JSON.stringify({ requestId: newCheck.id, workspaceId: workspaceId, url: input.url, method: input.method, headers: input.headers?.reduce((acc, { key, value }) => { if (!key) return acc; // key === "" is an invalid header return { // biome-ignore lint/performance/noAccumulatingSpread: <explanation> ...acc, [key]: value, }; }, {}), body: input.body ? input.body : undefined, }), }); currentFetch.push(r); } const allResults = await Promise.allSettled(currentFetch); result.push(...allResults); } const fulfilledRequest: z.infer<typeof ResponseSchema>[] = []; const filteredResult = result.filter((r) => r.status === "fulfilled"); for await (const r of filteredResult) { if (r.status !== "fulfilled") throw new Error("No value"); const json = await r.value.json(); const parsed = ResponseSchema.safeParse(json); if (!parsed.success) { logger.error("Failed to parse check response", { check_id: newCheck.id, workspace_id: workspaceId, validation_errors: parsed.error, }); throw new Error(`Failed to parse response: ${parsed.error.message}`); } fulfilledRequest.push(parsed.data); } let aggregatedResponse = null; if (aggregated) { const { dns, connect, tls, firstByte, transfer, latency } = getTiming(fulfilledRequest); aggregatedResponse = AggregatedResult.parse({ dns: getAggregate(dns), connect: getAggregate(connect), tls: getAggregate(tls), firstByte: getAggregate(firstByte), transfer: getAggregate(transfer), latency: getAggregate(latency), }); } const allTimings = fulfilledRequest.map((r) => r.timing); const lastResponse = fulfilledRequest[fulfilledRequest.length - 1]; const responseResult = CheckPostResponseSchema.parse({ id: newCheck.id, raw: allTimings, // TODO: we should return the region here as well! response: lastResponse, aggregated: aggregatedResponse ? aggregatedResponse : undefined, }); return c.json(responseResult, 200); }); } // This is a helper function to get the timing of the request type ReturnGetTiming = Record< "dns" | "connect" | "tls" | "firstByte" | "transfer" | "latency", number[] >; function getTiming(data: z.infer<typeof ResponseSchema>[]): ReturnGetTiming { return data.reduce( (prev, curr) => { prev.dns.push(curr.timing.dnsDone - curr.timing.dnsStart); prev.connect.push(curr.timing.connectDone - curr.timing.connectStart); prev.tls.push( curr.timing.tlsHandshakeDone - curr.timing.tlsHandshakeStart, ); prev.firstByte.push( curr.timing.firstByteDone - curr.timing.firstByteStart, ); prev.transfer.push(curr.timing.transferDone - curr.timing.transferStart); prev.latency.push(curr.latency); return prev; }, { dns: [], connect: [], tls: [], firstByte: [], transfer: [], latency: [], } as ReturnGetTiming, ); } function getAggregate(data: number[]) { const parsed = AggregatedResponseSchema.safeParse({ p50: percentile(50, data), p75: percentile(75, data), p95: percentile(95, data), p99: percentile(99, data), min: Math.min(...data), max: Math.max(...data), }); if (!parsed.success) { logger.error("Failed to parse aggregated response", { validation_errors: parsed.error, }); throw new Error(`Failed to parse response: ${parsed.error.message}`); } return parsed.data; } ================================================ FILE: apps/server/src/routes/v1/check/http/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { MonitorSchema } from "../../monitors/schema"; export const CheckSchema = MonitorSchema.pick({ url: true, body: true, headers: true, method: true, regions: true, }) .extend({ runCount: z .number() .max(5) .optional() .prefault(1) .openapi({ description: "The number of times to run the check" }), aggregated: z .boolean() .optional() .openapi({ description: "Whether to aggregate the results or not" }), // webhook: z // .string() // .optional() // .openapi({ description: "The webhook to send the result to" }), }) .openapi({ description: "The check request", }); export const TimingSchema = z.object({ dnsStart: z .number() .openapi({ description: "DNS timestamp start time in UTC " }), dnsDone: z .number() .openapi({ description: "DNS timestamp end time in UTC " }), connectStart: z .number() .openapi({ description: "Connect timestamp start time in UTC " }), connectDone: z .number() .openapi({ description: "Connect timestamp end time in UTC " }), tlsHandshakeStart: z .number() .openapi({ description: "TLS handshake timestamp start time in UTC " }), tlsHandshakeDone: z .number() .openapi({ description: "TLS handshake timestamp end time in UTC " }), firstByteStart: z .number() .openapi({ description: "First byte timestamp start time in UTC " }), firstByteDone: z .number() .openapi({ description: "First byte timestamp end time in UTC " }), transferStart: z .number() .openapi({ description: "Transfer timestamp start time in UTC " }), transferDone: z .number() .openapi({ description: "Transfer timestamp end time in UTC " }), }); export const AggregatedResponseSchema = z .object({ p50: z.number().openapi({ description: "The 50th percentile" }), p75: z.number().openapi({ description: "The 75th percentile" }), p95: z.number().openapi({ description: "The 95th percentile" }), p99: z.number().openapi({ description: "The 99th percentile" }), min: z.number().openapi({ description: "The minimum value" }), max: z.number().openapi({ description: "The maximum value" }), }) .openapi({ description: "The aggregated data of the check", }); export const ResponseSchema = z.object({ timestamp: z .number() .openapi({ description: "The timestamp of the response in UTC" }), status: z .number() .openapi({ description: "The status code of the response" }), latency: z.number().openapi({ description: "The latency of the response" }), body: z .string() .optional() .openapi({ description: "The body of the response" }), headers: z .record(z.string(), z.string()) .optional() .openapi({ description: "The headers of the response" }), timing: TimingSchema.openapi({ description: "The timing metrics of the response", }), aggregated: z .object({ dns: AggregatedResponseSchema.openapi({ description: "The aggregated DNS timing of the check", }), connection: AggregatedResponseSchema.openapi({ description: "The aggregated connection timing of the check", }), tls: AggregatedResponseSchema.openapi({ description: "The aggregated tls timing of the check", }), firstByte: AggregatedResponseSchema.openapi({ description: "The aggregated first byte timing of the check", }), transfer: AggregatedResponseSchema.openapi({ description: "The aggregated transfer timing of the check", }), latency: AggregatedResponseSchema.openapi({ description: "The aggregated latency timing of the check", }), }) .optional() .openapi({ description: "The aggregated data dns timing of the check", }), region: z.string().openapi({ description: "The region where the check ran" }), }); export const AggregatedResult = z.object({ dns: AggregatedResponseSchema, connect: AggregatedResponseSchema, tls: AggregatedResponseSchema, firstByte: AggregatedResponseSchema, transfer: AggregatedResponseSchema, latency: AggregatedResponseSchema, }); export const CheckPostResponseSchema = z.object({ id: z.int().openapi({ description: "The id of the check" }), raw: z.array(TimingSchema).openapi({ description: "The raw data of the check", }), response: ResponseSchema.openapi({ description: "The last response of the check", }), aggregated: AggregatedResult.optional().openapi({ description: "The aggregated data of the check", }), }); ================================================ FILE: apps/server/src/routes/v1/check/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import type { Variables } from "../index"; import { handleZodError } from "@/libs/errors"; import { registerHTTPPostCheck } from "./http/post"; const checkApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerHTTPPostCheck(checkApi); export { checkApi }; ================================================ FILE: apps/server/src/routes/v1/incidents/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { IncidentSchema } from "./schema"; test("return the incident", async () => { const res = await app.request("/v1/incident/2", { headers: { "x-openstatus-key": "1", }, }); const result = IncidentSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/incident/2"); expect(res.status).toBe(401); }); test("invalid incident id should return 400", async () => { const res = await app.request("/v1/incident/invalid-id", { headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(400); }); test("invalid incident id should return 404", async () => { const res = await app.request("/v1/incident/2", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/incidents/get.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { incidentsApi } from "./index"; import { IncidentSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["incident"], summary: "Get an incident", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: IncidentSchema, }, }, description: "Get an incident", }, ...openApiErrorResponses, }, }); export function registerGetIncident(app: typeof incidentsApi) { return app.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _incident = await db .select() .from(incidentTable) .where( and( eq(incidentTable.workspaceId, workspaceId), eq(incidentTable.id, Number(id)), ), ) .get(); if (!_incident) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Incident ${id} not found`, }); } const data = IncidentSchema.parse(_incident); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/incidents/get_all.test.ts ================================================ import { afterAll, beforeAll, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { incidentTable, monitor } from "@openstatus/db/src/schema"; import { app } from "@/index"; import { IncidentSchema } from "./schema"; const TEST_PREFIX = "v1-incident-getall-test"; let testMonitorId: number; let testIncidentId: number; beforeAll(async () => { await db .delete(incidentTable) .where(eq(incidentTable.title, `${TEST_PREFIX}-incident`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor`, url: "https://test.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", method: "GET", timeout: 30000, }) .returning() .get(); testMonitorId = mon.id; const incident = await db .insert(incidentTable) .values({ workspaceId: 1, monitorId: testMonitorId, title: `${TEST_PREFIX}-incident`, status: "investigating", startedAt: new Date("2099-01-01T00:00:00Z"), }) .returning() .get(); testIncidentId = incident.id; }); afterAll(async () => { await db .delete(incidentTable) .where(eq(incidentTable.title, `${TEST_PREFIX}-incident`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); }); test("return all incidents", async () => { const res = await app.request("/v1/incident", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = IncidentSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.some((i) => i.id === testIncidentId)).toBe(true); }); test("return empty incidents", async () => { const res = await app.request("/v1/incident", { method: "GET", headers: { "x-openstatus-key": "3", }, }); const result = IncidentSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/incident", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/incidents/get_all.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; import { openApiErrorResponses } from "@/libs/errors"; import type { incidentsApi } from "./index"; import { IncidentSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["incident"], summary: "List all incidents", path: "/", request: {}, responses: { 200: { content: { "application/json": { schema: IncidentSchema.array(), }, }, description: "Get all incidents", }, ...openApiErrorResponses, }, }); export function registerGetAllIncidents(app: typeof incidentsApi) { app.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _incidents = await db .select() .from(incidentTable) .where(eq(incidentTable.workspaceId, workspaceId)) .all(); const data = IncidentSchema.array().parse(_incidents); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/incidents/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetIncident } from "./get"; import { registerGetAllIncidents } from "./get_all"; import { registerPutIncident } from "./put"; const incidentsApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetAllIncidents(incidentsApi); registerGetIncident(incidentsApi); registerPutIncident(incidentsApi); export { incidentsApi }; ================================================ FILE: apps/server/src/routes/v1/incidents/put.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { IncidentSchema } from "./schema"; test("acknlowledge the incident", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ acknowledgedAt: date.toISOString(), }), }); const result = IncidentSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.acknowledgedAt?.toISOString()).toBe(date.toISOString()); }); test("resolve the incident", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ resolvedAt: date.toISOString(), }), }); const result = IncidentSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.resolvedAt?.toISOString()).toBe(date.toISOString()); }); test("invalid payload should return 400", async () => { const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ acknowledgedAt: "helloworld", }), }); const result = (await res.json()) as Record<string, unknown>; expect(result.message).toBe( "invalid_type in 'acknowledgedAt': Invalid input: expected date, received Date", ); expect(res.status).toBe(400); }); test("invalid incident id should return 400", async () => { const res = await app.request("/v1/incident/invalid-id", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ acknowledgedAt: new Date().toISOString(), }), }); expect(res.status).toBe(400); }); test("empty body should return 400", async () => { const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); test("invalid incident id should return 404", async () => { const res = await app.request("/v1/incident/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ acknowledgedAt: new Date().toISOString(), }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ acknowledgedAt: new Date().toISOString(), }), }); expect(res.status).toBe(401); }); test("update the incident with invalid data should return 400", async () => { const res = await app.request("/v1/incident/2", { method: "PUT", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ acknowledgedAt: "2023-11-0", }), }); expect(res.status).toBe(400); }); ================================================ FILE: apps/server/src/routes/v1/incidents/put.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { incidentTable } from "@openstatus/db/src/schema/incidents"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import type { incidentsApi } from "./index"; import { IncidentSchema, ParamsSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["incident"], summary: "Update an incident", description: "Acknowledge or resolve an incident", path: "/{id}", middleware: [trackMiddleware(Events.UpdateIncident)], request: { params: ParamsSchema, body: { description: "The incident to update", content: { "application/json": { schema: IncidentSchema.pick({ acknowledgedAt: true, resolvedAt: true, }) .partial() .refine( (data) => data.acknowledgedAt !== undefined || data.resolvedAt !== undefined, "Either acknowledgedAt or resolvedAt must be provided", ), }, }, }, }, responses: { 200: { content: { "application/json": { schema: IncidentSchema, }, }, description: "Update a monitor", }, ...openApiErrorResponses, }, }); export function registerPutIncident(app: typeof incidentsApi) { return app.openapi(putRoute, async (c) => { const input = c.req.valid("json"); const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _incident = await db .select() .from(incidentTable) .where( and( eq(incidentTable.id, Number(id)), eq(incidentTable.workspaceId, workspaceId), ), ) .get(); if (!_incident) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Incident ${id} not found`, }); } const _newIncident = await db .update(incidentTable) // TODO: we should set the acknowledgedBy and resolvedBy fields .set({ ...input, updatedAt: new Date() }) .where(eq(incidentTable.id, Number(id))) .returning() .get(); const data = IncidentSchema.parse(_newIncident); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/incidents/schema.ts ================================================ import { z } from "@hono/zod-openapi"; export const ParamsSchema = z.object({ id: z .string() .min(1) .regex(/^\d+$/, "ID must be a numeric string") .openapi({ param: { name: "id", in: "path", }, description: "The id of the Incident", example: "1", }), }); export const IncidentSchema = z .object({ id: z.number().openapi({ description: "The id of the incident", example: 1, }), startedAt: z.coerce.date().openapi({ description: "The date the incident started", }), monitorId: z.number().nullable().openapi({ description: "The id of the monitor associated with the incident", example: 1, }), acknowledgedAt: z.coerce.date().optional().nullable().openapi({ description: "The date the incident was acknowledged", }), acknowledgedBy: z.number().nullable().openapi({ description: "The user who acknowledged the incident", }), resolvedAt: z.coerce.date().optional().nullable().openapi({ description: "The date the incident was resolved", }), resolvedBy: z.number().nullable().openapi({ description: "The user who resolved the incident", }), }) .openapi("Incident"); export type IncidentSchema = z.infer<typeof IncidentSchema>; ================================================ FILE: apps/server/src/routes/v1/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { Scalar } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; import type { RequestIdVariables } from "hono/request-id"; import { handleZodError } from "@/libs/errors"; import { authMiddleware } from "@/libs/middlewares"; import type { Workspace } from "@openstatus/db/src/schema"; import { checkApi } from "./check"; import { incidentsApi } from "./incidents"; import { maintenancesApi } from "./maintenances"; import { monitorsApi } from "./monitors"; import { notificationsApi } from "./notifications"; import { pageSubscribersApi } from "./pageSubscribers"; import { pagesApi } from "./pages"; import { statusReportUpdatesApi } from "./statusReportUpdates"; import { statusReportsApi } from "./statusReports"; import { whoamiApi } from "./whoami"; export type Variables = RequestIdVariables & { workspace: Workspace; }; export const api = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); api.use("/openapi", cors()); api.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", { type: "apiKey", in: "header", name: "x-openstatus-key", "x-openstatus-key": "string", }); // this is a fix for the memory leak if (process.env.NODE_ENV === "production") { api.get("/openapi", (c) => c.redirect("https://api.openstatus.dev/openapi-v1.json"), ); } else { api.doc("/openapi", { openapi: "3.0.0", info: { version: "1.0.0", title: "OpenStatus API", contact: { email: "ping@openstatus.dev", url: "https://www.openstatus.dev", }, description: "This version is deprecated please use v2 API: Read more about the new API in the documentation: https://docs.openstatus.dev/reference/api", }, tags: [ { name: "monitor", description: "Monitor related endpoints", "x-displayName": "Monitor", }, { name: "page", description: "Page related endpoints", "x-displayName": "Page", }, { name: "status_report", description: "Status report related endpoints", "x-displayName": "Status Report", }, { name: "status_report_update", description: "Status report update related endpoints", "x-displayName": "Status Report Update", }, { name: "incident", description: "Incident related endpoints", "x-displayName": "Incident", }, { name: "maintenance", description: "Maintenance related endpoints", "x-displayName": "Maintenance", }, { name: "notification", description: "Notification related endpoints", "x-displayName": "Notification", }, { name: "page_subscriber", description: "Page subscriber related endpoints", "x-displayName": "Page Subscriber", }, { name: "check", description: "Check related endpoints", "x-displayName": "Check", }, { name: "whoami", description: "WhoAmI related endpoints", "x-displayName": "WhoAmI", }, ], security: [ { ApiKeyAuth: [], }, ], }); } api.get( "/", Scalar({ url: "/openapi-v1.json", servers: [ { url: "https://api.openstatus.dev/v1", description: "Production server", }, { url: "http://localhost:3000/v1", description: "Dev server", }, ], metaData: { title: "OpenStatus API", description: "Start building with OpenStatus API", ogDescription: "API Reference", ogTitle: "OpenStatus API", ogImage: "https://openstatus.dev/api/og?title=OpenStatus%20API&description=API%20Reference", twitterCard: "summary_large_image", }, }), ); /** * Middlewares */ api.use("/*", authMiddleware); /** * Routes */ api.route("/monitor", monitorsApi); api.route("/page", pagesApi); api.route("/status_report", statusReportsApi); api.route("/status_report_update", statusReportUpdatesApi); api.route("/incident", incidentsApi); api.route("/maintenance", maintenancesApi); api.route("/notification", notificationsApi); api.route("/page_subscriber", pageSubscribersApi); api.route("/check", checkApi); api.route("/whoami", whoamiApi); ================================================ FILE: apps/server/src/routes/v1/maintenances/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MaintenanceSchema } from "./schema"; test("return the maintenance", async () => { const res = await app.request("/v1/maintenance/1", { headers: { "x-openstatus-key": "1", }, }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("return the maintenance with monitorIds", async () => { const res = await app.request("/v1/maintenance/1", { headers: { "x-openstatus-key": "1", }, }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds).toBeDefined(); expect(Array.isArray(result.data?.monitorIds)).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/maintenance/1"); expect(res.status).toBe(401); }); test("invalid maintenance id should return 400", async () => { const res = await app.request("/v1/maintenance/invalid-id", { headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(400); }); test("invalid maintenance id should return 404", async () => { const res = await app.request("/v1/maintenance/999", { headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/maintenances/get.ts ================================================ import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { maintenance } from "@openstatus/db/src/schema/maintenances"; import type { maintenancesApi } from "./index"; import { MaintenanceSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["maintenance"], summary: "Get a maintenance", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: MaintenanceSchema, }, }, description: "Get a maintenance", }, ...openApiErrorResponses, }, }); export function registerGetMaintenance(api: typeof maintenancesApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _maintenance = await db.query.maintenance.findFirst({ with: { maintenancesToPageComponents: { with: { pageComponent: true } }, }, where: and( eq(maintenance.id, Number(id)), eq(maintenance.workspaceId, workspaceId), ), }); if (!_maintenance) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Maintenance ${id} not found`, }); } const data = MaintenanceSchema.parse({ ..._maintenance, monitorIds: _maintenance.maintenancesToPageComponents .map((m) => m.pageComponent.monitorId) .filter(notEmpty), }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/maintenances/get_all.test.ts ================================================ import { afterAll, beforeAll, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { maintenance, maintenancesToPageComponents, monitor, pageComponent, } from "@openstatus/db/src/schema"; import { app } from "@/index"; import { MaintenanceSchema } from "./schema"; const TEST_PREFIX = "v1-maint-getall-test"; let testMonitorId: number; let testPageComponentId: number; let testMaintenanceId: number; beforeAll(async () => { await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-maint`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor`, url: "https://test.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", method: "GET", timeout: 30000, }) .returning() .get(); testMonitorId = mon.id; const comp = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: 1, monitorId: testMonitorId, type: "monitor", name: `${TEST_PREFIX}-component`, order: 200, }) .returning() .get(); testPageComponentId = comp.id; const maint = await db .insert(maintenance) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-maint`, message: "Test maintenance", from: new Date("2099-01-01T00:00:00Z"), to: new Date("2099-01-02T00:00:00Z"), }) .returning() .get(); testMaintenanceId = maint.id; await db.insert(maintenancesToPageComponents).values({ maintenanceId: testMaintenanceId, pageComponentId: testPageComponentId, }); }); afterAll(async () => { await db .delete(maintenancesToPageComponents) .where(eq(maintenancesToPageComponents.maintenanceId, testMaintenanceId)); await db .delete(maintenance) .where(eq(maintenance.title, `${TEST_PREFIX}-maint`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); }); test("return all maintenances", async () => { const res = await app.request("/v1/maintenance", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = MaintenanceSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.some((m) => m.id === testMaintenanceId)).toBe(true); }); test("return all maintenances with monitorIds", async () => { const res = await app.request("/v1/maintenance", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = MaintenanceSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); const testMaint = result.data?.find((m) => m.id === testMaintenanceId); expect(testMaint).toBeDefined(); expect(testMaint?.monitorIds).toBeDefined(); expect(Array.isArray(testMaint?.monitorIds)).toBe(true); expect(testMaint?.monitorIds).toContain(testMonitorId); }); test("return empty maintenances", async () => { const res = await app.request("/v1/maintenance", { method: "GET", headers: { "x-openstatus-key": "3", }, }); const result = MaintenanceSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/maintenance", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/maintenances/get_all.ts ================================================ import { openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import { createRoute } from "@hono/zod-openapi"; import { db, desc, eq } from "@openstatus/db"; import { maintenance } from "@openstatus/db/src/schema/maintenances"; import type { maintenancesApi } from "./index"; import { MaintenanceSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["maintenance"], summary: "List all maintenances", path: "/", request: {}, responses: { 200: { content: { "application/json": { schema: MaintenanceSchema.array(), }, }, description: "Get all maintenances", }, ...openApiErrorResponses, }, }); export function registerGetAllMaintenances(api: typeof maintenancesApi) { return api.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _maintenances = await db.query.maintenance.findMany({ with: { maintenancesToPageComponents: { with: { pageComponent: true } }, }, where: eq(maintenance.workspaceId, workspaceId), orderBy: desc(maintenance.createdAt), }); const data = MaintenanceSchema.array().parse( _maintenances.map((m) => ({ ...m, monitorIds: m.maintenancesToPageComponents .map((mtm) => mtm.pageComponent.monitorId) .filter(notEmpty), })), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/maintenances/index.ts ================================================ import { handleZodError } from "@/libs/errors"; import { OpenAPIHono } from "@hono/zod-openapi"; import type { Variables } from "../index"; import { registerGetMaintenance } from "./get"; import { registerGetAllMaintenances } from "./get_all"; import { registerPostMaintenance } from "./post"; import { registerPutMaintenance } from "./put"; const maintenancesApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetAllMaintenances(maintenancesApi); registerGetMaintenance(maintenancesApi); registerPostMaintenance(maintenancesApi); registerPutMaintenance(maintenancesApi); export { maintenancesApi }; ================================================ FILE: apps/server/src/routes/v1/maintenances/post.test.ts ================================================ import { beforeEach, expect, test } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; import { maintenance } from "@openstatus/db/src/schema"; import { MaintenanceSchema } from "./schema"; // biome-ignore lint/suspicious/noExplicitAny: test utility const spies = (globalThis as any).__subscriptionSpies as { dispatchMaintenanceUpdate: { mockClear: () => void; mock: { calls: number[][] }; }; }; beforeEach(() => { spies.dispatchMaintenanceUpdate.mockClear(); }); test("create a valid maintenance without monitorIds", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); // 1 hour later const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Another Maintenance", message: "Scheduled maintenance without monitors", from: from.toISOString(), to: to.toISOString(), pageId: 1, }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(0); // Cleanup: delete the created maintenance if (result.success) { await db.delete(maintenance).where(eq(maintenance.id, result.data.id)); } }); test("create a maintenance with `from` date after `to` date should return 400", async () => { const to = new Date(); const from = new Date(to.getTime() + 3600000); // from is 1 hour after to const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Invalid Dates", message: "Test message", from: from.toISOString(), to: to.toISOString(), pageId: 1, }), }); expect(res.status).toBe(400); }); test("create a maintenance with non-existent monitorIds should return 400", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Invalid Monitors", message: "Test message", from: from.toISOString(), to: to.toISOString(), monitorIds: [9999], // Non-existent monitor ID pageId: 1, }), }); expect(res.status).toBe(400); }); test("create a maintenance with non-existent pageId should return 400", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Invalid Page", message: "Test message", from: from.toISOString(), to: to.toISOString(), monitorIds: [1], pageId: 9999, // Non-existent page ID }), }); expect(res.status).toBe(400); }); test("create a maintenance with empty body should return 400", async () => { const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); test("create a valid maintenance", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); // 1 hour later const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Database Upgrade", message: "Scheduled database maintenance", from: from.toISOString(), to: to.toISOString(), monitorIds: [1], pageId: 1, }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(1); if (result.success) { await db.delete(maintenance).where(eq(maintenance.id, result.data.id)); } }); test("create a maintenance with multiple monitorIds", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); // 1 hour later const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Multi-Monitor Maintenance", message: "Maintenance affecting multiple monitors", from: from.toISOString(), to: to.toISOString(), monitorIds: [1, 2], pageId: 1, }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(2); expect(result.data?.monitorIds).toEqual(expect.arrayContaining([1, 2])); // Cleanup: delete the created maintenance if (result.success) { await db.delete(maintenance).where(eq(maintenance.id, result.data.id)); } }); test("create a maintenance with invalid dates should return 400", async () => { const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Invalid Maintenance", message: "Test message", from: "invalid-date", to: "invalid-date", pageId: 1, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/maintenance", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create a maintenance calls dispatchMaintenanceUpdate", async () => { const from = new Date(); const to = new Date(from.getTime() + 3600000); const res = await app.request("/v1/maintenance", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Dispatch Test Maintenance", message: "Testing dispatcher integration", from: from.toISOString(), to: to.toISOString(), monitorIds: [1], pageId: 1, }), }); expect(res.status).toBe(200); const result = MaintenanceSchema.safeParse(await res.json()); expect(result.success).toBe(true); expect(spies.dispatchMaintenanceUpdate.mock.calls.length).toBe(1); expect(spies.dispatchMaintenanceUpdate.mock.calls[0][0]).toBeNumber(); if (result.success) { await db.delete(maintenance).where(eq(maintenance.id, result.data.id)); } }); ================================================ FILE: apps/server/src/routes/v1/maintenances/post.ts ================================================ import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { createRoute } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, inArray, isNull } from "@openstatus/db"; import { monitor, page } from "@openstatus/db/src/schema"; import { maintenance } from "@openstatus/db/src/schema/maintenances"; import { maintenancesToPageComponents, pageComponent, } from "@openstatus/db/src/schema/page_components"; import { dispatchMaintenanceUpdate } from "@openstatus/subscriptions"; import type { maintenancesApi } from "./index"; import { MaintenanceSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["maintenance"], summary: "Create a maintenance", path: "/", middleware: [trackMiddleware(Events.CreateMaintenance)], request: { body: { content: { "application/json": { schema: MaintenanceSchema.omit({ id: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: MaintenanceSchema, }, }, description: "Create a maintenance", }, ...openApiErrorResponses, }, }); export function registerPostMaintenance(api: typeof maintenancesApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const input = c.req.valid("json"); const limits = c.get("workspace").limits; const { monitorIds, pageId } = input; if (input.from > input.to) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "`date.from` cannot be after `date.to`", }); } const _newMaintenance = await db.transaction(async (tx) => { const _monitors = await tx .select() .from(monitor) .where( and( inArray(monitor.id, monitorIds), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitorIds.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitorIds.join(", ")} not found`, }); } const _page = await tx .select() .from(page) .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Page ${pageId} not found`, }); } const newMaintenance = await tx .insert(maintenance) .values({ ...input, workspaceId, }) .returning() .get(); if (monitorIds?.length && newMaintenance.pageId) { // Get page components for the given monitors and page const pageComponents = await tx .select({ id: pageComponent.id }) .from(pageComponent) .where( and( inArray(pageComponent.monitorId, monitorIds), eq(pageComponent.pageId, newMaintenance.pageId), ), ) .all(); if (pageComponents.length > 0) { // Insert to maintenancesToPageComponents await tx .insert(maintenancesToPageComponents) .values( pageComponents.map((pc) => ({ maintenanceId: newMaintenance.id, pageComponentId: pc.id, })), ) .run(); } } return newMaintenance; }); if (limits["status-subscribers"] && _newMaintenance.pageId) { await dispatchMaintenanceUpdate(_newMaintenance.id); } const data = MaintenanceSchema.parse({ ..._newMaintenance, monitorIds: input.monitorIds, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/maintenances/put.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MaintenanceSchema } from "./schema"; test("update the maintenance", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Updated Maintenance", message: "Updated message", }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.title).toBe("Updated Maintenance"); }); test("update maintenance monitors", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ monitorIds: [1, 2], }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(2); }); test("invalid maintenance id should return 400", async () => { const res = await app.request("/v1/maintenance/invalid-id", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Not Found", }), }); expect(res.status).toBe(400); }); test("update only the title", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Only Title Updated", }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.title).toBe("Only Title Updated"); }); test("update only the message", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ message: "Only Message Updated", }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.message).toBe("Only Message Updated"); }); test.todo("update only the dates", async () => { const from = new Date(); const to = new Date(from.getTime() + 7200000); // 2 hours later const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ from: from.toISOString(), to: to.toISOString(), }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.from).toEqual(from); expect(result.data?.to).toEqual(to); }); test.todo( "update maintenance with `from` date after `to` date should return 400", async () => { const to = new Date(); const from = new Date(to.getTime() + 3600000); // from is 1 hour after to const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ from: from.toISOString(), to: to.toISOString(), }), }); expect(res.status).toBe(400); }, ); test("remove all maintenance monitors", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ monitorIds: [], }), }); const result = MaintenanceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(0); }); test.todo("empty body should return 400", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({}), }); expect(res.status).toBe(400); }); test("invalid maintenance id should return 404", async () => { const res = await app.request("/v1/maintenance/999", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ title: "Not Found", }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({}), }); expect(res.status).toBe(401); }); test("update with invalid monitor ids should return 400", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ monitorIds: [999], // Non-existent monitor }), }); expect(res.status).toBe(400); }); test("update with invalid page id should return 400", async () => { const res = await app.request("/v1/maintenance/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ pageId: 999, // Non-existent page }), }); expect(res.status).toBe(400); }); ================================================ FILE: apps/server/src/routes/v1/maintenances/put.ts ================================================ import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { createRoute } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, inArray, isNull } from "@openstatus/db"; import { monitor, page } from "@openstatus/db/src/schema"; import { maintenance } from "@openstatus/db/src/schema/maintenances"; import { maintenancesToPageComponents, pageComponent, } from "@openstatus/db/src/schema/page_components"; import type { maintenancesApi } from "./index"; import { MaintenanceSchema, ParamsSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["maintenance"], summary: "Update a maintenance", path: "/{id}", middleware: [trackMiddleware(Events.UpdateMaintenance)], request: { params: ParamsSchema, body: { content: { "application/json": { schema: MaintenanceSchema.omit({ id: true }).partial(), }, }, }, }, responses: { 200: { content: { "application/json": { schema: MaintenanceSchema, }, }, description: "Update a maintenance", }, ...openApiErrorResponses, }, }); export function registerPutMaintenance(api: typeof maintenancesApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const input = c.req.valid("json"); const { monitorIds, pageId } = input; const _maintenance = await db.query.maintenance.findFirst({ with: { maintenancesToPageComponents: { with: { pageComponent: true, }, }, }, where: and( eq(maintenance.id, Number(id)), eq(maintenance.workspaceId, workspaceId), ), }); if (!_maintenance) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Maintenance ${id} not found`, }); } if (monitorIds?.length) { const _monitors = await db .select() .from(monitor) .where( and( inArray(monitor.id, monitorIds), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitorIds.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitorIds.join(", ")} not found`, }); } } if (pageId) { const _page = await db .select() .from(page) .where(and(eq(page.id, pageId), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Page ${pageId} not found`, }); } } const inputFrom = input?.from ?? _maintenance.from; const inputTo = input?.to ?? _maintenance?.to; if (inputFrom && inputTo && new Date(inputFrom) > new Date(inputTo)) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "`date.from` cannot be after `date.to`", }); } const updatedMaintenance = await db.transaction(async (tx) => { const updated = await tx .update(maintenance) .set({ ...input, updatedAt: new Date(), }) .where(eq(maintenance.id, Number(id))) .returning() .get(); if (monitorIds) { // Delete from maintenancesToPageComponents await tx .delete(maintenancesToPageComponents) .where(eq(maintenancesToPageComponents.maintenanceId, Number(id))) .run(); // Add new associations if (monitorIds.length > 0 && updated.pageId) { // Get page components for the new monitors const pageComponents = await tx .select({ id: pageComponent.id }) .from(pageComponent) .where( and( inArray(pageComponent.monitorId, monitorIds), eq(pageComponent.pageId, updated.pageId), ), ) .all(); if (pageComponents.length > 0) { // Insert to maintenancesToPageComponents await tx .insert(maintenancesToPageComponents) .values( pageComponents.map((pc) => ({ maintenanceId: Number(id), pageComponentId: pc.id, })), ) .run(); } } } return updated; }); const data = MaintenanceSchema.parse({ ...updatedMaintenance, monitorIds: monitorIds ?? _maintenance.maintenancesToPageComponents .map((m) => m.pageComponent.monitorId) .filter((id): id is number => id !== null), }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/maintenances/schema.ts ================================================ import { z } from "@hono/zod-openapi"; export const ParamsSchema = z.object({ id: z .string() .min(1) .regex(/^\d+$/, "ID must be a numeric string") .openapi({ param: { name: "id", in: "path", }, description: "The id of the maintenance", example: "1", }), }); export const MaintenanceSchema = z .object({ id: z.number().openapi({ description: "The id of the maintenance", example: 1, }), title: z.string().openapi({ description: "The title of the maintenance", example: "Database Upgrade", }), message: z.string().openapi({ description: "The message describing the maintenance", example: "Upgrading database to improve performance", }), from: z.coerce.date().openapi({ description: "When the maintenance starts", }), to: z.coerce.date().openapi({ description: "When the maintenance ends", }), monitorIds: z .array(z.number()) .optional() .prefault([]) .openapi({ description: "IDs of affected monitors" }), pageId: z.number().openapi({ description: "The id of the status page this maintenance belongs to", }), }) .refine((maintenance) => maintenance.from <= maintenance.to, { error: "'from' date must be before 'to' date", }) .openapi("Maintenance"); export type MaintenanceSchema = z.infer<typeof MaintenanceSchema>; ================================================ FILE: apps/server/src/routes/v1/monitors/delete.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("delete the monitor", async () => { // First create a monitor to delete const createRes = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Monitor to delete", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, }), }); const created = MonitorSchema.safeParse(await createRes.json()); expect(createRes.status).toBe(200); expect(created.success).toBe(true); // Now delete it const res = await app.request(`/v1/monitor/${created.data?.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({}); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/2", { method: "DELETE" }); expect(res.status).toBe(401); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/404", { method: "DELETE", headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/monitors/delete.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import type { monitorsApi } from "./index"; import { ParamsSchema } from "./schema"; const deleteRoute = createRoute({ method: "delete", tags: ["monitor"], summary: "Delete a monitor", path: "/{id}", request: { params: ParamsSchema, }, middleware: [trackMiddleware(Events.DeleteMonitor)], responses: { 200: { content: { "application/json": { schema: z.object({}), }, }, description: "The monitor was successfully deleted", }, ...openApiErrorResponses, }, }); export function registerDeleteMonitor(app: typeof monitorsApi) { return app.openapi(deleteRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } await db .update(monitor) .set({ active: false, deletedAt: new Date() }) .where(eq(monitor.id, Number(id))) .run(); // FIXME: Remove all relations of the monitor from all notifications, pages,.... return c.json({}, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("return the monitor", async () => { const res = await app.request("/v1/monitor/1", { headers: { "x-openstatus-key": "1", }, }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/2"); expect(res.status).toBe(401); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/2", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/monitors/get.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "./index"; import { MonitorSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["monitor"], summary: "Get a monitor", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "The monitor", }, ...openApiErrorResponses, }, }); export function registerGetMonitor(api: typeof monitorsApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const otelHeader = _monitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_monitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._monitor, openTelemetry: _monitor.otelEndpoint ? { headers: otelHeader, endpoint: _monitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/get_all.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("return all monitors", async () => { const res = await app.request("/v1/monitor", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = MonitorSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.length).toBeGreaterThan(0); }); test("return empty monitors", async () => { const res = await app.request("/v1/monitor", { method: "GET", headers: { "x-openstatus-key": "2", }, }); const result = MonitorSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/get_all.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "./index"; import { MonitorSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["monitor"], summary: "List all monitors", path: "/", request: {}, responses: { 200: { content: { "application/json": { schema: z.array(MonitorSchema), }, }, description: "All the monitors", }, ...openApiErrorResponses, }, }); export function registerGetAllMonitors(app: typeof monitorsApi) { return app.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _monitors = await db .select() .from(monitor) .where( and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all(); const data = z.array(MonitorSchema).parse( _monitors.map((monitor) => { const otelHeader = monitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(monitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; return { ...monitor, openTelemetry: monitor.otelEndpoint ? { endpoint: monitor.otelEndpoint ?? undefined, headers: otelHeader, } : undefined, }; }), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerDeleteMonitor } from "./delete"; import { registerGetMonitor } from "./get"; import { registerGetAllMonitors } from "./get_all"; import { registerPostMonitor } from "./post"; import { registerPostMonitorDNS } from "./post_dns"; import { registerPostMonitorHTTP } from "./post_http"; import { registerPostMonitorTCP } from "./post_tcp"; import { registerPutMonitor } from "./put"; import { registerPutDNSMonitor } from "./put_dns"; import { registerPutHTTPMonitor } from "./put_http"; import { registerPutTCPMonitor } from "./put_tcp"; import { registerGetMonitorResult } from "./results/get"; import { registerRunMonitor } from "./run/post"; import { registerGetMonitorSummary } from "./summary/get"; import { registerTriggerMonitor } from "./trigger/post"; const monitorsApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetAllMonitors(monitorsApi); registerGetMonitor(monitorsApi); registerPutMonitor(monitorsApi); registerDeleteMonitor(monitorsApi); registerPostMonitor(monitorsApi); registerPostMonitorHTTP(monitorsApi); registerPostMonitorTCP(monitorsApi); registerPostMonitorDNS(monitorsApi); registerPutHTTPMonitor(monitorsApi); registerPutTCPMonitor(monitorsApi); registerPutDNSMonitor(monitorsApi); // registerGetMonitorSummary(monitorsApi); registerTriggerMonitor(monitorsApi); registerGetMonitorResult(monitorsApi); registerRunMonitor(monitorsApi); export { monitorsApi }; ================================================ FILE: apps/server/src/routes/v1/monitors/post.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { createErrorSchema } from "@/libs/errors"; import { MonitorSchema } from "./schema"; test("create a valid monitor", async () => { const res = await app.request("/v1/monitor", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ periodicity: "10m", url: "https://www.openstatus.dev", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], method: "POST", body: '{"hello":"world"}', headers: [{ key: "key", value: "value" }], active: true, public: true, assertions: [ { type: "status", compare: "eq", target: 200, }, { type: "header", compare: "not_eq", key: "key", target: "value" }, ], }), }); expect(res.status).toBe(200); const result = MonitorSchema.safeParse(await res.json()); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create a monitor with invalid payload should return 400", async () => { const res = await app.request("/v1/monitor", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ periodicity: 32, //not valid value url: "https://www.openstatus.dev", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], method: "POST", body: '{"hello":"world"}', headers: [{ key: "key", value: "value" }], active: true, public: false, }), }); expect(res.status).toBe(400); }); test("create a monitor with invalid page id should return 400", async () => { const res = await app.request("/v1/monitor", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "New Status Report", message: "Message", monitorIds: [1], pageId: 404, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create a monitor with deprecated regions should return 400", async () => { const res = await app.request("/v1/monitor", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ regions: ["ams", "jnb", "hkg", "waw"], name: "Testing Deprecated Regions", description: "Testing Deprecated Regions", url: "https://www.openstatus.dev", method: "GET", active: true, }), }); const json = await res.json(); const errorSchema = createErrorSchema("BAD_REQUEST").safeParse(json); expect(res.status).toBe(400); expect(errorSchema.success).toBe(true); expect(errorSchema.data?.message).toMatch( "Deprecated regions are not allowed: hkg, waw", ); }); ================================================ FILE: apps/server/src/routes/v1/monitors/post.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { serialize } from "@openstatus/assertions"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import type { monitorsApi } from "./index"; import { MonitorSchema } from "./schema"; import { getAssertions } from "./utils"; const postRoute = createRoute({ method: "post", tags: ["monitor"], summary: "Create a monitor", path: "/", middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], request: { body: { description: "The monitor to create", content: { "application/json": { schema: MonitorSchema.omit({ id: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Create a monitor", }, ...openApiErrorResponses, }, }); export function registerPostMonitor(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where( and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all() )[0].count; if (count >= limits.monitors) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more monitors", }); } if (!limits.periodicity.includes(input.periodicity)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (limits["max-regions"] < input.regions.length) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } if (input.jobType && !["http", "tcp"].includes(input.jobType)) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Invalid jobType, currently only 'http' and 'tcp' are supported", }); } const { headers, regions, assertions, ...rest } = input; const assert = assertions ? getAssertions(assertions) : []; const _newMonitor = await db .insert(monitor) .values({ ...rest, workspaceId: workspaceId, regions: regions ? regions.join(",") : undefined, description: input.description ?? undefined, headers: input.headers ? JSON.stringify(input.headers) : undefined, assertions: assert.length > 0 ? serialize(assert) : undefined, timeout: input.timeout || 45000, }) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/post_dns.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("create a valid monitor", async () => { const res = await app.request("/v1/monitor/dns", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { uri: "openstatus.dev", }, active: true, public: true, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create a status report with invalid payload should return 400", async () => { const res = await app.request("/v1/monitor/dns", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "21m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { url: "openstatus.dev", }, active: true, public: true, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/dns", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/post_dns.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; // import { serialize } from "@openstatus/assertions"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import type { monitorsApi } from "./index"; import { DNSMonitorSchema, MonitorSchema } from "./schema"; // import { getAssertionNew } from "./utils"; const postRoute = createRoute({ method: "post", tags: ["monitor"], summary: "Create a dns monitor", path: "/dns", middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], request: { body: { description: "The monitor to create", content: { "application/json": { schema: DNSMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Create a monitor", }, ...openApiErrorResponses, }, }); export function registerPostMonitorDNS(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where( and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all() )[0].count; if (count >= limits.monitors) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more monitors", }); } if (!limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (limits["max-regions"] < input.regions.length) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } const { request, regions, assertions, openTelemetry, ...rest } = input; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; // const assert = assertions ? getAssertionNew(assertions) : []; const _newMonitor = await db .insert(monitor) .values({ ...rest, periodicity: input.frequency, jobType: "dns", url: input.request.uri, workspaceId: workspaceId, regions: regions ? regions.join(",") : undefined, // assertions: assert.length > 0 ? serialize(assert) : undefined, timeout: input.timeout || 45000, otelEndpoint: openTelemetry?.endpoint, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, }) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/post_http.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("create a valid monitor", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ active: true, degradedAfter: 60, description: "This is a test", frequency: "10m", name: "Test2", regions: ["iad"], request: { url: "https://api.openstatus.dev/health", method: "POST", body: '{"hello":"world"}', headers: { "content-type": "application/json" }, }, assertions: [ { kind: "statusCode", compare: "eq", target: 200, }, { kind: "header", compare: "not_eq", key: "key", target: "value" }, ], retry: 3, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create a status report with invalid payload should return 400", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "21m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { url: "https://www.openstatus.dev", method: "POST", body: '{"hello":"world"}', headers: { "content-type": "application/json" }, }, active: true, public: true, assertions: [ { kind: "status", compare: "eq", target: 200, }, { kind: "header", compare: "not_eq", key: "key", target: "value" }, ], }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create HTTP monitor with GET method should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "5m", name: "GET Monitor", description: "Monitor with GET method", regions: ["ams"], request: { url: "https://api.openstatus.dev/health", method: "GET", }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with PUT method should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "PUT Monitor", description: "Monitor with PUT method", regions: ["gru"], request: { url: "https://api.example.com/resource", method: "PUT", body: '{"data":"updated"}', headers: { authorization: "Bearer token123" }, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with textBody assertion should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Text Body Assertion Monitor", description: "Monitor with text body assertion", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, assertions: [ { kind: "textBody", compare: "contains", target: "OpenStatus", }, ], active: true, public: true, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with multiple assertions should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Multi Assertion Monitor", description: "Monitor with multiple assertions", regions: ["ams", "gru"], request: { url: "https://api.openstatus.dev", method: "GET", }, assertions: [ { kind: "statusCode", compare: "eq", target: 200, }, { kind: "header", compare: "contains", key: "content-type", target: "json", }, { kind: "textBody", compare: "contains", target: "success", }, ], active: true, public: true, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with timeout and retry configuration should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "HTTP with custom config", description: "HTTP monitor with timeout and retry", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, timeout: 60000, retry: 5, degradedAfter: 20000, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with OpenTelemetry configuration should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "HTTP with OTEL", description: "HTTP monitor with OpenTelemetry", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, openTelemetry: { endpoint: "https://otel.example.com/v1/traces", headers: { "x-api-key": "otel-key-123", "x-tenant-id": "tenant-456", }, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with 30s frequency should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "30s", name: "Fast HTTP Check", description: "HTTP monitor with 30s frequency", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with 1h frequency should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "1h", name: "Hourly HTTP Check", description: "HTTP monitor with 1h frequency", regions: ["gru"], request: { url: "https://www.openstatus.dev", method: "GET", }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor without optional fields should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Minimal HTTP Monitor", regions: ["ams"], request: { url: "https://www.openstatus.dev", method: "GET", }, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with PATCH method should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "PATCH Monitor", description: "Monitor with PATCH method", regions: ["ams"], request: { url: "https://api.example.com/resource", method: "PATCH", body: '{"field":"value"}', headers: { "content-type": "application/json" }, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with DELETE method should return 200", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "DELETE Monitor", description: "Monitor with DELETE method", regions: ["ams"], request: { url: "https://api.example.com/resource/123", method: "DELETE", headers: { authorization: "Bearer token" }, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create HTTP monitor with invalid URL should return 400", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Invalid URL Monitor", regions: ["ams"], request: { url: "not-a-valid-url", method: "GET", }, }), }); expect(res.status).toBe(400); }); test("create HTTP monitor with deprecated regions should return 400", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Deprecated Regions HTTP", regions: ["ams", "hkg", "waw"], request: { url: "https://www.openstatus.dev", method: "GET", }, }), }); expect(res.status).toBe(400); }); ================================================ FILE: apps/server/src/routes/v1/monitors/post_http.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { serialize } from "@openstatus/assertions"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import type { monitorsApi } from "./index"; import { HTTPMonitorSchema, MonitorSchema } from "./schema"; import { getAssertionNew } from "./utils"; const postRoute = createRoute({ method: "post", tags: ["monitor"], summary: "Create a http monitor", path: "/http", middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], request: { body: { description: "The monitor to create", content: { "application/json": { schema: HTTPMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Create a monitor", }, ...openApiErrorResponses, }, }); export function registerPostMonitorHTTP(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where( and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all() )[0].count; if (count >= limits.monitors) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more monitors", }); } if (!limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (limits["max-regions"] < input.regions.length) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } const { request, regions, assertions, openTelemetry, ...rest } = input; const headers = input.request.headers ? Object.entries(input.request.headers) : undefined; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; const headersEntries = headers ? headers.map(([key, value]) => ({ key: key, value: value })) : undefined; const assert = assertions ? getAssertionNew(assertions) : []; const _newMonitor = await db .insert(monitor) .values({ ...rest, periodicity: input.frequency, jobType: "http", url: request.url, method: request.method, body: request.body, workspaceId: workspaceId, regions: regions ? regions.join(",") : undefined, headers: headersEntries ? JSON.stringify(headersEntries) : undefined, assertions: assert.length > 0 ? serialize(assert) : undefined, timeout: input.timeout || 45000, otelEndpoint: openTelemetry?.endpoint, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, }) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/post_tcp.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("create a valid monitor", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { host: "openstatus.dev", port: 443, }, active: true, public: true, }), }); const r = await res.json(); const result = MonitorSchema.safeParse(r); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create a status report with invalid payload should return 400", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "21m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { host: "openstatus.dev", port: 443, }, active: true, public: true, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create TCP monitor with port 80 should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "5m", name: "HTTP Port Monitor", description: "Monitor port 80", regions: ["ams"], request: { host: "example.com", port: 80, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with custom port should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "1m", name: "Custom Port Monitor", description: "Monitor custom port 8080", regions: ["gru"], request: { host: "localhost", port: 8080, }, active: false, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with timeout and retry configuration should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "TCP with custom config", description: "TCP monitor with timeout and retry", regions: ["ams"], request: { host: "openstatus.dev", port: 443, }, timeout: 30000, retry: 5, degradedAfter: 10000, active: true, public: true, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with OpenTelemetry configuration should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "TCP with OTEL", description: "TCP monitor with OpenTelemetry", regions: ["ams"], request: { host: "openstatus.dev", port: 443, }, openTelemetry: { endpoint: "https://otel.example.com", headers: { "x-api-key": "test-key", }, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with multiple regions should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "30m", name: "Multi-region TCP", description: "TCP monitor across multiple regions", regions: ["ams", "gru", "syd"], request: { host: "openstatus.dev", port: 443, }, active: true, public: true, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with 30s frequency should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "30s", name: "Fast TCP Check", description: "TCP monitor with 30s frequency", regions: ["ams"], request: { host: "openstatus.dev", port: 443, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with 1h frequency should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "1h", name: "Hourly TCP Check", description: "TCP monitor with 1h frequency", regions: ["gru"], request: { host: "example.com", port: 443, }, active: true, public: false, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor without optional fields should return 200", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Minimal TCP Monitor", regions: ["ams"], request: { host: "openstatus.dev", port: 443, }, }), }); const result = MonitorSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created monitor if (result.success) { await app.request(`/v1/monitor/${result.data.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1" }, }); } }); test("create TCP monitor with invalid host should return 400", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Invalid TCP Monitor", regions: ["ams"], request: { host: "", port: 443, }, }), }); expect(res.status).toBe(400); }); test("create TCP monitor with invalid port should return 400", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Invalid Port Monitor", regions: ["ams"], request: { host: "openstatus.dev", port: "not-a-number", }, }), }); expect(res.status).toBe(400); }); test("create TCP monitor with deprecated regions should return 400", async () => { const res = await app.request("/v1/monitor/tcp", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "Deprecated Regions TCP", regions: ["ams", "hkg", "waw"], request: { host: "openstatus.dev", port: 443, }, }), }); expect(res.status).toBe(400); }); ================================================ FILE: apps/server/src/routes/v1/monitors/post_tcp.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { Events } from "@openstatus/analytics"; import { and, db, eq, isNull, sql } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import type { monitorsApi } from "./index"; import { MonitorSchema, TCPMonitorSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["monitor"], summary: "Create a tcp monitor", path: "/tcp", middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], request: { body: { description: "The monitor to create", content: { "application/json": { schema: TCPMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Create a monitor", }, ...openApiErrorResponses, }, }); export function registerPostMonitorTCP(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitor) .where( and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), ) .all() )[0].count; if (count >= limits.monitors) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more monitors", }); } if (!limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (limits["max-regions"] < input.regions.length) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } const { request, regions, openTelemetry, ...rest } = input; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; const _newMonitor = await db .insert(monitor) .values({ ...rest, jobType: "tcp", periodicity: input.frequency, url: `${request.host}:${request.port}`, workspaceId: workspaceId, regions: regions ? regions.join(",") : undefined, headers: undefined, assertions: undefined, timeout: input.timeout || 45000, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, otelEndpoint: openTelemetry?.endpoint, }) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/put.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("update the monitor", async () => { const res = await app.request("/v1/monitor/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ name: "New Name", }), }); const data = await res.json(); const monitor = MonitorSchema.parse(data); expect(res.status).toBe(200); expect(monitor.name).toBe("New Name"); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ /* */ }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ /* */ }), }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/put.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { serialize } from "@openstatus/assertions"; import type { monitorsApi } from "./index"; import { MonitorSchema, ParamsSchema } from "./schema"; import { getAssertions } from "./utils"; const putRoute = createRoute({ method: "put", tags: ["monitor"], summary: "Update a monitor", path: "/{id}", middleware: [trackMiddleware(Events.UpdateMonitor)], request: { params: ParamsSchema, body: { description: "The monitor to update", content: { "application/json": { schema: MonitorSchema.omit({ id: true }).partial(), }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Update a monitor", }, ...openApiErrorResponses, }, }); export function registerPutMonitor(api: typeof monitorsApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.periodicity && !limits.periodicity.includes(input.periodicity)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (input.regions) { if (limits["max-regions"] < input.regions.length) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } } const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), isNull(monitor.deletedAt), eq(monitor.workspaceId, workspaceId), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } if (input.jobType && input.jobType !== _monitor.jobType) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Cannot change jobType. Please delete and create a new monitor instead.", }); } const { headers, regions, assertions, ...rest } = input; const assert = assertions ? getAssertions(assertions) : []; const _newMonitor = await db .update(monitor) .set({ ...rest, regions: regions ? regions.join(",") : undefined, description: input.description ?? undefined, headers: input.headers ? JSON.stringify(input.headers) : undefined, assertions: assert.length > 0 ? serialize(assert) : undefined, timeout: input.timeout || 45000, updatedAt: new Date(), }) .where(eq(monitor.id, Number(_monitor.id))) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/put_dns.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; test("update the monitor", async () => { const res = await app.request("/v1/monitor/dns/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ name: "New Name", }), }); expect(res.status).toBe(400); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/dns/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { uri: "openstatus.dev", }, active: true, public: true, }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/dns/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ /* */ }), }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/put_dns.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import type { monitorsApi } from "./index"; import { DNSMonitorSchema, MonitorSchema, ParamsSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["monitor"], summary: "Update an DNS monitor", path: "/dns/{id}", middleware: [trackMiddleware(Events.UpdateMonitor)], request: { params: ParamsSchema, body: { description: "The monitor to update", content: { "application/json": { schema: DNSMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Update a monitor", }, ...openApiErrorResponses, }, }); export function registerPutDNSMonitor(api: typeof monitorsApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.frequency && !limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (input.regions) { for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } } const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), isNull(monitor.deletedAt), eq(monitor.workspaceId, workspaceId), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } if (_monitor.jobType !== "tcp") { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const { request, regions, openTelemetry, assertions, ...rest } = input; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; const _newMonitor = await db .update(monitor) .set({ ...rest, periodicity: input.frequency, url: input.request.uri, regions: regions ? regions.join(",") : undefined, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, otelEndpoint: openTelemetry?.endpoint, timeout: input.timeout || 45000, updatedAt: new Date(), }) .where(eq(monitor.id, Number(_monitor.id))) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/put_http.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { MonitorSchema } from "./schema"; test("update the monitor", async () => { const res = await app.request("/v1/monitor/http/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ name: "New Name", }), }); expect(res.status).toBe(400); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/http/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { url: "https://www.openstatus.dev", method: "POST", body: '{"hello":"world"}', headers: { "content-type": "application/json" }, }, active: true, public: true, assertions: [ { kind: "statusCode", compare: "eq", target: 200, }, { kind: "header", compare: "not_eq", key: "key", target: "value" }, ], }), }); expect(res.status).toBe(404); }); test("Update a valid monitor", async () => { const res = await app.request("/v1/monitor/http", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { url: "https://www.openstatus.dev", method: "POST", body: '{"hello":"world"}', headers: { "content-type": "application/json" }, }, active: true, public: true, assertions: [ { kind: "statusCode", compare: "eq", target: 200, }, { kind: "header", compare: "not_eq", key: "key", target: "value" }, ], }), }); const result = MonitorSchema.parse(await res.json()); expect(res.status).toBe(200); const updated = await app.request(`/v1/monitor/http/${result.id}`, { method: "PUT", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ frequency: "30m", name: "newName", description: "OpenStatus website", regions: ["ams", "gru"], request: { url: "https://www.openstatus.dev", method: "POST", body: '{"hello":"world"}', headers: { "content-type": "application/json" }, }, active: true, public: true, }), // expect(r.success).toBe(true); }); const r = MonitorSchema.parse(await updated.json()); expect(r.assertions?.length).toBe(0); expect(r.periodicity).toBe("30m"); expect(r.name).toBe("newName"); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/http/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ /* */ }), }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/put_http.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { serialize } from "@openstatus/assertions"; import type { monitorsApi } from "./index"; import { HTTPMonitorSchema, MonitorSchema, ParamsSchema } from "./schema"; import { getAssertionNew } from "./utils"; const putRoute = createRoute({ method: "put", tags: ["monitor"], summary: "Update an HTTP monitor", path: "/http/{id}", middleware: [trackMiddleware(Events.UpdateMonitor)], request: { params: ParamsSchema, body: { description: "The monitor to update", content: { "application/json": { schema: HTTPMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Update a monitor", }, ...openApiErrorResponses, }, }); export function registerPutHTTPMonitor(api: typeof monitorsApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.frequency && !limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (input.regions) { for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } } const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), isNull(monitor.deletedAt), eq(monitor.workspaceId, workspaceId), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } if (_monitor.jobType !== "http") { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const { request, regions, assertions, openTelemetry, ...rest } = input; const headers = input.request.headers ? Object.entries(input.request.headers) : undefined; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; const headersEntries = headers ? headers.map(([key, value]) => ({ key: key, value: value })) : undefined; const assert = assertions ? getAssertionNew(assertions) : []; const _newMonitor = await db .update(monitor) .set({ ...rest, periodicity: input.frequency, url: input.request.url, method: input.request.method, body: input.request.body, regions: regions ? regions.join(",") : undefined, headers: headersEntries ? JSON.stringify(headersEntries) : undefined, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, otelEndpoint: openTelemetry?.endpoint, assertions: assert ? serialize(assert) : "", timeout: input.timeout || 45000, updatedAt: new Date(), }) .where(eq(monitor.id, Number(_monitor.id))) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/put_tcp.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; test("update the monitor", async () => { const res = await app.request("/v1/monitor/tcp/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ name: "New Name", }), }); expect(res.status).toBe(400); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/tcp/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ frequency: "10m", name: "OpenStatus", description: "OpenStatus website", regions: ["ams", "gru"], request: { host: "openstatus.dev", port: 443, }, active: true, public: true, }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/tcp/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ /* */ }), }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/monitors/put_tcp.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import type { monitorsApi } from "./index"; import { MonitorSchema, ParamsSchema, TCPMonitorSchema } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["monitor"], summary: "Update an TCP monitor", path: "/tcp/{id}", middleware: [trackMiddleware(Events.UpdateMonitor)], request: { params: ParamsSchema, body: { description: "The monitor to update", content: { "application/json": { schema: TCPMonitorSchema, }, }, }, }, responses: { 200: { content: { "application/json": { schema: MonitorSchema, }, }, description: "Update a monitor", }, ...openApiErrorResponses, }, }); export function registerPutTCPMonitor(api: typeof monitorsApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.frequency && !limits.periodicity.includes(input.frequency)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more periodicity", }); } if (input.regions) { for (const region of input.regions) { if (!limits.regions.includes(region)) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more regions", }); } } } const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), isNull(monitor.deletedAt), eq(monitor.workspaceId, workspaceId), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } if (_monitor.jobType !== "tcp") { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const { request, regions, openTelemetry, ...rest } = input; const otelHeadersEntries = openTelemetry?.headers ? Object.entries(openTelemetry.headers).map(([key, value]) => ({ key: key, value: value, })) : undefined; const _newMonitor = await db .update(monitor) .set({ ...rest, periodicity: input.frequency, url: `${request.host}:${request.port}`, regions: regions ? regions.join(",") : undefined, otelHeaders: otelHeadersEntries ? JSON.stringify(otelHeadersEntries) : undefined, otelEndpoint: openTelemetry?.endpoint, timeout: input.timeout || 45000, updatedAt: new Date(), }) .where(eq(monitor.id, Number(_monitor.id))) .returning() .get(); const otelHeader = _newMonitor.otelHeaders ? z .array( z.object({ key: z.string(), value: z.string(), }), ) .parse(JSON.parse(_newMonitor.otelHeaders)) // biome-ignore lint/performance/noAccumulatingSpread: <explanation> .reduce((a, v) => ({ ...a, [v.key]: v.value }), {}) : undefined; const data = MonitorSchema.parse({ ..._newMonitor, openTelemetry: _newMonitor.otelEndpoint ? { headers: otelHeader, endpoint: _newMonitor.otelEndpoint ?? undefined, } : undefined, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/results/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { ResultRun } from "../schema"; test.todo("get monitor result with valid id should return 200", async () => { const res = await app.request("/v1/monitor/1/result/1", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = ResultRun.array().safeParse(json); expect(result.success).toBe(true); }); test("get monitor result with invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/999999/result/1", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); test("get monitor result with invalid result id should return 404", async () => { const res = await app.request("/v1/monitor/1/result/999999", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); test("get monitor result without auth key should return 401", async () => { const res = await app.request("/v1/monitor/1/result/1", { method: "GET", }); expect(res.status).toBe(401); }); test("get monitor result from different workspace should return 404", async () => { const res = await app.request("/v1/monitor/2/result/1", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); test.todo( "get monitor result with valid TCP monitor should return 200", async () => { const res = await app.request("/v1/monitor/4/result/2", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = ResultRun.array().safeParse(json); expect(result.success).toBe(true); }, ); test("get monitor result with non-matching result id should return 404", async () => { const res = await app.request("/v1/monitor/1/result/2", { method: "GET", headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/monitors/results/get.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { monitor, monitorRun } from "@openstatus/db/src/schema"; import { tb } from "@/libs/clients"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "../index"; import { ParamsSchema, ResultRun } from "../schema"; const getRoute = createRoute({ method: "get", tags: ["monitor"], summary: "Get a monitor result", // FIXME: Should work for all types of monitors description: "**WARNING:** This works only for HTTP monitors. We will add support for other types of monitors soon.", path: "/{id}/result/{resultId}", request: { params: ParamsSchema.extend({ resultId: z.string().openapi({ description: "The id of the result", }), }), }, responses: { 200: { content: { "application/json": { schema: ResultRun.array(), }, }, description: "All the metrics for the result id from the monitor", }, ...openApiErrorResponses, }, }); export function registerGetMonitorResult(api: typeof monitorsApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id, resultId } = c.req.valid("param"); const _monitorRun = await db .select() .from(monitorRun) .where( and( eq(monitorRun.id, Number(resultId)), eq(monitorRun.monitorId, Number(id)), eq(monitorRun.workspaceId, workspaceId), ), ) .get(); if (!_monitorRun || !_monitorRun?.runnedAt) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor run ${resultId} not found`, }); } const _monitor = await db .select() .from(monitor) .where(eq(monitor.id, Number(id))) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } // Fetch result from tb pipe const data = await tb.getResultForOnDemandCheckHttp({ monitorId: _monitor.id, timestamp: _monitorRun.runnedAt?.getTime(), url: _monitor.url, }); return c.json(data.data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/run/post.test.ts ================================================ import { expect, test } from "bun:test"; import { afterEach, mock } from "bun:test"; import { app } from "@/index"; import { TriggerResult } from "../schema"; const mockFetch = mock(); global.fetch = mockFetch as unknown as typeof fetch; mock.module("node-fetch", () => mockFetch); afterEach(() => { mockFetch.mockReset(); }); test("run monitor with valid id should return 200", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "http", status: 200, latency: 100, region: "ams", timestamp: 1234567890, timing: { dnsStart: 1, dnsDone: 2, connectStart: 3, connectDone: 4, tlsHandshakeStart: 5, tlsHandshakeDone: 6, firstByteStart: 7, firstByteDone: 8, transferStart: 9, transferDone: 10, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/1/run", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = TriggerResult.array().safeParse(json); expect(result.success).toBe(true); }); test("run monitor with no-wait parameter should return empty array", async () => { const res = await app.request("/v1/monitor/1/run?no-wait=true", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); expect(json).toEqual([]); }); test("run monitor with invalid id should return 404", async () => { const res = await app.request("/v1/monitor/999999/run", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(404); }); test("run monitor without auth key should return 401", async () => { const res = await app.request("/v1/monitor/1/run", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("run monitor from different workspace should return 404", async () => { const res = await app.request("/v1/monitor/55555/run", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(404); }); test("run TCP monitor with valid id should return 200", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "tcp", latency: 50, region: "ams", timestamp: 1234567890, timing: { tcpStart: 1, tcpDone: 2, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/4/run", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = TriggerResult.array().safeParse(json); expect(result.success).toBe(true); if (result.success && result.data[0]) { expect(result.data[0].jobType).toBe("tcp"); } }); test.todo( "run monitor with multiple regions should return array of results", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "http", status: 200, latency: 100, region: "ams", timestamp: 1234567890, timing: { dnsStart: 1, dnsDone: 2, connectStart: 3, connectDone: 4, tlsHandshakeStart: 5, tlsHandshakeDone: 6, firstByteStart: 7, firstByteDone: 8, transferStart: 9, transferDone: 10, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/5/run", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); expect(Array.isArray(json)).toBe(true); }, ); ================================================ FILE: apps/server/src/routes/v1/monitors/run/post.ts ================================================ import { env } from "@/env"; import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; import { openApiErrorResponses } from "@/libs/errors"; import { createRoute } from "@hono/zod-openapi"; import { getLogger } from "@logtape/logtape"; import { and, eq, gte, isNull, sql } from "@openstatus/db"; const logger = getLogger("api-server"); import { db } from "@openstatus/db/src/db"; import { monitorRun } from "@openstatus/db/src/schema"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; import { HTTPException } from "hono/http-exception"; import type { monitorsApi } from ".."; import { ParamsSchema, TriggerResult } from "../schema"; import { QuerySchema } from "./schema"; const postMonitor = createRoute({ method: "post", tags: ["monitor"], summary: "Create a monitor run", description: "Run a synthetic check for a specific monitor. It will take all configs into account.", path: "/{id}/run", request: { params: ParamsSchema, query: QuerySchema, }, responses: { 200: { content: { "application/json": { schema: TriggerResult.array(), }, }, description: "All the historical metrics", }, ...openApiErrorResponses, }, }); export function registerRunMonitor(api: typeof monitorsApi) { return api.openapi(postMonitor, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const limits = c.get("workspace").limits; const { "no-wait": noWait } = c.req.valid("query"); const lastMonth = new Date().setMonth(new Date().getMonth() - 1); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitorRun) .where( and( eq(monitorRun.workspaceId, workspaceId), gte(monitorRun.createdAt, new Date(lastMonth)), ), ) .all() )[0].count; if (count >= limits["synthetic-checks"]) { throw new HTTPException(403, { message: "Upgrade for more checks", }); } const monitorData = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!monitorData) { throw new HTTPException(404, { message: "Not Found" }); } const parseMonitor = selectMonitorSchema.safeParse(monitorData); if (!parseMonitor.success) { throw new HTTPException(400, { message: "Something went wrong" }); } const row = parseMonitor.data; // Maybe later overwrite the region const monitorStatusData = await db .select() .from(monitorStatusTable) .where(eq(monitorStatusTable.monitorId, monitorData.id)) .all(); const monitorStatus = selectMonitorStatusSchema .array() .safeParse(monitorStatusData); if (!monitorStatus.success) { logger.error("Failed to parse monitor status", { monitor_id: id, workspace_id: workspaceId, error: monitorStatus.error.message, }); throw new HTTPException(400, { message: "Something went wrong" }); } const timestamp = Date.now(); const newRun = await db .insert(monitorRun) .values({ monitorId: row.id, workspaceId: row.workspaceId, runnedAt: new Date(timestamp), }) .returning(); if (!newRun[0]) { throw new HTTPException(400, { message: "Something went wrong" }); } const allResult = []; for (const region of parseMonitor.data.regions) { const status = monitorStatus.data.find((m) => region === m.region)?.status || "active"; const payload = getCheckerPayload(row, status); const url = getCheckerUrl(row, { data: true }); const result = fetch(url, { headers: { "Content-Type": "application/json", "fly-prefer-region": region, // Specify the region you want the request to be sent to Authorization: `Basic ${env.CRON_SECRET}`, }, method: "POST", body: JSON.stringify(payload), }); allResult.push(result); } if (noWait) { return c.json([], 200); } const result = await Promise.all(allResult); const bodies = await Promise.all(result.map((r) => r.json())); const data = TriggerResult.array().safeParse(bodies); if (!data) { throw new HTTPException(400, { message: "Something went wrong" }); } if (!data.success) { logger.error("Failed to parse trigger result", { monitor_id: id, workspace_id: workspaceId, error: data.error.message, }); throw new HTTPException(400, { message: "Something went wrong" }); } return c.json(data.data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/run/schema.ts ================================================ import { z } from "@hono/zod-openapi"; export const QuerySchema = z .object({ "no-wait": z.coerce.boolean().optional().prefault(false).openapi({ description: "Don't wait for the result", }), }) .openapi({ description: "Query parameters", }); ================================================ FILE: apps/server/src/routes/v1/monitors/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { numberCompare, recordCompare, stringCompare, } from "@openstatus/assertions"; import { monitorJobTypes, monitorMethods } from "@openstatus/db/src/schema"; import { monitorPeriodicitySchema, monitorRegions, } from "@openstatus/db/src/schema/constants"; import { AVAILABLE_REGIONS } from "@openstatus/regions"; import { ZodError } from "zod"; const statusAssertion = z .object({ type: z.literal("status"), compare: z.enum(numberCompare.options).openapi({ description: "Comparison operator", examples: ["eq", "not_eq", "gt", "gte", "lt", "lte"], }), target: z.int().positive().openapi({ description: "The target value" }), }) .openapi({ description: "The status assertion", }); const headerAssertion = z .object({ type: z.literal("header"), compare: stringCompare, key: z.string().openapi({ description: "The key of the header", }), target: z.string().openapi({ description: "the header value", }), }) .openapi({ description: "The header assertion" }); const textBodyAssertion = z .object({ type: z.literal("textBody"), compare: stringCompare, target: z.string().openapi({ description: "The target value", }), }) .openapi({ description: "The text body assertion" }); // Not used yet const _jsonBodyAssertion = z.object({ type: z.literal("jsonBody"), path: z.string(), // https://www.npmjs.com/package/jsonpath-plus compare: stringCompare, target: z.string(), }); export const dnsRecords = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export const recordAssertion = z .object({ type: z.literal("dnsRecord"), key: z.enum(dnsRecords), compare: recordCompare, target: z.string(), }) .openapi({ description: "The DNS record assertion" }); export const assertion = z.discriminatedUnion("type", [ statusAssertion, headerAssertion, textBodyAssertion, recordAssertion, // jsonBodyAssertion, ]); export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the monitor", example: "1", }), }); const PeriodicityEnumHonoSchema = z.enum([...monitorPeriodicitySchema.options]); export const MonitorSchema = z .object({ id: z.number().openapi({ example: 123, description: "The id of the monitor", }), periodicity: PeriodicityEnumHonoSchema.openapi({ example: "1m", description: "How often the monitor should run", }), url: z.string().openapi({ example: "https://www.documenso.co", description: "The url to monitor", }), regions: z .preprocess( (val) => { let parsedRegions: Array<unknown> = []; if (!val) return parsedRegions; if (Array.isArray(val)) { parsedRegions = val; } if (String(val).length > 0) { parsedRegions = String(val).split(","); } return parsedRegions; }, z.array(z.enum(monitorRegions)), ) .superRefine((regions, ctx) => { const deprecatedRegions = regions.filter((r) => { return !AVAILABLE_REGIONS.includes( r as (typeof AVAILABLE_REGIONS)[number], ); }); if (deprecatedRegions.length > 0) { ctx.addIssue({ code: "custom", path: ["regions"], message: `Deprecated regions are not allowed: ${deprecatedRegions.join( ", ", )}`, }); } }) .prefault([]) .openapi({ example: ["ams"], description: "Where we should monitor it", }), name: z.string().openapi({ example: "documenso-web", description: "The name of the monitor", }), externalName: z.string().nullish().openapi({ example: "Documenso", description: "The external name of the monitor, used to display on the status page or in the external notifications", }), description: z.string().nullish().openapi({ example: "Documenso website", description: "The description of your monitor", }), method: z.enum(monitorMethods).openapi({ example: "GET" }), body: z .preprocess((val) => { return String(val); }, z.string()) .nullish() .prefault("") .openapi({ example: "Hello World", description: "The body", }), headers: z .preprocess( (val) => { try { if (Array.isArray(val)) return val; if (String(val).length > 0) { return JSON.parse(String(val)); } return []; } catch (e) { throw new ZodError([ { code: "custom", path: ["headers"], message: e instanceof Error ? e.message : "Invalid value", }, ]); } }, z.array(z.object({ key: z.string(), value: z.string() })).prefault([]), ) .nullish() .openapi({ description: "The headers of your request", example: [{ key: "x-apikey", value: "supersecrettoken" }], }), assertions: z .preprocess((val) => { try { if (Array.isArray(val)) return val; if (String(val).length > 0) { return JSON.parse(String(val)); } return []; } catch (e) { throw new ZodError([ { code: "custom", path: ["assertions"], message: e instanceof Error ? e.message : "Invalid value", }, ]); } }, z.array(assertion)) .nullish() .prefault([]) .openapi({ description: "The assertions to run", }), active: z .boolean() .prefault(false) .openapi({ description: "If the monitor is active" }), public: z .boolean() .prefault(false) .openapi({ description: "If the monitor is public" }), degradedAfter: z.number().nullish().openapi({ description: "The time after the monitor is considered degraded in milliseconds", }), timeout: z.number().nullish().prefault(45000).openapi({ description: "The timeout of the request in milliseconds", }), retry: z.number().prefault(3).openapi({ description: "The number of retries to attempt", }), followRedirects: z.boolean().prefault(true).openapi({ description: "If the monitor should follow redirects", }), jobType: z.enum(monitorJobTypes).optional().prefault("http").openapi({ description: "The type of the monitor", }), openTelemetry: z .object({ endpoint: z.url().optional().prefault("http://localhost:4317").openapi({ description: "The endpoint of the OpenTelemetry collector", }), headers: z .record(z.string(), z.string()) .optional() .prefault({}) .openapi({ description: "The headers to send to the OpenTelemetry collector", }), }) .optional() .openapi({ description: "The OpenTelemetry configuration", }), }) .openapi("Monitor"); export type MonitorSchema = z.infer<typeof MonitorSchema>; // TODO: Move to @/libs/checker/schema const timingSchema = z.object({ dnsStart: z.number(), dnsDone: z.number(), connectStart: z.number(), connectDone: z.number(), tlsHandshakeStart: z.number(), tlsHandshakeDone: z.number(), firstByteStart: z.number(), firstByteDone: z.number(), transferStart: z.number(), transferDone: z.number(), }); // Use a baseSchema with 'latency', 'region', 'timestamp' export const HTTPTriggerResult = z.object({ jobType: z.literal("http"), status: z.number(), latency: z.number(), region: z.enum(monitorRegions), timestamp: z.number(), timing: timingSchema, body: z.string().optional().nullable(), error: z.string().optional().nullable(), }); const tcptimingSchema = z.object({ tcpStart: z.number(), tcpDone: z.number(), }); export const TCPTriggerResult = z.object({ jobType: z.literal("tcp"), latency: z.number(), region: z.enum(monitorRegions), timestamp: z.number(), timing: tcptimingSchema, // check if it should be z.coerce.boolean()? error: z.number().optional().nullable(), errorMessage: z.string().optional().nullable(), }); export const TriggerResult = z.discriminatedUnion("jobType", [ HTTPTriggerResult, TCPTriggerResult, ]); export const ResultRun = z.object({ latency: z.int(), // in ms statusCode: z.int().nullable().prefault(null), monitorId: z.string().prefault(""), url: z.string().optional(), error: z.coerce.boolean().prefault(false), region: z.enum(monitorRegions), timestamp: z.int().optional(), message: z.string().nullable().optional(), timing: z .preprocess((val) => { if (!val) return null; const value = timingSchema.safeParse(JSON.parse(String(val))); if (value.success) return value.data; return null; }, timingSchema.nullable()) .optional(), }); const baseRequest = z.object({ name: z.string().openapi({ description: "Name of the monitor", }), description: z.string().optional(), retry: z .number() .max(10) .min(1) .optional() .openapi({ description: "Number of retries to attempt", examples: [1, 3, 5], default: 3, }), degradedAfter: z .number() .min(0) .optional() .openapi({ description: "Time in milliseconds to wait before marking the request as degraded", examples: [30000], default: 30000, }), timeout: z .number() .min(0) .optional() .openapi({ description: "Time in milliseconds to wait before marking the request as timed out", examples: [45000], default: 45000, }), frequency: z.enum(["30s", "1m", "5m", "10m", "30m", "1h"]), active: z.boolean().optional().openapi({ description: "Whether the monitor is active", default: false, }), public: z.boolean().optional().openapi({ description: "Whether the monitor is public", default: false, }), regions: z .preprocess( (val) => { let parsedRegions: Array<unknown> = []; if (!val) return parsedRegions; if (Array.isArray(val)) { parsedRegions = val; } if (String(val).length > 0) { parsedRegions = String(val).split(","); } return parsedRegions; }, z.array(z.enum(monitorRegions)), ) .superRefine((regions, ctx) => { const deprecatedRegions = regions.filter((r) => { return !AVAILABLE_REGIONS.includes( r as (typeof AVAILABLE_REGIONS)[number], ); }); if (deprecatedRegions.length > 0) { ctx.addIssue({ code: "custom", path: ["regions"], message: `Deprecated regions are not allowed: ${deprecatedRegions.join( ", ", )}`, }); } }) .prefault([]) .openapi({ example: ["ams"], description: "Where we should monitor it", }), openTelemetry: z .object({ endpoint: z .url() .optional() .openapi({ description: "OTEL endpoint to send metrics to", examples: ["https://otel.example.com"], }), headers: z .record(z.string(), z.string()) .optional() .openapi({ description: "Headers to send with the OTEL request", examples: [{ "Content-Type": "application/json" }], }), }) .nullish(), }); const httpRequestSchema = z.object({ method: z.enum(monitorMethods), url: z.url().openapi({ description: "URL to request", examples: ["https://openstat.us", "https://www.openstatus.dev"], }), headers: z .record(z.string(), z.string()) .optional() .openapi({ description: "Headers to send with the request", examples: [{ "Content-Type": "application/json" }], }), body: z .string() .optional() .openapi({ description: "Body to send with the request", examples: ['{ "key": "value" }', "Hello World"], }), }); const tcpRequestSchema = z.object({ host: z .string() .min(1) .openapi({ examples: ["example.com", "localhost"], description: "Host to connect to", }), port: z.number().openapi({ description: "Port to connect to", examples: [80, 443, 1337], }), }); const dnsRequestSchema = z.object({ uri: z.string().openapi({ description: "The DNS server to query", examples: ["openstatus.dev"], }), }); const statusCodeAssertion = z .object({ kind: z.literal("statusCode"), compare: z.enum(numberCompare.options).openapi({ description: "Comparison operator", examples: ["eq", "not_eq", "gt", "gte", "lt", "lte"], }), target: z.number().openapi({ description: "Status code to assert", examples: [200, 404, 418, 500], }), }) .openapi({ examples: [ { kind: "statusCode", compare: "eq", target: 200, }, { kind: "statusCode", compare: "not_eq", target: 404, }, { kind: "statusCode", compare: "gt", target: 300, }, ], }); const headerAssertions = z.object({ kind: z.literal("header"), compare: z.enum(stringCompare.options).openapi({ description: "Comparison operator", examples: ["eq", "not_eq", "contains", "not_contains"], }), key: z.string().openapi({ description: "Header key to assert", examples: ["Content-Type", "X-Request-ID"], }), target: z.string().openapi({ description: "Header value to assert", examples: ["application/json", "text/html"], }), }); const textBodyAssertions = z.object({ kind: z.literal("textBody"), compare: z.enum(stringCompare.options).openapi({ description: "Comparison operator", examples: ["eq", "not_eq", "contains", "not_contains"], }), target: z.string().openapi({ description: "Text body to assert", examples: ["Hello, world!", "404 Not Found"], }), }); const dnsRecordAssertion = z.object({ kind: z.literal("dnsRecord"), recordType: z.enum(["A", "AAAA", "CNAME", "MX", "TXT"]).openapi({ description: "Type of DNS record to check", examples: ["A", "CNAME"], }), compare: z.enum(recordCompare.options).openapi({ description: "Comparison operator", examples: ["eq", "not_eq", "contains", "not_contains"], }), target: z.string().openapi({ description: "DNS record value to assert", examples: ["example.com"], }), }); export const assertionsSchema = z.discriminatedUnion("kind", [ statusCodeAssertion, headerAssertions, textBodyAssertions, ]); export const HTTPMonitorSchema = baseRequest .extend({ assertions: z.array(assertionsSchema).optional().openapi({ description: "Assertions to run on the response", }), request: httpRequestSchema.openapi({ description: "The HTTP Request we are sending", }), }) .openapi({ title: "HTTP Monitor Schema", }); export const TCPMonitorSchema = baseRequest .extend({ request: tcpRequestSchema.openapi({ description: "The TCP Request we are sending", }), }) .openapi({ title: "TCP Monitor Schema", }); export const DNSMonitorSchema = baseRequest .extend({ request: dnsRequestSchema.openapi({ description: "The DNS Request we are sending", }), assertions: z.array(dnsRecordAssertion).optional().openapi({ description: "Assertions to run on the DNS response", }), }) .openapi({ title: "DNS Monitor Schema", }); ================================================ FILE: apps/server/src/routes/v1/monitors/summary/get.test.ts ================================================ import { expect, test } from "bun:test"; import { z } from "@hono/zod-openapi"; import { app } from "@/index"; import { SummarySchema } from "./schema"; test.todo("return the summary of the monitor", async () => { const res = await app.request("/v1/monitor/1/summary", { headers: { "x-openstatus-key": "1", }, }); const result = z .object({ data: SummarySchema.array() }) .safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/monitor/1/summary"); expect(res.status).toBe(401); }); test("invalid monitor id should return 404", async () => { const res = await app.request("/v1/monitor/404/summary", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/monitors/summary/get.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, isNull } from "@openstatus/db"; import { monitor } from "@openstatus/db/src/schema"; import { redis, tb } from "@/libs/clients"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from "../index"; import { ParamsSchema, SummarySchema } from "./schema"; // TODO: is there another better way to mock Redis/Tinybird? if (process.env.NODE_ENV === "test") { require("@/libs/test/preload"); } const getMonitorStats = createRoute({ method: "get", tags: ["monitor"], summary: "Get a monitor summary", description: "Get a monitor summary of the last 45 days of data to be used within a status page", path: "/{id}/summary", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: z.object({ data: SummarySchema.array(), }), }, }, description: "All the historical metrics", }, ...openApiErrorResponses, }, }); export function registerGetMonitorSummary(api: typeof monitorsApi) { return api.openapi(getMonitorStats, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const cache = await redis.get<SummarySchema[]>(`${id}-daily-stats`); if (cache) { // c.get("event").cache_hit = true; return c.json({ data: cache }, 200); } // c.get("event").cache_hit = false; const res = _monitor.jobType === "http" ? await tb.legacy_httpStatus45d({ monitorId: id }) : await tb.legacy_tcpStatus45d({ monitorId: id }); await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); return c.json({ data: res.data }, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/summary/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { ParamsSchema } from "../schema"; export { ParamsSchema }; export const SummarySchema = z.object({ ok: z.int().openapi({ description: "The number of ok responses (defined by the assertions - or by default status code 200)", }), count: z.int().openapi({ description: "The total number of request" }), day: z.coerce .date() .openapi({ description: "The date of the daily stat in ISO8601 format" }), }); export type SummarySchema = z.infer<typeof SummarySchema>; ================================================ FILE: apps/server/src/routes/v1/monitors/trigger/post.test.ts ================================================ import { expect, test } from "bun:test"; import { afterEach, mock } from "bun:test"; import { app } from "@/index"; import { TriggerSchema } from "./schema"; const mockFetch = mock(); global.fetch = mockFetch as unknown as typeof fetch; mock.module("node-fetch", () => mockFetch); afterEach(() => { mockFetch.mockReset(); }); test("trigger monitor with valid id should return 200", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "http", status: 200, latency: 100, region: "ams", timestamp: 1234567890, timing: { dnsStart: 1, dnsDone: 2, connectStart: 3, connectDone: 4, tlsHandshakeStart: 5, tlsHandshakeDone: 6, firstByteStart: 7, firstByteDone: 8, transferStart: 9, transferDone: 10, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/1/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = TriggerSchema.safeParse(json); expect(result.success).toBe(true); expect(json.resultId).toBeDefined(); expect(typeof json.resultId).toBe("number"); }); test("trigger monitor with invalid id should return 404", async () => { const res = await app.request("/v1/monitor/999999/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(404); }); test("trigger monitor without auth key should return 401", async () => { const res = await app.request("/v1/monitor/1/trigger", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("trigger monitor from different workspace should return 404", async () => { // Monitor 5 belongs to workspace 3, API key 1 is workspace 1 const res = await app.request("/v1/monitor/5/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(404); }); // TODO: fix this test create a monitor, delete it, then trigger it test.skip("trigger deleted monitor should return 404", async () => { const res = await app.request("/v1/monitor/3/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(404); }); test("trigger TCP monitor with valid id should return 200", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "tcp", latency: 50, region: "ams", timestamp: 1234567890, timing: { tcpStart: 1, tcpDone: 2, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/4/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = TriggerSchema.safeParse(json); expect(result.success).toBe(true); expect(json.resultId).toBeDefined(); expect(typeof json.resultId).toBe("number"); }); test("trigger monitor with multiple regions should return result id", async () => { mockFetch.mockReturnValue( Promise.resolve( new Response( JSON.stringify({ jobType: "http", status: 200, latency: 100, region: "ams", timestamp: 1234567890, timing: { dnsStart: 1, dnsDone: 2, connectStart: 3, connectDone: 4, tlsHandshakeStart: 5, tlsHandshakeDone: 6, firstByteStart: 7, firstByteDone: 8, transferStart: 9, transferDone: 10, }, }), { status: 200, headers: { "content-type": "application/json" } }, ), ), ); const res = await app.request("/v1/monitor/1/trigger", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, }); expect(res.status).toBe(200); const json = await res.json(); const result = TriggerSchema.safeParse(json); expect(result.success).toBe(true); expect(json.resultId).toBeDefined(); }); ================================================ FILE: apps/server/src/routes/v1/monitors/trigger/post.ts ================================================ import { env } from "@/env"; import { getCheckerPayload, getCheckerUrl } from "@/libs/checker"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { createRoute, z } from "@hono/zod-openapi"; import { and, eq, gte, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { monitorRun } from "@openstatus/db/src/schema"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; import { monitor } from "@openstatus/db/src/schema/monitors/monitor"; import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; import { HTTPException } from "hono/http-exception"; import type { monitorsApi } from ".."; import { ParamsSchema, TriggerSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["monitor"], summary: "Create a monitor trigger", description: "Trigger a monitor check without waiting the result", path: "/{id}/trigger", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: TriggerSchema, }, }, description: "Returns a result id that can be used to get the result of your trigger", }, ...openApiErrorResponses, }, }); export function registerTriggerMonitor(api: typeof monitorsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const limits = c.get("workspace").limits; const lastMonth = new Date().setMonth(new Date().getMonth() - 1); const count = ( await db .select({ count: sql<number>`count(*)` }) .from(monitorRun) .where( and( eq(monitorRun.workspaceId, workspaceId), gte(monitorRun.createdAt, new Date(lastMonth)), ), ) .all() )[0].count; if (count >= limits["synthetic-checks"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more checks", }); } const _monitor = await db .select() .from(monitor) .where( and( eq(monitor.id, Number(id)), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .get(); if (!_monitor) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Monitor ${id} not found`, }); } const validateMonitor = selectMonitorSchema.safeParse(_monitor); if (!validateMonitor.success) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Invalid monitor, please contact support", }); } const row = validateMonitor.data; // Maybe later overwrite the region const _monitorStatus = await db .select() .from(monitorStatusTable) .where(eq(monitorStatusTable.monitorId, _monitor.id)) .all(); const monitorStatus = z .array(selectMonitorStatusSchema) .safeParse(_monitorStatus); if (!monitorStatus.success) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Invalid monitor status, please contact support", }); } const timestamp = Date.now(); const newRun = await db .insert(monitorRun) .values({ monitorId: row.id, workspaceId: row.workspaceId, runnedAt: new Date(timestamp), }) .returning(); if (!newRun[0]) { throw new HTTPException(400, { message: "Something went wrong" }); } const allResult = []; for (const region of validateMonitor.data.regions) { const status = monitorStatus.data.find((m) => region === m.region)?.status || "active"; const payload = getCheckerPayload(row, status); const url = getCheckerUrl(row); const result = fetch(url, { headers: { "Content-Type": "application/json", "fly-prefer-region": region, // Specify the region you want the request to be sent to Authorization: `Basic ${env.CRON_SECRET}`, }, method: "POST", body: JSON.stringify(payload), }); allResult.push(result); } await Promise.all(allResult); return c.json({ resultId: newRun[0].id }, 200); }); } ================================================ FILE: apps/server/src/routes/v1/monitors/trigger/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { ParamsSchema } from "../schema"; export { ParamsSchema }; export const TriggerSchema = z.object({ resultId: z.number().openapi({ description: "the id of your check result" }), }); export type TriggerSchema = z.infer<typeof TriggerSchema>; ================================================ FILE: apps/server/src/routes/v1/monitors/utils.ts ================================================ import type { Assertion } from "@openstatus/assertions"; import { HeaderAssertion, StatusAssertion, TextBodyAssertion, } from "@openstatus/assertions"; import type { z } from "zod"; import type { assertion, assertionsSchema } from "./schema"; export const getAssertions = ( assertions: z.infer<typeof assertion>[], ): Assertion[] => { const assert: Assertion[] = []; for (const a of assertions) { if (a.type === "header") { assert.push(new HeaderAssertion({ ...a, version: "v1" })); } if (a.type === "textBody") { assert.push(new TextBodyAssertion({ ...a, version: "v1" })); } if (a.type === "status") { assert.push(new StatusAssertion({ ...a, version: "v1" })); } } return assert; }; export const getAssertionNew = ( assertions: z.infer<typeof assertionsSchema>[], ): Assertion[] => { const assert: Assertion[] = []; for (const a of assertions) { if (a.kind === "header") { const { kind, ...rest } = a; assert.push( new HeaderAssertion({ ...rest, type: "header", version: "v1", }), ); } if (a.kind === "textBody") { const { kind, ...rest } = a; assert.push( new TextBodyAssertion({ ...rest, type: "textBody", version: "v1" }), ); } if (a.kind === "statusCode") { const { kind, ...rest } = a; assert.push( new StatusAssertion({ ...rest, type: "status", version: "v1" }), ); } } return assert; }; ================================================ FILE: apps/server/src/routes/v1/notifications/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { NotificationSchema } from "./schema"; test("return the notification", async () => { const res = await app.request("/v1/notification/1", { headers: { "x-openstatus-key": "1", }, }); const result = NotificationSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/notification/1"); expect(res.status).toBe(401); }); test("invalid notification id should return 404", async () => { const res = await app.request("/v1/notification/404", { headers: { "x-openstatus-key": "1", }, }); expect(res.status).toBe(404); }); test("invalid auth key should return 404", async () => { const res = await app.request("/v1/notification/1", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/notifications/get.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { and, db, eq } from "@openstatus/db"; import { notification, notificationsToMonitors, } from "@openstatus/db/src/schema"; import type { notificationsApi } from "./index"; import { NotificationSchema, ParamsSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["notification"], summary: "Get a notification", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: NotificationSchema, }, }, description: "Get an Status page", }, ...openApiErrorResponses, }, }); export function registerGetNotification(api: typeof notificationsApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _notification = await db .select() .from(notification) .where( and( eq(notification.workspaceId, workspaceId), eq(notification.id, Number(id)), ), ) .get(); if (!_notification) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Notification ${id} not found`, }); } const _monitors = await db .select() .from(notificationsToMonitors) .where(eq(notificationsToMonitors.notificationId, Number(id))) .all(); const data = NotificationSchema.parse({ ..._notification, payload: JSON.parse(_notification.data || "{}"), monitors: _monitors.map((m) => m.monitorId), }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/notifications/get_all.test.ts ================================================ import { afterAll, beforeAll, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { notification } from "@openstatus/db/src/schema"; import { app } from "@/index"; import { NotificationSchema } from "./schema"; const TEST_PREFIX = "v1-notif-getall-test"; let testNotificationId: number; beforeAll(async () => { await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-email`)); const notif = await db .insert(notification) .values({ workspaceId: 1, name: `${TEST_PREFIX}-email`, provider: "email", data: '{"email":"test@test.com"}', }) .returning() .get(); testNotificationId = notif.id; }); afterAll(async () => { await db .delete(notification) .where(eq(notification.name, `${TEST_PREFIX}-email`)); }); test("return all notifications", async () => { const res = await app.request("/v1/notification", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = NotificationSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.some((n) => n.id === testNotificationId)).toBe(true); }); test("return empty notifications", async () => { const res = await app.request("/v1/notification", { method: "GET", headers: { "x-openstatus-key": "3", }, }); const result = NotificationSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/notification", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/notifications/get_all.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { openApiErrorResponses } from "@/libs/errors"; import { db, eq, inArray } from "@openstatus/db"; import { notification, notificationsToMonitors, } from "@openstatus/db/src/schema"; import type { notificationsApi } from "./index"; import { NotificationSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["notification"], summary: "List all notifications", path: "/", responses: { 200: { content: { "application/json": { schema: NotificationSchema.array(), }, }, description: "Get all your workspace notification", }, ...openApiErrorResponses, }, }); export function registerGetAllNotifications(app: typeof notificationsApi) { return app.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _notifications = await db .select() .from(notification) .where(eq(notification.workspaceId, workspaceId)) .all(); const _monitors = await db .select() .from(notificationsToMonitors) .where( inArray( notificationsToMonitors.notificationId, _notifications.map((n) => n.id), ), ) .all(); const data = NotificationSchema.array().parse( _notifications.map((n) => ({ ...n, payload: JSON.parse(n.data || "{}"), monitors: _monitors .filter((m) => m.notificationId === n.id) .map((m) => m.monitorId), })), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/notifications/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetNotification } from "./get"; import { registerGetAllNotifications } from "./get_all"; import { registerPostNotification } from "./post"; export const notificationsApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetAllNotifications(notificationsApi); registerGetNotification(notificationsApi); registerPostNotification(notificationsApi); ================================================ FILE: apps/server/src/routes/v1/notifications/post.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; import { notification } from "@openstatus/db/src/schema"; import { NotificationSchema } from "./schema"; test("create a notification", async () => { const res = await app.request("/v1/notification", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ name: "OpenStatus", provider: "email", payload: { email: "ping@openstatus.dev" }, monitors: [1], }), }); const result = NotificationSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created notification if (result.success) { await db.delete(notification).where(eq(notification.id, result.data.id)); } }); test("create a notification with invalid monitor ids should return a 400", async () => { const res = await app.request("/v1/notification", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ name: "OpenStatus", provider: "email", payload: { email: "ping@openstatus.dev" }, monitors: [404], }), }); expect(res.status).toBe(400); }); test("create a email notification with invalid payload should return a 400", async () => { const res = await app.request("/v1/notification", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ name: "OpenStatus", provider: "email", payload: { hello: "world" }, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/notification", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/notifications/post.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { and, db, eq, inArray, isNull, sql } from "@openstatus/db"; import { NotificationDataSchema, monitor, notification, notificationsToMonitors, selectNotificationSchema, } from "@openstatus/db/src/schema"; import type { notificationsApi } from "./index"; import { NotificationSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["notification"], summary: "Create a notification", path: "/", middleware: [trackMiddleware(Events.CreateNotification, ["provider"])], request: { body: { description: "The notification to create", content: { "application/json": { schema: NotificationSchema.omit({ id: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: NotificationSchema, }, }, description: "Return the created notification", }, ...openApiErrorResponses, }, }); export function registerPostNotification(api: typeof notificationsApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const workspacePlan = c.get("workspace").plan; const limits = c.get("workspace").limits; const input = c.req.valid("json"); if (input.provider === "sms" && workspacePlan === "free") { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for SMS", }); } const count = ( await db .select({ count: sql<number>`count(*)` }) .from(notification) .where(eq(notification.workspaceId, workspaceId)) .all() )[0].count; if (count >= limits["notification-channels"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more notification channels", }); } const { payload, monitors, ...rest } = input; if (monitors?.length) { const _monitors = await db .select() .from(monitor) .where( and( inArray(monitor.id, monitors), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitors.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitors.join(", ")} not found`, }); } } const _notification = await db .insert(notification) .values({ ...rest, workspaceId: workspaceId, data: JSON.stringify(payload), }) .returning() .get(); if (monitors?.length) { for (const monitorId of monitors) { await db .insert(notificationsToMonitors) .values({ notificationId: _notification.id, monitorId }) .run(); } } // FIXME: too complex const d = selectNotificationSchema.parse(_notification); const _payload = NotificationDataSchema.parse(JSON.parse(d.data)); const data = NotificationSchema.parse({ ..._notification, monitors, payload: _payload, }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/notifications/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { notificationProvider } from "@openstatus/db/src/schema/notifications/constants"; export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the notification", example: "1", }), }); export const NotificationSchema = z .object({ id: z .number() .openapi({ description: "The id of the notification", example: 1 }), name: z.string().openapi({ description: "The name of the notification", example: "OpenStatus Discord", }), provider: z.enum(notificationProvider).openapi({ description: "The provider of the notification", example: "discord", }), payload: z.any().openapi({ description: "The data of the notification", }), monitors: z .array(z.number()) .nullish() .openapi({ description: "The monitors that the notification is linked to", example: [1, 2], }), }) .openapi("Notification"); export type NotificationSchema = z.infer<typeof NotificationSchema>; ================================================ FILE: apps/server/src/routes/v1/pageSubscribers/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerPostPageSubscriber } from "./post"; export const pageSubscribersApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerPostPageSubscriber(pageSubscribersApi); ================================================ FILE: apps/server/src/routes/v1/pageSubscribers/post.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; import { pageSubscriber } from "@openstatus/db/src/schema"; import { PageSubscriberSchema } from "./schema"; test("create a page subscription", async () => { const res = await app.request("/v1/page_subscriber/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ email: "ping@openstatus.dev" }), }); const result = PageSubscriberSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created page subscriber if (result.success) { await db .delete(pageSubscriber) .where(eq(pageSubscriber.id, result.data.id)); } }); test("create a scubscriber with invalid email should return a 400", async () => { const res = await app.request("/v1/page_subscriber/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ email: "ping" }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/page_subscriber/1/update", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/pageSubscribers/post.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { and, eq, isNotNull } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { page, pageSubscriber } from "@openstatus/db/src/schema"; import { SubscribeEmail, sendEmail } from "@openstatus/emails"; import type { pageSubscribersApi } from "./index"; import { PageSubscriberSchema, ParamsSchema } from "./schema"; const postRouteSubscriber = createRoute({ method: "post", tags: ["page_subscriber"], summary: "Subscribe to a status page", path: "/{id}/update", middleware: [trackMiddleware(Events.SubscribePage)], description: "Add a subscriber to a status page", // TODO: how to define legacy routes request: { params: ParamsSchema, body: { description: "The subscriber payload", content: { "application/json": { schema: PageSubscriberSchema.pick({ email: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: PageSubscriberSchema, }, }, description: "The user has been subscribed", }, ...openApiErrorResponses, }, }); export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { return api.openapi(postRouteSubscriber, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); const { id } = c.req.valid("param"); if (!limits["status-subscribers"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for status subscribers", }); } const _page = await db .select() .from(page) .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Page ${id} not found`, }); } const alreadySubscribed = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.email, input.email), eq(pageSubscriber.pageId, Number(id)), isNotNull(pageSubscriber.acceptedAt), isNotNull(pageSubscriber.unsubscribedAt), ), ) .get(); if (alreadySubscribed) { throw new OpenStatusApiError({ code: "CONFLICT", message: `Email ${input.email} already subscribed`, }); } const token = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); const _statusReportSubscriberUpdate = await db .insert(pageSubscriber) .values({ pageId: _page.id, email: input.email, token, expiresAt, }) .returning() .get(); const link = `https://${_page.slug}.openstatus.dev/verify/${token}`; await sendEmail({ react: SubscribeEmail({ link, page: _page.title, }), from: "OpenStatus <notification@notifications.openstatus.dev>", to: [input.email], subject: "Verify your subscription", }); const data = PageSubscriberSchema.parse(_statusReportSubscriberUpdate); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/pageSubscribers/schema.ts ================================================ import { z } from "@hono/zod-openapi"; export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the page", example: "1", }), }); export const PageSubscriberSchema = z .object({ id: z.number().openapi({ description: "The id of the subscriber", example: 1, }), email: z.email().openapi({ description: "The email of the subscriber", }), pageId: z.number().openapi({ description: "The id of the page to subscribe to", example: 1, }), }) .openapi("PageSubscriber"); export type PageSubscriberSchema = z.infer<typeof PageSubscriberSchema>; ================================================ FILE: apps/server/src/routes/v1/pages/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { PageSchema } from "./schema"; test("return the page", async () => { const res = await app.request("/v1/page/1", { headers: { "x-openstatus-key": "1", }, }); const result = PageSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/page/2"); expect(res.status).toBe(401); }); test("invalid page id should return 404", async () => { const res = await app.request("/v1/page/2", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/pages/get.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import { and, eq } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { page } from "@openstatus/db/src/schema"; import type { pagesApi } from "./index"; import { PageSchema, ParamsSchema, transformPageData } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["page"], summary: "Get a status page", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: PageSchema, }, }, description: "Get an Status page", }, ...openApiErrorResponses, }, }); export function registerGetPage(api: typeof pagesApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _page = await db.query.page.findFirst({ where: and(eq(page.workspaceId, workspaceId), eq(page.id, Number(id))), with: { pageComponents: true, }, }); if (!_page) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Page ${id} not found`, }); } const monitorIds = _page.pageComponents .map((pc) => pc.monitorId) .filter(notEmpty); const data = transformPageData( PageSchema.parse({ ..._page, monitors: monitorIds.length > 0 ? monitorIds : undefined, }), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/pages/get_all.test.ts ================================================ import { afterAll, beforeAll, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { monitor, page, pageComponent } from "@openstatus/db/src/schema"; import { app } from "@/index"; import { PageSchema } from "./schema"; const TEST_PREFIX = "v1-page-getall-test"; let testMonitorId: number; let testPageId: number; beforeAll(async () => { await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor`, url: "https://test.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", method: "GET", timeout: 30000, }) .returning() .get(); testMonitorId = mon.id; const p = await db .insert(page) .values({ workspaceId: 1, title: `${TEST_PREFIX}-page`, slug: `${TEST_PREFIX}-slug`, description: "Test page", customDomain: "", }) .returning() .get(); testPageId = p.id; await db.insert(pageComponent).values({ workspaceId: 1, pageId: testPageId, monitorId: testMonitorId, type: "monitor", name: `${TEST_PREFIX}-component`, order: 0, }); }); afterAll(async () => { await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); }); test("return all pages", async () => { const res = await app.request("/v1/page", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = PageSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.some((p) => p.id === testPageId)).toBe(true); }); test("return empty pages", async () => { const res = await app.request("/v1/page", { method: "GET", headers: { "x-openstatus-key": "3", }, }); const result = PageSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/page", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/pages/get_all.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import { db, eq } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; import type { pagesApi } from "./index"; import { PageSchema, transformPageData } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["page"], summary: "List all status pages", path: "/", responses: { 200: { content: { "application/json": { schema: PageSchema.array(), }, }, description: "A list of your status pages", }, ...openApiErrorResponses, }, }); export function registerGetAllPages(api: typeof pagesApi) { return api.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _pages = await db.query.page.findMany({ where: eq(page.workspaceId, workspaceId), with: { pageComponents: true, }, }); const data = PageSchema.array() .parse( _pages.map((p) => { const monitorIds = p.pageComponents .map((pc) => pc.monitorId) .filter(notEmpty); return { ...p, monitors: monitorIds.length > 0 ? monitorIds : undefined, }; }), ) .map((page) => transformPageData(page)); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/pages/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetPage } from "./get"; import { registerGetAllPages } from "./get_all"; import { registerPostPage } from "./post"; import { registerPutPage } from "./put"; export const pagesApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetPage(pagesApi); registerGetAllPages(pagesApi); registerPutPage(pagesApi); registerPostPage(pagesApi); ================================================ FILE: apps/server/src/routes/v1/pages/post.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; import { monitor, page, pageComponent } from "@openstatus/db/src/schema"; import { PageSchema } from "./schema"; test("create a valid page", async () => { const uniqueSlug = `openstatus-${Date.now()}`; const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: uniqueSlug, monitors: [1], }), }); const result = PageSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // Cleanup: delete the created page if (result.success) { await db.delete(page).where(eq(page.id, result.data.id)); } }); test("create a page with invalid monitor ids should return a 400", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: "another-openstatus", monitors: [404], }), }); expect(res.status).toBe(400); }); test("create a page with password on free plan should return a 402", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "2", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: "password-openstatus", passwordProtected: true, }), }); expect(res.status).toBe(402); }); test("create a email page with invalid payload should return a 400", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ name: "OpenStatus", provider: "email", payload: { hello: "world" }, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create a page with custom domain without limits should return 402", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "2", // Free plan "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: `custom-domain-${Date.now()}`, customDomain: "status.example.com", }), }); expect(res.status).toBe(402); const json = await res.json(); expect(json.message).toBe("Upgrade for custom domains"); }); test("create a page with custom domain containing 'openstatus' should return 400", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: `openstatus-domain-${Date.now()}`, customDomain: "status.openstatus.dev", }), }); expect(res.status).toBe(400); const json = await res.json(); expect(json.message).toBe("Domain cannot contain 'openstatus'"); }); test("create a page with reserved slug should return 400", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: "api", // Reserved slug }), }); expect(res.status).toBe(400); const json = await res.json(); expect(json.message).toBe("Slug is reserved"); }); test("create a page with duplicate slug should return 400", async () => { const uniqueSlug = `duplicate-test-${Date.now()}`; // Create first page const res1 = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus First", description: "First page", slug: uniqueSlug, }), }); expect(res1.status).toBe(200); const result1 = PageSchema.safeParse(await res1.json()); expect(result1.success).toBe(true); // Try to create second page with same slug const res2 = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus Second", description: "Second page", slug: uniqueSlug, }), }); expect(res2.status).toBe(400); const json = await res2.json(); expect(json.message).toBe("Slug has to be unique and has already been taken"); // Cleanup if (result1.success) { await db.delete(page).where(eq(page.id, result1.data.id)); } }); test("create a page with email domain protection on free plan should return 402", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "2", // Free plan "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: `email-domain-${Date.now()}`, accessType: "email-domain", authEmailDomains: ["example.com"], }), }); expect(res.status).toBe(402); const json = await res.json(); expect(json.message).toBe("Upgrade for email domain protection"); }); test("create a page with accessType password on free plan should return 402", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "2", // Free plan "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: `access-type-password-${Date.now()}`, accessType: "password", password: "secret123", }), }); expect(res.status).toBe(402); const json = await res.json(); expect(json.message).toBe("Upgrade for password protection"); }); test("create a page with monitors as objects with order", async () => { const uniqueSlug = `ordered-monitors-${Date.now()}`; const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus Ordered", description: "Page with ordered monitors", slug: uniqueSlug, monitors: [ { monitorId: 1, order: 1 }, { monitorId: 2, order: 0 }, ], }), }); expect(res.status).toBe(200); const result = PageSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { // Verify pageComponent entries were created with correct order const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, result.data.id)) .all(); expect(components.length).toBe(2); expect(components.find((c) => c.monitorId === 1)?.order).toBe(1); expect(components.find((c) => c.monitorId === 2)?.order).toBe(0); // Cleanup await db.delete(page).where(eq(page.id, result.data.id)); } }); test("create a page without monitors should succeed", async () => { const uniqueSlug = `no-monitors-${Date.now()}`; const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus No Monitors", description: "Page without monitors", slug: uniqueSlug, }), }); expect(res.status).toBe(200); const result = PageSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { // Verify no pageComponent entries were created const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, result.data.id)) .all(); expect(components.length).toBe(0); // Cleanup await db.delete(page).where(eq(page.id, result.data.id)); } }); test("create a page with monitors as number array should use index as order", async () => { const uniqueSlug = `number-array-${Date.now()}`; const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus Number Array", description: "Page with monitors as numbers", slug: uniqueSlug, monitors: [2, 1], }), }); expect(res.status).toBe(200); const result = PageSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { // Verify pageComponent entries were created with index as order const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, result.data.id)) .all(); expect(components.length).toBe(2); expect(components.find((c) => c.monitorId === 2)?.order).toBe(0); expect(components.find((c) => c.monitorId === 1)?.order).toBe(1); // Cleanup await db.delete(page).where(eq(page.id, result.data.id)); } }); test("create a page with partial invalid monitors should return 400", async () => { const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "OpenStatus", description: "OpenStatus website", slug: `partial-invalid-${Date.now()}`, monitors: [1, 999], // 1 exists, 999 doesn't }), }); expect(res.status).toBe(400); const json = await res.json(); expect(json.message).toContain("not found"); }); test("create a page syncs correctly to pageComponent", async () => { const uniqueSlug = `sync-test-${Date.now()}`; const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "Sync Test", description: "Testing sync to both tables", slug: uniqueSlug, monitors: [{ monitorId: 1, order: 0 }], }), }); expect(res.status).toBe(200); const result = PageSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { // Verify pageComponent (primary table) const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, result.data.id)) .all(); expect(components.length).toBe(1); expect(components[0].monitorId).toBe(1); expect(components[0].type).toBe("monitor"); // Cleanup await db.delete(page).where(eq(page.id, result.data.id)); } }); test("create a page uses monitor externalName when available", async () => { const uniqueSlug = `external-name-${Date.now()}`; // First, check if monitor has externalName set const monitorData = await db .select() .from(monitor) .where(eq(monitor.id, 1)) .get(); const res = await app.request("/v1/page", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ title: "External Name Test", description: "Testing monitor external name", slug: uniqueSlug, monitors: [1], }), }); expect(res.status).toBe(200); const result = PageSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { const components = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, result.data.id)) .all(); expect(components.length).toBe(1); // Should use externalName if available, otherwise name const expectedName = monitorData?.externalName || monitorData?.name; if (!expectedName) { throw new Error("Expected name is undefined"); } expect(components[0].name).toBe(expectedName); // Cleanup await db.delete(page).where(eq(page.id, result.data.id)); } }); ================================================ FILE: apps/server/src/routes/v1/pages/post.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { monitor, page, pageComponent, subdomainSafeList, } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { Events } from "@openstatus/analytics"; import { isNumberArray } from "../utils"; import type { pagesApi } from "./index"; import { PageSchema, transformPageData } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["page"], summary: "Create a status page", path: "/", middleware: [trackMiddleware(Events.CreatePage, ["slug"])], request: { body: { description: "The status page to create", content: { "application/json": { schema: PageSchema.omit({ id: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: PageSchema, }, }, description: "Get an Status page", }, ...openApiErrorResponses, }, }); export function registerPostPage(api: typeof pagesApi) { return api.openapi(postRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const input = c.req.valid("json"); if (input.customDomain && !limits["custom-domain"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for custom domains", }); } if (input.customDomain?.toLowerCase().includes("openstatus")) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Domain cannot contain 'openstatus'", }); } if ( !limits["password-protection"] && (input?.passwordProtected || input?.password) ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for password protection", }); } if ( !limits["password-protection"] && (input?.accessType === "password" || input?.password) ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for password protection", }); } if ( !limits["email-domain-protection"] && (input?.accessType === "email-domain" || input?.authEmailDomains?.length) ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for email domain protection", }); } const count = ( await db .select({ count: sql<number>`count(*)` }) .from(page) .where(eq(page.workspaceId, workspaceId)) .all() )[0].count; if (count >= limits["status-pages"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for more status pages", }); } if (subdomainSafeList.includes(input.slug)) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Slug is reserved", }); } const countSlug = ( await db .select({ count: sql<number>`count(*)` }) .from(page) .where(eq(page.slug, input.slug)) .all() )[0].count; if (countSlug > 0) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Slug has to be unique and has already been taken", }); } const { monitors, ...rest } = input; if (monitors?.length) { const monitorIds = isNumberArray(monitors) ? monitors : monitors.map((m) => m.monitorId); const _monitors = await db .select() .from(monitor) .where( and( inArray(monitor.id, monitorIds), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== monitors.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitorIds.join(", ")} not found`, }); } } const _page = await db .insert(page) .values({ ...rest, workspaceId: workspaceId, customDomain: rest.customDomain ?? "", // TODO : make database migration to allow null accessType: rest.accessType ?? (rest.passwordProtected ? "password" : "public"), authEmailDomains: rest.authEmailDomains?.join(","), }) .returning() .get(); // TODO: missing order if (monitors?.length) { for (const [index, m] of monitors.entries()) { const values = typeof m === "number" ? { monitorId: m } : m; const _monitor = await db.query.monitor.findFirst({ where: and( eq(monitor.id, values.monitorId), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), }); if (!_monitor) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Monitor ${values.monitorId} not found`, }); } // Insert to pageComponent (primary table) await db .insert(pageComponent) .values({ workspaceId: _page.workspaceId, pageId: _page.id, type: "monitor", monitorId: values.monitorId, name: _monitor.externalName || _monitor.name, order: "order" in values ? values.order : index, groupId: null, groupOrder: 0, }) .run(); } } const data = transformPageData(PageSchema.parse(_page)); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/pages/put.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { PageSchema } from "./schema"; test("update the page with monitor ids", async () => { const res = await app.request("/v1/page/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ title: "New Title", monitors: [1, 2], }), }); const result = PageSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.title).toBe("New Title"); expect(result.data?.monitors).toEqual([1, 2]); }); test("update the page with monitor objects", async () => { const res = await app.request("/v1/page/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ monitors: [ { monitorId: 1, order: 1 }, { monitorId: 2, order: 2 }, ], }), }); const result = PageSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitors).toEqual([ { monitorId: 1, order: 1 }, { monitorId: 2, order: 2 }, ]); }); test("update the page with invalid monitors should return 400", async () => { const res = await app.request("/v1/page/1", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ monitors: [404], }), }); expect(res.status).toBe(400); }); test("invalid page id should return 404", async () => { const res = await app.request("/v1/page/404", { method: "PUT", headers: { "x-openstatus-key": "1", "Content-Type": "application/json", }, body: JSON.stringify({ acknowledgedAt: new Date().toISOString(), }), }); expect(res.status).toBe(404); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/page/2", { method: "PUT", headers: { "content-type": "application/json", }, body: JSON.stringify({ acknowledgedAt: new Date().toISOString(), }), }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/pages/put.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; import { notEmpty } from "@/utils/not-empty"; import { Events } from "@openstatus/analytics"; import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { monitor, page, pageComponent, subdomainSafeList, } from "@openstatus/db/src/schema"; import { isNumberArray } from "../utils"; import type { pagesApi } from "./index"; import { PageSchema, ParamsSchema, transformPageData } from "./schema"; const putRoute = createRoute({ method: "put", tags: ["page"], summary: "Update a status page", path: "/{id}", middleware: [trackMiddleware(Events.UpdatePage)], request: { params: ParamsSchema, body: { description: "The monitor to update", content: { "application/json": { schema: PageSchema.omit({ id: true }).partial(), }, }, }, }, responses: { 200: { content: { "application/json": { schema: PageSchema, }, }, description: "Get an Status page", }, ...openApiErrorResponses, }, }); export function registerPutPage(api: typeof pagesApi) { return api.openapi(putRoute, async (c) => { const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const { id } = c.req.valid("param"); const input = c.req.valid("json"); if (input.customDomain && !limits["custom-domain"]) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for custom domain", }); } if (input.customDomain?.toLowerCase().includes("openstatus")) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Domain cannot contain 'openstatus'", }); } if ( limits["password-protection"] === false && input?.passwordProtected === true ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for password protection", }); } if ( limits["email-domain-protection"] === false && (input?.accessType === "email-domain" || input?.authEmailDomains?.length) ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for email domain protection", }); } if ( limits["password-protection"] === false && (input?.accessType === "password" || input?.password) ) { throw new OpenStatusApiError({ code: "PAYMENT_REQUIRED", message: "Upgrade for password protection", }); } const _page = await db .select() .from(page) .where(and(eq(page.id, Number(id)), eq(page.workspaceId, workspaceId))) .get(); if (!_page) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Page ${id} not found`, }); } if (input.slug && _page.slug !== input.slug) { if (subdomainSafeList.includes(input.slug)) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Slug is reserved", }); } const countSlug = ( await db .select({ count: sql<number>`count(*)` }) .from(page) .where(eq(page.slug, input.slug)) .all() )[0].count; if (countSlug > 0) { throw new OpenStatusApiError({ code: "CONFLICT", message: "Slug has to be unique and has already been taken", }); } } const { monitors, ...rest } = input; const monitorIds = monitors ? isNumberArray(monitors) ? monitors : monitors.map((m) => m.monitorId) : []; if (monitors?.length) { const monitorsData = await db .select() .from(monitor) .where( and( inArray(monitor.id, monitorIds), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), ) .all(); if (monitorsData.length !== monitors.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${monitorIds.join(", ")} not found`, }); } } const newPage = await db .update(page) .set({ ...rest, customDomain: input.customDomain ?? "", accessType: rest.accessType ?? (rest.passwordProtected ? "password" : "public"), authEmailDomains: rest.authEmailDomains?.join(","), updatedAt: new Date(), }) .where(eq(page.id, _page.id)) .returning() .get(); const currentPageComponents = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, _page.id)) .all(); const currentMonitorIds = currentPageComponents .filter((pc) => pc.type === "monitor" && pc.monitorId !== null) .map((pc) => pc.monitorId as number); const removedMonitorIds = currentMonitorIds.filter( (id) => !monitorIds?.includes(id), ); // Delete removed monitors from pageComponent if (removedMonitorIds.length) { await db .delete(pageComponent) .where( and( inArray(pageComponent.monitorId, removedMonitorIds), eq(pageComponent.pageId, newPage.id), ), ); } // Insert or update pageComponents (primary table) if (monitors) { for (const [index, m] of monitors.entries()) { const values = typeof m === "number" ? { monitorId: m } : m; const _monitor = await db.query.monitor.findFirst({ where: and( eq(monitor.id, values.monitorId), eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt), ), }); if (!_monitor) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Monitor ${values.monitorId} not found`, }); } // Insert or update pageComponent await db .insert(pageComponent) .values({ workspaceId: newPage.workspaceId, pageId: newPage.id, type: "monitor", monitorId: values.monitorId, name: _monitor.externalName || _monitor.name, order: "order" in values ? values.order : index, groupId: null, groupOrder: 0, }) .onConflictDoUpdate({ target: [pageComponent.monitorId, pageComponent.pageId], set: { order: sql.raw("excluded.`order`"), name: sql.raw("excluded.`name`"), }, }) .run(); } } const data = transformPageData( PageSchema.parse({ ...newPage, monitors: monitors || currentPageComponents.map((pc) => pc.monitorId).filter(notEmpty), }), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/pages/schema.ts ================================================ import { z } from "@hono/zod-openapi"; export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the page", example: "1", }), }); export const PageSchema = z .object({ id: z.number().openapi({ description: "The id of the page", example: 1, }), title: z.string().openapi({ description: "The title of the page", example: "My Page", }), description: z.string().openapi({ description: "The description of the page", example: "My awesome status page", }), slug: z.string().openapi({ description: "The slug of the page", example: "my-page", }), // REMINDER: needs to be configured on Dashboard UI customDomain: z .string() .transform((val) => (val ? val : undefined)) .nullish() .openapi({ description: "The custom domain of the page. To be configured within the dashboard.", example: "status.acme.com", }), icon: z .url() .or(z.literal("")) .transform((val) => (val ? val : undefined)) .nullish() .openapi({ description: "The icon of the page", example: "https://example.com/icon.png", }), passwordProtected: z.boolean().optional().prefault(false).openapi({ description: "Deprecated in favor of `accessType`. Used to set the password protection type. Returns true if `accessType` is set to 'password' and false otherwise.", example: true, deprecated: true, }), accessType: z .enum(["public", "password", "email-domain"]) .default("public") .openapi({ description: "The access type of the page", example: "public", }), password: z.string().optional().nullish().openapi({ description: "Your password to protect the page from the public", example: "hidden-password", }), authEmailDomains: z .preprocess((val) => { let parsedDomains: Array<unknown> = []; if (!val) return parsedDomains; if (Array.isArray(val)) { parsedDomains = val; } if (String(val).length > 0) { parsedDomains = String(val).split(","); } return parsedDomains; }, z.array(z.string())) .optional() .nullish() .openapi({ description: "The email domains of the page", example: ["example.com", "example.org"], }), showMonitorValues: z.boolean().optional().nullish().prefault(true).openapi({ description: "Displays the total and failed request numbers for each monitor. Deprecated and will be removed in the future in favor for `configuration` property.", example: true, deprecated: true, }), monitors: z .array(z.number()) .openapi({ description: "The monitors of the page as an array of ids. We recommend using the object format to include the order.", deprecated: true, example: [1, 2], }) .or( z .array(z.object({ monitorId: z.number(), order: z.number() })) .openapi({ description: "The monitor as object allowing to pass id and order", example: [ { monitorId: 1, order: 0 }, { monitorId: 2, order: 1 }, ], }), ) .optional(), }) .openapi("Page"); export type PageSchema = z.infer<typeof PageSchema>; /** * Transforms page data to ensure passwordProtected reflects accessType * This should be used when parsing page data for responses * * NOTE: cannot be used in `PageSchema` because `.omit` is not supported otherwise */ export function transformPageData< T extends { accessType?: string; passwordProtected?: boolean }, >(data: T): T & { passwordProtected: boolean } { return { ...data, passwordProtected: data.accessType === "password" ? true : data.passwordProtected ?? false, }; } ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { StatusReportUpdateSchema } from "./schema"; test("return the status report update", async () => { const res = await app.request("/v1/status_report_update/2", { headers: { "x-openstatus-key": "1", }, }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report_update/2"); expect(res.status).toBe(401); }); test("invalid status report id should return 404", async () => { const res = await app.request("/v1/status_report_update/2", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/get.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportUpdatesApi } from "./index"; import { ParamsSchema, StatusReportUpdateSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["status_report_update"], summary: "Get a status report update", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: StatusReportUpdateSchema, }, }, description: "Get a status report update", }, ...openApiErrorResponses, }, }); export function registerGetStatusReportUpdate( api: typeof statusReportUpdatesApi, ) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _statusReport = await db .select() .from(statusReportUpdate) .innerJoin( statusReport, and( eq(statusReport.id, statusReportUpdate.statusReportId), eq(statusReport.workspaceId, workspaceId), ), ) .where(eq(statusReportUpdate.id, Number(id))) .get(); if (!_statusReport) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report Update ${id} not found`, }); } const data = StatusReportUpdateSchema.parse( _statusReport.status_report_update, ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerGetStatusReportUpdate } from "./get"; import { registerPostStatusReportUpdate } from "./post"; export const statusReportUpdatesApi = new OpenAPIHono<{ Variables: Variables; }>({ defaultHook: handleZodError, }); registerGetStatusReportUpdate(statusReportUpdatesApi); registerPostStatusReportUpdate(statusReportUpdatesApi); ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/post.test.ts ================================================ import { beforeEach, expect, test } from "bun:test"; import { app } from "@/index"; import { StatusReportUpdateSchema } from "./schema"; // biome-ignore lint/suspicious/noExplicitAny: test utility const spies = (globalThis as any).__subscriptionSpies as { dispatchStatusReportUpdate: { mockClear: () => void; mock: { calls: number[][] }; }; }; beforeEach(() => { spies.dispatchStatusReportUpdate.mockClear(); }); test("create a valid status report update", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "Message", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create a status report update without valid payload should return 400", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: "test", }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create status report update with identified status should return 200", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "identified", date: new Date().toISOString(), message: "We have identified the root cause", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with monitoring status should return 200", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", date: new Date().toISOString(), message: "The fix has been deployed and we are monitoring", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with resolved status should return 200", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "resolved", date: new Date().toISOString(), message: "Issue has been fully resolved", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update without date should use default", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", message: "Update without explicit date", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with past date should return 200", async () => { const pastDate = new Date(); pastDate.setTime(pastDate.getTime() - 24 * 60 * 60 * 1000); const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: pastDate.toISOString(), message: "Update with past date", statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with long message should return 200", async () => { const longMessage = "This is a very detailed status update message that provides comprehensive information about the incident, including what happened, what is being done to resolve it, and what measures are being taken to prevent similar issues in the future. We apologize for any inconvenience this may have caused."; const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", date: new Date().toISOString(), message: longMessage, statusReportId: 1, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with different status report ID should return 200", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "Update for different report", statusReportId: 2, }), }); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("create status report update with invalid status should return 400", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "invalid_status", date: new Date().toISOString(), message: "Test message", statusReportId: 1, }), }); expect(res.status).toBe(400); }); test("create status report update without message should return 400", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), statusReportId: 1, }), }); expect(res.status).toBe(400); }); test("create status report update without statusReportId should return 400", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "Test message", }), }); expect(res.status).toBe(400); }); test("create status report update with empty message should return 400", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "", statusReportId: 1, }), }); expect(res.status).toBe(400); }); test("create status report update with non-existent statusReportId should return 404", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "Update for non-existent report", statusReportId: 9999, }), }); expect(res.status).toBe(404); }); test("create a status report update calls dispatchStatusReportUpdate", async () => { const res = await app.request("/v1/status_report_update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", date: new Date().toISOString(), message: "Testing dispatcher integration", statusReportId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportUpdateSchema.safeParse(await res.json()); expect(result.success).toBe(true); expect(spies.dispatchStatusReportUpdate.mock.calls.length).toBe(1); expect(spies.dispatchStatusReportUpdate.mock.calls[0][0]).toBeNumber(); }); ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/post.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport, statusReportUpdate } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { dispatchStatusReportUpdate } from "@openstatus/subscriptions"; import type { statusReportUpdatesApi } from "./index"; import { StatusReportUpdateSchema } from "./schema"; const createStatusUpdate = createRoute({ method: "post", tags: ["status_report_update"], summary: "Create a status report update", path: "/", request: { body: { description: "The status report update to create", content: { "application/json": { schema: StatusReportUpdateSchema.omit({ id: true }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: StatusReportUpdateSchema, }, }, description: "The created status report update", }, ...openApiErrorResponses, }, }); export function registerPostStatusReportUpdate( api: typeof statusReportUpdatesApi, ) { return api.openapi(createStatusUpdate, async (c) => { const workspaceId = c.get("workspace").id; const input = c.req.valid("json"); const limits = c.get("workspace").limits; const _statusReport = await db.query.statusReport.findFirst({ where: and( eq(statusReport.id, input.statusReportId), eq(statusReport.workspaceId, workspaceId), ), with: { statusReportsToPageComponents: { with: { pageComponent: true, }, }, }, }); if (!_statusReport) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report ${input.statusReportId} not found`, }); } const _statusReportUpdate = await db.transaction(async (tx) => { const update = await tx .insert(statusReportUpdate) .values({ ...input, date: new Date(input.date), statusReportId: _statusReport.id, }) .returning() .get(); await tx .update(statusReport) .set({ status: input.status, updatedAt: new Date(), }) .where(eq(statusReport.id, _statusReport.id)); return update; }); if (limits["status-subscribers"] && _statusReport.pageId) { await dispatchStatusReportUpdate(_statusReportUpdate.id); } const data = StatusReportUpdateSchema.parse(_statusReportUpdate); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReportUpdates/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { statusReportStatus } from "@openstatus/db/src/schema"; export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the update", example: "1", }), }); export const StatusReportUpdateSchema = z .object({ id: z.coerce.string().openapi({ description: "The id of the update" }), status: z.enum(statusReportStatus).openapi({ description: "The status of the update", }), date: z.coerce.date().prefault(new Date()).openapi({ description: "The date of the update in ISO8601 format", }), message: z.string().min(1).openapi({ description: "The message of the update", }), statusReportId: z.number().openapi({ description: "The id of the status report", }), }) .openapi("StatusReportUpdate"); export type StatusReportUpdateSchema = z.infer<typeof StatusReportUpdateSchema>; ================================================ FILE: apps/server/src/routes/v1/statusReports/delete.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { StatusReportSchema } from "./schema"; test("delete the status report", async () => { // Create a status report we will delete const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "New Status Report", message: "Message", monitorIds: [1], date: date.toISOString(), pageId: 1, }), }); const result = StatusReportSchema.safeParse(await res.json()); const del = await app.request(`/v1/status_report/${result.data?.id}`, { method: "DELETE", headers: { "x-openstatus-key": "1", }, }); expect(del.status).toBe(200); expect(await del.json()).toMatchObject({}); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report/2", { method: "DELETE" }); expect(res.status).toBe(401); }); test("invalid status report id should return 404", async () => { const res = await app.request("/v1/status_report/2", { method: "DELETE", headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/statusReports/delete.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { statusReportsApi } from "./index"; import { ParamsSchema } from "./schema"; const deleteRoute = createRoute({ method: "delete", tags: ["status_report"], summary: "Delete a status report", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: z.object({}), }, }, description: "Status report deleted", }, ...openApiErrorResponses, }, }); export function registerDeleteStatusReport(api: typeof statusReportsApi) { return api.openapi(deleteRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _statusReport = await db .select() .from(statusReport) .where( and( eq(statusReport.id, Number(id)), eq(statusReport.workspaceId, workspaceId), ), ) .get(); if (!_statusReport) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report ${id} not found`, }); } await db .delete(statusReport) .where(eq(statusReport.id, Number(id))) .run(); return c.json({}, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReports/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { StatusReportSchema } from "./schema"; test("return the status report", async () => { const res = await app.request("/v1/status_report/2", { headers: { "x-openstatus-key": "1", }, }); const result = StatusReportSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); // expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); // expect(result.data?.monitorIds?.length).toBe(0); }); test("return the status report with correct monitorIds structure", async () => { const res = await app.request("/v1/status_report/2", { headers: { "x-openstatus-key": "1", }, }); const result = StatusReportSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds).toBeDefined(); expect(Array.isArray(result.data?.monitorIds)).toBe(true); // Ensure each monitorId is a number for (const monitorId of result.data?.monitorIds || []) { expect(typeof monitorId).toBe("number"); } }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report/2"); expect(res.status).toBe(401); }); test("invalid status report id should return 404", async () => { const res = await app.request("/v1/status_report/2", { headers: { "x-openstatus-key": "2", }, }); expect(res.status).toBe(404); }); ================================================ FILE: apps/server/src/routes/v1/statusReports/get.ts ================================================ import { createRoute } from "@hono/zod-openapi"; import { and, db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import type { statusReportsApi } from "./index"; import { ParamsSchema, StatusReportSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["status_report"], summary: "Get a status report", path: "/{id}", request: { params: ParamsSchema, }, responses: { 200: { content: { "application/json": { schema: StatusReportSchema, }, }, description: "Get all status reports", }, ...openApiErrorResponses, }, }); export function regsiterGetStatusReport(api: typeof statusReportsApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const { id } = c.req.valid("param"); const _statusUpdate = await db.query.statusReport.findFirst({ with: { statusReportUpdates: true, statusReportsToPageComponents: { with: { pageComponent: true } }, }, where: and( eq(statusReport.workspaceId, workspaceId), eq(statusReport.id, Number(id)), ), }); if (!_statusUpdate) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report ${id} not found`, }); } const { statusReportUpdates, statusReportsToPageComponents } = _statusUpdate; // most recent report information const { message, date } = statusReportUpdates[statusReportUpdates.length - 1]; const data = StatusReportSchema.parse({ ..._statusUpdate, message, date, monitorIds: statusReportsToPageComponents .map((sr) => sr.pageComponent.monitorId) .filter(notEmpty), statusReportUpdateIds: statusReportUpdates.map((update) => update.id), }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReports/get_all.test.ts ================================================ import { afterAll, beforeAll, expect, test } from "bun:test"; import { db, eq } from "@openstatus/db"; import { monitor, pageComponent, statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import { app } from "@/index"; import { StatusReportSchema } from "./schema"; const TEST_PREFIX = "v1-sr-getall-test"; let testMonitorId: number; let testPageComponentId: number; let testStatusReportId: number; beforeAll(async () => { await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-report`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-monitor`, url: "https://test.example.com", periodicity: "1m", active: true, regions: "ams", jobType: "http", method: "GET", timeout: 30000, }) .returning() .get(); testMonitorId = mon.id; const comp = await db .insert(pageComponent) .values({ workspaceId: 1, pageId: 1, monitorId: testMonitorId, type: "monitor", name: `${TEST_PREFIX}-component`, order: 200, }) .returning() .get(); testPageComponentId = comp.id; const report = await db .insert(statusReport) .values({ workspaceId: 1, pageId: 1, title: `${TEST_PREFIX}-report`, status: "investigating", }) .returning() .get(); testStatusReportId = report.id; await db.insert(statusReportUpdate).values({ statusReportId: testStatusReportId, status: "investigating", message: "Test investigating", date: new Date("2099-01-01T00:00:00Z"), }); await db.insert(statusReportsToPageComponents).values({ statusReportId: testStatusReportId, pageComponentId: testPageComponentId, }); }); afterAll(async () => { await db .delete(statusReportsToPageComponents) .where( eq(statusReportsToPageComponents.statusReportId, testStatusReportId), ); await db .delete(statusReportUpdate) .where(eq(statusReportUpdate.statusReportId, testStatusReportId)); await db .delete(statusReport) .where(eq(statusReport.title, `${TEST_PREFIX}-report`)); await db .delete(pageComponent) .where(eq(pageComponent.name, `${TEST_PREFIX}-component`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`)); }); test("return all status reports", async () => { const res = await app.request("/v1/status_report", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = StatusReportSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.some((r) => r.id === testStatusReportId)).toBe(true); }); test("return all status reports with monitorIds", async () => { const res = await app.request("/v1/status_report", { method: "GET", headers: { "x-openstatus-key": "1", }, }); const result = StatusReportSchema.array().safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); const testReport = result.data?.find((r) => r.id === testStatusReportId); expect(testReport).toBeDefined(); expect(testReport?.monitorIds).toBeDefined(); expect(Array.isArray(testReport?.monitorIds)).toBe(true); expect(testReport?.monitorIds).toContain(testMonitorId); }); test("return empty status reports", async () => { const res = await app.request("/v1/status_report", { method: "GET", headers: { "x-openstatus-key": "3", }, }); const result = StatusReportSchema.array().safeParse(await res.json()); expect(result.success).toBe(true); expect(res.status).toBe(200); expect(result.data?.length).toBe(0); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report", { method: "GET", }); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/statusReports/get_all.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { db, eq } from "@openstatus/db"; import { statusReport } from "@openstatus/db/src/schema"; import { openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import type { statusReportsApi } from "./index"; import { StatusReportSchema } from "./schema"; const getAllRoute = createRoute({ method: "get", tags: ["status_report"], summary: "List all status reports", path: "/", request: {}, responses: { 200: { content: { "application/json": { schema: z.array(StatusReportSchema), }, }, description: "Get all status reports", }, ...openApiErrorResponses, }, }); export function registerGetAllStatusReports(api: typeof statusReportsApi) { return api.openapi(getAllRoute, async (c) => { const workspaceId = c.get("workspace").id; const _statusReports = await db.query.statusReport.findMany({ with: { statusReportUpdates: true, statusReportsToPageComponents: { with: { pageComponent: true } }, }, where: eq(statusReport.workspaceId, workspaceId), }); const data = z.array(StatusReportSchema).parse( _statusReports.map((r) => ({ ...r, statusReportUpdateIds: r.statusReportUpdates.map((u) => u.id), monitorIds: r.statusReportsToPageComponents .map((sr) => sr.pageComponent.monitorId) .filter(notEmpty), })), ); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReports/index.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { handleZodError } from "@/libs/errors"; import type { Variables } from "../index"; import { registerDeleteStatusReport } from "./delete"; import { regsiterGetStatusReport } from "./get"; import { registerGetAllStatusReports } from "./get_all"; import { registerPostStatusReport } from "./post"; import { registerStatusReportUpdateRoutes } from "./update/post"; export const statusReportsApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetAllStatusReports(statusReportsApi); registerDeleteStatusReport(statusReportsApi); regsiterGetStatusReport(statusReportsApi); registerPostStatusReport(statusReportsApi); /** * @deprecated in favor of `/status_report_updates` */ registerStatusReportUpdateRoutes(statusReportsApi); ================================================ FILE: apps/server/src/routes/v1/statusReports/post.test.ts ================================================ import { beforeEach, expect, test } from "bun:test"; import { app } from "@/index"; import { db, eq } from "@openstatus/db"; import { pageComponent, statusReport, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import { StatusReportSchema } from "./schema"; // biome-ignore lint/suspicious/noExplicitAny: test utility const spies = (globalThis as any).__subscriptionSpies as { dispatchStatusReportUpdate: { mockClear: () => void; mock: { calls: number[][] }; }; }; beforeEach(() => { spies.dispatchStatusReportUpdate.mockClear(); }); test("create a valid status report", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "New Status Report", message: "Message", monitorIds: [1], date: date.toISOString(), pageId: 1, }), }); const result = StatusReportSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.statusReportUpdateIds?.length).toBeGreaterThan(0); expect(result.data?.monitorIds?.length).toBe(1); expect(result.data?.monitorIds).toEqual([1]); }); test("create a status report with multiple monitorIds", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Multi-Monitor Status Report", message: "Affecting multiple monitors", monitorIds: [1, 2], date: date.toISOString(), pageId: 1, }), }); const result = StatusReportSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds?.length).toBe(2); expect(result.data?.monitorIds).toEqual(expect.arrayContaining([1, 2])); }); test("create a status report without monitorIds", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "General Status Report", message: "No specific monitors affected", date: date.toISOString(), pageId: 1, }), }); const result = StatusReportSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); expect(result.data?.monitorIds).toBeDefined(); expect(Array.isArray(result.data?.monitorIds)).toBe(true); }); test("create a status report with partial invalid monitorIds should return 400", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Partial Invalid Monitors", message: "One valid, one invalid", monitorIds: [1, 9999], date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(400); }); test("create a status report with invalid monitor should return 400", async () => { const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "New Status Report", message: "Message", monitorIds: [404], pageId: 1, }), }); expect(res.status).toBe(400); }); test("create a status report with invalid page id should return 400", async () => { const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "New Status Report", message: "Message", monitorIds: [1], pageId: 404, }), }); expect(res.status).toBe(400); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/status_report", { method: "POST", headers: { "content-type": "application/json", }, }); expect(res.status).toBe(401); }); test("create a status report calls dispatchStatusReportUpdate", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Dispatch Test Report", message: "Testing dispatcher integration", monitorIds: [1], date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportSchema.safeParse(await res.json()); expect(result.success).toBe(true); expect(spies.dispatchStatusReportUpdate.mock.calls.length).toBe(1); expect(spies.dispatchStatusReportUpdate.mock.calls[0][0]).toBeNumber(); if (result.success) { await db .delete(statusReport) .where(eq(statusReport.id, result.data.id)) .run(); } }); test("create a status report links correctly to statusReportsToPageComponents", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Sync Test Status Report", message: "Testing link to statusReportsToPageComponents", monitorIds: [1], date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { const statusReportId = result.data.id; // Verify statusReportsToPageComponents const components = await db .select() .from(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) .all(); expect(components.length).toBeGreaterThan(0); // Get the page component to verify it's linked correctly const pageComponents = await db .select() .from(pageComponent) .where(eq(pageComponent.id, components[0].pageComponentId)) .all(); expect(pageComponents.length).toBe(1); expect(pageComponents[0].monitorId).toBe(1); expect(pageComponents[0].pageId).toBe(1); expect(pageComponents[0].type).toBe("monitor"); // Cleanup await db .delete(statusReport) .where(eq(statusReport.id, statusReportId)) .run(); } }); test("create a status report with multiple monitors links correctly to statusReportsToPageComponents", async () => { const date = new Date(); date.setMilliseconds(0); // First, check which monitors from [1, 2] exist as page components on page 1 const existingPageComponents = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, 1)) .all(); const existingMonitorIds = existingPageComponents .filter( (c) => c.monitorId !== null && c.type === "monitor" && [1, 2].includes(c.monitorId), ) .map((c) => c.monitorId as number); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Multi-Monitor Test", message: "Testing with multiple monitors", monitorIds: [1, 2], date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { const statusReportId = result.data.id; // Verify statusReportsToPageComponents const components = await db .select() .from(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) .all(); // Should only link monitors that exist as page components on this page expect(components.length).toBe(existingMonitorIds.length); // Cleanup await db .delete(statusReport) .where(eq(statusReport.id, statusReportId)) .run(); } }); test("create a status report without monitorIds should not create page component links", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "No Monitors Status Report", message: "No specific monitors affected", date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { const statusReportId = result.data.id; // Verify no statusReportsToPageComponents entries const components = await db .select() .from(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) .all(); expect(components.length).toBe(0); // Cleanup await db .delete(statusReport) .where(eq(statusReport.id, statusReportId)) .run(); } }); test("create a status report only links monitors that exist as page components", async () => { const date = new Date(); date.setMilliseconds(0); // First, check which monitors exist as page components on page 1 const existingComponents = await db .select() .from(pageComponent) .where(eq(pageComponent.pageId, 1)) .all(); const existingMonitorIds = existingComponents .filter((c) => c.monitorId !== null && c.type === "monitor") .map((c) => c.monitorId as number); if (existingMonitorIds.length === 0) { // Skip test if no monitors exist on the page return; } const res = await app.request("/v1/status_report", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "investigating", title: "Page Component Link Test", message: "Testing page component linking", monitorIds: existingMonitorIds, date: date.toISOString(), pageId: 1, }), }); expect(res.status).toBe(200); const result = StatusReportSchema.safeParse(await res.json()); expect(result.success).toBe(true); if (result.success) { const statusReportId = result.data.id; // Verify statusReportsToPageComponents entries match existing components const components = await db .select() .from(statusReportsToPageComponents) .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) .all(); // Each linked component should correspond to a page component for (const component of components) { const pageComp = await db .select() .from(pageComponent) .where(eq(pageComponent.id, component.pageComponentId)) .get(); expect(pageComp).toBeDefined(); expect(pageComp?.pageId).toBe(1); expect(existingMonitorIds).toContain(pageComp?.monitorId as number); } // Cleanup await db .delete(statusReport) .where(eq(statusReport.id, statusReportId)) .run(); } }); ================================================ FILE: apps/server/src/routes/v1/statusReports/post.ts ================================================ import { createRoute, z } from "@hono/zod-openapi"; import { and, db, eq, inArray, isNull } from "@openstatus/db"; import { monitor, page, pageComponent, statusReport, statusReportUpdate, statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { dispatchStatusReportUpdate } from "@openstatus/subscriptions"; import type { statusReportsApi } from "./index"; import { StatusReportSchema } from "./schema"; const postRoute = createRoute({ method: "post", tags: ["status_report"], summary: "Create a status report", path: "/", request: { body: { description: "The status report to create", content: { "application/json": { schema: StatusReportSchema.omit({ id: true, statusReportUpdateIds: true, }).extend({ date: z.coerce.date().optional().prefault(new Date()).openapi({ description: "The date of the report in ISO8601 format, defaults to now", }), message: z.string().openapi({ description: "The message of the current status of incident", }), }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: StatusReportSchema, }, }, description: "The created status report", }, ...openApiErrorResponses, }, }); export function registerPostStatusReport(api: typeof statusReportsApi) { return api.openapi(postRoute, async (c) => { const input = c.req.valid("json"); const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; if (input.monitorIds?.length) { const _monitors = await db .select() .from(monitor) .where( and( eq(monitor.workspaceId, workspaceId), inArray(monitor.id, input.monitorIds), isNull(monitor.deletedAt), ), ) .all(); if (_monitors.length !== input.monitorIds.length) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Some of the monitors ${input.monitorIds.join( ", ", )} not found`, }); } } const _pages = await db .select() .from(page) .where(and(eq(page.workspaceId, workspaceId), eq(page.id, input.pageId))) .all(); if (_pages.length !== 1) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: `Page ${input.pageId} not found`, }); } const { _newStatusReport, _newStatusReportUpdate } = await db.transaction( async (tx) => { const _newStatusReport = await tx .insert(statusReport) .values({ status: input.status, title: input.title, pageId: input.pageId, workspaceId: workspaceId, }) .returning() .get(); const _newStatusReportUpdate = await tx .insert(statusReportUpdate) .values({ status: input.status, message: input.message, date: input.date, statusReportId: _newStatusReport.id, }) .returning() .get(); if (!_newStatusReport.pageId) { throw new OpenStatusApiError({ code: "BAD_REQUEST", message: "Page ID is required", }); } if (input.monitorIds?.length) { // Find matching page_components for the monitors on this page const components = await tx .select({ id: pageComponent.id }) .from(pageComponent) .where( and( inArray(pageComponent.monitorId, input.monitorIds), eq(pageComponent.pageId, _newStatusReport.pageId), eq(pageComponent.type, "monitor"), ), ) .all(); // Insert to statusReportsToPageComponents if (components.length > 0) { await tx .insert(statusReportsToPageComponents) .values( components.map((c) => ({ statusReportId: _newStatusReport.id, pageComponentId: c.id, })), ) .run(); } } return { _newStatusReport, _newStatusReportUpdate }; }, ); if (limits["status-subscribers"] && _newStatusReport.pageId) { await dispatchStatusReportUpdate(_newStatusReportUpdate.id); } const data = StatusReportSchema.parse({ ..._newStatusReport, monitorIds: input.monitorIds, statusReportUpdateIds: [_newStatusReportUpdate.id], }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/statusReports/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/status_reports"; export const ParamsSchema = z.object({ id: z .string() .min(1) .openapi({ param: { name: "id", in: "path", }, description: "The id of the status report", example: "1", }), }); export const StatusReportSchema = z .object({ id: z.number().openapi({ description: "The id of the status report" }), title: z.string().openapi({ example: "Documenso", description: "The title of the status report", }), status: z.enum(statusReportStatus).openapi({ description: "The current status of the report", }), statusReportUpdateIds: z .array(z.number()) .optional() .nullable() .prefault([]) .openapi({ description: "The ids of the status report updates", }), monitorIds: z .array(z.number()) .optional() .prefault([]) .openapi({ description: "Ids of the monitors the status report." }), pageId: z.number().openapi({ description: "The id of the page this status report belongs to", }), }) .openapi("StatusReport"); export type StatusReportSchema = z.infer<typeof StatusReportSchema>; ================================================ FILE: apps/server/src/routes/v1/statusReports/subscriber-filtering.integration.test.ts ================================================ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { and, db, eq, isNotNull, isNull } from "@openstatus/db"; import { page, pageSubscriber } from "@openstatus/db/src/schema"; /** * Integration tests for subscriber filtering in status report email queries. * These tests verify that unsubscribed users are excluded from email notifications. */ let testPageId: number; const testWorkspaceId = 1; // Use existing test workspace from seed data beforeAll(async () => { // Clean up any existing test data await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "active-sub@test.com")); await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "unsubscribed-sub@test.com")); await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "pending-sub@test.com")); await db.delete(page).where(eq(page.slug, "test-filtering-page")); // Create a test page const testPage = await db .insert(page) .values({ workspaceId: testWorkspaceId, title: "Test Filtering Page", description: "A test page for subscriber filtering tests", slug: "test-filtering-page", customDomain: "", }) .returning() .get(); testPageId = testPage.id; // Create test subscribers with different states // 1. Active subscriber (verified, not unsubscribed) await db.insert(pageSubscriber).values({ pageId: testPageId, email: "active-sub@test.com", token: crypto.randomUUID(), acceptedAt: new Date(), unsubscribedAt: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }); // 2. Unsubscribed subscriber (verified, then unsubscribed) await db.insert(pageSubscriber).values({ pageId: testPageId, email: "unsubscribed-sub@test.com", token: crypto.randomUUID(), acceptedAt: new Date(), unsubscribedAt: new Date(), expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }); // 3. Pending subscriber (not verified) await db.insert(pageSubscriber).values({ pageId: testPageId, email: "pending-sub@test.com", token: crypto.randomUUID(), acceptedAt: null, unsubscribedAt: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), }); }); afterAll(async () => { // Clean up test data await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "active-sub@test.com")); await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "unsubscribed-sub@test.com")); await db .delete(pageSubscriber) .where(eq(pageSubscriber.email, "pending-sub@test.com")); await db.delete(page).where(eq(page.slug, "test-filtering-page")); }); describe("Subscriber filtering for email notifications", () => { test("should exclude unsubscribed users from email queries", async () => { // This query mirrors the exact query used in statusReports/post.ts and statusReportUpdates/post.ts const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); // Should only include active subscriber, not unsubscribed or pending expect(subscribers.length).toBe(1); expect(subscribers[0].email).toBe("active-sub@test.com"); }); test("should exclude pending (unverified) users from email queries", async () => { const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); const pendingEmails = subscribers.filter( (s) => s.email === "pending-sub@test.com", ); expect(pendingEmails.length).toBe(0); }); test("should not include unsubscribed user even if acceptedAt is set", async () => { const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); const unsubscribedEmails = subscribers.filter( (s) => s.email === "unsubscribed-sub@test.com", ); expect(unsubscribedEmails.length).toBe(0); }); test("should return all subscribers without unsubscribedAt filter", async () => { // Query without the unsubscribedAt filter - should include unsubscribed users const allVerifiedSubscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), ), ) .all(); // Should include both active and unsubscribed (both have acceptedAt set) expect(allVerifiedSubscribers.length).toBe(2); const emails = allVerifiedSubscribers.map((s) => s.email); expect(emails).toContain("active-sub@test.com"); expect(emails).toContain("unsubscribed-sub@test.com"); }); test("should correctly filter subscribers with valid tokens", async () => { const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); // Filter for valid tokens (non-null) const validSubscribers = subscribers.filter( (s): s is typeof s & { token: string } => s.token !== null, ); expect(validSubscribers.length).toBe(1); expect(validSubscribers[0].token).toBeDefined(); expect(validSubscribers[0].token).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); }); }); describe("Subscriber state transitions", () => { test("should allow re-subscribing after unsubscription", async () => { // Get the unsubscribed subscriber const unsubscribedSub = await db.query.pageSubscriber.findFirst({ where: eq(pageSubscriber.email, "unsubscribed-sub@test.com"), }); if (!unsubscribedSub) { throw new Error("Unsubscribed subscriber not found"); } expect(unsubscribedSub?.unsubscribedAt).not.toBeNull(); // Simulate re-subscription by clearing unsubscribedAt await db .update(pageSubscriber) .set({ unsubscribedAt: null, acceptedAt: null, // Reset for re-verification token: crypto.randomUUID(), // Generate new token }) .where(eq(pageSubscriber.id, unsubscribedSub?.id)); // After re-subscription + verification, subscriber should be included // (we need to set acceptedAt for verification) await db .update(pageSubscriber) .set({ acceptedAt: new Date() }) .where(eq(pageSubscriber.id, unsubscribedSub?.id)); const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); // Now should include both active and re-subscribed users expect(subscribers.length).toBe(2); // Restore original state for other tests await db .update(pageSubscriber) .set({ unsubscribedAt: new Date() }) .where(eq(pageSubscriber.id, unsubscribedSub?.id)); }); test("should track unsubscription timestamp", async () => { const subscriber = await db.query.pageSubscriber.findFirst({ where: eq(pageSubscriber.email, "unsubscribed-sub@test.com"), }); expect(subscriber?.unsubscribedAt).toBeInstanceOf(Date); }); }); describe("Query performance considerations", () => { test("should use proper index-friendly query conditions", async () => { // This test verifies the query uses conditions that can leverage indexes // The conditions: pageId = X AND acceptedAt IS NOT NULL AND unsubscribedAt IS NULL // can all be optimized with appropriate indexes const startTime = performance.now(); const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, testPageId), isNotNull(pageSubscriber.acceptedAt), isNull(pageSubscriber.unsubscribedAt), ), ) .all(); const endTime = performance.now(); // Query should complete quickly (under 100ms for small datasets) expect(endTime - startTime).toBeLessThan(100); expect(subscribers).toBeDefined(); }); }); ================================================ FILE: apps/server/src/routes/v1/statusReports/update/post.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { StatusReportSchema } from "../schema"; test("create status report update with valid data should return 200", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", message: "The issue has been resolved and we are monitoring", date: date.toISOString(), }), }); expect(res.status).toBe(200); const json = await res.json(); const result = StatusReportSchema.safeParse(json); expect(result.success).toBe(true); }); test("create status report update with different status should return 200", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "identified", message: "We have identified the issue", date: date.toISOString(), }), }); expect(res.status).toBe(200); const json = await res.json(); const result = StatusReportSchema.safeParse(json); expect(result.success).toBe(true); }); test("create status report update with invalid status report id should return 404", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/999999/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", message: "The issue has been resolved and we are monitoring", date: date.toISOString(), }), }); expect(res.status).toBe(404); }); test("create status report update with invalid status should return 400", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "invalid_status", message: "Test message", date: date.toISOString(), }), }); expect(res.status).toBe(400); }); test("create status report update without auth key should return 401", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", message: "Test message", date: date.toISOString(), }), }); expect(res.status).toBe(401); }); test("create status report update from different workspace should return 404", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "2", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", message: "Test message", date: date.toISOString(), }), }); expect(res.status).toBe(404); }); test("create status report update without message should return 400", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", date: date.toISOString(), }), }); expect(res.status).toBe(400); }); test("create status report update with resolved status should return 200", async () => { const date = new Date(); date.setMilliseconds(0); const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "resolved", message: "Issue has been fully resolved", date: date.toISOString(), }), }); expect(res.status).toBe(200); const json = await res.json(); const result = StatusReportSchema.safeParse(json); expect(result.success).toBe(true); }); test("create status report update without date should use default", async () => { const res = await app.request("/v1/status_report/1/update", { method: "POST", headers: { "x-openstatus-key": "1", "content-type": "application/json", }, body: JSON.stringify({ status: "monitoring", message: "Test message without explicit date", }), }); expect(res.status).toBe(200); const json = await res.json(); const result = StatusReportSchema.safeParse(json); expect(result.success).toBe(true); }); ================================================ FILE: apps/server/src/routes/v1/statusReports/update/post.ts ================================================ import { env } from "@/env"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { notEmpty } from "@/utils/not-empty"; import { createRoute } from "@hono/zod-openapi"; import { and, db, eq, isNotNull } from "@openstatus/db"; import { pageSubscriber, statusReport, statusReportUpdate, } from "@openstatus/db/src/schema"; import { EmailClient } from "@openstatus/emails/src/client"; import { StatusReportUpdateSchema } from "../../statusReportUpdates/schema"; import type { statusReportsApi } from "../index"; import { ParamsSchema, StatusReportSchema } from "../schema"; const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); const postRouteUpdate = createRoute({ method: "post", tags: ["status_report"], path: "/{id}/update", summary: "Create a status report update", deprecated: true, description: "Preferably use [`/status-report-updates`](#tag/status_report_update/POST/status_report_update) instead.", request: { params: ParamsSchema, body: { description: "the status report update", content: { "application/json": { schema: StatusReportUpdateSchema.omit({ id: true, statusReportId: true, }), }, }, }, }, responses: { 200: { content: { "application/json": { schema: StatusReportSchema, }, }, description: "Status report updated", }, ...openApiErrorResponses, }, }); export function registerStatusReportUpdateRoutes(api: typeof statusReportsApi) { return api.openapi(postRouteUpdate, async (c) => { const input = c.req.valid("json"); const { id } = c.req.valid("param"); const workspaceId = c.get("workspace").id; const limits = c.get("workspace").limits; const _statusReport = await db .update(statusReport) .set({ status: input.status, updatedAt: new Date() }) .where( and( eq(statusReport.id, Number(id)), eq(statusReport.workspaceId, workspaceId), ), ) .returning() .get(); if (!_statusReport) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report ${id} not found`, }); } const _statusReportUpdate = await db .insert(statusReportUpdate) .values({ status: input.status, message: input.message, date: input.date, statusReportId: Number(id), }) .returning() .get(); if (limits["status-subscribers"] && _statusReport.pageId) { const _statusReportWithRelations = await db.query.statusReport.findFirst({ where: eq(statusReport.id, Number(id)), with: { statusReportsToPageComponents: { with: { pageComponent: true, }, }, page: true, }, }); const subscribers = await db .select() .from(pageSubscriber) .where( and( eq(pageSubscriber.pageId, _statusReport.pageId), isNotNull(pageSubscriber.acceptedAt), ), ) .all(); const validSubscribers = subscribers.filter( (s): s is typeof s & { token: string } => s.token !== null && s.acceptedAt !== null && s.unsubscribedAt === null, ); if (_statusReportWithRelations?.page && validSubscribers.length > 0) { await emailClient.sendStatusReportUpdate({ subscribers: validSubscribers.map((subscriber) => ({ email: subscriber.email, token: subscriber.token, })), pageTitle: _statusReportWithRelations.page.title, pageSlug: _statusReportWithRelations.page.slug, customDomain: _statusReportWithRelations.page.customDomain, reportTitle: _statusReportWithRelations.title, status: _statusReportUpdate.status, message: _statusReportUpdate.message, date: _statusReportUpdate.date.toISOString(), pageComponents: _statusReportWithRelations.statusReportsToPageComponents.map( (i) => i.pageComponent.name, ), }); } } // Query the full status report with all its relationships const fullStatusReport = await db.query.statusReport.findFirst({ where: eq(statusReport.id, Number(id)), with: { statusReportUpdates: true, statusReportsToPageComponents: { with: { pageComponent: true, }, }, }, }); if (!fullStatusReport) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Status Report ${id} not found`, }); } const data = StatusReportSchema.parse({ ...fullStatusReport, statusReportUpdateIds: fullStatusReport.statusReportUpdates.map( (u) => u.id, ), monitorIds: fullStatusReport.statusReportsToPageComponents .map((m) => m.pageComponent.monitorId) .filter(notEmpty), }); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/utils.ts ================================================ import { z } from "@hono/zod-openapi"; import { ZodError } from "zod"; export const isoDate = z.preprocess((val) => { try { if (val) { return new Date(String(val)).toISOString(); } return new Date().toISOString(); } catch (_e) { throw new ZodError([ { code: "invalid_type", message: "Invalid date", expected: "string", path: [], }, ]); } }, z.string()); export function isNumberArray<T>( monitors: number[] | T[], ): monitors is number[] { return ( Array.isArray(monitors) && monitors.every((item) => typeof item === "number") ); } ================================================ FILE: apps/server/src/routes/v1/whoami/get.test.ts ================================================ import { expect, test } from "bun:test"; import { app } from "@/index"; import { WorkspaceSchema } from "./schema"; test("return the whoami", async () => { const res = await app.request("/v1/whoami", { headers: { "x-openstatus-key": "1", }, }); const result = WorkspaceSchema.safeParse(await res.json()); expect(res.status).toBe(200); expect(result.success).toBe(true); }); test("no auth key should return 401", async () => { const res = await app.request("/v1/whoami"); expect(res.status).toBe(401); }); ================================================ FILE: apps/server/src/routes/v1/whoami/get.ts ================================================ import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { createRoute } from "@hono/zod-openapi"; import { eq } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { workspace } from "@openstatus/db/src/schema/workspaces"; import type { whoamiApi } from "."; import { WorkspaceSchema } from "./schema"; const getRoute = createRoute({ method: "get", tags: ["whoami"], path: "/", summary: "Get your informations", description: "Get the current workspace information attached to the API key.", responses: { 200: { content: { "application/json": { schema: WorkspaceSchema, }, }, description: "The current workspace information with the limits", }, ...openApiErrorResponses, }, }); export function registerGetWhoami(api: typeof whoamiApi) { return api.openapi(getRoute, async (c) => { const workspaceId = c.get("workspace").id; const _workspace = await db .select() .from(workspace) .where(eq(workspace.id, workspaceId)) .get(); if (!_workspace) { throw new OpenStatusApiError({ code: "NOT_FOUND", message: `Workspace ${workspaceId} not found`, }); } const data = WorkspaceSchema.parse(_workspace); return c.json(data, 200); }); } ================================================ FILE: apps/server/src/routes/v1/whoami/index.ts ================================================ import { handleZodError } from "@/libs/errors"; import { OpenAPIHono } from "@hono/zod-openapi"; import type { Variables } from ".."; import { registerGetWhoami } from "./get"; export const whoamiApi = new OpenAPIHono<{ Variables: Variables }>({ defaultHook: handleZodError, }); registerGetWhoami(whoamiApi); ================================================ FILE: apps/server/src/routes/v1/whoami/schema.ts ================================================ import { z } from "@hono/zod-openapi"; import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; export const WorkspaceSchema = z .object({ name: z .string() .optional() .openapi({ description: "The current workspace name" }), slug: z.string().openapi({ description: "The current workspace slug" }), plan: z.enum(workspacePlans).nullable().prefault("free").openapi({ description: "The current workspace plan", }), }) .openapi("Workspace"); export type WorkspaceSchema = z.infer<typeof WorkspaceSchema>; ================================================ FILE: apps/server/src/types/index.ts ================================================ import type { Workspace } from "@openstatus/db/src/schema"; import type { RequestIdVariables } from "hono/request-id"; export type Variables = RequestIdVariables & { workspace: Workspace; event: Record<string, unknown>; }; ================================================ FILE: apps/server/src/utils/audit-log.ts ================================================ import { AuditLog, Tinybird } from "@openstatus/tinybird"; import { env } from "../env"; const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); export const checkerAudit = new AuditLog({ tb }); ================================================ FILE: apps/server/src/utils/not-empty.ts ================================================ export function notEmpty<TValue>( value: TValue | null | undefined, ): value is TValue { return value !== null && value !== undefined; } ================================================ FILE: apps/server/src/utils/page-component.ts ================================================ import type { PageComponentType } from "@openstatus/db/src/schema"; /** * Type guard to check if a pageComponent is a monitor type with a valid monitor relation * Filters out static components and ensures the monitor is active and not deleted */ export function isMonitorComponent(component: { type: PageComponentType; monitor?: { active: boolean | null; deletedAt: Date | null } | null; }): component is { type: "monitor"; monitor: { active: true; deletedAt: null }; } { return ( component.type === "monitor" && component.monitor !== null && component.monitor !== undefined && component.monitor.active === true && component.monitor.deletedAt === null ); } ================================================ FILE: apps/server/src/utils/random-promise.ts ================================================ export function fakePromiseWithRandomResolve() { return new Promise((resolve, reject) => { const randomTime = Math.floor(Math.random() * 1000); setTimeout(() => { const shouldResolve = Math.random() < 1; // 0.5 if (shouldResolve) { resolve("Promise resolved successfully."); } else { reject(new Error("Promise rejected.")); } }, randomTime); }); } ================================================ FILE: apps/server/static/openapi-v1.json ================================================ { "openapi": "3.0.0", "info": { "version": "1.0.0", "title": "OpenStatus API", "contact": { "email": "ping@openstatus.dev", "url": "https://www.openstatus.dev" }, "description": "This version is deprecated please use v2" }, "tags": [ { "name": "monitor", "description": "Monitor related endpoints", "x-displayName": "Monitor" }, { "name": "page", "description": "Page related endpoints", "x-displayName": "Page" }, { "name": "status_report", "description": "Status report related endpoints", "x-displayName": "Status Report" }, { "name": "status_report_update", "description": "Status report update related endpoints", "x-displayName": "Status Report Update" }, { "name": "incident", "description": "Incident related endpoints", "x-displayName": "Incident" }, { "name": "maintenance", "description": "Maintenance related endpoints", "x-displayName": "Maintenance" }, { "name": "notification", "description": "Notification related endpoints", "x-displayName": "Notification" }, { "name": "page_subscriber", "description": "Page subscriber related endpoints", "x-displayName": "Page Subscriber" }, { "name": "check", "description": "Check related endpoints", "x-displayName": "Check" }, { "name": "whoami", "description": "WhoAmI related endpoints", "x-displayName": "WhoAmI" } ], "security": [ { "ApiKeyAuth": [] } ], "components": { "securitySchemes": { "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "x-openstatus-key", "x-openstatus-key": "string" } }, "schemas": { "Monitor": { "type": "object", "properties": { "id": { "type": "number", "example": 123, "description": "The id of the monitor" }, "periodicity": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h", "other"], "example": "1m", "description": "How often the monitor should run" }, "url": { "type": "string", "example": "https://www.documenso.co", "description": "The url to monitor" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "name": { "type": "string", "example": "documenso-web", "description": "The name of the monitor" }, "externalName": { "type": "string", "nullable": true, "example": "Documenso", "description": "The external name of the monitor, used to display on the status page or in the external notifications" }, "description": { "type": "string", "nullable": true, "example": "Documenso website", "description": "The description of your monitor" }, "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ], "example": "GET" }, "body": { "type": "string", "nullable": true, "default": "", "example": "Hello World", "description": "The body" }, "headers": { "type": "array", "nullable": true, "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] }, "default": [], "description": "The headers of your request", "example": [ { "key": "x-apikey", "value": "supersecrettoken" } ] }, "assertions": { "type": "array", "nullable": true, "items": { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "enum": ["status"] }, "compare": { "type": "string", "enum": ["eq", "not_eq", "gt", "gte", "lt", "lte"], "description": "Comparison operator", "examples": ["eq", "not_eq", "gt", "gte", "lt", "lte"] }, "target": { "type": "integer", "minimum": 0, "exclusiveMinimum": true, "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The status assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["header"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "key": { "type": "string", "description": "The key of the header" }, "target": { "type": "string", "description": "the header value" } }, "required": ["type", "compare", "key", "target"], "description": "The header assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["textBody"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "target": { "type": "string", "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The text body assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["dnsRecord"] }, "key": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] }, "compare": { "type": "string", "enum": ["contains", "not_contains", "eq", "not_eq"] }, "target": { "type": "string" } }, "required": ["type", "key", "compare", "target"], "description": "The DNS record assertion" } ] }, "default": [], "description": "The assertions to run" }, "active": { "type": "boolean", "default": false, "description": "If the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "If the monitor is public" }, "degradedAfter": { "type": "number", "nullable": true, "description": "The time after the monitor is considered degraded in milliseconds" }, "timeout": { "type": "number", "nullable": true, "default": 45000, "description": "The timeout of the request in milliseconds" }, "retry": { "type": "number", "default": 3, "description": "The number of retries to attempt" }, "followRedirects": { "type": "boolean", "default": true, "description": "If the monitor should follow redirects" }, "jobType": { "type": "string", "enum": ["http", "tcp", "imcp", "udp", "dns", "ssl"], "default": "http", "description": "The type of the monitor" }, "openTelemetry": { "type": "object", "properties": { "endpoint": { "type": "string", "format": "uri", "default": "http://localhost:4317", "description": "The endpoint of the OpenTelemetry collector" }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "default": {}, "description": "The headers to send to the OpenTelemetry collector" } }, "description": "The OpenTelemetry configuration" } }, "required": ["id", "periodicity", "url", "name", "method"] }, "ErrBadRequest": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "BAD_REQUEST", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrUnauthorized": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "UNAUTHORIZED", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/UNAUTHORIZED" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrPaymentRequired": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "PAYMENT_REQUIRED", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/PAYMENT_REQUIRED" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrForbidden": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "FORBIDDEN", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/FORBIDDEN" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrNotFound": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "NOT_FOUND", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/NOT_FOUND" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrConflict": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "CONFLICT", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/CONFLICT" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "ErrInternalServerError": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", "PAYMENT_REQUIRED", "CONFLICT", "NOT_FOUND", "UNAUTHORIZED", "METHOD_NOT_ALLOWED", "UNPROCESSABLE_ENTITY" ], "example": "INTERNAL_SERVER_ERROR", "description": "The error code related to the status code." }, "message": { "type": "string", "description": "A human readable message describing the issue.", "example": "<string>" }, "docs": { "type": "string", "description": "A link to the documentation for the error.", "example": "https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR" }, "requestId": { "type": "string", "description": "The request id to be used for debugging and error reporting.", "example": "<uuid>" } }, "required": ["code", "message", "docs", "requestId"] }, "Page": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the page", "example": 1 }, "title": { "type": "string", "description": "The title of the page", "example": "My Page" }, "description": { "type": "string", "description": "The description of the page", "example": "My awesome status page" }, "slug": { "type": "string", "description": "The slug of the page", "example": "my-page" }, "customDomain": { "type": "string", "nullable": true, "description": "The custom domain of the page. To be configured within the dashboard.", "example": "status.acme.com" }, "icon": { "anyOf": [ { "type": "string", "format": "uri" }, { "type": "string", "enum": [""] }, { "nullable": true } ], "description": "The icon of the page", "example": "https://example.com/icon.png" }, "passwordProtected": { "type": "boolean", "default": false, "description": "Deprecated in favor of `accessType`. Used to set the password protection type. Returns true if `accessType` is set to 'password' and false otherwise.", "example": true, "deprecated": true }, "accessType": { "type": "string", "enum": ["public", "password", "email-domain"], "default": "public", "description": "The access type of the page", "example": "public" }, "password": { "type": "string", "nullable": true, "description": "Your password to protect the page from the public", "example": "hidden-password" }, "authEmailDomains": { "type": "array", "nullable": true, "items": { "type": "string" }, "description": "The email domains of the page", "example": ["example.com", "example.org"] }, "showMonitorValues": { "type": "boolean", "nullable": true, "default": true, "description": "Displays the total and failed request numbers for each monitor. Deprecated and will be removed in the future in favor for `configuration` property.", "example": true, "deprecated": true }, "monitors": { "anyOf": [ { "type": "array", "items": { "type": "number" }, "description": "The monitors of the page as an array of ids. We recommend using the object format to include the order.", "deprecated": true, "example": [1, 2] }, { "type": "array", "items": { "type": "object", "properties": { "monitorId": { "type": "number" }, "order": { "type": "number" } }, "required": ["monitorId", "order"] }, "description": "The monitor as object allowing to pass id and order", "example": [ { "monitorId": 1, "order": 0 }, { "monitorId": 2, "order": 1 } ] } ] } }, "required": ["id", "title", "description", "slug"] }, "StatusReport": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the status report" }, "title": { "type": "string", "example": "Documenso", "description": "The title of the status report" }, "status": { "type": "string", "enum": ["investigating", "identified", "monitoring", "resolved"], "description": "The current status of the report" }, "statusReportUpdateIds": { "type": "array", "nullable": true, "items": { "type": "number" }, "default": [], "description": "The ids of the status report updates" }, "monitorIds": { "type": "array", "items": { "type": "number" }, "default": [], "description": "Ids of the monitors the status report." }, "pageId": { "type": "number", "description": "The id of the page this status report belongs to" } }, "required": ["id", "title", "status", "pageId"] }, "StatusReportUpdate": { "type": "object", "properties": { "id": { "type": "string", "nullable": true, "description": "The id of the update" }, "status": { "type": "string", "enum": ["investigating", "identified", "monitoring", "resolved"], "description": "The status of the update" }, "date": { "type": "string", "nullable": true, "format": "date", "default": "2026-02-12T22:01:46.114Z", "description": "The date of the update in ISO8601 format" }, "message": { "type": "string", "minLength": 1, "description": "The message of the update" }, "statusReportId": { "type": "number", "description": "The id of the status report" } }, "required": ["status", "message", "statusReportId"] }, "Incident": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the incident", "example": 1 }, "startedAt": { "type": "string", "nullable": true, "format": "date", "description": "The date the incident started" }, "monitorId": { "type": "number", "nullable": true, "description": "The id of the monitor associated with the incident", "example": 1 }, "acknowledgedAt": { "type": "string", "nullable": true, "format": "date", "description": "The date the incident was acknowledged" }, "acknowledgedBy": { "type": "number", "nullable": true, "description": "The user who acknowledged the incident" }, "resolvedAt": { "type": "string", "nullable": true, "format": "date", "description": "The date the incident was resolved" }, "resolvedBy": { "type": "number", "nullable": true, "description": "The user who resolved the incident" } }, "required": [ "id", "startedAt", "monitorId", "acknowledgedBy", "resolvedBy" ] }, "Maintenance": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the maintenance", "example": 1 }, "title": { "type": "string", "description": "The title of the maintenance", "example": "Database Upgrade" }, "message": { "type": "string", "description": "The message describing the maintenance", "example": "Upgrading database to improve performance" }, "from": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance starts" }, "to": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance ends" }, "monitorIds": { "type": "array", "items": { "type": "number" }, "default": [], "description": "IDs of affected monitors" }, "pageId": { "type": "number", "description": "The id of the status page this maintenance belongs to" } }, "required": ["id", "title", "message", "from", "to", "pageId"] }, "Notification": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the notification", "example": 1 }, "name": { "type": "string", "description": "The name of the notification", "example": "OpenStatus Discord" }, "provider": { "type": "string", "enum": [ "discord", "email", "google-chat", "grafana-oncall", "ntfy", "pagerduty", "opsgenie", "slack", "sms", "telegram", "webhook", "whatsapp" ], "description": "The provider of the notification", "example": "discord" }, "payload": { "anyOf": [ { "type": "object", "properties": { "discord": { "type": "string", "format": "uri" } }, "required": ["discord"] }, { "type": "object", "properties": { "email": { "type": "string", "format": "email" } }, "required": ["email"] }, { "type": "object", "properties": { "grafana-oncall": { "type": "object", "properties": { "webhookUrl": { "type": "string", "format": "uri" } }, "required": ["webhookUrl"] } }, "required": ["grafana-oncall"] }, { "type": "object", "properties": { "ntfy": { "type": "object", "properties": { "topic": { "type": "string", "default": "" }, "serverUrl": { "type": "string", "default": "https://ntfy.sh" }, "token": { "type": "string" } } } }, "required": ["ntfy"] }, { "type": "object", "properties": { "opsgenie": { "type": "object", "properties": { "apiKey": { "type": "string" }, "region": { "type": "string", "enum": ["us", "eu"] } }, "required": ["apiKey", "region"] } }, "required": ["opsgenie"] }, { "type": "object", "properties": { "pagerduty": { "type": "string" } }, "required": ["pagerduty"] }, { "type": "object", "properties": { "sms": { "type": "string", "pattern": "^([+]?[\\s0-9]+)?(\\d{3}|[(]?[0-9]+[)])?([-]?[\\s]?[0-9])+$" } }, "required": ["sms"] }, { "type": "object", "properties": { "telegram": { "type": "object", "properties": { "chatId": { "type": "string" } }, "required": ["chatId"] } }, "required": ["telegram"] }, { "type": "object", "properties": { "slack": { "type": "string", "format": "uri" } }, "required": ["slack"] }, { "type": "object", "properties": { "webhook": { "type": "object", "properties": { "endpoint": { "type": "string", "format": "uri" }, "headers": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] } } }, "required": ["endpoint"] } }, "required": ["webhook"] }, { "type": "object", "properties": { "whatsapp": { "type": "string", "pattern": "^([+]?[\\s0-9]+)?(\\d{3}|[(]?[0-9]+[)])?([-]?[\\s]?[0-9])+$" } }, "required": ["whatsapp"] }, { "type": "object", "properties": { "google-chat": { "type": "string", "format": "uri" } }, "required": ["google-chat"] } ], "description": "The data of the notification" }, "monitors": { "type": "array", "nullable": true, "items": { "type": "number" }, "description": "The monitors that the notification is linked to", "example": [1, 2] } }, "required": ["id", "name", "provider", "payload"] }, "PageSubscriber": { "type": "object", "properties": { "id": { "type": "number", "description": "The id of the subscriber", "example": 1 }, "email": { "type": "string", "format": "email", "description": "The email of the subscriber" }, "pageId": { "type": "number", "description": "The id of the page to subscribe to", "example": 1 } }, "required": ["id", "email", "pageId"] }, "Workspace": { "type": "object", "properties": { "name": { "type": "string", "description": "The current workspace name" }, "slug": { "type": "string", "description": "The current workspace slug" }, "plan": { "type": "string", "nullable": true, "enum": ["free", "starter", "team"], "default": "free", "description": "The current workspace plan" } }, "required": ["slug"] } }, "parameters": {} }, "paths": { "/monitor": { "get": { "tags": ["monitor"], "summary": "List all monitors", "responses": { "200": { "description": "All the monitors", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Monitor" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "post": { "tags": ["monitor"], "summary": "Create a monitor", "requestBody": { "description": "The monitor to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "periodicity": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h", "other"], "example": "1m", "description": "How often the monitor should run" }, "url": { "type": "string", "example": "https://www.documenso.co", "description": "The url to monitor" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "name": { "type": "string", "example": "documenso-web", "description": "The name of the monitor" }, "externalName": { "type": "string", "nullable": true, "example": "Documenso", "description": "The external name of the monitor, used to display on the status page or in the external notifications" }, "description": { "type": "string", "nullable": true, "example": "Documenso website", "description": "The description of your monitor" }, "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ], "example": "GET" }, "body": { "type": "string", "nullable": true, "default": "", "example": "Hello World", "description": "The body" }, "headers": { "type": "array", "nullable": true, "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] }, "default": [], "description": "The headers of your request", "example": [ { "key": "x-apikey", "value": "supersecrettoken" } ] }, "assertions": { "type": "array", "nullable": true, "items": { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "enum": ["status"] }, "compare": { "type": "string", "enum": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ] }, "target": { "type": "integer", "minimum": 0, "exclusiveMinimum": true, "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The status assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["header"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "key": { "type": "string", "description": "The key of the header" }, "target": { "type": "string", "description": "the header value" } }, "required": ["type", "compare", "key", "target"], "description": "The header assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["textBody"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "target": { "type": "string", "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The text body assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["dnsRecord"] }, "key": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq" ] }, "target": { "type": "string" } }, "required": ["type", "key", "compare", "target"], "description": "The DNS record assertion" } ] }, "default": [], "description": "The assertions to run" }, "active": { "type": "boolean", "default": false, "description": "If the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "If the monitor is public" }, "degradedAfter": { "type": "number", "nullable": true, "description": "The time after the monitor is considered degraded in milliseconds" }, "timeout": { "type": "number", "nullable": true, "default": 45000, "description": "The timeout of the request in milliseconds" }, "retry": { "type": "number", "default": 3, "description": "The number of retries to attempt" }, "followRedirects": { "type": "boolean", "default": true, "description": "If the monitor should follow redirects" }, "jobType": { "type": "string", "enum": ["http", "tcp", "imcp", "udp", "dns", "ssl"], "default": "http", "description": "The type of the monitor" }, "openTelemetry": { "type": "object", "properties": { "endpoint": { "type": "string", "format": "uri", "default": "http://localhost:4317", "description": "The endpoint of the OpenTelemetry collector" }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "default": {}, "description": "The headers to send to the OpenTelemetry collector" } }, "description": "The OpenTelemetry configuration" } }, "required": ["periodicity", "url", "name", "method"] } } } }, "responses": { "200": { "description": "Create a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/{id}": { "get": { "tags": ["monitor"], "summary": "Get a monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "responses": { "200": { "description": "The monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "put": { "tags": ["monitor"], "summary": "Update a monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "requestBody": { "description": "The monitor to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "periodicity": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h", "other"], "example": "1m", "description": "How often the monitor should run" }, "url": { "type": "string", "example": "https://www.documenso.co", "description": "The url to monitor" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "name": { "type": "string", "example": "documenso-web", "description": "The name of the monitor" }, "externalName": { "type": "string", "nullable": true, "example": "Documenso", "description": "The external name of the monitor, used to display on the status page or in the external notifications" }, "description": { "type": "string", "nullable": true, "example": "Documenso website", "description": "The description of your monitor" }, "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ], "example": "GET" }, "body": { "type": "string", "nullable": true, "default": "", "example": "Hello World", "description": "The body" }, "headers": { "type": "array", "nullable": true, "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] }, "default": [], "description": "The headers of your request", "example": [ { "key": "x-apikey", "value": "supersecrettoken" } ] }, "assertions": { "type": "array", "nullable": true, "items": { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "enum": ["status"] }, "compare": { "type": "string", "enum": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ] }, "target": { "type": "integer", "minimum": 0, "exclusiveMinimum": true, "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The status assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["header"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "key": { "type": "string", "description": "The key of the header" }, "target": { "type": "string", "description": "the header value" } }, "required": ["type", "compare", "key", "target"], "description": "The header assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["textBody"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ] }, "target": { "type": "string", "description": "The target value" } }, "required": ["type", "compare", "target"], "description": "The text body assertion" }, { "type": "object", "properties": { "type": { "type": "string", "enum": ["dnsRecord"] }, "key": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq" ] }, "target": { "type": "string" } }, "required": ["type", "key", "compare", "target"], "description": "The DNS record assertion" } ] }, "default": [], "description": "The assertions to run" }, "active": { "type": "boolean", "default": false, "description": "If the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "If the monitor is public" }, "degradedAfter": { "type": "number", "nullable": true, "description": "The time after the monitor is considered degraded in milliseconds" }, "timeout": { "type": "number", "nullable": true, "default": 45000, "description": "The timeout of the request in milliseconds" }, "retry": { "type": "number", "default": 3, "description": "The number of retries to attempt" }, "followRedirects": { "type": "boolean", "default": true, "description": "If the monitor should follow redirects" }, "jobType": { "type": "string", "enum": ["http", "tcp", "imcp", "udp", "dns", "ssl"], "default": "http", "description": "The type of the monitor" }, "openTelemetry": { "type": "object", "properties": { "endpoint": { "type": "string", "format": "uri", "default": "http://localhost:4317", "description": "The endpoint of the OpenTelemetry collector" }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "default": {}, "description": "The headers to send to the OpenTelemetry collector" } }, "description": "The OpenTelemetry configuration" } } } } } }, "responses": { "200": { "description": "Update a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "delete": { "tags": ["monitor"], "summary": "Delete a monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "responses": { "200": { "description": "The monitor was successfully deleted", "content": { "application/json": { "schema": { "type": "object", "properties": {} } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/http": { "post": { "tags": ["monitor"], "summary": "Create a http monitor", "requestBody": { "description": "The monitor to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "assertions": { "type": "array", "items": { "oneOf": [ { "type": "object", "properties": { "kind": { "type": "string", "enum": ["statusCode"] }, "compare": { "type": "string", "enum": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ] }, "target": { "type": "number", "description": "Status code to assert", "examples": [200, 404, 418, 500] } }, "required": ["kind", "compare", "target"], "examples": [ { "kind": "statusCode", "compare": "eq", "target": 200 }, { "kind": "statusCode", "compare": "not_eq", "target": 404 }, { "kind": "statusCode", "compare": "gt", "target": 300 } ] }, { "type": "object", "properties": { "kind": { "type": "string", "enum": ["header"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "key": { "type": "string", "description": "Header key to assert", "examples": ["Content-Type", "X-Request-ID"] }, "target": { "type": "string", "description": "Header value to assert", "examples": ["application/json", "text/html"] } }, "required": ["kind", "compare", "key", "target"] }, { "type": "object", "properties": { "kind": { "type": "string", "enum": ["textBody"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "target": { "type": "string", "description": "Text body to assert", "examples": ["Hello, world!", "404 Not Found"] } }, "required": ["kind", "compare", "target"] } ] }, "description": "Assertions to run on the response" }, "request": { "type": "object", "properties": { "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ] }, "url": { "type": "string", "format": "uri", "description": "URL to request", "examples": [ "https://openstat.us", "https://www.openstatus.dev" ] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the request", "examples": [ { "Content-Type": "application/json" } ] }, "body": { "type": "string", "description": "Body to send with the request", "examples": ["{ \"key\": \"value\" }", "Hello World"] } }, "required": ["method", "url"], "description": "The HTTP Request we are sending" } }, "required": ["name", "frequency", "request"], "title": "HTTP Monitor Schema" } } } }, "responses": { "200": { "description": "Create a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/tcp": { "post": { "tags": ["monitor"], "summary": "Create a tcp monitor", "requestBody": { "description": "The monitor to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "request": { "type": "object", "properties": { "host": { "type": "string", "minLength": 1, "examples": ["example.com", "localhost"], "description": "Host to connect to" }, "port": { "type": "number", "description": "Port to connect to", "examples": [80, 443, 1337] } }, "required": ["host", "port"], "description": "The TCP Request we are sending" } }, "required": ["name", "frequency", "request"], "title": "TCP Monitor Schema" } } } }, "responses": { "200": { "description": "Create a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/dns": { "post": { "tags": ["monitor"], "summary": "Create a dns monitor", "requestBody": { "description": "The monitor to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "request": { "type": "object", "properties": { "uri": { "type": "string", "description": "The DNS server to query", "examples": ["openstatus.dev"] } }, "required": ["uri"], "description": "The DNS Request we are sending" }, "assertions": { "type": "array", "items": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["dnsRecord"] }, "recordType": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT"], "description": "Type of DNS record to check", "examples": ["A", "CNAME"] }, "compare": { "type": "string", "enum": ["contains", "not_contains", "eq", "not_eq"], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "target": { "type": "string", "description": "DNS record value to assert", "examples": ["example.com"] } }, "required": ["kind", "recordType", "compare", "target"] }, "description": "Assertions to run on the DNS response" } }, "required": ["name", "frequency", "request"], "title": "DNS Monitor Schema" } } } }, "responses": { "200": { "description": "Create a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/http/{id}": { "put": { "tags": ["monitor"], "summary": "Update an HTTP monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "requestBody": { "description": "The monitor to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "assertions": { "type": "array", "items": { "oneOf": [ { "type": "object", "properties": { "kind": { "type": "string", "enum": ["statusCode"] }, "compare": { "type": "string", "enum": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "gt", "gte", "lt", "lte" ] }, "target": { "type": "number", "description": "Status code to assert", "examples": [200, 404, 418, 500] } }, "required": ["kind", "compare", "target"], "examples": [ { "kind": "statusCode", "compare": "eq", "target": 200 }, { "kind": "statusCode", "compare": "not_eq", "target": 404 }, { "kind": "statusCode", "compare": "gt", "target": 300 } ] }, { "type": "object", "properties": { "kind": { "type": "string", "enum": ["header"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "key": { "type": "string", "description": "Header key to assert", "examples": ["Content-Type", "X-Request-ID"] }, "target": { "type": "string", "description": "Header value to assert", "examples": ["application/json", "text/html"] } }, "required": ["kind", "compare", "key", "target"] }, { "type": "object", "properties": { "kind": { "type": "string", "enum": ["textBody"] }, "compare": { "type": "string", "enum": [ "contains", "not_contains", "eq", "not_eq", "empty", "not_empty", "gt", "gte", "lt", "lte" ], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "target": { "type": "string", "description": "Text body to assert", "examples": ["Hello, world!", "404 Not Found"] } }, "required": ["kind", "compare", "target"] } ] }, "description": "Assertions to run on the response" }, "request": { "type": "object", "properties": { "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ] }, "url": { "type": "string", "format": "uri", "description": "URL to request", "examples": [ "https://openstat.us", "https://www.openstatus.dev" ] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the request", "examples": [ { "Content-Type": "application/json" } ] }, "body": { "type": "string", "description": "Body to send with the request", "examples": ["{ \"key\": \"value\" }", "Hello World"] } }, "required": ["method", "url"], "description": "The HTTP Request we are sending" } }, "required": ["name", "frequency", "request"], "title": "HTTP Monitor Schema" } } } }, "responses": { "200": { "description": "Update a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/tcp/{id}": { "put": { "tags": ["monitor"], "summary": "Update an TCP monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "requestBody": { "description": "The monitor to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "request": { "type": "object", "properties": { "host": { "type": "string", "minLength": 1, "examples": ["example.com", "localhost"], "description": "Host to connect to" }, "port": { "type": "number", "description": "Port to connect to", "examples": [80, 443, 1337] } }, "required": ["host", "port"], "description": "The TCP Request we are sending" } }, "required": ["name", "frequency", "request"], "title": "TCP Monitor Schema" } } } }, "responses": { "200": { "description": "Update a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/dns/{id}": { "put": { "tags": ["monitor"], "summary": "Update an DNS monitor", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "requestBody": { "description": "The monitor to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the monitor" }, "description": { "type": "string" }, "retry": { "type": "number", "maximum": 10, "minimum": 1, "default": 3, "description": "Number of retries to attempt", "examples": [1, 3, 5] }, "degradedAfter": { "type": "number", "minimum": 0, "default": 30000, "description": "Time in milliseconds to wait before marking the request as degraded", "examples": [30000] }, "timeout": { "type": "number", "minimum": 0, "default": 45000, "description": "Time in milliseconds to wait before marking the request as timed out", "examples": [45000] }, "frequency": { "type": "string", "enum": ["30s", "1m", "5m", "10m", "30m", "1h"] }, "active": { "type": "boolean", "default": false, "description": "Whether the monitor is active" }, "public": { "type": "boolean", "default": false, "description": "Whether the monitor is public" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "openTelemetry": { "type": "object", "nullable": true, "properties": { "endpoint": { "type": "string", "format": "uri", "description": "OTEL endpoint to send metrics to", "examples": ["https://otel.example.com"] }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Headers to send with the OTEL request", "examples": [ { "Content-Type": "application/json" } ] } } }, "request": { "type": "object", "properties": { "uri": { "type": "string", "description": "The DNS server to query", "examples": ["openstatus.dev"] } }, "required": ["uri"], "description": "The DNS Request we are sending" }, "assertions": { "type": "array", "items": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["dnsRecord"] }, "recordType": { "type": "string", "enum": ["A", "AAAA", "CNAME", "MX", "TXT"], "description": "Type of DNS record to check", "examples": ["A", "CNAME"] }, "compare": { "type": "string", "enum": ["contains", "not_contains", "eq", "not_eq"], "description": "Comparison operator", "examples": [ "eq", "not_eq", "contains", "not_contains" ] }, "target": { "type": "string", "description": "DNS record value to assert", "examples": ["example.com"] } }, "required": ["kind", "recordType", "compare", "target"] }, "description": "Assertions to run on the DNS response" } }, "required": ["name", "frequency", "request"], "title": "DNS Monitor Schema" } } } }, "responses": { "200": { "description": "Update a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Monitor" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/{id}/summary": { "get": { "tags": ["monitor"], "summary": "Get a monitor summary", "description": "Get a monitor summary of the last 45 days of data to be used within a status page", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "responses": { "200": { "description": "All the historical metrics", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "object", "properties": { "ok": { "type": "integer", "description": "The number of ok responses (defined by the assertions - or by default status code 200)" }, "count": { "type": "integer", "description": "The total number of request" }, "day": { "type": "string", "nullable": true, "format": "date", "description": "The date of the daily stat in ISO8601 format" } }, "required": ["ok", "count", "day"] } } }, "required": ["data"] } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/{id}/trigger": { "post": { "tags": ["monitor"], "summary": "Create a monitor trigger", "description": "Trigger a monitor check without waiting the result", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Returns a result id that can be used to get the result of your trigger", "content": { "application/json": { "schema": { "type": "object", "properties": { "resultId": { "type": "number", "description": "the id of your check result" } }, "required": ["resultId"] } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/{id}/result/{resultId}": { "get": { "tags": ["monitor"], "summary": "Get a monitor result", "description": "**WARNING:** This works only for HTTP monitors. We will add support for other types of monitors soon.", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" }, { "schema": { "type": "string", "description": "The id of the result" }, "required": true, "description": "The id of the result", "name": "resultId", "in": "path" } ], "responses": { "200": { "description": "All the metrics for the result id from the monitor", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "properties": { "latency": { "type": "integer" }, "statusCode": { "type": "integer", "nullable": true, "default": null }, "monitorId": { "type": "string", "default": "" }, "url": { "type": "string" }, "error": { "type": "boolean", "nullable": true, "default": false }, "region": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "timestamp": { "type": "integer" }, "message": { "type": "string", "nullable": true }, "timing": { "type": "object", "nullable": true, "properties": { "dnsStart": { "type": "number" }, "dnsDone": { "type": "number" }, "connectStart": { "type": "number" }, "connectDone": { "type": "number" }, "tlsHandshakeStart": { "type": "number" }, "tlsHandshakeDone": { "type": "number" }, "firstByteStart": { "type": "number" }, "firstByteDone": { "type": "number" }, "transferStart": { "type": "number" }, "transferDone": { "type": "number" } }, "required": [ "dnsStart", "dnsDone", "connectStart", "connectDone", "tlsHandshakeStart", "tlsHandshakeDone", "firstByteStart", "firstByteDone", "transferStart", "transferDone" ] } }, "required": ["latency", "region"] } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/monitor/{id}/run": { "post": { "tags": ["monitor"], "summary": "Create a monitor run", "description": "Run a synthetic check for a specific monitor. It will take all configs into account.", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the monitor", "example": "1" }, "required": true, "description": "The id of the monitor", "name": "id", "in": "path" }, { "schema": { "type": "boolean", "nullable": true, "default": false, "description": "Don't wait for the result" }, "required": false, "description": "Don't wait for the result", "name": "no-wait", "in": "query" } ], "responses": { "200": { "description": "All the historical metrics", "content": { "application/json": { "schema": { "type": "array", "items": { "oneOf": [ { "type": "object", "properties": { "jobType": { "type": "string", "enum": ["http"] }, "status": { "type": "number" }, "latency": { "type": "number" }, "region": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "timestamp": { "type": "number" }, "timing": { "type": "object", "properties": { "dnsStart": { "type": "number" }, "dnsDone": { "type": "number" }, "connectStart": { "type": "number" }, "connectDone": { "type": "number" }, "tlsHandshakeStart": { "type": "number" }, "tlsHandshakeDone": { "type": "number" }, "firstByteStart": { "type": "number" }, "firstByteDone": { "type": "number" }, "transferStart": { "type": "number" }, "transferDone": { "type": "number" } }, "required": [ "dnsStart", "dnsDone", "connectStart", "connectDone", "tlsHandshakeStart", "tlsHandshakeDone", "firstByteStart", "firstByteDone", "transferStart", "transferDone" ] }, "body": { "type": "string", "nullable": true }, "error": { "type": "string", "nullable": true } }, "required": [ "jobType", "status", "latency", "region", "timestamp", "timing" ] }, { "type": "object", "properties": { "jobType": { "type": "string", "enum": ["tcp"] }, "latency": { "type": "number" }, "region": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "timestamp": { "type": "number" }, "timing": { "type": "object", "properties": { "tcpStart": { "type": "number" }, "tcpDone": { "type": "number" } }, "required": ["tcpStart", "tcpDone"] }, "error": { "type": "number", "nullable": true }, "errorMessage": { "type": "string", "nullable": true } }, "required": [ "jobType", "latency", "region", "timestamp", "timing" ] } ] } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/page/{id}": { "get": { "tags": ["page"], "summary": "Get a status page", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the page", "example": "1" }, "required": true, "description": "The id of the page", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get an Status page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Page" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "put": { "tags": ["page"], "summary": "Update a status page", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the page", "example": "1" }, "required": true, "description": "The id of the page", "name": "id", "in": "path" } ], "requestBody": { "description": "The monitor to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the page", "example": "My Page" }, "description": { "type": "string", "description": "The description of the page", "example": "My awesome status page" }, "slug": { "type": "string", "description": "The slug of the page", "example": "my-page" }, "customDomain": { "type": "string", "nullable": true, "description": "The custom domain of the page. To be configured within the dashboard.", "example": "status.acme.com" }, "icon": { "anyOf": [ { "type": "string", "format": "uri" }, { "type": "string", "enum": [""] }, { "nullable": true } ], "description": "The icon of the page", "example": "https://example.com/icon.png" }, "passwordProtected": { "type": "boolean", "default": false, "description": "Deprecated in favor of `accessType`. Used to set the password protection type. Returns true if `accessType` is set to 'password' and false otherwise.", "example": true, "deprecated": true }, "accessType": { "type": "string", "enum": ["public", "password", "email-domain"], "default": "public", "description": "The access type of the page", "example": "public" }, "password": { "type": "string", "nullable": true, "description": "Your password to protect the page from the public", "example": "hidden-password" }, "authEmailDomains": { "type": "array", "nullable": true, "items": { "type": "string" }, "description": "The email domains of the page", "example": ["example.com", "example.org"] }, "showMonitorValues": { "type": "boolean", "nullable": true, "default": true, "description": "Displays the total and failed request numbers for each monitor. Deprecated and will be removed in the future in favor for `configuration` property.", "example": true, "deprecated": true }, "monitors": { "anyOf": [ { "type": "array", "items": { "type": "number" }, "description": "The monitors of the page as an array of ids. We recommend using the object format to include the order.", "deprecated": true, "example": [1, 2] }, { "type": "array", "items": { "type": "object", "properties": { "monitorId": { "type": "number" }, "order": { "type": "number" } }, "required": ["monitorId", "order"] }, "description": "The monitor as object allowing to pass id and order", "example": [ { "monitorId": 1, "order": 0 }, { "monitorId": 2, "order": 1 } ] } ] } } } } } }, "responses": { "200": { "description": "Get an Status page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Page" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/page": { "get": { "tags": ["page"], "summary": "List all status pages", "responses": { "200": { "description": "A list of your status pages", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Page" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "post": { "tags": ["page"], "summary": "Create a status page", "requestBody": { "description": "The status page to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the page", "example": "My Page" }, "description": { "type": "string", "description": "The description of the page", "example": "My awesome status page" }, "slug": { "type": "string", "description": "The slug of the page", "example": "my-page" }, "customDomain": { "type": "string", "nullable": true, "description": "The custom domain of the page. To be configured within the dashboard.", "example": "status.acme.com" }, "icon": { "anyOf": [ { "type": "string", "format": "uri" }, { "type": "string", "enum": [""] }, { "nullable": true } ], "description": "The icon of the page", "example": "https://example.com/icon.png" }, "passwordProtected": { "type": "boolean", "default": false, "description": "Deprecated in favor of `accessType`. Used to set the password protection type. Returns true if `accessType` is set to 'password' and false otherwise.", "example": true, "deprecated": true }, "accessType": { "type": "string", "enum": ["public", "password", "email-domain"], "default": "public", "description": "The access type of the page", "example": "public" }, "password": { "type": "string", "nullable": true, "description": "Your password to protect the page from the public", "example": "hidden-password" }, "authEmailDomains": { "type": "array", "nullable": true, "items": { "type": "string" }, "description": "The email domains of the page", "example": ["example.com", "example.org"] }, "showMonitorValues": { "type": "boolean", "nullable": true, "default": true, "description": "Displays the total and failed request numbers for each monitor. Deprecated and will be removed in the future in favor for `configuration` property.", "example": true, "deprecated": true }, "monitors": { "anyOf": [ { "type": "array", "items": { "type": "number" }, "description": "The monitors of the page as an array of ids. We recommend using the object format to include the order.", "deprecated": true, "example": [1, 2] }, { "type": "array", "items": { "type": "object", "properties": { "monitorId": { "type": "number" }, "order": { "type": "number" } }, "required": ["monitorId", "order"] }, "description": "The monitor as object allowing to pass id and order", "example": [ { "monitorId": 1, "order": 0 }, { "monitorId": 2, "order": 1 } ] } ] } }, "required": ["title", "description", "slug"] } } } }, "responses": { "200": { "description": "Get an Status page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Page" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/status_report": { "get": { "tags": ["status_report"], "summary": "List all status reports", "responses": { "200": { "description": "Get all status reports", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/StatusReport" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "post": { "tags": ["status_report"], "summary": "Create a status report", "requestBody": { "description": "The status report to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string", "example": "Documenso", "description": "The title of the status report" }, "status": { "type": "string", "enum": [ "investigating", "identified", "monitoring", "resolved" ], "description": "The current status of the report" }, "monitorIds": { "type": "array", "items": { "type": "number" }, "default": [], "description": "Ids of the monitors the status report." }, "pageId": { "type": "number", "description": "The id of the page this status report belongs to" }, "date": { "type": "string", "nullable": true, "format": "date", "default": "2026-02-12T22:01:46.114Z", "description": "The date of the report in ISO8601 format, defaults to now" }, "message": { "type": "string", "description": "The message of the current status of incident" } }, "required": ["title", "status", "pageId", "message"] } } } }, "responses": { "200": { "description": "The created status report", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusReport" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/status_report/{id}": { "delete": { "tags": ["status_report"], "summary": "Delete a status report", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the status report", "example": "1" }, "required": true, "description": "The id of the status report", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Status report deleted", "content": { "application/json": { "schema": { "type": "object", "properties": {} } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "get": { "tags": ["status_report"], "summary": "Get a status report", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the status report", "example": "1" }, "required": true, "description": "The id of the status report", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get all status reports", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusReport" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/status_report/{id}/update": { "post": { "tags": ["status_report"], "summary": "Create a status report update", "deprecated": true, "description": "Preferably use [`/status-report-updates`](#tag/status_report_update/POST/status_report_update) instead.", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the status report", "example": "1" }, "required": true, "description": "The id of the status report", "name": "id", "in": "path" } ], "requestBody": { "description": "the status report update", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "investigating", "identified", "monitoring", "resolved" ], "description": "The status of the update" }, "date": { "type": "string", "nullable": true, "format": "date", "default": "2026-02-12T22:01:46.114Z", "description": "The date of the update in ISO8601 format" }, "message": { "type": "string", "minLength": 1, "description": "The message of the update" } }, "required": ["status", "message"] } } } }, "responses": { "200": { "description": "Status report updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusReport" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/status_report_update/{id}": { "get": { "tags": ["status_report_update"], "summary": "Get a status report update", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the update", "example": "1" }, "required": true, "description": "The id of the update", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get a status report update", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusReportUpdate" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/status_report_update": { "post": { "tags": ["status_report_update"], "summary": "Create a status report update", "requestBody": { "description": "The status report update to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "investigating", "identified", "monitoring", "resolved" ], "description": "The status of the update" }, "date": { "type": "string", "nullable": true, "format": "date", "default": "2026-02-12T22:01:46.114Z", "description": "The date of the update in ISO8601 format" }, "message": { "type": "string", "minLength": 1, "description": "The message of the update" }, "statusReportId": { "type": "number", "description": "The id of the status report" } }, "required": ["status", "message", "statusReportId"] } } } }, "responses": { "200": { "description": "The created status report update", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusReportUpdate" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/incident": { "get": { "tags": ["incident"], "summary": "List all incidents", "responses": { "200": { "description": "Get all incidents", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Incident" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/incident/{id}": { "get": { "tags": ["incident"], "summary": "Get an incident", "parameters": [ { "schema": { "type": "string", "minLength": 1, "pattern": "^\\d+$", "description": "The id of the Incident", "example": "1" }, "required": true, "description": "The id of the Incident", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get an incident", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Incident" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "put": { "tags": ["incident"], "summary": "Update an incident", "description": "Acknowledge or resolve an incident", "parameters": [ { "schema": { "type": "string", "minLength": 1, "pattern": "^\\d+$", "description": "The id of the Incident", "example": "1" }, "required": true, "description": "The id of the Incident", "name": "id", "in": "path" } ], "requestBody": { "description": "The incident to update", "content": { "application/json": { "schema": { "type": "object", "properties": { "acknowledgedAt": { "type": "string", "nullable": true, "format": "date", "description": "The date the incident was acknowledged" }, "resolvedAt": { "type": "string", "nullable": true, "format": "date", "description": "The date the incident was resolved" } } } } } }, "responses": { "200": { "description": "Update a monitor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Incident" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/maintenance": { "get": { "tags": ["maintenance"], "summary": "List all maintenances", "responses": { "200": { "description": "Get all maintenances", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Maintenance" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "post": { "tags": ["maintenance"], "summary": "Create a maintenance", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the maintenance", "example": "Database Upgrade" }, "message": { "type": "string", "description": "The message describing the maintenance", "example": "Upgrading database to improve performance" }, "from": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance starts" }, "to": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance ends" }, "monitorIds": { "type": "array", "items": { "type": "number" }, "default": [], "description": "IDs of affected monitors" }, "pageId": { "type": "number", "description": "The id of the status page this maintenance belongs to" } }, "required": ["title", "message", "from", "to", "pageId"] } } } }, "responses": { "200": { "description": "Create a maintenance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Maintenance" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/maintenance/{id}": { "get": { "tags": ["maintenance"], "summary": "Get a maintenance", "parameters": [ { "schema": { "type": "string", "minLength": 1, "pattern": "^\\d+$", "description": "The id of the maintenance", "example": "1" }, "required": true, "description": "The id of the maintenance", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get a maintenance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Maintenance" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "put": { "tags": ["maintenance"], "summary": "Update a maintenance", "parameters": [ { "schema": { "type": "string", "minLength": 1, "pattern": "^\\d+$", "description": "The id of the maintenance", "example": "1" }, "required": true, "description": "The id of the maintenance", "name": "id", "in": "path" } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string", "description": "The title of the maintenance", "example": "Database Upgrade" }, "message": { "type": "string", "description": "The message describing the maintenance", "example": "Upgrading database to improve performance" }, "from": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance starts" }, "to": { "type": "string", "nullable": true, "format": "date", "description": "When the maintenance ends" }, "monitorIds": { "type": "array", "items": { "type": "number" }, "default": [], "description": "IDs of affected monitors" }, "pageId": { "type": "number", "description": "The id of the status page this maintenance belongs to" } } } } } }, "responses": { "200": { "description": "Update a maintenance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Maintenance" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/notification": { "get": { "tags": ["notification"], "summary": "List all notifications", "responses": { "200": { "description": "Get all your workspace notification", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Notification" } } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } }, "post": { "tags": ["notification"], "summary": "Create a notification", "requestBody": { "description": "The notification to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "description": "The name of the notification", "example": "OpenStatus Discord" }, "provider": { "type": "string", "enum": [ "discord", "email", "google-chat", "grafana-oncall", "ntfy", "pagerduty", "opsgenie", "slack", "sms", "telegram", "webhook", "whatsapp" ], "description": "The provider of the notification", "example": "discord" }, "payload": { "anyOf": [ { "type": "object", "properties": { "discord": { "type": "string", "format": "uri" } }, "required": ["discord"] }, { "type": "object", "properties": { "email": { "type": "string", "format": "email" } }, "required": ["email"] }, { "type": "object", "properties": { "grafana-oncall": { "type": "object", "properties": { "webhookUrl": { "type": "string", "format": "uri" } }, "required": ["webhookUrl"] } }, "required": ["grafana-oncall"] }, { "type": "object", "properties": { "ntfy": { "type": "object", "properties": { "topic": { "type": "string", "default": "" }, "serverUrl": { "type": "string", "default": "https://ntfy.sh" }, "token": { "type": "string" } } } }, "required": ["ntfy"] }, { "type": "object", "properties": { "opsgenie": { "type": "object", "properties": { "apiKey": { "type": "string" }, "region": { "type": "string", "enum": ["us", "eu"] } }, "required": ["apiKey", "region"] } }, "required": ["opsgenie"] }, { "type": "object", "properties": { "pagerduty": { "type": "string" } }, "required": ["pagerduty"] }, { "type": "object", "properties": { "sms": { "type": "string", "pattern": "^([+]?[\\s0-9]+)?(\\d{3}|[(]?[0-9]+[)])?([-]?[\\s]?[0-9])+$" } }, "required": ["sms"] }, { "type": "object", "properties": { "telegram": { "type": "object", "properties": { "chatId": { "type": "string" } }, "required": ["chatId"] } }, "required": ["telegram"] }, { "type": "object", "properties": { "slack": { "type": "string", "format": "uri" } }, "required": ["slack"] }, { "type": "object", "properties": { "webhook": { "type": "object", "properties": { "endpoint": { "type": "string", "format": "uri" }, "headers": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] } } }, "required": ["endpoint"] } }, "required": ["webhook"] }, { "type": "object", "properties": { "whatsapp": { "type": "string", "pattern": "^([+]?[\\s0-9]+)?(\\d{3}|[(]?[0-9]+[)])?([-]?[\\s]?[0-9])+$" } }, "required": ["whatsapp"] }, { "type": "object", "properties": { "google-chat": { "type": "string", "format": "uri" } }, "required": ["google-chat"] } ], "description": "The data of the notification" }, "monitors": { "type": "array", "nullable": true, "items": { "type": "number" }, "description": "The monitors that the notification is linked to", "example": [1, 2] } }, "required": ["name", "provider", "payload"] } } } }, "responses": { "200": { "description": "Return the created notification", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Notification" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/notification/{id}": { "get": { "tags": ["notification"], "summary": "Get a notification", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the notification", "example": "1" }, "required": true, "description": "The id of the notification", "name": "id", "in": "path" } ], "responses": { "200": { "description": "Get an Status page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Notification" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/page_subscriber/{id}/update": { "post": { "tags": ["page_subscriber"], "summary": "Subscribe to a status page", "description": "Add a subscriber to a status page", "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "The id of the page", "example": "1" }, "required": true, "description": "The id of the page", "name": "id", "in": "path" } ], "requestBody": { "description": "The subscriber payload", "content": { "application/json": { "schema": { "type": "object", "properties": { "email": { "type": "string", "format": "email", "description": "The email of the subscriber" } }, "required": ["email"] } } } }, "responses": { "200": { "description": "The user has been subscribed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PageSubscriber" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/check/http": { "post": { "tags": ["check"], "summary": "Run a single check", "requestBody": { "description": "The run request to create", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "example": "https://www.documenso.co", "description": "The url to monitor" }, "body": { "type": "string", "nullable": true, "default": "", "example": "Hello World", "description": "The body" }, "headers": { "type": "array", "nullable": true, "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": ["key", "value"] }, "default": [], "description": "The headers of your request", "example": [ { "key": "x-apikey", "value": "supersecrettoken" } ] }, "method": { "type": "string", "enum": [ "GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "TRACE", "CONNECT", "OPTIONS" ], "example": "GET" }, "regions": { "type": "array", "nullable": true, "items": { "type": "string", "enum": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "qro", "scl", "sjc", "sea", "sin", "syd", "waw", "yul", "yyz", "koyeb_fra", "koyeb_was", "koyeb_sin", "koyeb_tyo", "koyeb_par", "koyeb_sfo", "railway_europe-west4-drams3a", "railway_us-east4-eqdc4a", "railway_asia-southeast1-eqsg3a", "railway_us-west2" ] }, "default": [], "example": ["ams"], "description": "Where we should monitor it" }, "runCount": { "type": "number", "maximum": 5, "default": 1, "description": "The number of times to run the check" }, "aggregated": { "type": "boolean", "description": "Whether to aggregate the results or not" } }, "required": ["url", "method"], "description": "The check request" } } } }, "responses": { "200": { "description": "Return a run result", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "integer", "description": "The id of the check" }, "raw": { "type": "array", "items": { "type": "object", "properties": { "dnsStart": { "type": "number", "description": "DNS timestamp start time in UTC " }, "dnsDone": { "type": "number", "description": "DNS timestamp end time in UTC " }, "connectStart": { "type": "number", "description": "Connect timestamp start time in UTC " }, "connectDone": { "type": "number", "description": "Connect timestamp end time in UTC " }, "tlsHandshakeStart": { "type": "number", "description": "TLS handshake timestamp start time in UTC " }, "tlsHandshakeDone": { "type": "number", "description": "TLS handshake timestamp end time in UTC " }, "firstByteStart": { "type": "number", "description": "First byte timestamp start time in UTC " }, "firstByteDone": { "type": "number", "description": "First byte timestamp end time in UTC " }, "transferStart": { "type": "number", "description": "Transfer timestamp start time in UTC " }, "transferDone": { "type": "number", "description": "Transfer timestamp end time in UTC " } }, "required": [ "dnsStart", "dnsDone", "connectStart", "connectDone", "tlsHandshakeStart", "tlsHandshakeDone", "firstByteStart", "firstByteDone", "transferStart", "transferDone" ] }, "description": "The raw data of the check" }, "response": { "type": "object", "properties": { "timestamp": { "type": "number", "description": "The timestamp of the response in UTC" }, "status": { "type": "number", "description": "The status code of the response" }, "latency": { "type": "number", "description": "The latency of the response" }, "body": { "type": "string", "description": "The body of the response" }, "headers": { "type": "object", "additionalProperties": { "type": "string" }, "description": "The headers of the response" }, "timing": { "type": "object", "properties": { "dnsStart": { "type": "number", "description": "DNS timestamp start time in UTC " }, "dnsDone": { "type": "number", "description": "DNS timestamp end time in UTC " }, "connectStart": { "type": "number", "description": "Connect timestamp start time in UTC " }, "connectDone": { "type": "number", "description": "Connect timestamp end time in UTC " }, "tlsHandshakeStart": { "type": "number", "description": "TLS handshake timestamp start time in UTC " }, "tlsHandshakeDone": { "type": "number", "description": "TLS handshake timestamp end time in UTC " }, "firstByteStart": { "type": "number", "description": "First byte timestamp start time in UTC " }, "firstByteDone": { "type": "number", "description": "First byte timestamp end time in UTC " }, "transferStart": { "type": "number", "description": "Transfer timestamp start time in UTC " }, "transferDone": { "type": "number", "description": "Transfer timestamp end time in UTC " } }, "required": [ "dnsStart", "dnsDone", "connectStart", "connectDone", "tlsHandshakeStart", "tlsHandshakeDone", "firstByteStart", "firstByteDone", "transferStart", "transferDone" ], "description": "The timing metrics of the response" }, "aggregated": { "type": "object", "properties": { "dns": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated DNS timing of the check" }, "connection": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated connection timing of the check" }, "tls": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated tls timing of the check" }, "firstByte": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated first byte timing of the check" }, "transfer": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated transfer timing of the check" }, "latency": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated latency timing of the check" } }, "required": [ "dns", "connection", "tls", "firstByte", "transfer", "latency" ], "description": "The aggregated data dns timing of the check" }, "region": { "type": "string", "description": "The region where the check ran" } }, "required": [ "timestamp", "status", "latency", "timing", "region" ], "description": "The last response of the check" }, "aggregated": { "type": "object", "properties": { "dns": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" }, "connect": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" }, "tls": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" }, "firstByte": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" }, "transfer": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" }, "latency": { "type": "object", "properties": { "p50": { "type": "number", "description": "The 50th percentile" }, "p75": { "type": "number", "description": "The 75th percentile" }, "p95": { "type": "number", "description": "The 95th percentile" }, "p99": { "type": "number", "description": "The 99th percentile" }, "min": { "type": "number", "description": "The minimum value" }, "max": { "type": "number", "description": "The maximum value" } }, "required": [ "p50", "p75", "p95", "p99", "min", "max" ], "description": "The aggregated data of the check" } }, "required": [ "dns", "connect", "tls", "firstByte", "transfer", "latency" ], "description": "The aggregated data of the check" } }, "required": ["id", "raw", "response"] } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } }, "/whoami": { "get": { "tags": ["whoami"], "summary": "Get your informations", "description": "Get the current workspace information attached to the API key.", "responses": { "200": { "description": "The current workspace information with the limits", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, "400": { "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrBadRequest" } } } }, "401": { "description": "The client must authenticate itself to get the requested response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrUnauthorized" } } } }, "402": { "description": "A higher pricing plan is required to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrPaymentRequired" } } } }, "403": { "description": "The client does not have the necessary permissions to access the resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrForbidden" } } } }, "404": { "description": "The server can't find the requested resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrNotFound" } } } }, "409": { "description": "The request could not be completed due to a conflict mainly due to unique constraints.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrConflict" } } } }, "500": { "description": "The server has encountered a situation it doesn't know how to handle.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrInternalServerError" } } } } } } } } } ================================================ FILE: apps/server/static/openapi.yaml ================================================ openapi: 3.1.0 info: description: OpenStatus is a open-source status page platform with global uptime monitoring. The OpenStatus API allows you to interact with the OpenStatus platform programmatically. To get started you need to create an account on https://www.openstatus.dev/ and create an api token in your settings. title: OpenStatus API version: v2.0.0 contact: email: ping@openstatus.dev url: https://www.openstatus.dev externalDocs: description: OpenStatus Documentation url: https://docs.openstatus.dev components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: x-openstatus-key schemas: connect.error: type: object properties: code: type: string examples: - not_found enum: - canceled - unknown - invalid_argument - deadline_exceeded - not_found - already_exists - permission_denied - resource_exhausted - failed_precondition - aborted - out_of_range - unimplemented - internal - unavailable - data_loss - unauthenticated description: The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]. message: type: string description: A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client. details: type: array items: $ref: '#/components/schemas/connect.error_details.Any' description: A list of messages that carry the error details. There is no limit on the number of messages. title: Connect Error additionalProperties: true description: 'Error type returned by Connect: https://connectrpc.com/docs/go/errors/#http-representation' connect.error_details.Any: type: object properties: type: type: string description: 'A URL that acts as a globally unique identifier for the type of the serialized message. For example: `type.googleapis.com/google.rpc.ErrorInfo`. This is used to determine the schema of the data in the `value` field and is the discriminator for the `debug` field.' value: type: string format: binary description: The Protobuf message, serialized as bytes and base64-encoded. The specific message type is identified by the `type` field. debug: oneOf: - type: object title: Any additionalProperties: true description: Detailed error information. discriminator: propertyName: type title: Debug description: Deserialized error detail payload. The 'type' field indicates the schema. This field is for easier debugging and should not be relied upon for application logic. additionalProperties: true description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message, with an additional debug field for ConnectRPC error details. openstatus.health.v1.CheckRequest: type: object properties: service: type: string title: service description: Optional service name to check. If empty, checks overall service health. title: CheckRequest additionalProperties: false description: CheckRequest is the request message for health checks. openstatus.health.v1.CheckResponse: type: object properties: status: title: status description: The serving status of the service. $ref: '#/components/schemas/openstatus.health.v1.CheckResponse.ServingStatus' title: CheckResponse additionalProperties: false description: CheckResponse is the response message for health checks. openstatus.health.v1.CheckResponse.ServingStatus: type: string title: ServingStatus enum: - SERVING_STATUS_UNSPECIFIED - SERVING_STATUS_SERVING - SERVING_STATUS_NOT_SERVING description: ServingStatus represents the health status of the service. openstatus.maintenance.v1.CreateMaintenanceRequest: type: object properties: title: type: string examples: - Database Migration title: title maxLength: 256 minLength: 1 description: Title of the maintenance (required, 1-256 characters). message: type: string title: message minLength: 1 description: Message describing the maintenance (required). from: type: string examples: - "2024-03-01T02:00:00Z" title: from pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: Start time of the maintenance window (RFC 3339 format, required). to: type: string examples: - "2024-03-01T06:00:00Z" title: to pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: End time of the maintenance window (RFC 3339 format, required). pageId: type: string title: page_id minLength: 1 description: Page ID to associate with this maintenance (required). pageComponentIds: type: array items: type: string title: page_component_ids description: Page component IDs to associate with this maintenance (optional). notify: type: - boolean - "null" title: notify description: Whether to notify subscribers about this maintenance (optional, defaults to false). title: CreateMaintenanceRequest additionalProperties: false description: CreateMaintenanceRequest is the request to create a new maintenance window. openstatus.maintenance.v1.CreateMaintenanceResponse: type: object properties: maintenance: title: maintenance description: The created maintenance. $ref: '#/components/schemas/openstatus.maintenance.v1.Maintenance' title: CreateMaintenanceResponse additionalProperties: false description: CreateMaintenanceResponse is the response after creating a maintenance window. openstatus.maintenance.v1.DeleteMaintenanceRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the maintenance to delete (required). title: DeleteMaintenanceRequest additionalProperties: false description: DeleteMaintenanceRequest is the request to delete a maintenance window. openstatus.maintenance.v1.DeleteMaintenanceResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteMaintenanceResponse additionalProperties: false description: DeleteMaintenanceResponse is the response after deleting a maintenance window. openstatus.maintenance.v1.GetMaintenanceRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the maintenance to retrieve (required). title: GetMaintenanceRequest additionalProperties: false description: GetMaintenanceRequest is the request to get a maintenance window by ID. openstatus.maintenance.v1.GetMaintenanceResponse: type: object properties: maintenance: title: maintenance description: The requested maintenance. $ref: '#/components/schemas/openstatus.maintenance.v1.Maintenance' title: GetMaintenanceResponse additionalProperties: false description: GetMaintenanceResponse is the response containing the maintenance window. openstatus.maintenance.v1.ListMaintenancesRequest: type: object properties: limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of maintenances to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of maintenances to skip for pagination (defaults to 0). pageId: type: - string - "null" title: page_id description: Filter by page ID (optional). title: ListMaintenancesRequest additionalProperties: false description: ListMaintenancesRequest is the request to list maintenance windows. openstatus.maintenance.v1.ListMaintenancesResponse: type: object properties: maintenances: type: array items: $ref: '#/components/schemas/openstatus.maintenance.v1.MaintenanceSummary' title: maintenances description: List of maintenances. totalSize: type: integer title: total_size format: int32 description: Total number of maintenances matching the filter. title: ListMaintenancesResponse additionalProperties: false description: ListMaintenancesResponse is the response containing maintenance window summaries. openstatus.maintenance.v1.Maintenance: type: object properties: id: type: string title: id description: Unique identifier for the maintenance. title: type: string title: title description: Title of the maintenance. message: type: string title: message description: Message describing the maintenance. from: type: string title: from description: Start time of the maintenance window (RFC 3339 format). to: type: string title: to description: End time of the maintenance window (RFC 3339 format). pageId: type: string title: page_id description: ID of the page this maintenance is associated with. pageComponentIds: type: array items: type: string title: page_component_ids description: IDs of affected page components. createdAt: type: string title: created_at description: Timestamp when the maintenance was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the maintenance was last updated (RFC 3339 format). title: Maintenance additionalProperties: false description: Maintenance represents a maintenance window with full details. openstatus.maintenance.v1.MaintenanceSummary: type: object properties: id: type: string title: id description: Unique identifier for the maintenance. title: type: string title: title description: Title of the maintenance. message: type: string title: message description: Message describing the maintenance. from: type: string title: from description: Start time of the maintenance window (RFC 3339 format). to: type: string title: to description: End time of the maintenance window (RFC 3339 format). pageId: type: string title: page_id description: ID of the page this maintenance is associated with. pageComponentIds: type: array items: type: string title: page_component_ids description: IDs of affected page components. createdAt: type: string title: created_at description: Timestamp when the maintenance was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the maintenance was last updated (RFC 3339 format). title: MaintenanceSummary additionalProperties: false description: MaintenanceSummary represents metadata for a maintenance window (used in list responses). openstatus.maintenance.v1.UpdateMaintenanceRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the maintenance to update (required). title: type: - string - "null" title: title maxLength: 256 minLength: 1 description: New title for the maintenance (optional). message: type: - string - "null" title: message description: New message for the maintenance (optional). from: type: - string - "null" title: from pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: New start time (RFC 3339 format, optional). to: type: - string - "null" title: to pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: New end time (RFC 3339 format, optional). pageId: type: - string - "null" title: page_id description: New page ID (optional). pageComponentIds: type: array items: type: string title: page_component_ids description: New list of page component IDs (optional, replaces existing list). title: UpdateMaintenanceRequest additionalProperties: false description: UpdateMaintenanceRequest is the request to update a maintenance window. openstatus.maintenance.v1.UpdateMaintenanceResponse: type: object properties: maintenance: title: maintenance description: The updated maintenance. $ref: '#/components/schemas/openstatus.maintenance.v1.Maintenance' title: UpdateMaintenanceResponse additionalProperties: false description: UpdateMaintenanceResponse is the response after updating a maintenance window. openstatus.monitor.v1.BodyAssertion: type: object properties: target: type: string title: target description: Target value to compare against. comparator: not: enum: - STRING_COMPARATOR_UNSPECIFIED title: comparator description: Comparison operation (required, must not be UNSPECIFIED). $ref: '#/components/schemas/openstatus.monitor.v1.StringComparator' title: BodyAssertion additionalProperties: false description: BodyAssertion defines an assertion for response body content. openstatus.monitor.v1.CreateDNSMonitorRequest: type: object properties: monitor: title: monitor description: Monitor configuration (required). $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: CreateDNSMonitorRequest required: - monitor additionalProperties: false description: CreateDNSMonitorRequest is the request to create a new DNS monitor. openstatus.monitor.v1.CreateDNSMonitorResponse: type: object properties: monitor: title: monitor description: The created monitor with assigned ID. $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: CreateDNSMonitorResponse additionalProperties: false description: CreateDNSMonitorResponse is the response after creating a DNS monitor. openstatus.monitor.v1.CreateHTTPMonitorRequest: type: object properties: monitor: title: monitor description: Monitor configuration (required). $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' title: CreateHTTPMonitorRequest required: - monitor additionalProperties: false description: CreateHTTPMonitorRequest is the request to create a new HTTP monitor. openstatus.monitor.v1.CreateHTTPMonitorResponse: type: object properties: monitor: title: monitor description: The created monitor with assigned ID. $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' title: CreateHTTPMonitorResponse additionalProperties: false description: CreateHTTPMonitorResponse is the response after creating an HTTP monitor. openstatus.monitor.v1.CreateTCPMonitorRequest: type: object properties: monitor: title: monitor description: Monitor configuration (required). $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' title: CreateTCPMonitorRequest required: - monitor additionalProperties: false description: CreateTCPMonitorRequest is the request to create a new TCP monitor. openstatus.monitor.v1.CreateTCPMonitorResponse: type: object properties: monitor: title: monitor description: The created monitor with assigned ID. $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' title: CreateTCPMonitorResponse additionalProperties: false description: CreateTCPMonitorResponse is the response after creating a TCP monitor. openstatus.monitor.v1.DNSMonitor: type: object properties: id: type: string title: id description: Unique identifier for the monitor (output only for create requests). name: type: string examples: - DNS Resolution Check title: name maxLength: 256 minLength: 1 description: Name of the monitor (required, max 256 characters). uri: type: string examples: - example.com title: uri maxLength: 2048 minLength: 1 description: Domain to resolve (required, max 2048 characters). periodicity: not: enum: - PERIODICITY_UNSPECIFIED title: periodicity description: Check periodicity (required). $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' timeout: type: - integer - string title: timeout maximum: 120000 minimum: 0 format: int64 description: Timeout in milliseconds (0-120000, defaults to 45000). degradedAt: type: - integer - string - "null" title: degraded_at maximum: 120000 minimum: 0 format: int64 description: Latency threshold for degraded status in milliseconds (optional, 0-120000). retry: type: - integer - string title: retry maximum: 10 minimum: 0 format: int64 description: Number of retry attempts (0-10, defaults to 3). recordAssertions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.RecordAssertion' title: record_assertions maxItems: 10 description: DNS record assertions for validation. description: type: string title: description maxLength: 1024 description: Description of the monitor (optional). active: type: boolean title: active description: Whether the monitor is active (defaults to false). public: type: boolean title: public description: Whether the monitor is publicly visible (defaults to false). regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Region' title: regions maxItems: 28 description: Geographic regions to run checks from. openTelemetry: title: open_telemetry description: OpenTelemetry configuration for exporting metrics. $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' status: title: status description: Current operational status of the monitor. $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' title: DNSMonitor additionalProperties: false description: DNSMonitor defines the configuration for a DNS monitor. openstatus.monitor.v1.DeleteMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to delete (required). title: DeleteMonitorRequest additionalProperties: false description: DeleteMonitorRequest is the request to delete a monitor. openstatus.monitor.v1.DeleteMonitorResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteMonitorResponse additionalProperties: false description: DeleteMonitorResponse is the response after deleting a monitor. openstatus.monitor.v1.GetMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to retrieve (required). title: GetMonitorRequest additionalProperties: false description: GetMonitorRequest is the request to get a single monitor by ID. openstatus.monitor.v1.GetMonitorResponse: type: object properties: monitor: title: monitor description: The monitor configuration (one of HTTP, TCP, or DNS). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false description: GetMonitorResponse is the response containing the monitor. openstatus.monitor.v1.GetMonitorStatusRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to get status for (required). title: GetMonitorStatusRequest additionalProperties: false description: GetMonitorStatusRequest is the request to get the status of all regions for a monitor. openstatus.monitor.v1.GetMonitorStatusResponse: type: object properties: id: type: string title: id description: Monitor ID. regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.RegionStatus' title: regions description: Status for each region. title: GetMonitorStatusResponse additionalProperties: false description: GetMonitorStatusResponse is the response containing the status of all regions for a monitor. openstatus.monitor.v1.GetMonitorSummaryRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to get summary for (required). timeRange: title: time_range description: Time range for metrics aggregation (defaults to 1 day if unspecified). $ref: '#/components/schemas/openstatus.monitor.v1.TimeRange' regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Region' title: regions maxItems: 28 description: Optional filter by regions. If empty, returns metrics for all regions. title: GetMonitorSummaryRequest additionalProperties: false description: GetMonitorSummaryRequest is the request to get aggregated metrics for a monitor. openstatus.monitor.v1.GetMonitorSummaryResponse: type: object properties: id: type: string title: id description: Monitor ID. lastPingAt: type: string title: last_ping_at description: Timestamp of the last check in RFC 3339 format. totalSuccessful: type: - integer - string title: total_successful format: int64 description: Total number of successful requests. totalDegraded: type: - integer - string title: total_degraded format: int64 description: Total number of degraded requests. totalFailed: type: - integer - string title: total_failed format: int64 description: Total number of failed requests. p50: type: - integer - string title: p50 format: int64 description: 50th percentile (median) latency in milliseconds. p75: type: - integer - string title: p75 format: int64 description: 75th percentile latency in milliseconds. p90: type: - integer - string title: p90 format: int64 description: 90th percentile latency in milliseconds. p95: type: - integer - string title: p95 format: int64 description: 95th percentile latency in milliseconds. p99: type: - integer - string title: p99 format: int64 description: 99th percentile latency in milliseconds. timeRange: title: time_range description: Time range used for the metrics. $ref: '#/components/schemas/openstatus.monitor.v1.TimeRange' regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Region' title: regions description: Regions included in the metrics. title: GetMonitorSummaryResponse additionalProperties: false description: GetMonitorSummaryResponse is the response containing aggregated metrics for a monitor. openstatus.monitor.v1.HTTPMethod: type: string title: HTTPMethod enum: - HTTP_METHOD_UNSPECIFIED - HTTP_METHOD_GET - HTTP_METHOD_POST - HTTP_METHOD_HEAD - HTTP_METHOD_PUT - HTTP_METHOD_PATCH - HTTP_METHOD_DELETE - HTTP_METHOD_TRACE - HTTP_METHOD_CONNECT - HTTP_METHOD_OPTIONS description: HTTP methods supported for monitors. openstatus.monitor.v1.HTTPMonitor: type: object properties: id: type: string title: id description: Unique identifier for the monitor (output only for create requests). name: type: string examples: - Production API Health Check title: name maxLength: 256 minLength: 1 description: Name of the monitor (required, max 256 characters). url: type: string examples: - https://api.example.com/health title: url maxLength: 2048 minLength: 1 format: uri description: URL to monitor (required, max 2048 characters). periodicity: not: enum: - PERIODICITY_UNSPECIFIED title: periodicity description: Check periodicity (required). $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' method: not: enum: - HTTP_METHOD_UNSPECIFIED title: method description: HTTP method to use (defaults to GET). $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMethod' body: type: string examples: - map[key:value] title: body description: Request body (optional). timeout: type: - integer - string title: timeout maximum: 120000 minimum: 0 format: int64 description: Timeout in milliseconds (0-120000, defaults to 45000). degradedAt: type: - integer - string - "null" title: degraded_at maximum: 120000 minimum: 0 format: int64 description: Latency threshold for degraded status in milliseconds (optional, 0-120000). retry: type: - integer - string title: retry maximum: 10 minimum: 0 format: int64 description: Number of retry attempts (0-10, defaults to 3). followRedirects: type: - boolean - "null" title: follow_redirects description: Whether to follow HTTP redirects (defaults to true when not specified). headers: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Headers' title: headers maxItems: 20 description: Custom headers for the request. statusCodeAssertions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.StatusCodeAssertion' title: status_code_assertions maxItems: 10 description: Status code assertions for the response. bodyAssertions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.BodyAssertion' title: body_assertions maxItems: 10 description: Body content assertions for the response. headerAssertions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.HeaderAssertion' title: header_assertions maxItems: 10 description: Header assertions for the response. description: type: string title: description maxLength: 1024 description: Description of the monitor (optional). active: type: boolean title: active description: Whether the monitor is active (defaults to false). public: type: boolean title: public description: Whether the monitor is publicly visible (defaults to false). regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Region' title: regions maxItems: 28 description: Geographic regions to run checks from. openTelemetry: title: open_telemetry description: OpenTelemetry configuration for exporting metrics. $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' status: title: status description: Current operational status of the monitor. $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' title: HTTPMonitor additionalProperties: false description: HTTPMonitor defines the configuration for an HTTP monitor. openstatus.monitor.v1.HeaderAssertion: type: object properties: target: type: string title: target description: Target value to compare against. comparator: not: enum: - STRING_COMPARATOR_UNSPECIFIED title: comparator description: Comparison operation (required, must not be UNSPECIFIED). $ref: '#/components/schemas/openstatus.monitor.v1.StringComparator' key: type: string title: key minLength: 1 description: Header key to check (required). title: HeaderAssertion additionalProperties: false description: HeaderAssertion defines an assertion for response headers. openstatus.monitor.v1.Headers: type: object properties: key: type: string examples: - Authorization title: key minLength: 1 description: Header name. value: type: string examples: - Bearer token123 title: value description: Header value. title: Headers additionalProperties: false description: Headers represents a key-value pair for HTTP headers. openstatus.monitor.v1.ListMonitorsRequest: type: object properties: limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of monitors to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of monitors to skip for pagination (defaults to 0). title: ListMonitorsRequest additionalProperties: false description: ListMonitorsRequest is the request to list monitors. openstatus.monitor.v1.ListMonitorsResponse: type: object properties: httpMonitors: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' title: http_monitors description: HTTP monitors in the workspace. tcpMonitors: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' title: tcp_monitors description: TCP monitors in the workspace. dnsMonitors: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns_monitors description: DNS monitors in the workspace. totalSize: type: integer title: total_size format: int32 description: Total number of monitors across all types. title: ListMonitorsResponse additionalProperties: false description: ListMonitorsResponse is the response containing a list of monitors. openstatus.monitor.v1.MonitorConfig: type: object oneOf: - properties: dns: title: dns description: DNS monitor configuration. $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns required: - dns - properties: http: title: http description: HTTP monitor configuration. $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' title: http required: - http - properties: tcp: title: tcp description: TCP monitor configuration. $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' title: tcp required: - tcp title: MonitorConfig additionalProperties: false description: MonitorConfig represents the type-specific configuration for a monitor. openstatus.monitor.v1.MonitorStatus: type: string title: MonitorStatus enum: - MONITOR_STATUS_UNSPECIFIED - MONITOR_STATUS_ACTIVE - MONITOR_STATUS_DEGRADED - MONITOR_STATUS_ERROR description: MonitorStatus represents the operational status of a monitor. openstatus.monitor.v1.NumberComparator: type: string title: NumberComparator enum: - NUMBER_COMPARATOR_UNSPECIFIED - NUMBER_COMPARATOR_EQUAL - NUMBER_COMPARATOR_NOT_EQUAL - NUMBER_COMPARATOR_GREATER_THAN - NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL - NUMBER_COMPARATOR_LESS_THAN - NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL description: NumberComparator defines comparison operations for numeric values. openstatus.monitor.v1.OpenTelemetryConfig: type: object properties: endpoint: type: string title: endpoint maxLength: 2048 description: OTEL endpoint URL. headers: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Headers' title: headers maxItems: 20 description: Custom headers for OTEL requests. title: OpenTelemetryConfig additionalProperties: false description: OpenTelemetry configuration for exporting metrics. openstatus.monitor.v1.Periodicity: type: string title: Periodicity enum: - PERIODICITY_UNSPECIFIED - PERIODICITY_30S - PERIODICITY_1M - PERIODICITY_5M - PERIODICITY_10M - PERIODICITY_30M - PERIODICITY_1H description: Monitor periodicity options. openstatus.monitor.v1.RecordAssertion: type: object properties: record: type: string title: record enum: - A - AAAA - CNAME - MX - TXT description: DNS record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT"). comparator: not: enum: - RECORD_COMPARATOR_UNSPECIFIED title: comparator description: Comparison operation (required, must not be UNSPECIFIED). $ref: '#/components/schemas/openstatus.monitor.v1.RecordComparator' target: type: string title: target description: Target value to compare against. title: RecordAssertion additionalProperties: false description: RecordAssertion defines an assertion for DNS records. openstatus.monitor.v1.RecordComparator: type: string title: RecordComparator enum: - RECORD_COMPARATOR_UNSPECIFIED - RECORD_COMPARATOR_EQUAL - RECORD_COMPARATOR_NOT_EQUAL - RECORD_COMPARATOR_CONTAINS - RECORD_COMPARATOR_NOT_CONTAINS description: RecordComparator defines comparison operations for DNS records. openstatus.monitor.v1.Region: type: string title: Region enum: - REGION_UNSPECIFIED - REGION_FLY_AMS - REGION_FLY_ARN - REGION_FLY_BOM - REGION_FLY_CDG - REGION_FLY_DFW - REGION_FLY_EWR - REGION_FLY_FRA - REGION_FLY_GRU - REGION_FLY_IAD - REGION_FLY_JNB - REGION_FLY_LAX - REGION_FLY_LHR - REGION_FLY_NRT - REGION_FLY_ORD - REGION_FLY_SJC - REGION_FLY_SIN - REGION_FLY_SYD - REGION_FLY_YYZ - REGION_KOYEB_FRA - REGION_KOYEB_PAR - REGION_KOYEB_SFO - REGION_KOYEB_SIN - REGION_KOYEB_TYO - REGION_KOYEB_WAS - REGION_RAILWAY_US_WEST2 - REGION_RAILWAY_US_EAST4 - REGION_RAILWAY_EUROPE_WEST4 - REGION_RAILWAY_ASIA_SOUTHEAST1 description: Geographic regions where monitors can run checks from. openstatus.monitor.v1.RegionStatus: type: object properties: region: title: region description: The region identifier. $ref: '#/components/schemas/openstatus.monitor.v1.Region' status: title: status description: The status of the monitor in this region. $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' title: RegionStatus additionalProperties: false description: RegionStatus represents the status of a monitor in a specific region. openstatus.monitor.v1.StatusCodeAssertion: type: object properties: target: type: - integer - string title: target maximum: 599 minimum: 100 format: int64 description: Target status code to compare against (100-599). comparator: not: enum: - NUMBER_COMPARATOR_UNSPECIFIED title: comparator description: Comparison operation (required, must not be UNSPECIFIED). $ref: '#/components/schemas/openstatus.monitor.v1.NumberComparator' title: StatusCodeAssertion additionalProperties: false description: StatusCodeAssertion defines an assertion for HTTP status codes. openstatus.monitor.v1.StringComparator: type: string title: StringComparator enum: - STRING_COMPARATOR_UNSPECIFIED - STRING_COMPARATOR_CONTAINS - STRING_COMPARATOR_NOT_CONTAINS - STRING_COMPARATOR_EQUAL - STRING_COMPARATOR_NOT_EQUAL - STRING_COMPARATOR_EMPTY - STRING_COMPARATOR_NOT_EMPTY - STRING_COMPARATOR_GREATER_THAN - STRING_COMPARATOR_GREATER_THAN_OR_EQUAL - STRING_COMPARATOR_LESS_THAN - STRING_COMPARATOR_LESS_THAN_OR_EQUAL description: StringComparator defines comparison operations for string values. openstatus.monitor.v1.TCPMonitor: type: object properties: id: type: string title: id description: Unique identifier for the monitor (output only for create requests). name: type: string examples: - Database Connection Check title: name maxLength: 256 minLength: 1 description: Name of the monitor (required, max 256 characters). uri: type: string examples: - tcp://db.example.com:5432 title: uri maxLength: 2048 minLength: 1 description: URI to monitor in format "host:port" (required, max 2048 characters). periodicity: not: enum: - PERIODICITY_UNSPECIFIED title: periodicity description: Check periodicity (required). $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' timeout: type: - integer - string title: timeout maximum: 120000 minimum: 0 format: int64 description: Timeout in milliseconds (0-120000, defaults to 45000). degradedAt: type: - integer - string - "null" title: degraded_at maximum: 120000 minimum: 0 format: int64 description: Latency threshold for degraded status in milliseconds (optional, 0-120000). retry: type: - integer - string title: retry maximum: 10 minimum: 0 format: int64 description: Number of retry attempts (0-10, defaults to 3). description: type: string title: description maxLength: 1024 description: Description of the monitor (optional). active: type: boolean title: active description: Whether the monitor is active (defaults to false). public: type: boolean title: public description: Whether the monitor is publicly visible (defaults to false). regions: type: array items: $ref: '#/components/schemas/openstatus.monitor.v1.Region' title: regions maxItems: 28 description: Geographic regions to run checks from. openTelemetry: title: open_telemetry description: OpenTelemetry configuration for exporting metrics. $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' status: title: status description: Current operational status of the monitor. $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' title: TCPMonitor additionalProperties: false description: TCPMonitor defines the configuration for a TCP monitor. openstatus.monitor.v1.TimeRange: type: string title: TimeRange enum: - TIME_RANGE_UNSPECIFIED - TIME_RANGE_1D - TIME_RANGE_7D - TIME_RANGE_14D description: TimeRange represents the time period for metrics aggregation. openstatus.monitor.v1.TriggerMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to trigger (required). title: TriggerMonitorRequest additionalProperties: false description: TriggerMonitorRequest is the request to trigger a monitor check. openstatus.monitor.v1.TriggerMonitorResponse: type: object properties: success: type: boolean title: success description: Whether the trigger was successful. title: TriggerMonitorResponse additionalProperties: false description: TriggerMonitorResponse is the response after triggering a monitor. openstatus.monitor.v1.UpdateDNSMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to update (required). monitor: oneOf: - $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' - type: "null" title: monitor description: Updated monitor configuration (all fields optional for partial updates). title: UpdateDNSMonitorRequest additionalProperties: false description: UpdateDNSMonitorRequest is the request to update an existing DNS monitor. openstatus.monitor.v1.UpdateDNSMonitorResponse: type: object properties: monitor: title: monitor description: The updated monitor. $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: UpdateDNSMonitorResponse additionalProperties: false description: UpdateDNSMonitorResponse is the response after updating a DNS monitor. openstatus.monitor.v1.UpdateHTTPMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to update (required). monitor: oneOf: - $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' - type: "null" title: monitor description: Updated monitor configuration (all fields optional for partial updates). title: UpdateHTTPMonitorRequest additionalProperties: false description: UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. openstatus.monitor.v1.UpdateHTTPMonitorResponse: type: object properties: monitor: title: monitor description: The updated monitor. $ref: '#/components/schemas/openstatus.monitor.v1.HTTPMonitor' title: UpdateHTTPMonitorResponse additionalProperties: false description: UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. openstatus.monitor.v1.UpdateTCPMonitorRequest: type: object properties: id: type: string title: id minLength: 1 description: Monitor ID to update (required). monitor: oneOf: - $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' - type: "null" title: monitor description: Updated monitor configuration (all fields optional for partial updates). title: UpdateTCPMonitorRequest additionalProperties: false description: UpdateTCPMonitorRequest is the request to update an existing TCP monitor. openstatus.monitor.v1.UpdateTCPMonitorResponse: type: object properties: monitor: title: monitor description: The updated monitor. $ref: '#/components/schemas/openstatus.monitor.v1.TCPMonitor' title: UpdateTCPMonitorResponse additionalProperties: false description: UpdateTCPMonitorResponse is the response after updating a TCP monitor. openstatus.notification.v1.CheckNotificationLimitRequest: type: object title: CheckNotificationLimitRequest additionalProperties: false description: CheckNotificationLimitRequest is the request to check notification limits. openstatus.notification.v1.CheckNotificationLimitResponse: type: object properties: limitReached: type: boolean title: limit_reached description: Whether the workspace has reached its notification limit. currentCount: type: integer title: current_count format: int32 description: Current number of notification channels. maxCount: type: integer title: max_count format: int32 description: Maximum allowed notification channels. title: CheckNotificationLimitResponse additionalProperties: false description: CheckNotificationLimitResponse is the response containing limit information. openstatus.notification.v1.CreateNotificationRequest: type: object properties: name: type: string examples: - Slack Ops Channel title: name minLength: 1 description: Display name for the notification channel. provider: not: enum: - NOTIFICATION_PROVIDER_UNSPECIFIED title: provider description: Provider type. $ref: '#/components/schemas/openstatus.notification.v1.NotificationProvider' data: title: data description: Provider-specific configuration. $ref: '#/components/schemas/openstatus.notification.v1.NotificationData' monitorIds: type: array items: type: string title: monitor_ids description: IDs of monitors to associate with this notification. title: CreateNotificationRequest required: - data additionalProperties: false description: CreateNotificationRequest is the request to create a new notification channel. openstatus.notification.v1.CreateNotificationResponse: type: object properties: notification: title: notification description: The created notification channel. $ref: '#/components/schemas/openstatus.notification.v1.Notification' title: CreateNotificationResponse additionalProperties: false description: CreateNotificationResponse is the response after creating a notification channel. openstatus.notification.v1.DeleteNotificationRequest: type: object properties: id: type: string title: id minLength: 1 description: Notification ID to delete (required). title: DeleteNotificationRequest additionalProperties: false description: DeleteNotificationRequest is the request to delete a notification channel. openstatus.notification.v1.DeleteNotificationResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteNotificationResponse additionalProperties: false description: DeleteNotificationResponse is the response after deleting a notification channel. openstatus.notification.v1.DiscordData: type: object properties: webhookUrl: type: string examples: - https://discord.com/api/webhooks/123/abc title: webhook_url format: uri description: Discord webhook URL. title: DiscordData additionalProperties: false description: DiscordData contains configuration for Discord notifications. openstatus.notification.v1.EmailData: type: object properties: email: type: string examples: - ops-team@example.com title: email format: email description: Email address to send notifications to. title: EmailData additionalProperties: false description: EmailData contains configuration for email notifications. openstatus.notification.v1.GetNotificationRequest: type: object properties: id: type: string title: id minLength: 1 description: Notification ID to retrieve (required). title: GetNotificationRequest additionalProperties: false description: GetNotificationRequest is the request to get a notification channel. openstatus.notification.v1.GetNotificationResponse: type: object properties: notification: title: notification description: The notification channel. $ref: '#/components/schemas/openstatus.notification.v1.Notification' title: GetNotificationResponse additionalProperties: false description: GetNotificationResponse is the response containing the notification channel. openstatus.notification.v1.GoogleChatData: type: object properties: webhookUrl: type: string title: webhook_url format: uri description: Google Chat webhook URL. title: GoogleChatData additionalProperties: false description: GoogleChatData contains configuration for Google Chat notifications. openstatus.notification.v1.GrafanaOncallData: type: object properties: webhookUrl: type: string title: webhook_url format: uri description: Grafana OnCall webhook URL. title: GrafanaOncallData additionalProperties: false description: GrafanaOncallData contains configuration for Grafana OnCall notifications. openstatus.notification.v1.ListNotificationsRequest: type: object properties: limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of notifications to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of notifications to skip for pagination (defaults to 0). title: ListNotificationsRequest additionalProperties: false description: ListNotificationsRequest is the request to list notification channels. openstatus.notification.v1.ListNotificationsResponse: type: object properties: notifications: type: array items: $ref: '#/components/schemas/openstatus.notification.v1.NotificationSummary' title: notifications description: Notification channel summaries. totalSize: type: integer title: total_size format: int32 description: Total number of notification channels. title: ListNotificationsResponse additionalProperties: false description: ListNotificationsResponse is the response containing notification channels. openstatus.notification.v1.Notification: type: object properties: id: type: string title: id description: Unique identifier for the notification. name: type: string title: name description: Display name for the notification channel. provider: title: provider description: Provider type. $ref: '#/components/schemas/openstatus.notification.v1.NotificationProvider' data: title: data description: Provider-specific configuration. $ref: '#/components/schemas/openstatus.notification.v1.NotificationData' monitorIds: type: array items: type: string title: monitor_ids description: IDs of monitors associated with this notification. createdAt: type: string title: created_at description: Timestamp when the notification was created (RFC 3339). updatedAt: type: string title: updated_at description: Timestamp when the notification was last updated (RFC 3339). title: Notification additionalProperties: false description: Notification represents a notification channel with full details. openstatus.notification.v1.NotificationData: type: object oneOf: - properties: discord: title: discord description: Discord configuration. $ref: '#/components/schemas/openstatus.notification.v1.DiscordData' title: discord required: - discord - properties: email: title: email description: Email configuration. $ref: '#/components/schemas/openstatus.notification.v1.EmailData' title: email required: - email - properties: googleChat: title: google_chat description: Google Chat configuration. $ref: '#/components/schemas/openstatus.notification.v1.GoogleChatData' title: google_chat required: - googleChat - properties: grafanaOncall: title: grafana_oncall description: Grafana OnCall configuration. $ref: '#/components/schemas/openstatus.notification.v1.GrafanaOncallData' title: grafana_oncall required: - grafanaOncall - properties: ntfy: title: ntfy description: Ntfy configuration. $ref: '#/components/schemas/openstatus.notification.v1.NtfyData' title: ntfy required: - ntfy - properties: opsgenie: title: opsgenie description: Opsgenie configuration. $ref: '#/components/schemas/openstatus.notification.v1.OpsgenieData' title: opsgenie required: - opsgenie - properties: pagerduty: title: pagerduty description: PagerDuty configuration. $ref: '#/components/schemas/openstatus.notification.v1.PagerDutyData' title: pagerduty required: - pagerduty - properties: slack: title: slack description: Slack configuration. $ref: '#/components/schemas/openstatus.notification.v1.SlackData' title: slack required: - slack - properties: sms: title: sms description: SMS configuration. $ref: '#/components/schemas/openstatus.notification.v1.SmsData' title: sms required: - sms - properties: telegram: title: telegram description: Telegram configuration. $ref: '#/components/schemas/openstatus.notification.v1.TelegramData' title: telegram required: - telegram - properties: webhook: title: webhook description: Webhook configuration. $ref: '#/components/schemas/openstatus.notification.v1.WebhookData' title: webhook required: - webhook - properties: whatsapp: title: whatsapp description: WhatsApp configuration. $ref: '#/components/schemas/openstatus.notification.v1.WhatsappData' title: whatsapp required: - whatsapp title: NotificationData additionalProperties: false description: NotificationData is a union of provider-specific configuration. openstatus.notification.v1.NotificationProvider: type: string title: NotificationProvider enum: - NOTIFICATION_PROVIDER_UNSPECIFIED - NOTIFICATION_PROVIDER_DISCORD - NOTIFICATION_PROVIDER_EMAIL - NOTIFICATION_PROVIDER_GOOGLE_CHAT - NOTIFICATION_PROVIDER_GRAFANA_ONCALL - NOTIFICATION_PROVIDER_NTFY - NOTIFICATION_PROVIDER_PAGERDUTY - NOTIFICATION_PROVIDER_OPSGENIE - NOTIFICATION_PROVIDER_SLACK - NOTIFICATION_PROVIDER_SMS - NOTIFICATION_PROVIDER_TELEGRAM - NOTIFICATION_PROVIDER_WEBHOOK - NOTIFICATION_PROVIDER_WHATSAPP description: NotificationProvider represents the supported notification channel types. openstatus.notification.v1.NotificationSummary: type: object properties: id: type: string title: id description: Unique identifier for the notification. name: type: string title: name description: Display name for the notification channel. provider: title: provider description: Provider type. $ref: '#/components/schemas/openstatus.notification.v1.NotificationProvider' monitorCount: type: integer title: monitor_count format: int32 description: Number of monitors associated with this notification. createdAt: type: string title: created_at description: Timestamp when the notification was created (RFC 3339). updatedAt: type: string title: updated_at description: Timestamp when the notification was last updated (RFC 3339). title: NotificationSummary additionalProperties: false description: NotificationSummary represents a notification channel summary for list responses. openstatus.notification.v1.NtfyData: type: object properties: topic: type: string examples: - openstatus-alerts title: topic minLength: 1 description: Ntfy topic to publish to. serverUrl: type: string title: server_url description: Ntfy server URL (defaults to https://ntfy.sh). token: type: - string - "null" title: token description: Optional authentication token. title: NtfyData additionalProperties: false description: NtfyData contains configuration for Ntfy notifications. openstatus.notification.v1.OpsgenieData: type: object properties: apiKey: type: string title: api_key minLength: 1 description: Opsgenie API key. region: title: region description: Opsgenie region. $ref: '#/components/schemas/openstatus.notification.v1.OpsgenieRegion' title: OpsgenieData additionalProperties: false description: OpsgenieData contains configuration for Opsgenie notifications. openstatus.notification.v1.OpsgenieRegion: type: string title: OpsgenieRegion enum: - OPSGENIE_REGION_UNSPECIFIED - OPSGENIE_REGION_US - OPSGENIE_REGION_EU description: OpsgenieRegion represents the Opsgenie API region. openstatus.notification.v1.PagerDutyData: type: object properties: integrationKey: type: string examples: - a1b2c3d4e5f6g7h8i9j0 title: integration_key minLength: 1 description: PagerDuty integration key. title: PagerDutyData additionalProperties: false description: PagerDutyData contains configuration for PagerDuty notifications. openstatus.notification.v1.SendTestNotificationRequest: type: object properties: provider: not: enum: - NOTIFICATION_PROVIDER_UNSPECIFIED title: provider description: Provider type. $ref: '#/components/schemas/openstatus.notification.v1.NotificationProvider' data: title: data description: Provider-specific configuration. $ref: '#/components/schemas/openstatus.notification.v1.NotificationData' title: SendTestNotificationRequest required: - data additionalProperties: false description: SendTestNotificationRequest is the request to send a test notification. openstatus.notification.v1.SendTestNotificationResponse: type: object properties: success: type: boolean title: success description: Whether the test was successful. errorMessage: type: - string - "null" title: error_message description: Optional error message if the test failed. title: SendTestNotificationResponse additionalProperties: false description: SendTestNotificationResponse is the response after sending a test notification. openstatus.notification.v1.SlackData: type: object properties: webhookUrl: type: string examples: - https://hooks.slack.com/services/T00/B00/xxx title: webhook_url format: uri description: Slack webhook URL. title: SlackData additionalProperties: false description: SlackData contains configuration for Slack notifications. openstatus.notification.v1.SmsData: type: object properties: phoneNumber: type: string examples: - "+14155551234" title: phone_number minLength: 1 description: Phone number to send SMS to. title: SmsData additionalProperties: false description: SmsData contains configuration for SMS notifications. openstatus.notification.v1.TelegramData: type: object properties: chatId: type: string examples: - "-1001234567890" title: chat_id minLength: 1 description: Telegram chat ID. title: TelegramData additionalProperties: false description: TelegramData contains configuration for Telegram notifications. openstatus.notification.v1.UpdateNotificationRequest: type: object properties: id: type: string title: id minLength: 1 description: Notification ID to update (required). name: type: - string - "null" title: name description: Updated display name. data: oneOf: - $ref: '#/components/schemas/openstatus.notification.v1.NotificationData' - type: "null" title: data description: Updated provider-specific configuration. monitorIds: type: array items: type: string title: monitor_ids description: Updated monitor IDs to associate. title: UpdateNotificationRequest additionalProperties: false description: UpdateNotificationRequest is the request to update a notification channel. openstatus.notification.v1.UpdateNotificationResponse: type: object properties: notification: title: notification description: The updated notification channel. $ref: '#/components/schemas/openstatus.notification.v1.Notification' title: UpdateNotificationResponse additionalProperties: false description: UpdateNotificationResponse is the response after updating a notification channel. openstatus.notification.v1.WebhookData: type: object properties: endpoint: type: string examples: - https://api.example.com/webhooks/openstatus title: endpoint format: uri description: Webhook endpoint URL. headers: type: array items: $ref: '#/components/schemas/openstatus.notification.v1.WebhookHeader' title: headers description: Optional custom headers. title: WebhookData additionalProperties: false description: WebhookData contains configuration for custom webhook notifications. openstatus.notification.v1.WebhookHeader: type: object properties: key: type: string title: key minLength: 1 description: Header name. value: type: string title: value description: Header value. title: WebhookHeader additionalProperties: false description: WebhookHeader represents a custom header for webhook requests. openstatus.notification.v1.WhatsappData: type: object properties: phoneNumber: type: string title: phone_number minLength: 1 description: Phone number to send WhatsApp messages to. title: WhatsappData additionalProperties: false description: WhatsappData contains configuration for WhatsApp notifications. openstatus.status_page.v1.AddMonitorComponentRequest: type: object properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to add the component to (required). monitorId: type: string title: monitor_id minLength: 1 description: ID of the monitor to associate with this component (required). name: type: - string - "null" title: name maxLength: 256 description: Display name for the component (optional, defaults to monitor name). description: type: - string - "null" title: description maxLength: 1024 description: Description of the component (optional). order: type: - integer - "null" title: order format: int32 description: Display order of the component (optional). groupId: type: - string - "null" title: group_id description: ID of the group to add this component to (optional). title: AddMonitorComponentRequest additionalProperties: false description: AddMonitorComponentRequest is the request to add a monitor-based component to a status page. openstatus.status_page.v1.AddMonitorComponentResponse: type: object properties: component: title: component description: The created component. $ref: '#/components/schemas/openstatus.status_page.v1.PageComponent' title: AddMonitorComponentResponse additionalProperties: false description: AddMonitorComponentResponse is the response after adding a monitor component. openstatus.status_page.v1.AddStaticComponentRequest: type: object properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to add the component to (required). name: type: string title: name maxLength: 256 minLength: 1 description: Display name for the component (required). description: type: - string - "null" title: description maxLength: 1024 description: Description of the component (optional). order: type: - integer - "null" title: order format: int32 description: Display order of the component (optional). groupId: type: - string - "null" title: group_id description: ID of the group to add this component to (optional). title: AddStaticComponentRequest additionalProperties: false description: AddStaticComponentRequest is the request to add a static component to a status page. openstatus.status_page.v1.AddStaticComponentResponse: type: object properties: component: title: component description: The created component. $ref: '#/components/schemas/openstatus.status_page.v1.PageComponent' title: AddStaticComponentResponse additionalProperties: false description: AddStaticComponentResponse is the response after adding a static component. openstatus.status_page.v1.ComponentStatus: type: object properties: componentId: type: string title: component_id description: ID of the component. status: title: status description: Current status of the component. $ref: '#/components/schemas/openstatus.status_page.v1.OverallStatus' title: ComponentStatus additionalProperties: false description: ComponentStatus represents the status of a single component. openstatus.status_page.v1.CreateComponentGroupRequest: type: object properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to create the group in (required). name: type: string title: name maxLength: 256 minLength: 1 description: Display name for the group (required). title: CreateComponentGroupRequest additionalProperties: false description: CreateComponentGroupRequest is the request to create a new component group. openstatus.status_page.v1.CreateComponentGroupResponse: type: object properties: group: title: group description: The created component group. $ref: '#/components/schemas/openstatus.status_page.v1.PageComponentGroup' title: CreateComponentGroupResponse additionalProperties: false description: CreateComponentGroupResponse is the response after creating a component group. openstatus.status_page.v1.CreateStatusPageRequest: type: object properties: title: type: string examples: - Acme Corp Status title: title maxLength: 256 minLength: 1 description: Title of the status page (required). description: type: - string - "null" title: description maxLength: 1024 description: Description of the status page (optional). slug: type: string examples: - my-status-page title: slug maxLength: 256 minLength: 1 pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ description: URL-friendly slug for the status page (required). Must be lowercase alphanumeric with hyphens. homepageUrl: type: - string - "null" examples: - https://www.example.com title: homepage_url description: URL to the homepage (optional). contactUrl: type: - string - "null" title: contact_url description: URL to the contact page (optional). title: CreateStatusPageRequest additionalProperties: false description: CreateStatusPageRequest is the request to create a new status page. openstatus.status_page.v1.CreateStatusPageResponse: type: object properties: statusPage: title: status_page description: The created status page. $ref: '#/components/schemas/openstatus.status_page.v1.StatusPage' title: CreateStatusPageResponse additionalProperties: false description: CreateStatusPageResponse is the response after creating a status page. openstatus.status_page.v1.DeleteComponentGroupRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the component group to delete (required). title: DeleteComponentGroupRequest additionalProperties: false description: DeleteComponentGroupRequest is the request to delete a component group. openstatus.status_page.v1.DeleteComponentGroupResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteComponentGroupResponse additionalProperties: false description: DeleteComponentGroupResponse is the response after deleting a component group. openstatus.status_page.v1.DeleteStatusPageRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status page to delete (required). title: DeleteStatusPageRequest additionalProperties: false description: DeleteStatusPageRequest is the request to delete a status page. openstatus.status_page.v1.DeleteStatusPageResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteStatusPageResponse additionalProperties: false description: DeleteStatusPageResponse is the response after deleting a status page. openstatus.status_page.v1.GetOverallStatusRequest: type: object oneOf: - properties: id: type: string title: id description: ID of the status page. title: id required: - id - properties: slug: type: string title: slug description: Slug of the status page. title: slug required: - slug title: GetOverallStatusRequest additionalProperties: false description: GetOverallStatusRequest is the request to get the overall status of a status page. openstatus.status_page.v1.GetOverallStatusResponse: type: object properties: overallStatus: title: overall_status description: Aggregated status across all components. $ref: '#/components/schemas/openstatus.status_page.v1.OverallStatus' componentStatuses: type: array items: $ref: '#/components/schemas/openstatus.status_page.v1.ComponentStatus' title: component_statuses description: Status of individual components. title: GetOverallStatusResponse additionalProperties: false description: GetOverallStatusResponse is the response containing the overall status and individual component statuses. openstatus.status_page.v1.GetStatusPageContentRequest: type: object oneOf: - properties: id: type: string title: id description: ID of the status page. title: id required: - id - properties: slug: type: string title: slug description: Slug of the status page. title: slug required: - slug title: GetStatusPageContentRequest additionalProperties: false description: GetStatusPageContentRequest is the request to get the full content of a status page. openstatus.status_page.v1.GetStatusPageContentResponse: type: object properties: statusPage: title: status_page description: The status page details. $ref: '#/components/schemas/openstatus.status_page.v1.StatusPage' components: type: array items: $ref: '#/components/schemas/openstatus.status_page.v1.PageComponent' title: components description: Components on the status page. groups: type: array items: $ref: '#/components/schemas/openstatus.status_page.v1.PageComponentGroup' title: groups description: Component groups on the status page. statusReports: type: array items: $ref: '#/components/schemas/openstatus.status_report.v1.StatusReport' title: status_reports description: Active and recent status reports. maintenances: type: array items: $ref: '#/components/schemas/openstatus.maintenance.v1.MaintenanceSummary' title: maintenances description: Scheduled maintenances. title: GetStatusPageContentResponse additionalProperties: false description: GetStatusPageContentResponse is the response containing the full status page content. openstatus.status_page.v1.GetStatusPageRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status page to retrieve (required). title: GetStatusPageRequest additionalProperties: false description: GetStatusPageRequest is the request to get a status page by ID. openstatus.status_page.v1.GetStatusPageResponse: type: object properties: statusPage: title: status_page description: The requested status page. $ref: '#/components/schemas/openstatus.status_page.v1.StatusPage' title: GetStatusPageResponse additionalProperties: false description: GetStatusPageResponse is the response containing the status page. openstatus.status_page.v1.ListStatusPagesRequest: type: object properties: limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of pages to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of pages to skip for pagination (defaults to 0). title: ListStatusPagesRequest additionalProperties: false description: ListStatusPagesRequest is the request to list status pages. openstatus.status_page.v1.ListStatusPagesResponse: type: object properties: statusPages: type: array items: $ref: '#/components/schemas/openstatus.status_page.v1.StatusPageSummary' title: status_pages description: List of status pages (metadata only). totalSize: type: integer title: total_size format: int32 description: Total number of status pages. title: ListStatusPagesResponse additionalProperties: false description: ListStatusPagesResponse is the response containing status page summaries. openstatus.status_page.v1.ListSubscribersRequest: type: object properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to list subscribers for (required). limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of subscribers to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of subscribers to skip for pagination (defaults to 0). includeUnsubscribed: type: - boolean - "null" title: include_unsubscribed description: Whether to include unsubscribed users (defaults to false). title: ListSubscribersRequest additionalProperties: false description: ListSubscribersRequest is the request to list subscribers of a status page. openstatus.status_page.v1.ListSubscribersResponse: type: object properties: subscribers: type: array items: $ref: '#/components/schemas/openstatus.status_page.v1.PageSubscriber' title: subscribers description: List of subscribers. totalSize: type: integer title: total_size format: int32 description: Total number of subscribers matching the filter. title: ListSubscribersResponse additionalProperties: false description: ListSubscribersResponse is the response containing status page subscribers. openstatus.status_page.v1.OverallStatus: type: string title: OverallStatus enum: - OVERALL_STATUS_UNSPECIFIED - OVERALL_STATUS_OPERATIONAL - OVERALL_STATUS_DEGRADED - OVERALL_STATUS_PARTIAL_OUTAGE - OVERALL_STATUS_MAJOR_OUTAGE - OVERALL_STATUS_MAINTENANCE - OVERALL_STATUS_UNKNOWN description: OverallStatus represents the aggregated status of all components on a page. openstatus.status_page.v1.PageAccessType: type: string title: PageAccessType enum: - PAGE_ACCESS_TYPE_UNSPECIFIED - PAGE_ACCESS_TYPE_PUBLIC - PAGE_ACCESS_TYPE_PASSWORD_PROTECTED - PAGE_ACCESS_TYPE_AUTHENTICATED description: PageAccessType defines who can access the status page. openstatus.status_page.v1.PageComponent: type: object properties: id: type: string title: id description: Unique identifier for the component. pageId: type: string title: page_id description: ID of the status page this component belongs to. name: type: string title: name description: Display name of the component. description: type: string title: description description: Description of the component (optional). type: title: type description: Type of the component (monitor or static). $ref: '#/components/schemas/openstatus.status_page.v1.PageComponentType' monitorId: type: string title: monitor_id description: ID of the monitor if type is MONITOR (optional). order: type: integer title: order format: int32 description: Display order of the component. groupId: type: string title: group_id description: ID of the group this component belongs to (optional). groupOrder: type: integer title: group_order format: int32 description: Order within the group if grouped. createdAt: type: string title: created_at description: Timestamp when the component was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the component was last updated (RFC 3339 format). title: PageComponent additionalProperties: false description: PageComponent represents a component displayed on a status page. openstatus.status_page.v1.PageComponentGroup: type: object properties: id: type: string title: id description: Unique identifier for the group. pageId: type: string title: page_id description: ID of the status page this group belongs to. name: type: string title: name description: Display name of the group. createdAt: type: string title: created_at description: Timestamp when the group was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the group was last updated (RFC 3339 format). title: PageComponentGroup additionalProperties: false description: PageComponentGroup represents a group of components on a status page. openstatus.status_page.v1.PageComponentType: type: string title: PageComponentType enum: - PAGE_COMPONENT_TYPE_UNSPECIFIED - PAGE_COMPONENT_TYPE_MONITOR - PAGE_COMPONENT_TYPE_STATIC description: PageComponentType defines the type of a component on a status page. openstatus.status_page.v1.PageSubscriber: type: object properties: id: type: string title: id description: Unique identifier for the subscriber. pageId: type: string title: page_id description: ID of the status page the user is subscribed to. email: type: string title: email description: Email address of the subscriber. acceptedAt: type: string title: accepted_at description: Timestamp when the subscription was accepted/confirmed (RFC 3339 format, optional). unsubscribedAt: type: string title: unsubscribed_at description: Timestamp when the user unsubscribed (RFC 3339 format, optional). createdAt: type: string title: created_at description: Timestamp when the subscription was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the subscription was last updated (RFC 3339 format). title: PageSubscriber additionalProperties: false description: PageSubscriber represents a subscriber to a status page. openstatus.status_page.v1.PageTheme: type: string title: PageTheme enum: - PAGE_THEME_UNSPECIFIED - PAGE_THEME_SYSTEM - PAGE_THEME_LIGHT - PAGE_THEME_DARK description: PageTheme defines the visual theme of the status page. openstatus.status_page.v1.RemoveComponentRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the component to remove (required). title: RemoveComponentRequest additionalProperties: false description: RemoveComponentRequest is the request to remove a component from a status page. openstatus.status_page.v1.RemoveComponentResponse: type: object properties: success: type: boolean title: success description: Whether the removal was successful. title: RemoveComponentResponse additionalProperties: false description: RemoveComponentResponse is the response after removing a component. openstatus.status_page.v1.StatusPage: type: object properties: id: type: string title: id description: Unique identifier for the status page. title: type: string title: title description: Title of the status page. description: type: string title: description description: Description of the status page. slug: type: string examples: - acme-corp title: slug description: URL-friendly slug for the status page. customDomain: type: string examples: - status.example.com title: custom_domain description: Custom domain for the status page (optional). published: type: boolean title: published description: Whether the status page is published and visible. accessType: title: access_type description: Access type for the status page. $ref: '#/components/schemas/openstatus.status_page.v1.PageAccessType' theme: title: theme description: Visual theme for the status page. $ref: '#/components/schemas/openstatus.status_page.v1.PageTheme' homepageUrl: type: string title: homepage_url description: URL to the homepage (optional). contactUrl: type: string title: contact_url description: URL to the contact page (optional). icon: type: string title: icon description: Icon URL for the status page (optional). createdAt: type: string examples: - "2024-01-15T09:00:00Z" title: created_at description: Timestamp when the page was created (RFC 3339 format). updatedAt: type: string examples: - "2024-06-20T14:30:00Z" title: updated_at description: Timestamp when the page was last updated (RFC 3339 format). title: StatusPage additionalProperties: false description: StatusPage represents a full status page with all details. openstatus.status_page.v1.StatusPageSummary: type: object properties: id: type: string title: id description: Unique identifier for the status page. title: type: string title: title description: Title of the status page. slug: type: string title: slug description: URL-friendly slug for the status page. published: type: boolean title: published description: Whether the status page is published and visible. createdAt: type: string title: created_at description: Timestamp when the page was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the page was last updated (RFC 3339 format). title: StatusPageSummary additionalProperties: false description: StatusPageSummary represents metadata for a status page (used in list responses). openstatus.status_page.v1.SubscribeToPageRequest: type: object properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to subscribe to (required). email: type: string examples: - user@example.com title: email format: email description: Email address to subscribe (required). title: SubscribeToPageRequest additionalProperties: false description: SubscribeToPageRequest is the request to subscribe an email to a status page. openstatus.status_page.v1.SubscribeToPageResponse: type: object properties: subscriber: title: subscriber description: The created subscriber. $ref: '#/components/schemas/openstatus.status_page.v1.PageSubscriber' title: SubscribeToPageResponse additionalProperties: false description: SubscribeToPageResponse is the response after subscribing to a status page. openstatus.status_page.v1.UnsubscribeFromPageRequest: type: object allOf: - properties: pageId: type: string title: page_id minLength: 1 description: ID of the status page to unsubscribe from (required). - oneOf: - properties: email: type: string title: email description: Email address to unsubscribe. title: email required: - email - properties: id: type: string title: id description: Subscriber ID. title: id required: - id title: UnsubscribeFromPageRequest additionalProperties: false description: UnsubscribeFromPageRequest is the request to unsubscribe from a status page. openstatus.status_page.v1.UnsubscribeFromPageResponse: type: object properties: success: type: boolean title: success description: Whether the unsubscription was successful. title: UnsubscribeFromPageResponse additionalProperties: false description: UnsubscribeFromPageResponse is the response after unsubscribing from a status page. openstatus.status_page.v1.UpdateComponentGroupRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the component group to update (required). name: type: - string - "null" title: name maxLength: 256 minLength: 1 description: New display name for the group (optional). title: UpdateComponentGroupRequest additionalProperties: false description: UpdateComponentGroupRequest is the request to update a component group. openstatus.status_page.v1.UpdateComponentGroupResponse: type: object properties: group: title: group description: The updated component group. $ref: '#/components/schemas/openstatus.status_page.v1.PageComponentGroup' title: UpdateComponentGroupResponse additionalProperties: false description: UpdateComponentGroupResponse is the response after updating a component group. openstatus.status_page.v1.UpdateComponentRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the component to update (required). name: type: - string - "null" title: name maxLength: 256 description: New display name for the component (optional). description: type: - string - "null" title: description maxLength: 1024 description: New description for the component (optional). order: type: - integer - "null" title: order format: int32 description: New display order (optional). groupId: type: - string - "null" title: group_id description: New group ID (optional, set to empty string to remove from group). groupOrder: type: - integer - "null" title: group_order format: int32 description: New order within the group (optional). title: UpdateComponentRequest additionalProperties: false description: UpdateComponentRequest is the request to update a component. openstatus.status_page.v1.UpdateComponentResponse: type: object properties: component: title: component description: The updated component. $ref: '#/components/schemas/openstatus.status_page.v1.PageComponent' title: UpdateComponentResponse additionalProperties: false description: UpdateComponentResponse is the response after updating a component. openstatus.status_page.v1.UpdateStatusPageRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status page to update (required). title: type: - string - "null" title: title maxLength: 256 minLength: 1 description: New title for the status page (optional). description: type: - string - "null" title: description maxLength: 1024 description: New description for the status page (optional). slug: type: - string - "null" title: slug maxLength: 256 minLength: 1 pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ description: New slug for the status page (optional). homepageUrl: type: - string - "null" title: homepage_url description: New homepage URL (optional). contactUrl: type: - string - "null" title: contact_url description: New contact URL (optional). title: UpdateStatusPageRequest additionalProperties: false description: UpdateStatusPageRequest is the request to update a status page. openstatus.status_page.v1.UpdateStatusPageResponse: type: object properties: statusPage: title: status_page description: The updated status page. $ref: '#/components/schemas/openstatus.status_page.v1.StatusPage' title: UpdateStatusPageResponse additionalProperties: false description: UpdateStatusPageResponse is the response after updating a status page. openstatus.status_report.v1.AddStatusReportUpdateRequest: type: object properties: statusReportId: type: string title: status_report_id minLength: 1 description: ID of the status report to update (required). status: title: status description: New status for the report (required). $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' message: type: string title: message minLength: 1 description: Message describing what changed (required). date: type: - string - "null" title: date pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: Optional date for the update (RFC 3339 format). Defaults to current time if not provided. notify: type: - boolean - "null" title: notify description: Whether to notify subscribers about this update (optional, defaults to false). title: AddStatusReportUpdateRequest additionalProperties: false description: AddStatusReportUpdateRequest is the request to add a new update to a status report. openstatus.status_report.v1.AddStatusReportUpdateResponse: type: object properties: statusReport: title: status_report description: The updated status report with the new update included. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReport' title: AddStatusReportUpdateResponse additionalProperties: false description: AddStatusReportUpdateResponse is the response after adding an update to a status report. openstatus.status_report.v1.CreateStatusReportRequest: type: object properties: title: type: string examples: - API Degradation Investigation title: title minLength: 1 description: Title of the status report (required). status: title: status description: Initial status (required). $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' message: type: string examples: - We are investigating reports of increased API latency. title: message minLength: 1 description: Initial message describing the incident (required). date: type: string examples: - "2024-03-15T10:30:00Z" title: date pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$ description: Date when the event occurred (RFC 3339 format, required). pageId: type: string title: page_id minLength: 1 description: Page ID to associate with this report (required). pageComponentIds: type: array items: type: string title: page_component_ids description: Page component IDs to associate with this report (optional). notify: type: - boolean - "null" title: notify description: Whether to notify subscribers about this status report (optional, defaults to false). title: CreateStatusReportRequest additionalProperties: false description: CreateStatusReportRequest is the request to create a new status report. openstatus.status_report.v1.CreateStatusReportResponse: type: object properties: statusReport: title: status_report description: The created status report. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReport' title: CreateStatusReportResponse additionalProperties: false description: CreateStatusReportResponse is the response after creating a status report. openstatus.status_report.v1.DeleteStatusReportRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status report to delete (required). title: DeleteStatusReportRequest additionalProperties: false description: DeleteStatusReportRequest is the request to delete a status report. openstatus.status_report.v1.DeleteStatusReportResponse: type: object properties: success: type: boolean title: success description: Whether the deletion was successful. title: DeleteStatusReportResponse additionalProperties: false description: DeleteStatusReportResponse is the response after deleting a status report. openstatus.status_report.v1.GetStatusReportRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status report to retrieve (required). title: GetStatusReportRequest additionalProperties: false description: GetStatusReportRequest is the request to get a status report by ID. openstatus.status_report.v1.GetStatusReportResponse: type: object properties: statusReport: title: status_report description: The requested status report. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReport' title: GetStatusReportResponse additionalProperties: false description: GetStatusReportResponse is the response containing the status report with its full update timeline. openstatus.status_report.v1.ListStatusReportsRequest: type: object properties: limit: type: - integer - "null" title: limit maximum: 100 minimum: 1 format: int32 description: Maximum number of reports to return (1-100, defaults to 50). offset: type: - integer - "null" title: offset minimum: 0 format: int32 description: Number of reports to skip for pagination (defaults to 0). statuses: type: array items: $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' title: statuses description: Filter by status (optional). If empty, returns all statuses. title: ListStatusReportsRequest additionalProperties: false description: ListStatusReportsRequest is the request to list status reports. openstatus.status_report.v1.ListStatusReportsResponse: type: object properties: statusReports: type: array items: $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportSummary' title: status_reports description: List of status reports (metadata only, use GetStatusReport for full details). totalSize: type: integer title: total_size format: int32 description: Total number of reports matching the filter. title: ListStatusReportsResponse additionalProperties: false description: ListStatusReportsResponse is the response containing status report summaries. openstatus.status_report.v1.StatusReport: type: object properties: id: type: string title: id description: Unique identifier for the status report. status: title: status description: Current status of the report. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' title: type: string title: title description: Title of the status report. pageComponentIds: type: array items: type: string title: page_component_ids description: IDs of affected page components. updates: type: array items: $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportUpdate' title: updates description: Timeline of updates for this report (only included in GetStatusReport). createdAt: type: string title: created_at description: Timestamp when the report was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the report was last updated (RFC 3339 format). title: StatusReport additionalProperties: false description: StatusReport represents an incident or maintenance report with full details. openstatus.status_report.v1.StatusReportStatus: type: string title: StatusReportStatus enum: - STATUS_REPORT_STATUS_UNSPECIFIED - STATUS_REPORT_STATUS_INVESTIGATING - STATUS_REPORT_STATUS_IDENTIFIED - STATUS_REPORT_STATUS_MONITORING - STATUS_REPORT_STATUS_RESOLVED description: StatusReportStatus represents the current state of a status report. openstatus.status_report.v1.StatusReportSummary: type: object properties: id: type: string title: id description: Unique identifier for the status report. status: title: status description: Current status of the report. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' title: type: string title: title description: Title of the status report. pageComponentIds: type: array items: type: string title: page_component_ids description: IDs of affected page components. createdAt: type: string title: created_at description: Timestamp when the report was created (RFC 3339 format). updatedAt: type: string title: updated_at description: Timestamp when the report was last updated (RFC 3339 format). title: StatusReportSummary additionalProperties: false description: StatusReportSummary represents metadata for a status report (used in list responses). openstatus.status_report.v1.StatusReportUpdate: type: object properties: id: type: string title: id description: Unique identifier for the update. status: title: status description: Status at the time of this update. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReportStatus' date: type: string title: date description: Timestamp when this update occurred (RFC 3339 format). message: type: string title: message description: Message describing the update. createdAt: type: string title: created_at description: Timestamp when the update was created (RFC 3339 format). title: StatusReportUpdate additionalProperties: false description: StatusReportUpdate represents a single update entry in a status report timeline. openstatus.status_report.v1.UpdateStatusReportRequest: type: object properties: id: type: string title: id minLength: 1 description: ID of the status report to update (required). title: type: - string - "null" title: title description: New title for the report (optional). pageComponentIds: type: array items: type: string title: page_component_ids description: New list of page component IDs (optional, replaces existing list). title: UpdateStatusReportRequest additionalProperties: false description: UpdateStatusReportRequest is the request to update a status report's metadata. openstatus.status_report.v1.UpdateStatusReportResponse: type: object properties: statusReport: title: status_report description: The updated status report. $ref: '#/components/schemas/openstatus.status_report.v1.StatusReport' title: UpdateStatusReportResponse additionalProperties: false description: UpdateStatusReportResponse is the response after updating a status report. security: - ApiKeyAuth: [] tags: - name: MonitorService description: | Create, update, delete, and query monitors. Supports HTTP, TCP, and DNS monitor types with configurable check intervals, regions, assertions, and alerting thresholds. - name: StatusPageService description: | Manage public status pages with components, component groups, and email subscribers. Includes endpoints for retrieving full page content and aggregated status. - name: NotificationService description: | Configure notification channels (Slack, Discord, PagerDuty, email, webhooks, etc.) and associate them with monitors. Supports 12 notification providers. - name: StatusReportService description: | Create and manage incident reports with status updates. Reports follow a lifecycle: investigating -> identified -> monitoring -> resolved. - name: MaintenanceService description: | Schedule maintenance windows for status page components. Subscribers can be notified automatically when maintenance is created. - name: HealthService description: Health check endpoint for load balancer probes. No authentication required. paths: /rpc/openstatus.health.v1.HealthService/Check: get: tags: - HealthService summary: Check description: Check returns the current serving status of the service. operationId: HealthService_Check.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.health.v1.CheckRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.health.v1.CheckResponse' post: tags: - HealthService summary: Check description: Check returns the current serving status of the service. operationId: HealthService_Check requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.health.v1.CheckRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.health.v1.CheckResponse' /rpc/openstatus.maintenance.v1.MaintenanceService/CreateMaintenance: post: tags: - MaintenanceService summary: CreateMaintenance description: CreateMaintenance creates a new maintenance window. operationId: MaintenanceService_CreateMaintenance requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.CreateMaintenanceRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.CreateMaintenanceResponse' /rpc/openstatus.maintenance.v1.MaintenanceService/DeleteMaintenance: post: tags: - MaintenanceService summary: DeleteMaintenance description: DeleteMaintenance removes a maintenance window. operationId: MaintenanceService_DeleteMaintenance requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.DeleteMaintenanceRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.DeleteMaintenanceResponse' /rpc/openstatus.maintenance.v1.MaintenanceService/GetMaintenance: get: tags: - MaintenanceService summary: GetMaintenance description: GetMaintenance retrieves a specific maintenance window by ID. operationId: MaintenanceService_GetMaintenance.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.GetMaintenanceRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.GetMaintenanceResponse' post: tags: - MaintenanceService summary: GetMaintenance description: GetMaintenance retrieves a specific maintenance window by ID. operationId: MaintenanceService_GetMaintenance requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.GetMaintenanceRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.GetMaintenanceResponse' /rpc/openstatus.maintenance.v1.MaintenanceService/ListMaintenances: get: tags: - MaintenanceService summary: ListMaintenances description: ListMaintenances returns all maintenance windows for the workspace. operationId: MaintenanceService_ListMaintenances.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.ListMaintenancesRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.ListMaintenancesResponse' post: tags: - MaintenanceService summary: ListMaintenances description: ListMaintenances returns all maintenance windows for the workspace. operationId: MaintenanceService_ListMaintenances requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.ListMaintenancesRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.ListMaintenancesResponse' /rpc/openstatus.maintenance.v1.MaintenanceService/UpdateMaintenance: post: tags: - MaintenanceService summary: UpdateMaintenance description: UpdateMaintenance updates a maintenance window. operationId: MaintenanceService_UpdateMaintenance requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.UpdateMaintenanceRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.maintenance.v1.UpdateMaintenanceResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateDNSMonitor: post: tags: - MonitorService summary: CreateDNSMonitor description: CreateDNSMonitor creates a new DNS monitor. operationId: MonitorService_CreateDNSMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateHTTPMonitor: post: tags: - MonitorService summary: CreateHTTPMonitor description: Creates a new HTTP monitor in the authenticated workspace. Configure the target URL, HTTP method, request headers and body, response assertions (status code, body content, headers), check periodicity, geographic regions, and optional OpenTelemetry export. The monitor starts checking immediately if set to active. operationId: MonitorService_CreateHTTPMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateTCPMonitor: post: tags: - MonitorService summary: CreateTCPMonitor description: CreateTCPMonitor creates a new TCP monitor. operationId: MonitorService_CreateTCPMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateTCPMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateTCPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/DeleteMonitor: post: tags: - MonitorService summary: DeleteMonitor description: DeleteMonitor removes a monitor. operationId: MonitorService_DeleteMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.DeleteMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.DeleteMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/GetMonitor: get: tags: - MonitorService summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorResponse' post: tags: - MonitorService summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/GetMonitorStatus: get: tags: - MonitorService summary: GetMonitorStatus description: GetMonitorStatus returns the current status of all regions for a monitor. operationId: MonitorService_GetMonitorStatus.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorStatusRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorStatusResponse' post: tags: - MonitorService summary: GetMonitorStatus description: GetMonitorStatus returns the current status of all regions for a monitor. operationId: MonitorService_GetMonitorStatus requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorStatusRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorStatusResponse' /rpc/openstatus.monitor.v1.MonitorService/GetMonitorSummary: get: tags: - MonitorService summary: GetMonitorSummary description: Returns aggregated metrics for a monitor including latency percentiles (p50, p75, p90, p95, p99), request counts by status (successful, degraded, failed), and the timestamp of the last check. Metrics can be scoped to a time range (1 day, 7 days, or 14 days) and filtered by specific regions. operationId: MonitorService_GetMonitorSummary.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorSummaryRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorSummaryResponse' post: tags: - MonitorService summary: GetMonitorSummary description: Returns aggregated metrics for a monitor including latency percentiles (p50, p75, p90, p95, p99), request counts by status (successful, degraded, failed), and the timestamp of the last check. Metrics can be scoped to a time range (1 day, 7 days, or 14 days) and filtered by specific regions. operationId: MonitorService_GetMonitorSummary requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorSummaryRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.GetMonitorSummaryResponse' /rpc/openstatus.monitor.v1.MonitorService/ListMonitors: get: tags: - MonitorService summary: ListMonitors description: ListMonitors returns a paginated list of all monitors in the workspace. operationId: MonitorService_ListMonitors.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.ListMonitorsRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.ListMonitorsResponse' post: tags: - MonitorService summary: ListMonitors description: ListMonitors returns a paginated list of all monitors in the workspace. operationId: MonitorService_ListMonitors requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.ListMonitorsRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.ListMonitorsResponse' /rpc/openstatus.monitor.v1.MonitorService/TriggerMonitor: post: tags: - MonitorService summary: TriggerMonitor description: Manually triggers an immediate check for the specified monitor across all configured regions. This operation is rate-limited under the synthetic-checks quota. A monitor run record is created and the check is dispatched to the checker service. operationId: MonitorService_TriggerMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.TriggerMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.TriggerMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateDNSMonitor: post: tags: - MonitorService summary: UpdateDNSMonitor description: UpdateDNSMonitor updates an existing DNS monitor. operationId: MonitorService_UpdateDNSMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor: post: tags: - MonitorService summary: UpdateHTTPMonitor description: UpdateHTTPMonitor updates an existing HTTP monitor. operationId: MonitorService_UpdateHTTPMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateTCPMonitor: post: tags: - MonitorService summary: UpdateTCPMonitor description: UpdateTCPMonitor updates an existing TCP monitor. operationId: MonitorService_UpdateTCPMonitor requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateTCPMonitorRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateTCPMonitorResponse' /rpc/openstatus.notification.v1.NotificationService/CheckNotificationLimit: get: tags: - NotificationService summary: CheckNotificationLimit description: CheckNotificationLimit checks if the workspace has reached its notification limit. operationId: NotificationService_CheckNotificationLimit.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CheckNotificationLimitRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CheckNotificationLimitResponse' post: tags: - NotificationService summary: CheckNotificationLimit description: CheckNotificationLimit checks if the workspace has reached its notification limit. operationId: NotificationService_CheckNotificationLimit requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CheckNotificationLimitRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CheckNotificationLimitResponse' /rpc/openstatus.notification.v1.NotificationService/CreateNotification: post: tags: - NotificationService summary: CreateNotification description: CreateNotification creates a new notification channel. operationId: NotificationService_CreateNotification requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CreateNotificationRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.CreateNotificationResponse' /rpc/openstatus.notification.v1.NotificationService/DeleteNotification: post: tags: - NotificationService summary: DeleteNotification description: DeleteNotification removes a notification channel. operationId: NotificationService_DeleteNotification requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.DeleteNotificationRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.DeleteNotificationResponse' /rpc/openstatus.notification.v1.NotificationService/GetNotification: get: tags: - NotificationService summary: GetNotification description: GetNotification retrieves a notification channel by ID. operationId: NotificationService_GetNotification.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.GetNotificationRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.GetNotificationResponse' post: tags: - NotificationService summary: GetNotification description: GetNotification retrieves a notification channel by ID. operationId: NotificationService_GetNotification requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.GetNotificationRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.GetNotificationResponse' /rpc/openstatus.notification.v1.NotificationService/ListNotifications: get: tags: - NotificationService summary: ListNotifications description: ListNotifications returns a list of notification channels. operationId: NotificationService_ListNotifications.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.ListNotificationsRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.ListNotificationsResponse' post: tags: - NotificationService summary: ListNotifications description: ListNotifications returns a list of notification channels. operationId: NotificationService_ListNotifications requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.ListNotificationsRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.ListNotificationsResponse' /rpc/openstatus.notification.v1.NotificationService/SendTestNotification: post: tags: - NotificationService summary: SendTestNotification description: Sends a test notification to the specified provider to verify that the configuration is correct. This does not require an existing notification channel - just provide the provider type and its configuration data. Returns success status and an error message if the test failed. operationId: NotificationService_SendTestNotification requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.SendTestNotificationRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.SendTestNotificationResponse' /rpc/openstatus.notification.v1.NotificationService/UpdateNotification: post: tags: - NotificationService summary: UpdateNotification description: UpdateNotification updates an existing notification channel. operationId: NotificationService_UpdateNotification requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.UpdateNotificationRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.notification.v1.UpdateNotificationResponse' /rpc/openstatus.status_page.v1.StatusPageService/AddMonitorComponent: post: tags: - StatusPageService summary: AddMonitorComponent description: AddMonitorComponent adds a monitor-based component to a status page. operationId: StatusPageService_AddMonitorComponent requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.AddMonitorComponentRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.AddMonitorComponentResponse' /rpc/openstatus.status_page.v1.StatusPageService/AddStaticComponent: post: tags: - StatusPageService summary: AddStaticComponent description: AddStaticComponent adds a static component to a status page. operationId: StatusPageService_AddStaticComponent requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.AddStaticComponentRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.AddStaticComponentResponse' /rpc/openstatus.status_page.v1.StatusPageService/CreateComponentGroup: post: tags: - StatusPageService summary: CreateComponentGroup description: CreateComponentGroup creates a new component group. operationId: StatusPageService_CreateComponentGroup requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.CreateComponentGroupRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.CreateComponentGroupResponse' /rpc/openstatus.status_page.v1.StatusPageService/CreateStatusPage: post: tags: - StatusPageService summary: CreateStatusPage description: CreateStatusPage creates a new status page. operationId: StatusPageService_CreateStatusPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.CreateStatusPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.CreateStatusPageResponse' /rpc/openstatus.status_page.v1.StatusPageService/DeleteComponentGroup: post: tags: - StatusPageService summary: DeleteComponentGroup description: DeleteComponentGroup removes a component group. operationId: StatusPageService_DeleteComponentGroup requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.DeleteComponentGroupRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.DeleteComponentGroupResponse' /rpc/openstatus.status_page.v1.StatusPageService/DeleteStatusPage: post: tags: - StatusPageService summary: DeleteStatusPage description: DeleteStatusPage removes a status page. operationId: StatusPageService_DeleteStatusPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.DeleteStatusPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.DeleteStatusPageResponse' /rpc/openstatus.status_page.v1.StatusPageService/GetOverallStatus: get: tags: - StatusPageService summary: GetOverallStatus description: 'Returns the overall status of a status page along with individual component statuses. The overall status is computed from active status reports and maintenances with the following priority: degraded (from active status reports) > maintenance (from active maintenance windows) > operational.' operationId: StatusPageService_GetOverallStatus.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetOverallStatusRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetOverallStatusResponse' post: tags: - StatusPageService summary: GetOverallStatus description: 'Returns the overall status of a status page along with individual component statuses. The overall status is computed from active status reports and maintenances with the following priority: degraded (from active status reports) > maintenance (from active maintenance windows) > operational.' operationId: StatusPageService_GetOverallStatus requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetOverallStatusRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetOverallStatusResponse' /rpc/openstatus.status_page.v1.StatusPageService/GetStatusPage: get: tags: - StatusPageService summary: GetStatusPage description: GetStatusPage retrieves a specific status page by ID. operationId: StatusPageService_GetStatusPage.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageResponse' post: tags: - StatusPageService summary: GetStatusPage description: GetStatusPage retrieves a specific status page by ID. operationId: StatusPageService_GetStatusPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageResponse' /rpc/openstatus.status_page.v1.StatusPageService/GetStatusPageContent: get: tags: - StatusPageService summary: GetStatusPageContent description: 'Returns the full content of a status page including its components, component groups, active status reports, and scheduled maintenances. Supports two access paths: by ID (requires authentication, workspace-scoped) or by slug (public access, requires the page to be published with access_type=PUBLIC).' operationId: StatusPageService_GetStatusPageContent.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageContentRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageContentResponse' post: tags: - StatusPageService summary: GetStatusPageContent description: 'Returns the full content of a status page including its components, component groups, active status reports, and scheduled maintenances. Supports two access paths: by ID (requires authentication, workspace-scoped) or by slug (public access, requires the page to be published with access_type=PUBLIC).' operationId: StatusPageService_GetStatusPageContent requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageContentRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.GetStatusPageContentResponse' /rpc/openstatus.status_page.v1.StatusPageService/ListStatusPages: get: tags: - StatusPageService summary: ListStatusPages description: ListStatusPages returns all status pages for the workspace. operationId: StatusPageService_ListStatusPages.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListStatusPagesRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListStatusPagesResponse' post: tags: - StatusPageService summary: ListStatusPages description: ListStatusPages returns all status pages for the workspace. operationId: StatusPageService_ListStatusPages requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListStatusPagesRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListStatusPagesResponse' /rpc/openstatus.status_page.v1.StatusPageService/ListSubscribers: get: tags: - StatusPageService summary: ListSubscribers description: ListSubscribers returns all subscribers for a status page. operationId: StatusPageService_ListSubscribers.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListSubscribersRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListSubscribersResponse' post: tags: - StatusPageService summary: ListSubscribers description: ListSubscribers returns all subscribers for a status page. operationId: StatusPageService_ListSubscribers requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListSubscribersRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.ListSubscribersResponse' /rpc/openstatus.status_page.v1.StatusPageService/RemoveComponent: post: tags: - StatusPageService summary: RemoveComponent description: RemoveComponent removes a component from a status page. operationId: StatusPageService_RemoveComponent requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.RemoveComponentRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.RemoveComponentResponse' /rpc/openstatus.status_page.v1.StatusPageService/SubscribeToPage: post: tags: - StatusPageService summary: SubscribeToPage description: Subscribes an email address to receive notifications from a status page. If the email was previously unsubscribed, the subscription is reactivated instead of creating a duplicate. operationId: StatusPageService_SubscribeToPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.SubscribeToPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.SubscribeToPageResponse' /rpc/openstatus.status_page.v1.StatusPageService/UnsubscribeFromPage: post: tags: - StatusPageService summary: UnsubscribeFromPage description: UnsubscribeFromPage removes a subscription from a status page. operationId: StatusPageService_UnsubscribeFromPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UnsubscribeFromPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UnsubscribeFromPageResponse' /rpc/openstatus.status_page.v1.StatusPageService/UpdateComponent: post: tags: - StatusPageService summary: UpdateComponent description: UpdateComponent updates an existing component. operationId: StatusPageService_UpdateComponent requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateComponentRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateComponentResponse' /rpc/openstatus.status_page.v1.StatusPageService/UpdateComponentGroup: post: tags: - StatusPageService summary: UpdateComponentGroup description: UpdateComponentGroup updates an existing component group. operationId: StatusPageService_UpdateComponentGroup requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateComponentGroupRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateComponentGroupResponse' /rpc/openstatus.status_page.v1.StatusPageService/UpdateStatusPage: post: tags: - StatusPageService summary: UpdateStatusPage description: UpdateStatusPage updates an existing status page. operationId: StatusPageService_UpdateStatusPage requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateStatusPageRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_page.v1.UpdateStatusPageResponse' /rpc/openstatus.status_report.v1.StatusReportService/AddStatusReportUpdate: post: tags: - StatusReportService summary: AddStatusReportUpdate description: 'Adds a new update entry to an existing status report and transitions the report to the specified status. Status reports follow a lifecycle: investigating -> identified -> monitoring -> resolved. If notify is true, subscribers of the associated page are notified by email about the update.' operationId: StatusReportService_AddStatusReportUpdate requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.AddStatusReportUpdateRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.AddStatusReportUpdateResponse' /rpc/openstatus.status_report.v1.StatusReportService/CreateStatusReport: post: tags: - StatusReportService summary: CreateStatusReport description: Creates a new status report with an initial update entry. The report is associated with a status page and optionally specific page components. An initial StatusReportUpdate is created automatically with the provided status, message, and date. If notify is true, subscribers of the associated page are notified by email. operationId: StatusReportService_CreateStatusReport requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.CreateStatusReportRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.CreateStatusReportResponse' /rpc/openstatus.status_report.v1.StatusReportService/DeleteStatusReport: post: tags: - StatusReportService summary: DeleteStatusReport description: DeleteStatusReport removes a status report and all its updates. operationId: StatusReportService_DeleteStatusReport requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.DeleteStatusReportRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.DeleteStatusReportResponse' /rpc/openstatus.status_report.v1.StatusReportService/GetStatusReport: get: tags: - StatusReportService summary: GetStatusReport description: GetStatusReport retrieves a specific status report by ID (includes full update timeline). operationId: StatusReportService_GetStatusReport.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.GetStatusReportRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.GetStatusReportResponse' post: tags: - StatusReportService summary: GetStatusReport description: GetStatusReport retrieves a specific status report by ID (includes full update timeline). operationId: StatusReportService_GetStatusReport requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.GetStatusReportRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.GetStatusReportResponse' /rpc/openstatus.status_report.v1.StatusReportService/ListStatusReports: get: tags: - StatusReportService summary: ListStatusReports description: ListStatusReports returns all status reports for the workspace (metadata only). operationId: StatusReportService_ListStatusReports.get parameters: - name: message in: query content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.ListStatusReportsRequest' responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.ListStatusReportsResponse' post: tags: - StatusReportService summary: ListStatusReports description: ListStatusReports returns all status reports for the workspace (metadata only). operationId: StatusReportService_ListStatusReports requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.ListStatusReportsRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.ListStatusReportsResponse' /rpc/openstatus.status_report.v1.StatusReportService/UpdateStatusReport: post: tags: - StatusReportService summary: UpdateStatusReport description: UpdateStatusReport updates the metadata of a status report (title, page components). operationId: StatusReportService_UpdateStatusReport requestBody: content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.UpdateStatusReportRequest' required: true responses: default: description: Error content: application/json: schema: $ref: '#/components/schemas/connect.error' "200": description: Success content: application/json: schema: $ref: '#/components/schemas/openstatus.status_report.v1.UpdateStatusReportResponse' ================================================ FILE: apps/server/tsconfig.json ================================================ { "extends": "@openstatus/tsconfig/base.json", "include": ["src", "*.ts", "**/*.ts"], "compilerOptions": { "jsx": "react-jsx", "module": "ESNext", "target": "ESNext", "moduleResolution": "bundler", "jsxImportSource": "react", "allowJs": true, "types": ["bun-types"], "paths": { "@/*": ["./src/*"] } } } ================================================ FILE: apps/ssh-server/.dockerignore ================================================ **/.ssh ================================================ FILE: apps/ssh-server/.gitignore ================================================ .ssh/ ================================================ FILE: apps/ssh-server/Dockerfile ================================================ ARG GO_VERSION=1 FROM golang:1.25.1-alpine as builder WORKDIR /usr/src/app COPY go.mod go.sum ./ RUN go mod download && go mod verify COPY . . RUN go build -v -o /ssh-status-server . FROM debian:bookworm COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /ssh-status-server /app/ssh-status-server CMD ["/app/ssh-status-server"] ================================================ FILE: apps/ssh-server/banner.txt ================================================ _ _ | | | | ___ _ __ ___ _ __ ___| |_ __ _| |_ _ _ ___ / _ \| '_ \ / _ \ '_ \/ __| __/ _` | __| | | / __| | (_) | |_) | __/ | | \__ \ || (_| | |_| |_| \__ \ \___/| .__/ \___|_| |_|___/\__\__,_|\__|\__,_|___/ | | |_| ================================================ FILE: apps/ssh-server/fly.toml ================================================ # fly.toml app configuration file generated for ssh-server-status on 2025-09-16T08:46:39+02:00 # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = 'ssh-server-status' primary_region = 'cdg' [build] dockerfile = './Dockerfile' [[services]] internal_port = 2222 protocol = "tcp" auto_stop_machines = true auto_start_machines = true [[services.ports]] port = 22 [env] PORT = "2222" [mounts] source = "ssh_key" destination = "/data" [[vm]] size = 'shared-cpu-1x' memory = '256MB' ================================================ FILE: apps/ssh-server/go.mod ================================================ module github.com/openstatusHQ/openstatus/apps/ssh-server go 1.25.1 require github.com/gliderlabs/ssh v0.3.8 require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/sys v0.36.0 // indirect ) ================================================ FILE: apps/ssh-server/go.sum ================================================ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= ================================================ FILE: apps/ssh-server/main.go ================================================ package main import ( _ "embed" "encoding/json" "fmt" "io" "log" "net/http" "github.com/gliderlabs/ssh" ) //go:embed banner.txt var banner string func bannerfunc(ctx ssh.Context) string { return banner } var statusOk = ` +----------------------------------+ | | | All Systems Operational | | | +----------------------------------+ ` var statusDegraded = ` +----------------------------------+ | | | System is degraded | | | +----------------------------------+ ` var statusPartialOutage = ` +----------------------------------+ | | | System is partially out of | | service | | | +----------------------------------+ ` var statusMajorOutage = ` +----------------------------------+ | | | System is out of service | | | +----------------------------------+ ` var statusUnderMaintenance = ` +----------------------------------+ | | | System is under | | maintenance | | | +----------------------------------+ ` var statusIncident = ` +----------------------------------+ | | | System is partially out of | | service | | | +----------------------------------+ ` type status struct { Status string `json:"status"` } func handler(s ssh.Session) { url := fmt.Sprintf("https://api.openstatus.dev/public/status/%s", s.User()) res, err := http.Get(url) if err != nil { fmt.Fprintf(s, "Error fetching status: %v\n", err) return } defer res.Body.Close() var status status json.NewDecoder(res.Body).Decode(&status) var currentStatus string switch status.Status { case "operational": currentStatus = statusOk case "degraded_performance": currentStatus = statusDegraded case "partial_outage": currentStatus = statusPartialOutage case "major_outage": currentStatus = statusMajorOutage case "under_maintenance": currentStatus = statusUnderMaintenance case "incident": currentStatus = statusIncident default: currentStatus = "" } if currentStatus == "" { io.WriteString(s, "Unknown status page") return } io.WriteString(s, fmt.Sprintf("\nCurrent Status for: %s\n\n%s\n\nVisit the status page at https://%s.openstatus.dev/\n\n", s.User(), currentStatus, s.User())) } func main() { server := &ssh.Server{ Addr: ":2222", BannerHandler: bannerfunc, Handler: handler, } ssh.HostKeyFile("/data/id_rsa") log.Println("starting ssh server on port 2222...") log.Fatal(server.ListenAndServe()) } ================================================ FILE: apps/status-page/.dockerignore ================================================ # This file is generated by Dofigen v2.5.1 # See https://github.com/lenra-io/dofigen ================================================ FILE: apps/status-page/.gitignore ================================================ .vercel ================================================ FILE: apps/status-page/Dockerfile ================================================ # syntax=docker/dockerfile:1.11 # This file is generated by Dofigen v2.5.1 # See https://github.com/lenra-io/dofigen # builder FROM node@sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2 AS builder LABEL \ org.opencontainers.image.base.digest="sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2" \ org.opencontainers.image.base.name="docker.io/node:24-slim" ENV \ PATH="$PNPM_HOME:$PATH" \ CRON_SECRET="test" \ RESEND_API_KEY="test" \ STRIPE_SECRET_KEY="test" \ TINY_BIRD_API_KEY="test" \ TEAM_ID_VERCEL="test" \ PROJECT_ID_VERCEL="test" \ SELF_HOST="true" \ NODE_ENV="production" \ NEXT_PUBLIC_OPENPANEL_CLIENT_ID="test" \ OPENPANEL_CLIENT_SECRET="test" \ NEXT_PUBLIC_URL="http://localhost:3002" \ UNKEY_TOKEN="test" \ RANDOM_YOLO="YOLO" \ UPSTASH_REDIS_REST_TOKEN="test" \ UNKEY_API_ID="test" \ DATABASE_URL="http://libsql:8080" \ UPSTASH_REDIS_REST_URL="test" \ VERCEL_AUTH_BEARER_TOKEN="test" \ PNPM_HOME="/pnpm" \ DATABASE_AUTH_TOKEN="test" WORKDIR /app COPY \ --link \ "." "/app/" RUN <<EOF corepack enable pnpm install --frozen-lockfile pnpm turbo run build --filter=@openstatus/status-page EOF # runtime FROM node@sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2 AS runtime LABEL \ io.dofigen.version="2.5.1" \ org.opencontainers.image.base.digest="sha256:0afb7822fac7bf9d7c1bf3b6e6c496dee6b2b64d8dfa365501a3c68e8eba94b2" \ org.opencontainers.image.base.name="docker.io/node:24-slim" WORKDIR /app/apps/status-page COPY \ --from=builder \ --chown=1000:1000 \ --chmod=555 \ --link \ "/app/apps/status-page/.next/standalone/apps/status-page/" "./" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/node_modules/" "/app/node_modules/" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/apps/status-page/.next/static/" "./.next/static/" COPY \ --from=builder \ --chown=1000:1000 \ --link \ "/app/apps/status-page/public/" "./public/" USER 0:0 RUN <<EOF apt-get update apt-get install -y --no-install-recommends curl rm -rf /var/lib/apt/lists/* EOF USER 1000:1000 EXPOSE 3000 HEALTHCHECK \ --interval=30s \ --timeout=10s \ --start-period=45s \ --retries=3 \ CMD curl -f http://localhost:3000/ || exit 1 CMD ["node", "server.js"] ================================================ FILE: apps/status-page/README.md ================================================ # openstatus status page ## Theme Store dev ```sh pnpm install ``` ```sh pnpm run env ``` ```sh pnpm dev ``` ================================================ FILE: apps/status-page/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@openstatus/ui/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/status-page/docker-compose.yaml ================================================ name: server services: server: build: context: ../.. dockerfile: apps/status-page/Dockerfile ports: - 3000:3000 image: status-page env_file: - ../../.env.docker command: . ================================================ FILE: apps/status-page/dofigen.yml ================================================ builders: # Stage 1: Next.js build with Node.js builder: fromImage: node:24-slim workdir: /app copy: - . /app/ env: NODE_ENV: production PNPM_HOME: /pnpm PATH: $PNPM_HOME:$PATH # Build-time environment variables (placeholder values, overwritten by .env.docker at runtime) DATABASE_URL: http://libsql:8080 DATABASE_AUTH_TOKEN: test NEXT_PUBLIC_OPENPANEL_CLIENT_ID: test NEXT_PUBLIC_URL: http://localhost:3002 TEAM_ID_VERCEL: test PROJECT_ID_VERCEL: test VERCEL_AUTH_BEARER_TOKEN: test OPENPANEL_CLIENT_SECRET: test RESEND_API_KEY: test UPSTASH_REDIS_REST_URL: test UPSTASH_REDIS_REST_TOKEN: test UNKEY_TOKEN: test UNKEY_API_ID: test TINY_BIRD_API_KEY: test CRON_SECRET: test STRIPE_SECRET_KEY: test SELF_HOST: "true" run: - corepack enable - pnpm install --frozen-lockfile - pnpm turbo run build --filter=@openstatus/status-page # Runtime stage fromImage: node:24-slim workdir: /app/apps/status-page # Copy artifacts from builder copy: # Copy Next.js standalone output - fromBuilder: builder source: /app/apps/status-page/.next/standalone/apps/status-page/ target: ./ chmod: "555" # Copy root node_modules (required for pnpm symlinks) - fromBuilder: builder source: /app/node_modules/ target: /app/node_modules/ # Copy static assets - fromBuilder: builder source: /app/apps/status-page/.next/static/ target: ./.next/static/ # Copy public directory - fromBuilder: builder source: /app/apps/status-page/public/ target: ./public/ # Install curl for health checks root: run: - apt-get update - apt-get install -y --no-install-recommends curl - rm -rf /var/lib/apt/lists/* # Security: run as non-root user user: "1000:1000" # Expose port expose: "3000" # Health check healthcheck: interval: 30s timeout: 10s start: 45s retries: 3 cmd: curl -f http://localhost:3000/ || exit 1 # Start application cmd: - node - server.js ================================================ FILE: apps/status-page/env.ts ================================================ const file = Bun.file("./.env.example"); await Bun.write("./.env", file); ================================================ FILE: apps/status-page/instrumentation-client.ts ================================================ // This file configures the initialization of Sentry on the client. // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_FRONTEND, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.5, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, replaysOnErrorSampleRate: 1.0, // This sets the sample rate to be 10%. You may want this to be 100% while // in development and sample at a lower rate in production replaysSessionSampleRate: 0.1, // You can remove this option if you're not planning to use the Sentry Session Replay feature: integrations: [ Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), Sentry.captureConsoleIntegration({ levels: ["error"] }), ], }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export const onRequestError = Sentry.captureRequestError; ================================================ FILE: apps/status-page/next-env.d.ts ================================================ /// <reference types="next" /> /// <reference types="next/image-types/global" /> import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. ================================================ FILE: apps/status-page/next.config.ts ================================================ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: process.env.SELF_HOST === "true" ? "standalone" : undefined, experimental: { authInterrupts: true, }, images: { remotePatterns: [ new URL("https://openstatus.dev/**"), new URL("https://**.public.blob.vercel-storage.com/**"), ], }, logging: { fetches: { fullUrl: true, }, }, async rewrites() { return { beforeFiles: [ { source: "/:path((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", has: [ { type: "host", value: process.env.NODE_ENV === "production" ? "(?<subdomain>[^.]+).stpg.dev" : "(?<subdomain>[^.]+).localhost", }, ], missing: [ // Skip this rewrite when the request came via proxy from web app { type: "header", key: "x-proxy", value: "1", }, { type: "host", value: process.env.NODE_ENV === "production" ? "www.stpg.dev" : "localhost", }, ], destination: "/:subdomain/:path*", }, ], }; }, }; // For detailed options, refer to the official documentation: // - Webpack plugin options: https://github.com/getsentry/sentry-webpack-plugin#options // - Next.js Sentry setup guide: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ const sentryConfig = { // Prevent log output unless running in a CI environment (helps reduce noise in logs) silent: !process.env.CI, org: "openstatus", project: "openstatus", authToken: process.env.SENTRY_AUTH_TOKEN, // Upload a larger set of source maps for improved stack trace accuracy (increases build time) widenClientFileUpload: true, // If set to true, transpiles Sentry SDK to be compatible with IE11 (increases bundle size) transpileClientSDK: false, // Tree-shake Sentry logger statements to reduce bundle size webpack: { treeshake: { removeDebugLogging: true, }, }, }; export default withSentryConfig(nextConfig, sentryConfig); ================================================ FILE: apps/status-page/package.json ================================================ { "name": "@openstatus/status-page", "version": "1.0.0", "private": true, "scripts": { "env": "bun env.ts", "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "tsc": "tsc --noEmit" }, "dependencies": { "@auth/core": "0.40.0", "@auth/drizzle-adapter": "1.10.0", "@date-fns/tz": "1.2.0", "@date-fns/utc": "2.1.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@hookform/devtools": "4.4.0", "@hookform/resolvers": "5.1.0", "@libsql/client": "0.15.15", "@openpanel/nextjs": "1.2.0", "@openstatus/analytics": "workspace:*", "@openstatus/api": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", "@openstatus/react": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", "@openstatus/ui": "workspace:*", "@openstatus/utils": "workspace:*", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-hover-card": "1.1.14", "@sentry/nextjs": "10.31.0", "@stripe/stripe-js": "2.1.6", "@tanstack/react-query": "5.81.5", "@tanstack/react-table": "8.21.3", "@trpc/client": "11.4.4", "@trpc/next": "11.4.4", "@trpc/react-query": "11.4.4", "@trpc/server": "11.4.4", "@trpc/tanstack-react-query": "11.4.4", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "date-fns": "3.6.0", "feed": "4.2.2", "lucide-react": "0.525.0", "next": "16.1.6", "next-auth": "5.0.0-beta.29", "next-plausible": "3.12.5", "next-themes": "0.4.6", "nuqs": "2.8.5", "react": "19.2.3", "react-day-picker": "8.10.1", "react-dom": "19.2.3", "react-hook-form": "7.68.0", "recharts": "2.15.0", "rehype-react": "8.0.0", "remark-gfm": "4.0.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "sonner": "2.0.5", "superjson": "2.2.2", "tailwind-merge": "3.3.1", "unified": "11.0.5", "zod": "4.1.13" }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", "@tailwindcss/postcss": "4.1.11", "@tailwindcss/typography": "0.5.10", "@types/dom-speech-recognition": "0.0.6", "@types/node": "24.0.8", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "shadcn": "3.8.4", "tailwindcss": "4.1.11", "tw-animate-css": "1.3.4", "typescript": "5.9.3" } } ================================================ FILE: apps/status-page/postcss.config.mjs ================================================ const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ================================================ FILE: apps/status-page/sentry.edge.config.ts ================================================ // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). // The config you add here will be used whenever one of the edge features is loaded. // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/status-page/sentry.server.config.ts ================================================ // This file configures the initialization of Sentry on the server. // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.2, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/status-page/src/app/(public)/client.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { Section, SectionDescription, SectionGroup, SectionGroupHeader, SectionHeader, SectionTitle, } from "@/components/content/section"; import { recomputeStyles } from "@/components/status-page/floating-button"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { StatusBanner } from "@/components/status-page/status-banner"; import { StatusMonitor } from "@/components/status-page/status-monitor"; import { ThemePalettePicker } from "@/components/themes/theme-palette-picker"; import { ThemeSelect } from "@/components/themes/theme-select"; import { monitors } from "@/data/monitors"; import { useTRPC } from "@/lib/trpc/client"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useSidebar } from "@openstatus/ui/components/ui/sidebar"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; import { searchParamsParsers } from "./search-params"; const MAIN_COLORS = [ { key: "--primary", label: "Primary" }, { key: "--success", label: "Operational" }, { key: "--destructive", label: "Error" }, { key: "--warning", label: "Degraded" }, { key: "--info", label: "Maintenance" }, ] as const; // TODO: add keyboard navigation for selection? export function Client() { const { resolvedTheme } = useTheme(); const [isMounted, setIsMounted] = useState(false); const [{ q, t }, setSearchParams] = useQueryStates(searchParamsParsers); const theme = t ? THEMES[t as keyof typeof THEMES] : undefined; const { toggleSidebar } = useSidebar(); useEffect(() => { setIsMounted(true); }, []); useEffect(() => { if (isMounted && t) { recomputeStyles(t); } }, [t, isMounted]); return ( <SectionGroup> <SectionGroupHeader> <h1 className="font-bold text-2xl md:text-4xl"> Status Page Theme Explorer </h1> <h2 className="font-medium text-muted-foreground md:text-lg"> View all the openstatus themes for your status page and learn how to create your own theme. </h2> </SectionGroupHeader> <Section> <SectionHeader> <SectionTitle>Explorer</SectionTitle> <SectionDescription> Search for your favorite status page theme.{" "} <Link href="#contribute-theme">Contribute your own?</Link> </SectionDescription> </SectionHeader> <div className="sticky top-0 z-10 overflow-hidden rounded-lg border border-border bg-background outline-[3px] outline-background sm:relative"> <div className="relative"> <div className="absolute top-0 right-0 rounded-bl-lg border-border border-b border-l bg-muted/50 px-2 py-0.5 text-[10px]"> {theme?.name} </div> <div className="sm:p-8"> <ThemePlaygroundStatus className="scale-80 sm:scale-100" /> </div> </div> </div> <div className="flex gap-3"> <ThemeSelect className="min-w-[125px] max-w-[125px]" /> <Input placeholder={`Search from ${THEME_KEYS.length} themes`} value={q ?? ""} onChange={(e) => { if (e.target.value.length === 0) { setSearchParams({ q: null }); } setSearchParams({ q: e.target.value.trim().toLowerCase() }); }} /> <ThemePalettePicker /> </div> <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {THEME_KEYS.filter((k) => { const theme = THEMES[k]; return ( theme.author.name .toLowerCase() .includes(q?.toLowerCase() ?? "") || theme.name.toLowerCase().includes(q?.toLowerCase() ?? "") ); }).map((k) => { const theme = THEMES[k]; const style = isMounted ? theme[resolvedTheme as "dark" | "light"] : undefined; return ( <li key={k} className="group/theme-card space-y-1.5"> <div data-active={k === t} data-slot="theme-card" data-theme={k} className="relative h-40 cursor-pointer overflow-hidden rounded-md border border-border outline-none transition-all focus:outline-ring/50 focus:ring-2 focus:ring-ring/50 data-[active=true]:border-ring data-[active=true]:outline-[3px] data-[active=true]:outline-ring/50" onClick={() => setSearchParams({ t: k })} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { setSearchParams({ t: k }); } }} > {isMounted ? ( <div className="absolute h-full w-full bg-background text-foreground" style={style as React.CSSProperties} inert > <ThemePlaygroundStatus className="pointer-events-none scale-80" /> </div> ) : ( <Skeleton className="absolute h-full w-full" /> )} </div> <div className="flex items-start justify-between gap-2"> <div className="space-y-0.5 truncate"> <div className="truncate font-medium text-foreground text-sm leading-none"> {theme.name} </div> <div className="font-mono text-xs"> <Link href={theme.author.url} target="_blank" rel="noopener noreferrer" className="text-muted-foreground" > by {theme.author.name} </Link> </div> </div> <div className="flex gap-0.5"> {MAIN_COLORS.map((color) => { const backgroundColor = style ? style[color.key] : undefined; if (!isMounted) { return ( <Skeleton key={color.key} className="size-3.5 rounded-sm" /> ); } return ( <TooltipProvider key={color.key}> <Tooltip> <TooltipTrigger> <div className="size-3.5 rounded-sm border bg-muted-foreground" style={{ backgroundColor }} /> </TooltipTrigger> <TooltipContent>{color.label}</TooltipContent> </Tooltip> </TooltipProvider> ); })} </div> </div> </li> ); })} </ul> </Section> <Separator /> <Section> <SectionHeader id="contribute-theme"> <SectionTitle>Contribute Theme</SectionTitle> <SectionDescription> Contribute your own theme to the community. </SectionDescription> </SectionHeader> <div className="prose dark:prose-invert prose-sm max-w-none"> <p> You can contribute your own theme by creating a new file in the{" "} <code>@openstatus/theme-store</code> package. You'll only need to override css variables. If you are familiar with shadcn, you'll know the trick (it also allows you to override `--radius`). Make sure your object is satisfying the <code>Theme</code> interface. We provide a theme builder to help you with the process. </p> <Button onClick={toggleSidebar}>Toggle Theme Builder</Button> <p> Go to the{" "} <Link href="https://github.com/openstatusHQ/openstatus/tree/main/packages/theme-store"> GitHub directory </Link>{" "} to see the existing themes and create a new one by forking and creating a pull request. </p> <p> Once you're done, you can test it by adding the following snippet to your status page: </p> <pre> <code>sessionStorage.setItem("community-theme", "true");</code> </pre> <p> Or use the following button to test it on the `status` page slug: </p> <Button onClick={() => { // NOTE: we use it to display the 'floating-theme' component sessionStorage.setItem("community-theme", "true"); window.location.href = "/status"; }} > Test it </Button> {/* TODO: OR go to the status-page config and click on the View and Configure button */} </div> </Section> <Separator /> <Section> <div className="prose dark:prose-invert prose-sm max-w-none"> <p> Why don't we allow custom css styles to be overridden and only support themes? </p> <ul> <li>Keep it simple for the user</li> <li>Don't end up with a xmas tree</li> <li>Keep the theme consistent</li> <li>Avoid conflicts with other styles</li> <li> Keep the theme maintainable (but this will also mean, a change will affect all users) </li> </ul> </div> </Section> </SectionGroup> ); } function ThemePlaygroundStatus({ className, ...props }: React.ComponentProps<"div"> & {}) { const trpc = useTRPC(); const { data: uptimeData, isLoading } = useQuery( trpc.statusPage.getNoopUptime.queryOptions(), ); return ( // NOTE: we use pointer-events-none to prevent the hover card or tooltip from being interactive - the Portal container is document body and we loose the styles <div className={cn("h-full w-full", className)} {...props}> <Status variant="success"> <StatusHeader> <StatusTitle>Acme Inc.</StatusTitle> <StatusDescription> Get informed about our services. </StatusDescription> </StatusHeader> <StatusBanner status="success" /> <StatusContent> {/* TODO: create mock data */} <StatusMonitor status="success" data={uptimeData?.data || []} monitor={monitors[0]} showUptime={true} uptime={uptimeData?.uptime} isLoading={isLoading} /> </StatusContent> </Status> </div> ); } ================================================ FILE: apps/status-page/src/app/(public)/layout.tsx ================================================ import { Link } from "@/components/common/link"; import { ThemeProvider } from "@/components/themes/theme-provider"; import { SidebarTrigger, ThemeSidebar, } from "@/components/themes/theme-sidebar"; import { generateThemeStyles } from "@openstatus/theme-store"; import { SidebarInset, SidebarProvider, } from "@openstatus/ui/components/ui/sidebar"; import { Toaster } from "@openstatus/ui/components/ui/sonner"; import PlausibleProvider from "next-plausible"; import { Suspense } from "react"; const SIDEBAR_WIDTH = "20rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; export default async function Layout({ children, }: { children: React.ReactNode; }) { return ( <PlausibleProvider domain="themes.openstatus.dev"> <style id="theme-styles" // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> dangerouslySetInnerHTML={{ __html: generateThemeStyles() }} /> <ThemeProvider attribute="class" enableSystem disableTransitionOnChange> <SidebarProvider defaultOpen={true} style={ { "--sidebar-width": SIDEBAR_WIDTH, "--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE, } as React.CSSProperties } > <SidebarInset className="relative"> <SidebarTrigger className="absolute top-2 right-2" /> <main className="mx-auto">{children}</main> <footer className="flex items-center justify-center gap-4 p-4 text-center font-mono text-muted-foreground text-sm"> <p> powered by <Link href="https://openstatus.dev">openstatus</Link> </p> </footer> </SidebarInset> <Suspense> <ThemeSidebar /> </Suspense> </SidebarProvider> <Toaster richColors expand /> </ThemeProvider> </PlausibleProvider> ); } ================================================ FILE: apps/status-page/src/app/(public)/page.tsx ================================================ import type { SearchParams } from "nuqs"; import { Client } from "./client"; import { searchParamsCache } from "./search-params"; export default async function Page({ searchParams, }: { searchParams: Promise<SearchParams>; }) { await searchParamsCache.parse(searchParams); return <Client />; } ================================================ FILE: apps/status-page/src/app/(public)/search-params.ts ================================================ import { THEME_KEYS } from "@openstatus/theme-store"; import { createSearchParamsCache, parseAsBoolean, parseAsString, parseAsStringEnum, } from "nuqs/server"; export const searchParamsParsers = { q: parseAsString, // q = query t: parseAsStringEnum(THEME_KEYS).withDefault("default"), // t = theme b: parseAsBoolean.withDefault(false), // b = builder }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(auth)/layout.tsx ================================================ import { Footer } from "@/components/nav/footer"; import { getQueryClient, trpc } from "@/lib/trpc/server"; import { Suspense } from "react"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ domain: string }>; }) { const queryClient = getQueryClient(); const { domain } = await params; await queryClient.prefetchQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); return ( <Suspense> <div className="flex min-h-screen flex-col gap-4"> <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> {children} </main> <Footer className="w-full border-t" /> </div> </Suspense> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(auth)/login/_components/section-magic-link.tsx ================================================ "use client"; import { EmptyStateContainer, EmptyStateDescription, EmptyStateTitle, } from "@/components/content/empty-state"; import { Section, SectionDescription, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormEmail, type FormValues } from "@/components/forms/form-email"; import { generateServerActionPromise } from "@/lib/server-actions"; import { Button } from "@openstatus/ui/components/ui/button"; import { Inbox } from "lucide-react"; import { useParams } from "next/navigation"; import { useState } from "react"; import { flushSync } from "react-dom"; import { signInWithResendAction } from "../actions"; export function SectionMagicLink() { const { domain } = useParams<{ domain: string }>(); const [state, setState] = useState<"idle" | "pending" | "success">("idle"); async function submitAction(values: FormValues) { // NOTE: we can improve a bit if we use pathname instead of subdomain/hostname // like http://localhost:3000/hello, the redirectTo should be http://localhost:3000/hello // this only affects local development if not using chrome and subdomain const redirectTo = process.env.NODE_ENV === "development" ? `http://${window.location.hostname}:${window.location.port}` : `https://${window.location.hostname}`; const formData = new FormData(); formData.append("redirectTo", redirectTo); formData.append("email", values.email); formData.append("domain", domain); // we need this because submitAction is called in a startTransition and we need to update the state immediately flushSync(() => setState("pending")); try { await new Promise((resolve) => setTimeout(resolve, 1000)); await generateServerActionPromise(signInWithResendAction(formData)); setState("success"); } catch (error) { setState("idle"); throw error; } } return ( <Section className="m-auto w-full max-w-lg rounded-lg border bg-card p-4"> <SectionHeader> <SectionTitle>Authenticate</SectionTitle> <SectionDescription> Enter your email to receive a magic link for accessing the status page. Note: Only emails from approved domains are accepted. </SectionDescription> </SectionHeader> {state !== "success" ? ( <div className="flex flex-col gap-2"> <FormEmail id="email-form" onSubmit={submitAction} /> <Button type="submit" form="email-form" disabled={state === "pending"} > {state === "pending" ? "Submitting..." : "Submit"} </Button> </div> ) : ( <SuccessState /> )} </Section> ); } function SuccessState() { return ( <EmptyStateContainer> <Inbox className="size-4 shrink-0" /> <EmptyStateTitle>Check your inbox!</EmptyStateTitle> <EmptyStateDescription> Access the status page by clicking the link in the email. </EmptyStateDescription> </EmptyStateContainer> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(auth)/login/_components/section-password.tsx ================================================ "use client"; import { Section, SectionDescription, SectionHeader, SectionTitle, } from "@/components/content/section"; import { FormPassword } from "@/components/forms/form-password"; import { createProtectedCookieKey } from "@/lib/protected"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation } from "@tanstack/react-query"; import { useParams, useRouter, useSearchParams } from "next/navigation"; export function SectionPassword() { const { domain } = useParams<{ domain: string }>(); const searchParams = useSearchParams(); const trpc = useTRPC(); const [_, setPassword] = useCookieState(createProtectedCookieKey(domain)); const router = useRouter(); const verifyPasswordMutation = useMutation( trpc.statusPage.verifyPassword.mutationOptions({}), ); return ( <Section className="m-auto w-full max-w-lg rounded-lg border bg-card p-4"> <SectionHeader> <SectionTitle>Protected Page</SectionTitle> <SectionDescription> Enter the password to access the status page. </SectionDescription> </SectionHeader> <div className="flex flex-col gap-2"> <FormPassword id="password-form" onSubmit={async (values) => { const result = await verifyPasswordMutation.mutateAsync({ slug: domain, password: values.password, }); if (result) { setPassword(values.password); const redirect = searchParams.get("redirect"); // Only allow safe relative paths to prevent XSS via javascript: URLs if (redirect?.startsWith("/") && !redirect.startsWith("//")) { router.push(redirect); } else { router.push("/"); } } }} /> <Button type="submit" form="password-form"> Submit </Button> </div> </Section> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(auth)/login/actions.ts ================================================ "use server"; import { signIn } from "@/lib/auth"; import { getQueryClient, trpc } from "@/lib/trpc/server"; import { TRPCClientError } from "@trpc/client"; import { AuthError } from "next-auth"; import { isRedirectError } from "next/dist/client/components/redirect-error"; export async function signInWithResendAction(formData: FormData) { try { const email = formData.get("email") as string; const redirectTo = formData.get("redirectTo") as string; const domain = formData.get("domain") as string; if (!email || !redirectTo) { return { success: false, error: "Email and redirectTo are required", }; } const queryClient = getQueryClient(); // NOTE: throws an error if the email domain is not allowed try { await queryClient.fetchQuery( trpc.statusPage.validateEmailDomain.queryOptions({ slug: domain, email, }), ); } catch (error) { console.error("[SignIn] Email validation failed", error); if (error instanceof TRPCClientError) { return { success: false, error: error.message }; } if (error instanceof Error) { return { success: false, error: error.message }; } return { success: false, error: "An unexpected error occurred during sign in", }; } await signIn("resend", { email, redirectTo, }); return { success: true }; } catch (e) { // NOTE: https://github.com/nextauthjs/next-auth/discussions/9389 if (isRedirectError(e)) { return { success: true }; } console.error("[SignIn] Error:", e); if (e instanceof AuthError) { return { success: false, error: e.type }; } if (e instanceof Error) { return { success: false, error: e.message }; } return { success: false, error: "An unexpected error occurred during sign in", }; } } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(auth)/login/page.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { notFound, useParams } from "next/navigation"; import { SectionMagicLink } from "./_components/section-magic-link"; import { SectionPassword } from "./_components/section-password"; export default function LoginPage() { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (page?.accessType === "password") { return <SectionPassword />; } if (page?.accessType === "email-domain") { return <SectionMagicLink />; } return notFound(); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/badge/route.tsx ================================================ import { ImageResponse } from "next/og"; import type { NextRequest } from "next/server"; import type { Status } from "@openstatus/react"; import { getStatus } from "@openstatus/react"; // Keep the `label` size within a maximum of 'Operational' to stay within the `SIZE` restriction const statusDictionary: Record<Status, { label: string; color: string }> = { operational: { label: "Operational", color: "bg-green-500", }, degraded_performance: { label: "Degraded", color: "bg-yellow-500", }, partial_outage: { label: "Outage", color: "bg-yellow-500", }, major_outage: { label: "Outage", color: "bg-red-500", }, unknown: { label: "Unknown", color: "bg-gray-500", }, incident: { label: "Incident", color: "bg-yellow-500", }, under_maintenance: { label: "Maintenance", color: "bg-blue-500", }, } as const; // const SIZE = { width: 120, height: 34 }; const SIZE: Record<string, { width: number; height: number }> = { sm: { width: 120, height: 34 }, md: { width: 160, height: 46 }, lg: { width: 200, height: 56 }, xl: { width: 240, height: 68 }, }; export async function GET( req: NextRequest, props: { params: Promise<{ domain: string }> }, ) { const params = await props.params; const { status } = await getStatus(params.domain); const theme = req.nextUrl.searchParams.get("theme"); const size = req.nextUrl.searchParams.get("size"); const s = SIZE[size ?? "sm"] ?? SIZE.sm; const { label, color } = statusDictionary[status]; const light = "border-gray-200 text-gray-700 bg-white"; const dark = "border-gray-800 text-gray-300 bg-gray-900"; return new ImageResponse( <div tw={`flex items-center justify-center rounded-md border px-3 py-1 ${size === "sm" && "text-sm"}${size === "md" && "text-md"} ${ size === "lg" && "text-lg" } ${size === "xl" && "text-xl"} ${!size && "text-sm"} ${ theme === "dark" ? dark : light }`} style={{ ...s }} > {label} <div tw={`flex h-2 w-2 rounded-full ml-2 ${color}`} /> </div>, { ...s }, ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/badge/v2/route.ts ================================================ import type { NextRequest } from "next/server"; import type { Status } from "@openstatus/react"; import { getStatus } from "@openstatus/react"; const statusDictionary: Record<Status, { label: string; hexColor: string }> = { operational: { label: "All Systems Operational", hexColor: "#10b981", }, degraded_performance: { label: "Degraded Performance", hexColor: "#f59e0b", }, partial_outage: { label: "Partial Outage", hexColor: "#f59e0b", }, major_outage: { label: "Major Outage", hexColor: "#ef4444", }, unknown: { label: "Unknown", hexColor: "#6b7280", }, incident: { label: "Ongoing Incident", hexColor: "#f59e0b", }, under_maintenance: { label: "Under Maintenance", hexColor: "#3b82f6", }, } as const; const SIZE: Record< string, { height: number; padding: number; gap: number; radius: number; fontSize: number; } > = { sm: { height: 34, padding: 8, gap: 12, radius: 4, fontSize: 12 }, md: { height: 46, padding: 8, gap: 12, radius: 4, fontSize: 14 }, lg: { height: 56, padding: 12, gap: 16, radius: 6, fontSize: 16 }, xl: { height: 68, padding: 12, gap: 16, radius: 6, fontSize: 18 }, }; function getTextWidth(text: string, fontSize: number): number { const monoCharWidthRatio = 0.6; return text.length * monoCharWidthRatio * fontSize; } export async function GET( req: NextRequest, props: { params: Promise<{ domain: string }> }, ) { const params = await props.params; const { status } = await getStatus(params.domain); const theme = req.nextUrl.searchParams.get("theme") ?? "light"; const variant = req.nextUrl.searchParams.get("variant") ?? "default"; const size = req.nextUrl.searchParams.get("size") ?? "sm"; const { height, padding, gap, radius, fontSize } = SIZE[size] ?? SIZE.sm; const { label, hexColor } = statusDictionary[status]; const textWidth = getTextWidth(label, fontSize); const width = Math.ceil(padding + textWidth + gap + radius * 2 + padding); const textColor = theme === "dark" ? "#d1d5db" : "#374151"; const bgColor = theme === "dark" ? "#111827" : "#ffffff"; const borderColor = variant === "outline" ? "#d1d5db" : "transparent"; const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"> <rect x="0.5" y="0.5" width="${width - 1}" height="${ height - 1 }" fill="${bgColor}" stroke="${borderColor}" stroke-width="1" rx="${radius}" ry="${radius}" /> <text x="${padding}" y="50%" dominant-baseline="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace" font-size="${fontSize}" font-weight="600" fill="${textColor}"> ${label} </text> <circle cx="${width - padding - radius}" cy="${ height / 2 }" r="${radius}" fill="${hexColor}"/> </svg> `; return new Response(svg, { headers: { "Content-Type": "image/svg+xml" }, }); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx ================================================ "use client"; import { StatusBlankEvents } from "@/components/status-page/status-blank"; import { StatusEvent, StatusEventAffected, StatusEventAffectedBadge, StatusEventAside, StatusEventContent, StatusEventDate, StatusEventGroup, StatusEventTimelineMaintenance, StatusEventTimelineReport, StatusEventTitle, StatusEventTitleCheck, } from "@/components/status-page/status-events"; import { useTRPC } from "@/lib/trpc/client"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import { searchParamsParsers } from "./search-params"; export default function Page() { const [{ tab }, setSearchParams] = useQueryStates(searchParamsParsers); const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return null; const { statusReports, maintenances } = page; return ( <Tabs defaultValue={tab} onValueChange={(value) => setSearchParams({ tab: value as "reports" | "maintenances" }) } className="gap-4" > <TabsList> <TabsTrigger value="reports">Reports</TabsTrigger> <TabsTrigger value="maintenances">Maintenances</TabsTrigger> </TabsList> <TabsContent value="reports"> <StatusEventGroup> {statusReports.length > 0 ? ( statusReports.map((report) => { const updates = report.statusReportUpdates.sort( (a, b) => b.date.getTime() - a.date.getTime(), ); const firstUpdate = updates[updates.length - 1]; const lastUpdate = updates[0]; // NOTE: updates are sorted descending by date const startedAt = firstUpdate.date; // HACKY: LEGACY: only resolved via report and not via report update const isReportResolvedOnly = report.status === "resolved" && lastUpdate.status !== "resolved"; return ( <StatusEvent key={report.id}> <StatusEventAside> <StatusEventDate date={startedAt} /> </StatusEventAside> <Link href={`./events/report/${report.id}`} className="rounded-lg" > <StatusEventContent> <StatusEventTitle className="inline-flex gap-1"> {report.title} {isReportResolvedOnly ? ( <StatusEventTitleCheck /> ) : null} </StatusEventTitle> {report.statusReportsToPageComponents.length > 0 ? ( <StatusEventAffected> {report.statusReportsToPageComponents.map( (affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id} > {affected.pageComponent.name} </StatusEventAffectedBadge> ), )} </StatusEventAffected> ) : null} <StatusEventTimelineReport updates={report.statusReportUpdates} reportId={report.id} /> </StatusEventContent> </Link> </StatusEvent> ); }) ) : ( <StatusBlankEvents /> )} </StatusEventGroup> </TabsContent> <TabsContent value="maintenances"> <StatusEventGroup> {maintenances.length > 0 ? ( maintenances.map((maintenance) => { return ( <StatusEvent key={maintenance.id}> <StatusEventAside> <StatusEventDate date={maintenance.from} /> </StatusEventAside> <Link href={`./events/maintenance/${maintenance.id}`} className="rounded-lg" > <StatusEventContent> <StatusEventTitle>{maintenance.title}</StatusEventTitle> {maintenance.maintenancesToPageComponents.length > 0 ? ( <StatusEventAffected> {maintenance.maintenancesToPageComponents.map( (affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id} > {affected.pageComponent.name} </StatusEventAffectedBadge> ), )} </StatusEventAffected> ) : null} <StatusEventTimelineMaintenance maintenance={maintenance} /> </StatusEventContent> </Link> </StatusEvent> ); }) ) : ( <StatusBlankEvents title="No maintenances found" description="No maintenances found for this status page." /> )} </StatusEventGroup> </TabsContent> </Tabs> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/search-params.ts ================================================ import { createSearchParamsCache, parseAsStringEnum } from "nuqs/server"; export const searchParamsParsers = { tab: parseAsStringEnum(["reports", "maintenances"]).withDefault("reports"), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string; domain: string }>; }) { const { id, domain } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.statusPage.getMaintenance.queryOptions({ id: Number(id), slug: domain, }), ); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { ButtonBack } from "@/components/button/button-back"; import { ButtonCopyLink } from "@/components/button/button-copy-link"; import { StatusBlankEvents } from "@/components/status-page/status-blank"; import { StatusEvent, StatusEventAffected, StatusEventAffectedBadge, StatusEventAside, StatusEventContent, StatusEventDate, StatusEventTimelineMaintenance, StatusEventTitle, } from "@/components/status-page/status-events"; import { useParams } from "next/navigation"; export default function MaintenancePage() { const trpc = useTRPC(); const { id, domain } = useParams<{ id: string; domain: string }>(); const { data: maintenance } = useQuery( trpc.statusPage.getMaintenance.queryOptions({ id: Number(id), slug: domain, }), ); if (!maintenance) { return ( <StatusBlankEvents title="Maintenance not found" description="The maintenance you are looking for does not exist." /> ); } return ( <div className="flex flex-col gap-4"> <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> <ButtonBack href="../" /> <ButtonCopyLink /> </div> <StatusEvent> <StatusEventAside> <StatusEventDate date={maintenance.from} /> </StatusEventAside> <StatusEventContent hoverable={false}> <StatusEventTitle>{maintenance.title}</StatusEventTitle> <StatusEventAffected> {maintenance.maintenancesToPageComponents.map((affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id}> {affected.pageComponent.name} </StatusEventAffectedBadge> ))} </StatusEventAffected> <StatusEventTimelineMaintenance maintenance={maintenance} /> </StatusEventContent> </StatusEvent> </div> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ id: string; domain: string }>; }) { const { id, domain } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.statusPage.getReport.queryOptions({ id: Number(id), slug: domain, }), ); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx ================================================ "use client"; import { ButtonBack } from "@/components/button/button-back"; import { ButtonCopyLink } from "@/components/button/button-copy-link"; import { StatusBlankEvents } from "@/components/status-page/status-blank"; import { StatusEvent, StatusEventAffected, StatusEventAffectedBadge, StatusEventAside, StatusEventContent, StatusEventDate, StatusEventTimelineReport, StatusEventTitle, StatusEventTitleCheck, } from "@/components/status-page/status-events"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function ReportPage() { const trpc = useTRPC(); const { id, domain } = useParams<{ id: string; domain: string }>(); const { data: report } = useQuery( trpc.statusPage.getReport.queryOptions({ id: Number(id), slug: domain }), ); if (!report) { return ( <StatusBlankEvents title="Report not found" description="The report you are looking for does not exist." /> ); } const updates = report.statusReportUpdates.sort( (a, b) => b.date.getTime() - a.date.getTime(), ); const firstUpdate = updates[updates.length - 1]; const lastUpdate = updates[0]; // HACKY: LEGACY: only resolved via report and not via report update const isReportResolvedOnly = report.status === "resolved" && lastUpdate.status !== "resolved"; return ( <div className="flex flex-col gap-4"> <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> <ButtonBack href="../" /> <ButtonCopyLink /> </div> <StatusEvent> <StatusEventAside> <StatusEventDate date={firstUpdate.date} /> </StatusEventAside> <StatusEventContent hoverable={false}> <StatusEventTitle className="inline-flex gap-1"> {report.title} {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} </StatusEventTitle> {report.statusReportsToPageComponents.length > 0 ? ( <StatusEventAffected> {report.statusReportsToPageComponents.map((affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id}> {affected.pageComponent.name} </StatusEventAffectedBadge> ))} </StatusEventAffected> ) : null} <StatusEventTimelineReport updates={report.statusReportUpdates} reportId={report.id} /> </StatusEventContent> </StatusEvent> </div> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx ================================================ "use client"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function EventLayout({ children, }: { children: React.ReactNode; }) { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return null; return ( <Status> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> <StatusContent>{children}</StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/feed/[type]/route.ts ================================================ import { auth } from "@/lib/auth"; import { getBaseUrl } from "@/lib/base-url"; import { getQueryClient, trpc } from "@/lib/trpc/server"; import { Feed } from "feed"; import { notFound, unauthorized } from "next/navigation"; const STATUS_LABELS = { investigating: "Investigating", identified: "Identified", monitoring: "Monitoring", resolved: "Resolved", maintenance: "Maintenance", } as const; export const revalidate = 60; export async function GET( _request: Request, props: { params: Promise<{ domain: string; type: string }> }, ) { try { const queryClient = getQueryClient(); const { domain, type } = await props.params; if (!["rss", "atom"].includes(type)) return notFound(); const _page = await queryClient.fetchQuery( trpc.statusPage.getLight.queryOptions({ slug: domain }), ); if (!_page) return notFound(); if (_page.accessType === "password") { const url = new URL(_request.url); const password = url.searchParams.get("pw"); console.log({ url, _page, password }); if (password !== _page.password) return unauthorized(); } if (_page.accessType === "email-domain") { const session = await auth(); const user = session?.user; const allowedDomains = _page.authEmailDomains ?? []; if (!user || !user.email) return unauthorized(); if (!allowedDomains.includes(user.email.split("@")[1])) return unauthorized(); } const page = await queryClient.fetchQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return notFound(); const baseUrl = getBaseUrl({ slug: page.slug, customDomain: page.customDomain, }); const feed = new Feed({ id: `${baseUrl}/feed/${type}`, title: page.title, description: page.description, generator: "OpenStatus - Status Page Updates", feedLinks: { rss: `${baseUrl}/feed/rss`, atom: `${baseUrl}/feed/atom`, }, link: baseUrl, author: { name: page.title, email: page.contactUrl?.startsWith("mailto:") && page.contactUrl !== null ? page.contactUrl.slice(7) : undefined, link: page.homepageUrl || baseUrl, }, copyright: `Copyright ${new Date() .getFullYear() .toString()} openstatus.dev`, language: "en-US", updated: new Date(), ttl: 60, }); for (const maintenance of page.maintenances ?? []) { const maintenanceUrl = `${baseUrl}/events/maintenance/${maintenance.id}`; feed.addItem({ id: maintenanceUrl, title: `Maintenance - ${maintenance.title}`, link: maintenanceUrl, description: maintenance.message, date: maintenance.updatedAt ?? maintenance.createdAt ?? new Date(), }); } for (const statusReport of page.statusReports ?? []) { const statusReportUrl = `${baseUrl}/events/report/${statusReport.id}`; const status = STATUS_LABELS[statusReport.status] ?? statusReport.status; const statusReportUpdates = (statusReport.statusReportUpdates ?? []) .map((update) => { const updateStatus = STATUS_LABELS[update.status] ?? update.status; return `${updateStatus}: ${update.message}.`; }) .join("\n\n"); feed.addItem({ id: statusReportUrl, title: `${status} - ${statusReport.title}`, link: statusReportUrl, description: statusReportUpdates, date: statusReport.updatedAt ?? statusReport.createdAt ?? new Date(), }); } feed.items.sort((a, b) => a.date.getTime() - b.date.getTime()); const res = type === "atom" ? feed.atom1() : feed.rss2(); return new Response(res, { headers: { "Content-Type": "application/xml; charset=utf-8", }, }); } catch (error) { console.error("Error generating feed:", error); throw error; } } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/feed/json/route.ts ================================================ import { auth } from "@/lib/auth"; import { getQueryClient, trpc } from "@/lib/trpc/server"; import { notFound, unauthorized } from "next/navigation"; export const revalidate = 60; export async function GET( _request: Request, props: { params: Promise<{ domain: string }> }, ) { try { const queryClient = getQueryClient(); const { domain } = await props.params; const _page = await queryClient.fetchQuery( trpc.statusPage.getLight.queryOptions({ slug: domain }), ); if (!_page) return notFound(); if (_page.accessType === "password") { const url = new URL(_request.url); const password = url.searchParams.get("pw"); console.log({ url, _page, password }); if (password !== _page.password) return unauthorized(); } if (_page.accessType === "email-domain") { const session = await auth(); const user = session?.user; const allowedDomains = _page.authEmailDomains ?? []; if (!user || !user.email) return unauthorized(); if (!allowedDomains.includes(user.email.split("@")[1])) return unauthorized(); } const page = await queryClient.fetchQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return notFound(); const res = { title: page.title, description: page.description, status: page.status, updatedAt: new Date(), // @deprecated Use pageComponents instead monitors: page.monitors.map((monitor) => ({ id: monitor.id, name: monitor.name, description: monitor.description, status: monitor.status, })), // New field - exposes the page component structure pageComponents: page.pageComponents.map((component) => ({ id: component.id, name: component.name, description: component.description, monitorId: component.monitorId, order: component.order, groupId: component.groupId, groupOrder: component.groupOrder, })), pageComponentGroups: page.pageComponentGroups.map((group) => ({ id: group.id, name: group.name, })), maintenances: page.maintenances.map((maintenance) => ({ id: maintenance.id, name: maintenance.title, message: maintenance.message, from: maintenance.from, to: maintenance.to, updatedAt: maintenance.updatedAt, // @deprecated Use components instead - returning monitor IDs for backwards compatibility monitors: maintenance.maintenancesToPageComponents .map((item) => item.pageComponent.monitorId) .filter((id): id is number => id !== null), // New field - references page component IDs pageComponents: maintenance.maintenancesToPageComponents.map( (item) => item.pageComponentId, ), })), statusReports: page.statusReports.map((report) => ({ id: report.id, title: report.title, updatedAt: report.updatedAt, status: report.status, // @deprecated Use components instead - returning monitor IDs for backwards compatibility monitors: report.statusReportsToPageComponents .map((item) => item.pageComponent.monitorId) .filter((id): id is number => id !== null), // New field - references page component IDs pageComponents: report.statusReportsToPageComponents.map( (item) => item.pageComponentId, ), statusReportUpdates: report.statusReportUpdates.map((update) => ({ id: update.id, status: update.status, message: update.message, date: update.date, updatedAt: update.updatedAt, })), })), }; return new Response(JSON.stringify(res), { headers: { "Content-Type": "application/json; charset=utf-8", }, }); } catch (error) { console.error("Error generating feed:", error); throw error; } } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx ================================================ import { Footer } from "@/components/nav/footer"; import { Header } from "@/components/nav/header"; import { Suspense } from "react"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <Suspense> <div className="flex min-h-screen flex-col gap-4"> <Header className="w-full border-b" /> <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> {children} </main> <Footer className="w-full border-t" /> </div> </Suspense> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/manage/[token]/layout.tsx ================================================ import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ token: string; domain: string }>; }) { const { token, domain } = await params; const queryClient = getQueryClient(); await queryClient.prefetchQuery( trpc.statusPage.getSubscriptionByToken.queryOptions({ token, slug: domain, }), ); return <HydrateClient>{children}</HydrateClient>; } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/manage/[token]/page.tsx ================================================ "use client"; import { ButtonBack } from "@/components/button/button-back"; import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardTitle, } from "@/components/forms/form-card"; import { FormManageSubscription } from "@/components/forms/form-manage-subscription"; import { StatusBlankContainer, StatusBlankContent, StatusBlankDescription, StatusBlankLink, StatusBlankTitle, } from "@/components/status-page/status-blank"; import { useTRPC } from "@/lib/trpc/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@openstatus/ui/components/ui/alert-dialog"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { useParams } from "next/navigation"; import { toast } from "sonner"; export default function VerifyPage() { const trpc = useTRPC(); const { token, domain } = useParams<{ token: string; domain: string }>(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); const { data: subscription, refetch } = useQuery( trpc.statusPage.getSubscriptionByToken.queryOptions({ slug: domain, token, }), ); const manageSubscriptionMutation = useMutation( trpc.statusPage.updateSubscription.mutationOptions({}), ); const unsubscribeMutation = useMutation( trpc.statusPage.unsubscribe.mutationOptions({ onSuccess: () => { refetch(); toast.success("Unsubscribed successfully"); }, onError: (error) => { if (isTRPCClientError(error)) { toast.error(error.message); } else { toast.error("Failed to unsubscribe"); } }, }), ); if (!subscription) return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle>Invalid subscription token</StatusBlankTitle> <StatusBlankDescription> This subscription token is no longer valid. You may have already unsubscribed or the link has expired. </StatusBlankDescription> <StatusBlankLink href="../">Go back</StatusBlankLink> </StatusBlankContent> </StatusBlankContainer> ); return ( <div className="flex flex-col gap-4"> <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> <ButtonBack href="../" /> </div> <FormCard> <FormCardHeader> <FormCardTitle>{subscription.email}</FormCardTitle> <FormCardDescription> Manage your subscription to receive updates on the status page. </FormCardDescription> </FormCardHeader> <FormCardContent className="px-0"> <FormManageSubscription id="manage-subscription-form" defaultValues={{ pageComponents: subscription?.componentIds ?? [], subscribeComponents: (subscription?.componentIds?.length ?? 0) > 0, }} page={page} onSubmit={async (values) => { await manageSubscriptionMutation.mutateAsync({ slug: domain, token, ...values, }); }} /> </FormCardContent> <FormCardFooter> <FormCardFooterInfo> {subscription.unsubscribedAt ? ( <span className="text-destructive"> Unsubscribed on{" "} {Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", }).format(subscription.unsubscribedAt)} </span> ) : null} </FormCardFooterInfo> <div className="flex flex-row gap-2"> <AlertDialog> <AlertDialogTrigger asChild> <Button size="sm" variant="ghost" className="text-destructive hover:bg-destructive/10 hover:text-destructive focus-visible:ring-destructive/20 dark:hover:bg-destructive/10" disabled={ unsubscribeMutation.isPending || !!subscription.unsubscribedAt } > {unsubscribeMutation.isPending ? "Unsubscribing..." : "Unsubscribe"} </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Unsubscribe</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to unsubscribe from this status page? You will no longer receive updates. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={() => unsubscribeMutation.mutate({ token, domain }) } > Unsubscribe </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> <Button size="sm" variant="outline" type="submit" form="manage-subscription-form" disabled={ manageSubscriptionMutation.isPending || !!subscription.unsubscribedAt } > {manageSubscriptionMutation.isPending ? "Submitting..." : "Submit"} </Button> </div> </FormCardFooter> </FormCard> </div> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/manage/layout.tsx ================================================ "use client"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function EventLayout({ children, }: { children: React.ReactNode; }) { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return null; return ( <Status> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> <StatusContent>{children}</StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/page.tsx ================================================ "use client"; import { ButtonBack } from "@/components/button/button-back"; import { ButtonCopyLink } from "@/components/button/button-copy-link"; import { ChartAreaPercentiles, ChartAreaPercentilesSkeleton, } from "@/components/chart/chart-area-percentiles"; import { ChartBarUptime, ChartBarUptimeSkeleton, } from "@/components/chart/chart-bar-uptime"; import { ChartLineRegions, ChartLineRegionsSkeleton, } from "@/components/chart/chart-line-regions"; import { PopoverQuantile } from "@/components/popover/popover-quantile"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { StatusBlankMonitors } from "@/components/status-page/status-blank"; import { StatusChartContent, StatusChartDescription, StatusChartHeader, StatusChartTitle, } from "@/components/status-page/status-charts"; import { StatusMonitorTabs, StatusMonitorTabsContent, StatusMonitorTabsList, StatusMonitorTabsTrigger, StatusMonitorTabsTriggerLabel, StatusMonitorTabsTriggerValue, StatusMonitorTabsTriggerValueSkeleton, } from "@/components/status-page/status-monitor-tabs"; import { formatMillisecondsRange, formatNumber, formatPercentage, } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { useQuery } from "@tanstack/react-query"; import { TrendingUp } from "lucide-react"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import { useMemo } from "react"; import { searchParamsParsers } from "./search-params"; export default function Page() { const [{ tab }, setSearchParams] = useQueryStates(searchParamsParsers); const trpc = useTRPC(); const { id, domain } = useParams<{ id: string; domain: string }>(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); const tempMonitor = useMemo(() => { return page?.monitors.find((monitor) => monitor.id === Number(id)); }, [page, id]); if (!page) return null; const { data: monitor, isLoading } = useQuery( trpc.statusPage.getMonitor.queryOptions({ id: Number(id), slug: domain }), ); const globalLatencyData = useMemo(() => { if (!monitor?.data.latency?.data) return []; return monitor.data.latency.data .sort((a, b) => a.timestamp - b.timestamp) .map((item) => ({ ...item, timestamp: new Date(item.timestamp).toLocaleString("default", { day: "numeric", month: "short", hour: "numeric", minute: "numeric", timeZoneName: "short", }), })); }, [monitor?.data.latency?.data]); const regionLatencyData = useMemo(() => { if (!monitor?.data.regions?.data) return []; const grouped = monitor.data.regions.data .sort((a, b) => a.timestamp - b.timestamp) .reduce( (acc, item) => { const timestamp = new Date(item.timestamp).toLocaleString("default", { day: "numeric", month: "short", hour: "numeric", minute: "numeric", timeZoneName: "short", }); if (!acc[timestamp]) { acc[timestamp] = { timestamp }; } acc[timestamp][item.region] = item.p75Latency; return acc; }, {} as Record< string, { timestamp: string; [region: string]: number | string | null } >, ); return Object.values(grouped); }, [monitor?.data.regions?.data]); const uptimeData = useMemo(() => { if (!monitor?.data.uptime?.data) return []; return monitor.data.uptime.data .sort((a, b) => a.interval.getTime() - b.interval.getTime()) .map((item) => ({ timestamp: item.interval.toLocaleString("default", { day: "numeric", month: "short", hour: "numeric", minute: "numeric", timeZoneName: "short", }), ...item, })); }, [monitor?.data.uptime?.data]); const { totalChecks, uptimePercentage, slowestRegion, p75Range } = useMemo(() => { const p75Range = globalLatencyData.reduce( (acc, item) => ({ min: Math.min(acc.min, item.p75Latency), max: Math.max(acc.max, item.p75Latency), }), { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY, }, ); const uptimeStats = uptimeData.reduce( (acc, item) => { return { total: acc.total + item.success + item.degraded + item.error, success: acc.success + item.success, degraded: acc.degraded + item.degraded, error: acc.error + item.error, }; }, { total: 0, success: 0, degraded: 0, error: 0 }, ); const uptimePercentage = uptimeStats.total > 0 ? (uptimeStats.success + uptimeStats.degraded) / uptimeStats.total : 0; const regionAverages = regionLatencyData.reduce( (acc, item) => { Object.keys(item).forEach((key) => { if (key !== "timestamp" && typeof item[key] === "number") { if (!acc[key]) { acc[key] = { sum: 0, count: 0 }; } acc[key].sum += item[key] as number; acc[key].count += 1; } }); return acc; }, {} as Record<string, { sum: number; count: number }>, ); const slowestRegion = Object.entries(regionAverages) .map(([region, stats]) => ({ region, avgLatency: stats.count > 0 ? stats.sum / stats.count : 0, })) .sort((a, b) => b.avgLatency - a.avgLatency)[0]; return { totalChecks: formatNumber(uptimeStats.total, { notation: "compact", compactDisplay: "short", }).replace("K", "k"), uptimePercentage: uptimeStats.total > 0 ? formatPercentage(uptimePercentage) : "N/A", slowestRegion: slowestRegion?.region || "N/A", p75Range: p75Range.min !== Number.POSITIVE_INFINITY || p75Range.max !== Number.NEGATIVE_INFINITY ? formatMillisecondsRange(p75Range.min, p75Range.max) : "N/A", }; }, [uptimeData, regionLatencyData, globalLatencyData]); if (!isLoading && !monitor) { return ( <StatusBlankMonitors title="Monitor not found" description="The monitor you are looking for does not exist." /> ); } return ( <Status> <StatusHeader> <StatusTitle>{tempMonitor?.name}</StatusTitle> <StatusDescription>{tempMonitor?.description}</StatusDescription> </StatusHeader> <StatusContent className="flex flex-col gap-6"> <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> <ButtonBack href="./" /> <ButtonCopyLink /> </div> <StatusMonitorTabs defaultValue={tab} onValueChange={(value) => setSearchParams({ tab: value as "global" | "region" | "uptime" }) } > <StatusMonitorTabsList className="grid grid-cols-3"> <StatusMonitorTabsTrigger value="global"> <StatusMonitorTabsTriggerLabel> Global Latency </StatusMonitorTabsTriggerLabel> {isLoading ? ( <StatusMonitorTabsTriggerValueSkeleton /> ) : ( <StatusMonitorTabsTriggerValue> {p75Range}{" "} <Badge variant="outline" className="py-px text-[10px]"> p75 </Badge> </StatusMonitorTabsTriggerValue> )} </StatusMonitorTabsTrigger> <StatusMonitorTabsTrigger value="region"> <StatusMonitorTabsTriggerLabel> Region Latency </StatusMonitorTabsTriggerLabel> {isLoading ? ( <StatusMonitorTabsTriggerValueSkeleton /> ) : ( <StatusMonitorTabsTriggerValue> {tempMonitor?.regions.length} regions{" "} <Badge variant="outline" className="py-px font-mono text-[10px]" > {slowestRegion} <TrendingUp className="size-3" /> </Badge> </StatusMonitorTabsTriggerValue> )} </StatusMonitorTabsTrigger> <StatusMonitorTabsTrigger value="uptime"> <StatusMonitorTabsTriggerLabel> Uptime </StatusMonitorTabsTriggerLabel> {isLoading ? ( <StatusMonitorTabsTriggerValueSkeleton /> ) : ( <StatusMonitorTabsTriggerValue> {uptimePercentage}{" "} <Badge variant="outline" className="py-px text-[10px]"> {totalChecks} checks </Badge> </StatusMonitorTabsTriggerValue> )} </StatusMonitorTabsTrigger> </StatusMonitorTabsList> <StatusMonitorTabsContent value="global"> <StatusChartContent> <StatusChartHeader> <StatusChartTitle>Global Latency</StatusChartTitle> <StatusChartDescription> The aggregated latency from all active regions based on different <PopoverQuantile>quantiles</PopoverQuantile>. </StatusChartDescription> </StatusChartHeader> {isLoading ? ( <ChartAreaPercentilesSkeleton className="h-[250px]" /> ) : ( <ChartAreaPercentiles className="h-[250px]" legendClassName="justify-start pt-1 ps-1" legendVerticalAlign="top" xAxisHide={false} data={globalLatencyData} yAxisDomain={[0, "dataMax"]} /> )} </StatusChartContent> </StatusMonitorTabsContent> <StatusMonitorTabsContent value="region"> <StatusChartContent> <StatusChartHeader> <StatusChartTitle>Latency by Region</StatusChartTitle> <StatusChartDescription> {/* TODO: we could add an information to p95 that it takes the highest selected global latency percentile */} Region latency per{" "} <code className="font-medium text-foreground">p75</code>{" "} <PopoverQuantile>quantile</PopoverQuantile>, sorted by slowest region. Compare up to{" "} <code className="font-medium text-foreground">6</code>{" "} regions. </StatusChartDescription> </StatusChartHeader> {isLoading ? ( <ChartLineRegionsSkeleton className="h-[250px]" /> ) : ( <ChartLineRegions className="h-[250px]" data={regionLatencyData} defaultRegions={tempMonitor?.regions} /> )} </StatusChartContent> </StatusMonitorTabsContent> <StatusMonitorTabsContent value="uptime"> <StatusChartContent> <StatusChartHeader> <StatusChartTitle>Total Uptime</StatusChartTitle> <StatusChartDescription> Main values of uptime and availability, transparent. </StatusChartDescription> </StatusChartHeader> {isLoading ? ( <ChartBarUptimeSkeleton className="h-[250px]" /> ) : ( <ChartBarUptime className="h-[250px]" data={uptimeData} /> )} </StatusChartContent> </StatusMonitorTabsContent> </StatusMonitorTabs> </StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/search-params.ts ================================================ import { createSearchParamsCache, parseAsStringEnum } from "nuqs/server"; export const searchParamsParsers = { tab: parseAsStringEnum(["global", "region", "uptime"]).withDefault("global"), }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx ================================================ "use client"; import { ChartAreaPercentiles, ChartAreaPercentilesSkeleton, } from "@/components/chart/chart-area-percentiles"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { StatusBlankMonitors } from "@/components/status-page/status-blank"; import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useParams } from "next/navigation"; export default function Page() { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); const { data: monitors, isLoading } = useQuery( trpc.statusPage.getMonitors.queryOptions({ slug: domain }), ); if (!page) return null; const publicMonitors = page.monitors.filter((monitor) => monitor.public); return ( <Status> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> <StatusContent className="flex flex-col gap-6"> {publicMonitors.length > 0 ? ( publicMonitors.map((monitor) => { const data = monitors ?.find((item) => item.id === monitor.id) ?.data?.map((item) => ({ ...item, // TODO: create formatter timestamp: new Date(item.timestamp).toLocaleString( "default", { day: "numeric", month: "short", hour: "numeric", minute: "numeric", timeZoneName: "short", }, ), })) ?? []; return ( <Link key={monitor.id} href={`./monitors/${monitor.id}`} className="rounded-lg" > <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> <div className="flex flex-row items-center justify-start gap-2"> <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> <StatusMonitorDescription> {monitor.description} </StatusMonitorDescription> </div> {isLoading ? ( <ChartAreaPercentilesSkeleton className="h-[80px]" /> ) : ( <ChartAreaPercentiles className="h-[80px]" legendClassName="pb-1 justify-start" data={data} singleSeries /> )} </div> </Link> ); }) ) : ( <StatusBlankMonitors /> )} </StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx ================================================ "use client"; import { useStatusPage } from "@/components/status-page/floating-button"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { StatusBanner, StatusBannerContainer, StatusBannerContent, StatusBannerTabs, StatusBannerTabsContent, StatusBannerTabsList, StatusBannerTabsTrigger, } from "@/components/status-page/status-banner"; import { StatusEventAffected, StatusEventAffectedBadge, StatusEventTimelineMaintenance, StatusEventTimelineReportUpdate, } from "@/components/status-page/status-events"; import { StatusFeed } from "@/components/status-page/status-feed"; import { StatusMonitor } from "@/components/status-page/status-monitor"; import { StatusTrackerGroup } from "@/components/status-page/status-tracker-group"; import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; import { useTRPC } from "@/lib/trpc/client"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { notFound, useParams } from "next/navigation"; import { useMemo } from "react"; export default function Page() { const prefix = usePathnamePrefix(); const { domain } = useParams<{ domain: string }>(); const { cardType, barType, showUptime } = useStatusPage(); const trpc = useTRPC(); // NOTE: we cannot use `cardType` and `barType` here because of queryKey changes // It wouldn't match the server prefetch keys and we would have to refetch the page here const { data: pageInitial, error } = useQuery({ ...trpc.statusPage.get.queryOptions({ slug: domain, }), enabled: !!domain, }); // Handle case where page doesn't exist or query fails if (error || (!pageInitial && domain)) { notFound(); } const hasCustomConfig = pageInitial?.configuration ? pageInitial.configuration.type !== barType || pageInitial.configuration.value !== cardType : false; // NOTE: instead, we use the `enabled` flag to only fetch the page if the configuration differs const { data: pageWithCustomConfiguration } = useQuery({ ...trpc.statusPage.get.queryOptions({ slug: domain, cardType, barType, }), enabled: !!domain && hasCustomConfig, }); // NOTE: we can prefetch that to avoid loading state const { data: uptimeData, isLoading } = useQuery({ ...trpc.statusPage.getUptime.queryOptions({ slug: domain, pageComponentIds: pageInitial?.pageComponents?.map((c) => c.id.toString()) || [], cardType, barType, }), enabled: !!pageInitial && pageInitial.pageComponents.length > 0, }); // NOTE: we need to filter out the incidents as we don't want to show all of them in the banner - a single one is enough // REMINDER: we could move that to the server - but we might wanna have the info of all openEvents actually const events = useMemo(() => { let hasIncident = false; return ( pageInitial?.openEvents.filter((e) => { if (e.type !== "incident") return true; if (hasIncident) return false; hasIncident = true; return true; }) ?? [] ); }, [pageInitial]); if (!pageInitial) return null; // REMINDER: if we are using the custom configuration, we need to use the pageWithCustomConfiguration const page = pageWithCustomConfiguration ?? pageInitial; const firstGroupIndex = useMemo( () => page.trackers.findIndex((tracker) => tracker.type === "group"), [page.trackers], ); return ( <div className="flex flex-col gap-6"> <Status variant={page.status}> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> {events.length > 0 ? ( <StatusContent> <StatusBannerTabs defaultValue={`${events[0].type}-${events[0].id}`} > <StatusBannerTabsList> {events.map((e, i) => { return ( <StatusBannerTabsTrigger value={`${e.type}-${e.id}`} status={e.status} key={`${e.type}-${e.id}`} className={cn( i === 0 && "rounded-tl-lg", i === events.length - 1 && "rounded-tr-lg", )} > {e.name} </StatusBannerTabsTrigger> ); })} </StatusBannerTabsList> {events.map((e) => { if (e.type === "report") { const report = page.statusReports.find( (report) => report.id === e.id, ); if (!report) return null; const lastUpdate = report.statusReportUpdates.sort( (a, b) => b.date.getTime() - a.date.getTime(), )[0]; if (!lastUpdate) return null; return ( <StatusBannerTabsContent value={`${e.type}-${e.id}`} key={`${e.type}-${e.id}`} > <Link href={`${prefix ? `/${prefix}` : ""}/events/report/${report.id}`} className="rounded-lg" > <StatusBannerContainer status={e.status}> <StatusBannerContent> <StatusEventTimelineReportUpdate report={lastUpdate} withDot={false} isLast={true} withSeparator={false} /> {report.statusReportsToPageComponents.length > 0 ? ( <StatusEventAffected> {report.statusReportsToPageComponents.map( (affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id} > {affected.pageComponent.name} </StatusEventAffectedBadge> ), )} </StatusEventAffected> ) : null} </StatusBannerContent> </StatusBannerContainer> </Link> </StatusBannerTabsContent> ); } if (e.type === "maintenance") { const maintenance = page.maintenances.find( (maintenance) => maintenance.id === e.id, ); if (!maintenance) return null; return ( <StatusBannerTabsContent value={`${e.type}-${e.id}`} key={e.id} > <Link href={`${prefix ? `/${prefix}` : ""}/events/maintenance/${maintenance.id}`} className="rounded-lg" > <StatusBannerContainer status={e.status}> <StatusBannerContent> <StatusEventTimelineMaintenance maintenance={maintenance} withDot={false} /> {maintenance.maintenancesToPageComponents.length > 0 ? ( <StatusEventAffected> {maintenance.maintenancesToPageComponents.map( (affected) => ( <StatusEventAffectedBadge key={affected.pageComponent.id} > {affected.pageComponent.name} </StatusEventAffectedBadge> ), )} </StatusEventAffected> ) : null} </StatusBannerContent> </StatusBannerContainer> </Link> </StatusBannerTabsContent> ); } if (e.type === "incident") { return ( <StatusBannerTabsContent value={`${e.type}-${e.id}`} key={e.id} > <StatusBanner status={e.status} /> </StatusBannerTabsContent> ); } return null; })} </StatusBannerTabs> </StatusContent> ) : ( <StatusBanner status={page.status} /> )} {/* NOTE: check what gap feels right */} {page.trackers.length > 0 ? ( <StatusContent className="gap-5"> {page.trackers.map((tracker, index) => { if (tracker.type === "component") { const component = tracker.component; // Fetch uptime data by component ID const { data, uptime } = uptimeData?.find((u) => u.pageComponentId === component.id) ?? {}; return ( <StatusMonitor key={`component-${component.id}`} status={component.status} data={data} monitor={{ name: component.name, description: component.description, }} uptime={uptime} showUptime={showUptime} isLoading={isLoading} /> ); } return ( <StatusTrackerGroup key={`group-${tracker.groupId}`} title={tracker.groupName} status={tracker.status} // NOTE: we only want to open the first group if it is the first one defaultOpen={firstGroupIndex === index && index === 0} > {tracker.components.map((component) => { const { data, uptime } = uptimeData?.find( (u) => u.pageComponentId === component.id, ) ?? {}; return ( <StatusMonitor key={`component-${component.id}`} status={component.status} data={data} monitor={{ name: component.name, description: component.description, }} uptime={uptime} showUptime={showUptime} isLoading={isLoading} /> ); })} </StatusTrackerGroup> ); })} </StatusContent> ) : null} <Separator /> <StatusContent> <StatusFeed statusReports={page.statusReports .filter((report) => page.lastEvents.some( (event) => event.id === report.id && event.type === "report", ), ) .map((report) => ({ ...report, affected: report.statusReportsToPageComponents.map( (component) => component.pageComponent.name, ), updates: report.statusReportUpdates, }))} maintenances={page.maintenances .filter((maintenance) => page.lastEvents.some( (event) => event.id === maintenance.id && event.type === "maintenance", ), ) .map((maintenance) => ({ ...maintenance, affected: maintenance.maintenancesToPageComponents.map( (component) => component.pageComponent.name, ), }))} /> </StatusContent> </Status> </div> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/unsubscribe/[token]/layout.tsx ================================================ "use client"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function UnsubscribeLayout({ children, }: { children: React.ReactNode; }) { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return null; return ( <Status> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> <StatusContent>{children}</StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/unsubscribe/[token]/page.tsx ================================================ "use client"; import { StatusBlankContainer, StatusBlankContent, StatusBlankDescription, StatusBlankLink, StatusBlankTitle, } from "@/components/status-page/status-blank"; import { useTRPC } from "@/lib/trpc/client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function UnsubscribePage() { const trpc = useTRPC(); const { token, domain } = useParams<{ token: string; domain: string }>(); const subscriberQuery = useQuery( trpc.statusPage.getSubscriberByToken.queryOptions({ token, domain }), ); const unsubscribeMutation = useMutation( trpc.statusPage.unsubscribe.mutationOptions({}), ); const handleUnsubscribe = () => { unsubscribeMutation.mutate({ token, domain }); }; // Loading state if (subscriberQuery.isLoading) { return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle>Loading...</StatusBlankTitle> </StatusBlankContent> </StatusBlankContainer> ); } // Invalid/expired token or already unsubscribed if (!subscriberQuery.data) { return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle className="text-destructive"> Invalid or expired link </StatusBlankTitle> <StatusBlankDescription> This unsubscribe link is no longer valid. You may have already unsubscribed. </StatusBlankDescription> <StatusBlankLink href="../">Go back</StatusBlankLink> </StatusBlankContent> </StatusBlankContainer> ); } // Success state after unsubscribing if (unsubscribeMutation.isSuccess) { return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle className="text-success"> Successfully unsubscribed </StatusBlankTitle> <StatusBlankDescription> You will no longer receive email notifications from{" "} {subscriberQuery.data.pageName}. </StatusBlankDescription> <StatusBlankLink href="../">Go back</StatusBlankLink> </StatusBlankContent> </StatusBlankContainer> ); } // Error state if (unsubscribeMutation.isError) { return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle className="text-destructive"> {unsubscribeMutation.error?.message || "Something went wrong"} </StatusBlankTitle> <StatusBlankDescription> Please try again or contact support if the issue persists. </StatusBlankDescription> <StatusBlankLink href="../">Go back</StatusBlankLink> </StatusBlankContent> </StatusBlankContainer> ); } // Confirmation state (initial view) return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle>Unsubscribe from notifications</StatusBlankTitle> <StatusBlankDescription> You are about to unsubscribe{" "} <span className="font-semibold"> {subscriberQuery.data.maskedEmail} </span>{" "} from{" "} <span className="font-semibold">{subscriberQuery.data.pageName}</span>{" "} status updates. </StatusBlankDescription> <div className="flex justify-center gap-2"> <StatusBlankLink href="../">Cancel</StatusBlankLink> <Button variant="destructive" size="sm" onClick={handleUnsubscribe} disabled={unsubscribeMutation.isPending} > {unsubscribeMutation.isPending ? "Unsubscribing..." : "Unsubscribe"} </Button> </div> </StatusBlankContent> </StatusBlankContainer> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/layout.tsx ================================================ "use client"; import { Status, StatusContent, StatusDescription, StatusHeader, StatusTitle, } from "@/components/status-page/status"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; export default function EventLayout({ children, }: { children: React.ReactNode; }) { const { domain } = useParams<{ domain: string }>(); const trpc = useTRPC(); const { data: page } = useQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return null; return ( <Status> <StatusHeader> <StatusTitle>{page.title}</StatusTitle> <StatusDescription>{page.description}</StatusDescription> </StatusHeader> <StatusContent>{children}</StatusContent> </Status> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/page.tsx ================================================ "use client"; import { StatusBlankContainer, StatusBlankContent, StatusBlankLink, StatusBlankTitle, } from "@/components/status-page/status-blank"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useEffect } from "react"; export default function VerifyPage() { const trpc = useTRPC(); const { token, domain } = useParams<{ token: string; domain: string }>(); const verifyEmailMutation = useMutation( trpc.statusPage.verifyEmail.mutationOptions({}), ); // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> useEffect(() => { verifyEmailMutation.mutate({ slug: domain, token }); }, [domain, token]); const title = verifyEmailMutation.isSuccess ? `All set to receive updates to ${verifyEmailMutation.data?.email}!` : verifyEmailMutation.isError ? verifyEmailMutation.error?.message || "Something went wrong" : "Hang tight - we're confirming your subscription"; return ( <StatusBlankContainer> <StatusBlankContent> <StatusBlankTitle className={cn({ "text-destructive": verifyEmailMutation.isError, "text-success": verifyEmailMutation.isSuccess, })} > {title} </StatusBlankTitle> <div className="flex justify-center gap-2"> <StatusBlankLink href="/" disabled={ verifyEmailMutation.isPending || !verifyEmailMutation.data } > Go back </StatusBlankLink> {verifyEmailMutation.isSuccess && ( <StatusBlankLink href={`/manage/${token}`} disabled={ verifyEmailMutation.isPending || !verifyEmailMutation.data } > Manage </StatusBlankLink> )} </div> </StatusBlankContent> </StatusBlankContainer> ); } ================================================ FILE: apps/status-page/src/app/(status-page)/[domain]/layout.tsx ================================================ import { defaultMetadata, ogMetadata, twitterMetadata } from "@/app/metadata"; import { PasswordWrapper } from "@/components/password-wrapper"; import { FloatingButton, StatusPageProvider, } from "@/components/status-page/floating-button"; import { FloatingTheme } from "@/components/status-page/floating-theme"; import { ThemeProvider } from "@/components/themes/theme-provider"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; import { THEME_KEYS, type ThemeKey, generateThemeStyles, } from "@openstatus/theme-store"; import { Toaster } from "@openstatus/ui/components/ui/sonner"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { z } from "zod"; export const schema = z.object({ value: z.enum(["duration", "requests", "manual"]).prefault("duration"), type: z.enum(["absolute", "manual"]).prefault("absolute"), uptime: z.coerce.boolean().prefault(true), theme: z.enum(THEME_KEYS as [ThemeKey, ...ThemeKey[]]).prefault("default"), }); export default async function Layout({ children, params, }: { children: React.ReactNode; params: Promise<{ domain: string }>; }) { const queryClient = getQueryClient(); const { domain } = await params; const page = await queryClient.fetchQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return notFound(); const validation = schema.safeParse(page?.configuration); const communityTheme = validation.data?.theme; return ( <HydrateClient> <style id="theme-styles" // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> dangerouslySetInnerHTML={{ __html: generateThemeStyles(communityTheme), }} /> <ThemeProvider attribute="class" defaultTheme={page?.forceTheme ?? "system"} enableSystem disableTransitionOnChange > <StatusPageProvider defaultBarType={validation.data?.type} defaultCardType={validation.data?.value} defaultShowUptime={validation.data?.uptime} defaultCommunityTheme={validation.data?.theme} > {children} <FloatingButton pageId={page?.id} // NOTE: token to avoid showing the floating button to random users // timestamp is our token - it is hard to guess token={page?.createdAt?.getTime().toString()} /> <FloatingTheme /> <Toaster toastOptions={{ classNames: {}, style: { borderRadius: "var(--radius-lg)" }, }} richColors expand /> <PasswordWrapper /> </StatusPageProvider> </ThemeProvider> </HydrateClient> ); } export async function generateMetadata({ params, }: { params: Promise<{ domain: string }>; }): Promise<Metadata> { const queryClient = getQueryClient(); const { domain } = await params; const page = await queryClient.fetchQuery( trpc.statusPage.get.queryOptions({ slug: domain }), ); if (!page) return notFound(); return { ...defaultMetadata, title: { template: `%s | ${page.title}`, default: page?.title, }, description: page?.description, icons: page?.icon, alternates: { canonical: page?.customDomain ? `https://${page.customDomain}` : `https://${page.slug}.openstatus.dev`, }, twitter: { ...twitterMetadata, images: [`/api/og/page?slug=${page?.slug}`], title: page?.title, description: page?.description, }, openGraph: { ...ogMetadata, images: [`/api/og/page?slug=${page?.slug}`], title: page?.title, description: page?.description, }, }; } ================================================ FILE: apps/status-page/src/app/api/auth/[...nextauth]/route.ts ================================================ import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers; ================================================ FILE: apps/status-page/src/app/api/trpc/edge/[trpc]/route.ts ================================================ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { createTRPCContext } from "@openstatus/api"; import { edgeRouter } from "@openstatus/api/src/edge"; export const runtime = "edge"; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: "/api/trpc/edge", router: edgeRouter, req: req, createContext: () => createTRPCContext({ req, auth }), onError: ({ error }) => { console.log("Error in tRPC handler (edge)"); console.error(error); }, }); export { handler as GET, handler as POST }; ================================================ FILE: apps/status-page/src/app/api/trpc/lambda/[trpc]/route.ts ================================================ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { createTRPCContext } from "@openstatus/api"; import { lambdaRouter } from "@openstatus/api/src/lambda"; // Stripe is incompatible with Edge runtimes due to using Node.js events // export const runtime = "edge"; const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: "/api/trpc/lambda", router: lambdaRouter, req: req, createContext: () => createTRPCContext({ req, auth }), onError: ({ error }) => { console.log("Error in tRPC handler (lambda)"); console.error(error); }, }); export { handler as GET, handler as POST }; ================================================ FILE: apps/status-page/src/app/global-error.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { Button } from "@openstatus/ui/components/ui/button"; import * as Sentry from "@sentry/nextjs"; import { useEffect } from "react"; export default function GlobalError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { Sentry.captureException(error); }, [error]); return ( <html lang="en"> <body> <main className="flex min-h-screen w-full flex-col space-y-6 bg-background p-4 md:p-8"> <div className="flex flex-1 flex-col items-center justify-center gap-8"> <div className="mx-auto max-w-xl border bg-card text-center"> <div className="flex flex-col gap-4 p-6 sm:p-12"> <div className="flex flex-col gap-1"> <h2 className="font-cal text-2xl text-foreground"> Application Error </h2> <p className="text-muted-foreground text-sm sm:text-base"> An unexpected error occurred. This has been reported and we're working on it.{" "} <Link href="mailto:ping@openstatus.dev">Contact us</Link> if it persists. </p> </div> <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> <Button variant="outline" size="lg" onClick={reset} className="cursor-pointer" > Try Again </Button> <Button size="lg" asChild> <Link href="/">Go Home</Link> </Button> </div> </div> </div> </div> </main> </body> </html> ); } ================================================ FILE: apps/status-page/src/app/globals.css ================================================ @import "tailwindcss"; @import "@openstatus/ui/globals"; @import "tw-animate-css"; @plugin "@tailwindcss/typography"; /* safelist */ @source inline("has-data-[slot=slider-range]:bg-red-500"); @theme { --breakpoint-xs: 30rem; } @theme inline { --font-sans: var(--font-geist-sans); --font-mono: var(--font-commit-mono, var(--font-geist-mono)); --radius-xs: calc(var(--radius) - 8px); } :root { /* Override the base radius for status-page */ --radius: 0rem; } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } @layer utilities { /* NOTE: allows us to --radius: 0px and avoid rounding issues - otherwise it is 'infinite * 1px' */ .rounded-full { border-radius: calc(var(--radius) * 99999999); } .rounded-b-full { border-bottom-left-radius: calc(var(--radius) * 99999999); border-bottom-right-radius: calc(var(--radius) * 99999999); } .rounded-t-full { border-top-left-radius: calc(var(--radius) * 99999999); border-top-right-radius: calc(var(--radius) * 99999999); } } ================================================ FILE: apps/status-page/src/app/layout.tsx ================================================ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TailwindIndicator } from "@/components/tailwind-indicator"; import { TRPCReactProvider } from "@/lib/trpc/client"; import { cn } from "@openstatus/ui/lib/utils"; import LocalFont from "next/font/local"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import { ogMetadata, twitterMetadata } from "./metadata"; import { defaultMetadata } from "./metadata"; const cal = LocalFont({ src: "../../public/fonts/CalSans-SemiBold.ttf", variable: "--font-cal-sans", }); const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], }); const commitMono = LocalFont({ src: [ { path: "../../public/fonts/CommitMono-400-Regular.otf", weight: "400", style: "normal", }, { path: "../../public/fonts/CommitMono-400-Italic.otf", weight: "400", style: "italic", }, { path: "../../public/fonts/CommitMono-700-Regular.otf", weight: "700", style: "normal", }, { path: "../../public/fonts/CommitMono-700-Italic.otf", weight: "700", style: "italic", }, ], variable: "--font-commit-mono", }); export const metadata: Metadata = { ...defaultMetadata, twitter: { ...twitterMetadata, }, openGraph: { ...ogMetadata, }, }; // export const dynamic = "error"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en" suppressHydrationWarning> <body className={cn( geistSans.variable, geistMono.variable, cal.variable, commitMono.variable, "antialiased", )} > <NuqsAdapter> <TRPCReactProvider> {children} <TailwindIndicator /> </TRPCReactProvider> </NuqsAdapter> </body> </html> ); } ================================================ FILE: apps/status-page/src/app/metadata.ts ================================================ import type { Metadata } from "next"; export const TITLE = "Status Page"; export const DESCRIPTION = "Status page customization with built-in themes. Explore all themes and contribute your own theme."; const OG_TITLE = "Theme Explorer"; const OG_DESCRIPTION = "Explore all themes for your status page and contribute new ones to the community."; const FOOTER = "themes.openstatus.dev"; const IMAGE = "assets/og/theme-explorer.png"; export const defaultMetadata: Metadata = { title: { template: `%s | ${TITLE}`, default: TITLE, }, icons: "https://www.openstatus.dev/favicon.ico", description: DESCRIPTION, metadataBase: new URL("https://www.openstatus.dev"), }; export const twitterMetadata: Metadata["twitter"] = { title: TITLE, description: DESCRIPTION, card: "summary_large_image", images: [ `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, ], }; export const ogMetadata: Metadata["openGraph"] = { title: TITLE, description: DESCRIPTION, type: "website", images: [ `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, ], }; ================================================ FILE: apps/status-page/src/app/not-found.tsx ================================================ "use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ThemeProvider } from "@/components/themes/theme-provider"; import { Button } from "@openstatus/ui/components/ui/button"; export default function NotFound() { const router = useRouter(); return ( <ThemeProvider attribute="class" enableSystem disableTransitionOnChange> <main className="flex min-h-screen w-full flex-col space-y-6 bg-background p-4 md:p-8"> <div className="flex flex-1 flex-col items-center justify-center gap-8"> <div className="mx-auto max-w-xl border bg-card text-center"> <div className="flex flex-col gap-4 p-6 sm:p-12"> <div className="flex flex-col gap-1"> <p className="font-mono text-foreground">404 Page not found</p> <h2 className="font-cal text-2xl text-foreground"> Oops, something went wrong. </h2> <p className="text-muted-foreground text-sm sm:text-base"> The page you are looking for doesn't exist. </p> </div> <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> <Button variant="outline" size="lg" onClick={router.back} className="cursor-pointer" > Go Back </Button> <Button size="lg" asChild> <Link href="/">Home</Link> </Button> </div> </div> </div> </div> </main> </ThemeProvider> ); } ================================================ FILE: apps/status-page/src/app/react-table.d.ts ================================================ import "@tanstack/react-table"; declare module "@tanstack/react-table" { interface ColumnMeta { headerClassName?: string; cellClassName?: string; } } ================================================ FILE: apps/status-page/src/components/button/button-back.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import { ArrowLeft } from "lucide-react"; import Link from "next/link"; export function ButtonBack({ className, href = "/", ...props }: React.ComponentProps<typeof Button> & { href?: string }) { return ( <Button variant="ghost" size="sm" className={cn("text-muted-foreground", className)} asChild {...props} > <Link href={href}> <ArrowLeft /> Back </Link> </Button> ); } ================================================ FILE: apps/status-page/src/components/button/button-copy-link.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, Copy } from "lucide-react"; export function ButtonCopyLink({ className, ...props }: React.ComponentProps<typeof Button>) { const { copy, isCopied } = useCopyToClipboard(); return ( <Button variant="outline" size="icon" onClick={() => copy(window.location.href, { successMessage: "Link copied to clipboard", withToast: true, }) } className={cn("size-8", className)} {...props} > {isCopied ? <Check /> : <Copy />} <span className="sr-only">Copy Link</span> </Button> ); } ================================================ FILE: apps/status-page/src/components/chart/chart-area-percentiles.tsx ================================================ "use client"; import { formatMilliseconds } from "@/lib/formatter"; import { type ChartConfig, ChartContainer, ChartLegend, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { useState } from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import type { AxisDomain } from "recharts/types/util/types"; import { ChartLegendBadge } from "./chart-legend-badge"; import { ChartTooltipNumber } from "./chart-tooltip-number"; const chartConfig = { p50Latency: { label: "p50", color: "var(--chart-1)", }, p75Latency: { label: "p75", color: "var(--chart-2)", }, p90Latency: { label: "p90", color: "var(--chart-4)", }, p95Latency: { label: "p95", color: "var(--chart-3)", }, p99Latency: { label: "p99", color: "var(--chart-5)", }, } satisfies ChartConfig; function avg(values: number[]) { return Math.round( values.reduce((acc, curr) => acc + curr, 0) / values.length, ); } function formatAnnotation(values: number[]) { if (values.length === 0) return "N/A"; return formatMilliseconds(avg(values)); } export function ChartAreaPercentiles({ className, singleSeries, xAxisHide = true, legendVerticalAlign = "bottom", legendClassName, yAxisDomain = ["dataMin", "dataMax"], data, }: { className?: string; singleSeries?: boolean; xAxisHide?: boolean; legendVerticalAlign?: "top" | "bottom"; legendClassName?: string; yAxisDomain?: AxisDomain; data: { timestamp: string; p50Latency: number; p75Latency: number; p90Latency: number; p95Latency: number; p99Latency: number; }[]; }) { const [activeSeries, setActiveSeries] = useState< Array<keyof typeof chartConfig> >(["p75Latency"]); const annotation = { p50Latency: formatAnnotation(data.map((item) => item.p50Latency)), p75Latency: formatAnnotation(data.map((item) => item.p75Latency)), p90Latency: formatAnnotation(data.map((item) => item.p90Latency)), p95Latency: formatAnnotation(data.map((item) => item.p95Latency)), p99Latency: formatAnnotation(data.map((item) => item.p99Latency)), }; return ( <ChartContainer config={chartConfig} className={cn("h-[100px] w-full", className)} > <AreaChart accessibilityLayer data={data} margin={{ left: 0, right: 0, // NOTE: otherwise the line is cut off top: 2, bottom: 2, }} > <ChartLegend verticalAlign={legendVerticalAlign} content={ <ChartLegendBadge handleActive={(item) => { setActiveSeries((prev) => { if (item.dataKey) { const key = item.dataKey as keyof typeof chartConfig; if (singleSeries) { return [key]; } if (prev.includes(key)) { return prev.filter((item) => item !== key); } return [...prev, key]; } return prev; }); }} active={activeSeries} annotation={annotation} className={cn("overflow-x-scroll", legendClassName)} /> } /> <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" hide={xAxisHide} /> <ChartTooltip cursor={false} content={ <ChartTooltipContent className="w-[200px]" formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> )} /> } /> <defs> <linearGradient id="fillP50" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-p50Latency)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-p50Latency)" stopOpacity={0.1} /> </linearGradient> <linearGradient id="fillP75" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-p75Latency)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-p75Latency)" stopOpacity={0.1} /> </linearGradient> <linearGradient id="fillP90" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-p90Latency)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-p90Latency)" stopOpacity={0.1} /> </linearGradient> <linearGradient id="fillP95" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-p95Latency)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-p95Latency)" stopOpacity={0.1} /> </linearGradient> <linearGradient id="fillP99" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-p99Latency)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-p99Latency)" stopOpacity={0.1} /> </linearGradient> </defs> <Area hide={!activeSeries.includes("p50Latency")} dataKey="p50Latency" type="monotone" stroke="var(--color-p50Latency)" fill="url(#fillP50)" fillOpacity={0.4} dot={false} yAxisId="percentile" connectNulls /> <Area hide={!activeSeries.includes("p75Latency")} dataKey="p75Latency" type="monotone" stroke="var(--color-p75Latency)" fill="url(#fillP75)" fillOpacity={0.4} dot={false} yAxisId="percentile" connectNulls /> {/* <Area hide={!activeSeries.includes("p90Latency")} dataKey="p90Latency" type="monotone" stroke="var(--color-p90Latency)" fill="url(#fillP90)" fillOpacity={0.4} dot={false} yAxisId="percentile" connectNulls /> */} <Area hide={!activeSeries.includes("p95Latency")} dataKey="p95Latency" type="monotone" stroke="var(--color-p95Latency)" fill="url(#fillP95)" fillOpacity={0.4} dot={false} yAxisId="percentile" connectNulls /> <Area hide={!activeSeries.includes("p99Latency")} dataKey="p99Latency" type="monotone" stroke="var(--color-p99Latency)" fill="url(#fillP99)" fillOpacity={0.4} dot={false} yAxisId="percentile" connectNulls /> <YAxis domain={yAxisDomain} tickLine={false} axisLine={false} tickMargin={8} orientation="right" yAxisId="percentile" tickFormatter={(value) => `${value}ms`} /> </AreaChart> </ChartContainer> ); } export function ChartAreaPercentilesSkeleton({ className, ...props }: React.ComponentProps<typeof Skeleton>) { return ( <Skeleton className={cn("h-[100px] w-full rounded-lg", className)} {...props} /> ); } ================================================ FILE: apps/status-page/src/components/chart/chart-bar-uptime.tsx ================================================ "use client"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { formatNumber } from "@/lib/formatter"; import { type ChartConfig, ChartContainer, ChartLegend, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { useState } from "react"; import { ChartLegendBadge } from "./chart-legend-badge"; const chartConfig = { success: { label: "success", // WTF: why is var(--color-success) not working color: "var(--success)", }, degraded: { label: "degraded", color: "var(--color-warning)", }, error: { label: "failed", color: "var(--color-destructive)", }, } satisfies ChartConfig; export function ChartBarUptime({ className, data, }: { className?: string; data: { timestamp: string; success: number; error: number; degraded: number; }[]; }) { const [activeSeries, setActiveSeries] = useState< Array<keyof typeof chartConfig> >(["success", "error", "degraded"]); const annotation = { success: formatNumber(data.reduce((acc, item) => acc + item.success, 0)), error: formatNumber(data.reduce((acc, item) => acc + item.error, 0)), degraded: formatNumber(data.reduce((acc, item) => acc + item.degraded, 0)), }; return ( <ChartContainer config={chartConfig} className={cn("h-[130px] w-full", className)} > <BarChart accessibilityLayer data={data} barCategoryGap={2}> <CartesianGrid vertical={false} /> <ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dot" />} /> <Bar dataKey="success" fill="var(--color-success)" stackId="a" hide={!activeSeries.includes("success")} /> <Bar dataKey="degraded" fill="var(--color-degraded)" stackId="a" hide={!activeSeries.includes("degraded")} /> <Bar dataKey="error" fill="var(--color-error)" stackId="a" hide={!activeSeries.includes("error")} /> <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" /> <XAxis dataKey="timestamp" tickLine={false} tickMargin={8} minTickGap={10} axisLine={false} /> <ChartLegend verticalAlign="top" content={ <ChartLegendBadge active={activeSeries} handleActive={(item) => { setActiveSeries((prev) => { if (item.dataKey) { const key = item.dataKey as keyof typeof chartConfig; if (prev.includes(key)) { return prev.filter((item) => item !== key); } return [...prev, key]; } return prev; }); }} annotation={annotation} className="justify-start overflow-x-scroll ps-1 pt-1" /> } /> </BarChart> </ChartContainer> ); } export function ChartBarUptimeSkeleton({ className }: { className?: string }) { return <Skeleton className={cn("h-[130px] w-full", className)} />; } ================================================ FILE: apps/status-page/src/components/chart/chart-legend-badge.tsx ================================================ import { getPayloadConfigFromPayload } from "@/lib/chart"; import { badgeVariants } from "@openstatus/ui/components/ui/badge"; import { useChart } from "@openstatus/ui/components/ui/chart"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import * as React from "react"; import type * as RechartsPrimitive from "recharts"; import type { Payload } from "recharts/types/component/DefaultLegendContent"; export function ChartLegendBadge({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey, handleActive, active, maxActive, annotation, tooltip, }: React.ComponentProps<"div"> & Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { hideIcon?: boolean; nameKey?: string; // NOTE: additional props compared to default shadcn/ui Legend component handleActive?: (item: Payload) => void; active?: Payload["dataKey"][]; maxActive?: number; annotation?: Record<string, string | number | undefined>; tooltip?: Record<string, string | undefined>; }) { const { config } = useChart(); const [focusedIndex, setFocusedIndex] = React.useState(0); const buttonRefs = React.useRef<(HTMLButtonElement | null)[]>([]); if (!payload?.length) { return null; } const filteredPayload = payload.filter((item) => item.type !== "none"); const hasMaxActive = active && maxActive ? active.length >= maxActive : false; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); const direction = event.key === "ArrowLeft" ? -1 : 1; let nextIndex = 0; nextIndex = (focusedIndex + direction + filteredPayload.length) % filteredPayload.length; setFocusedIndex(nextIndex); while (buttonRefs.current[nextIndex]?.disabled === true) { nextIndex = (nextIndex + direction + filteredPayload.length) % filteredPayload.length; } buttonRefs.current[nextIndex]?.focus(); } }; return ( <div className={cn( "flex items-center justify-center gap-1.5", verticalAlign === "top" ? "pb-3" : "pt-3", className, )} onKeyDown={handleKeyDown} role="group" aria-label="Chart legend" > {filteredPayload.map((item, index) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const suffix = annotation?.[item.dataKey as string]; const tooltipLabel = tooltip?.[item.dataKey as string]; const isActive = active ? active?.includes(item.dataKey) : true; const isFocused = index === focusedIndex; const badge = ( <button key={item.value} type="button" ref={(el) => { buttonRefs.current[index] = el; }} className={cn( badgeVariants({ variant: "outline" }), "outline-none", "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", !isActive && "opacity-60", !isActive && hasMaxActive && "cursor-not-allowed opacity-40", )} onClick={(e) => { e.stopPropagation(); e.preventDefault(); setFocusedIndex(index); handleActive?.(item); }} onFocus={() => setFocusedIndex(index)} disabled={!isActive && hasMaxActive} tabIndex={isFocused ? 0 : -1} > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div className="h-2 w-2 shrink-0 rounded-(--radius-xs)" style={{ backgroundColor: item.color, }} /> )} {itemConfig?.label} {suffix !== undefined ? ( <span className="font-mono text-[10px] text-muted-foreground"> {suffix} </span> ) : null} </button> ); if (tooltipLabel) { return ( <ChartLegendTooltip key={item.value} tooltip={tooltipLabel}> {badge} </ChartLegendTooltip> ); } return badge; })} </div> ); } function ChartLegendTooltip({ children, tooltip, ...props }: React.ComponentProps<typeof TooltipTrigger> & { tooltip: string }) { return ( <TooltipProvider> <Tooltip delayDuration={0}> <TooltipTrigger className="rounded-md" asChild {...props}> {children} </TooltipTrigger> <TooltipContent>{tooltip}</TooltipContent> </Tooltip> </TooltipProvider> ); } ================================================ FILE: apps/status-page/src/components/chart/chart-line-region.tsx ================================================ "use client"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { cn } from "@openstatus/ui/lib/utils"; import { ChartTooltipNumber } from "./chart-tooltip-number"; const chartConfig = { latency: { label: "Latency", color: "var(--success)", }, } satisfies ChartConfig; export type TrendPoint = { timestamp: number; // unix millis latency: number; // milliseconds }; export function ChartLineRegion({ className, data, }: { className?: string; data: TrendPoint[]; }) { const trendData = data ?? []; const chartData = trendData.map((d) => ({ timestamp: new Date(d.timestamp).toLocaleString("default", { hour: "numeric", minute: "numeric", day: "numeric", month: "short", }), latency: d.latency, })); return ( <ChartContainer config={chartConfig} className={cn("h-[100px] w-full", className)} > <LineChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12, }} > <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" hide /> <ChartTooltip cursor={false} content={ <ChartTooltipContent className="w-[180px]" formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} /> )} /> } /> <Line dataKey="latency" type="monotone" stroke="var(--color-latency)" strokeWidth={2} dot={false} /> <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> </LineChart> </ChartContainer> ); } ================================================ FILE: apps/status-page/src/components/chart/chart-line-regions.tsx ================================================ "use client"; import { CartesianGrid, Line, LineChart, XAxis, // XAxis, YAxis, } from "recharts"; import { regions } from "@/data/regions"; import { formatMilliseconds } from "@/lib/formatter"; import type { MonitorRegion } from "@openstatus/db/src/schema"; import { type ChartConfig, ChartContainer, ChartLegend, ChartTooltip, ChartTooltipContent, } from "@openstatus/ui/components/ui/chart"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { useState } from "react"; import { ChartLegendBadge } from "./chart-legend-badge"; import { ChartTooltipNumber } from "./chart-tooltip-number"; function avg(values: (number | null | string)[]) { const n = values.filter((val): val is number => typeof val === "number"); return Math.round(n.reduce((acc, curr) => acc + curr, 0) / n.length); } function formatAnnotation(values: (number | null | string)[]) { if (values.length === 0) return "N/A"; return formatMilliseconds(avg(values)); } function getChartConfig( data: { timestamp: string; [key: string]: string | number | null; }[], ): ChartConfig { const regions = data.length > 0 ? Object.keys(data[0]).filter((item) => item !== "timestamp") : []; return regions .sort((a, b) => { return ( avg(data.map((item) => item[b])) - avg(data.map((item) => item[a])) ); }) .map((region, index) => ({ code: region, color: `var(--rainbow-${((index + 5) % 17) + 1})`, })) .reduce( (acc, item) => { acc[item.code] = { label: item.code, color: item.color, }; return acc; }, {} as Record<string, { label: string; color: string }>, ) satisfies ChartConfig; } function getChartConfigDefault(regions: MonitorRegion[]) { return regions.reduce( (acc, region, index) => { acc[region] = { label: region, color: `var(--rainbow-${((index + 5) % 17) + 1})`, }; return acc; }, {} as Record<string, { label: string; color: string }>, ) satisfies ChartConfig; } export function ChartLineRegions({ className, data, defaultRegions, }: { className?: string; data: { timestamp: string; [key: string]: string | number | null; }[]; defaultRegions?: MonitorRegion[]; }) { const chartConfig = data.length > 0 ? getChartConfig(data) : getChartConfigDefault(defaultRegions ?? []); const [activeSeries, setActiveSeries] = useState< Array<keyof typeof chartConfig> >(Object.keys(chartConfig).slice(0, 2)); const annotation = Object.keys(chartConfig).reduce( (acc, region) => { acc[region] = formatAnnotation(data.map((item) => item[region])); return acc; }, {} as Record<string, string>, ); const tooltip = regions.reduce( (acc, region) => { acc[region.code] = region.location; return acc; }, {} as Record<string, string>, ); return ( <ChartContainer config={chartConfig} className={cn("h-[100px] w-full", className)} > <LineChart accessibilityLayer data={data} margin={{ left: 0, right: 0, top: 2, bottom: 2, }} > <CartesianGrid vertical={false} /> <XAxis dataKey="timestamp" /> <ChartTooltip cursor={false} content={ <ChartTooltipContent formatter={(value, name) => ( <ChartTooltipNumber chartConfig={chartConfig} value={value} name={name} labelFormatter={(_, name) => { const region = regions.find((r) => r.code === name); return ( <> <span className="font-mono">{name}</span>{" "} <span className="text-muted-foreground text-xs"> {region?.location} </span> </> ); }} /> )} /> } /> {Object.keys(chartConfig).map((item) => ( <Line key={item} dataKey={item} type="monotone" stroke={`var(--color-${item})`} dot={false} hide={!activeSeries.includes(item)} connectNulls /> ))} <YAxis domain={["dataMin", "dataMax"]} tickLine={false} axisLine={false} tickMargin={8} orientation="right" tickFormatter={(value) => `${value}ms`} /> <ChartLegend verticalAlign="top" content={ <ChartLegendBadge handleActive={(item) => { setActiveSeries((prev) => { if (item.dataKey) { const key = item.dataKey as keyof typeof chartConfig; if (prev.includes(key)) { return prev.filter((item) => item !== key); } return [...prev, key]; } return prev; }); }} active={activeSeries} annotation={annotation} tooltip={tooltip} maxActive={6} className="justify-start overflow-x-scroll ps-1 pt-1 font-mono" /> } /> </LineChart> </ChartContainer> ); } export function ChartLineRegionsSkeleton({ className, ...props }: React.ComponentProps<typeof Skeleton>) { return ( <Skeleton className={cn("h-[100px] w-full rounded-lg", className)} {...props} /> ); } ================================================ FILE: apps/status-page/src/components/chart/chart-tooltip-number.tsx ================================================ import type { ChartConfig } from "@openstatus/ui/components/ui/chart"; import type { NameType, ValueType, } from "recharts/types/component/DefaultTooltipContent"; interface ChartTooltipNumberProps { chartConfig: ChartConfig; value: ValueType; name: NameType; labelFormatter?: (value: ValueType, name: NameType) => React.ReactNode; } export function ChartTooltipNumber({ value, name, chartConfig, labelFormatter, }: ChartTooltipNumberProps) { const label: React.ReactNode = labelFormatter ? labelFormatter(value, name) : chartConfig[name as keyof typeof chartConfig]?.label || name; return ( <> <div className="h-2.5 w-2.5 shrink-0 rounded-(--radius-xs) bg-(--color-bg)" style={ { "--color-bg": `var(--color-${name})`, } as React.CSSProperties } /> <span>{label}</span> <div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums"> {value} <span className="font-normal text-muted-foreground">ms</span> </div> </> ); } ================================================ FILE: apps/status-page/src/components/common/kbd.tsx ================================================ import { cn } from "@openstatus/ui/lib/utils"; export function Kbd({ children, className, ...props }: React.ComponentProps<"kbd">) { return ( <kbd className={cn( "-me-1 ms-2 inline-flex h-5 max-h-full items-center rounded border bg-background px-1 font-[inherit] font-medium text-[0.625rem] text-muted-foreground/70", className, )} {...props} > {children} </kbd> ); } ================================================ FILE: apps/status-page/src/components/common/link.tsx ================================================ import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; import NextLink from "next/link"; export const linkVariants = cva( // NOTE: use same ring styles as the button "outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-sm", { variants: { variant: { default: "text-foreground font-medium", container: "focus-visible:border-ring", }, }, defaultVariants: { variant: "default", }, }, ); export function Link({ children, className, variant, ...props }: React.ComponentProps<typeof NextLink> & VariantProps<typeof linkVariants>) { return ( <NextLink className={cn(linkVariants({ variant, className }))} {...props}> {children} </NextLink> ); } ================================================ FILE: apps/status-page/src/components/content/empty-state.tsx ================================================ import { cn } from "@openstatus/ui/lib/utils"; export function EmptyStateContainer({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "flex h-full flex-col items-center justify-center gap-2 rounded-lg border border-border border-dashed p-4", className, )} {...props} > {children} </div> ); } export function EmptyStateTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-foreground", className)} {...props}> {children} </p> ); } export function EmptyStateDescription({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-center text-muted-foreground text-sm", className)} {...props} > {children} </p> ); } ================================================ FILE: apps/status-page/src/components/content/metric-card.tsx ================================================ import type { VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { cn } from "@openstatus/ui/lib/utils"; const metricCardVariants = cva( "flex flex-col gap-1 border rounded-lg px-3 py-2 text-card-foreground", { variants: { variant: { default: "border-input bg-card", ghost: "border-transparent", destructive: "border-destructive/80 bg-destructive/10", success: "border-success/80 bg-success/10", warning: "border-warning/80 bg-warning/10", }, }, defaultVariants: { variant: "default", }, }, ); export function MetricCard({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof metricCardVariants>) { return ( <div data-variant={variant} className={cn(metricCardVariants({ variant, className }), "group/metric")} {...props} > {children} </div> ); } export function MetricCardTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-medium text-sm", className)} {...props}> {children} </p> ); } export function MetricCardHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "text-muted-foreground", "group-data-[variant=destructive]/metric:text-destructive", "group-data-[variant=success]/metric:text-success", "group-data-[variant=warning]/metric:text-warning", className, )} {...props} > {children} </div> ); } export function MetricCardValue({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-semibold text-foreground", className)} {...props}> {children} </p> ); } export function MetricCardGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5", className, )} {...props} > {children} </div> ); } const badgeVariants = cva("px-1.5 font-mono text-[10px]", { variants: { variant: { default: "border-border", increase: "border-destructive/20 bg-destructive/10 hover:bg-destructive/10 text-destructive", decrease: "border-success/20 bg-success/10 hover:bg-success/10 text-success", }, }, defaultVariants: { variant: "default", }, }); export function MetricCardBadge({ value, decimal = 1, className, ...props }: React.ComponentProps<typeof Badge> & { value: number; decimal?: number; }) { const round = 10 ** decimal; // 10^1 = 10 (1 decimal), 10^2 = 100 (2 decimals), etc. const percentage = Math.round((value - 1) * 100 * round) / round; const variant: VariantProps<typeof badgeVariants>["variant"] = percentage > 0 ? "increase" : percentage < 0 ? "decrease" : "default"; return ( <Badge variant="secondary" className={badgeVariants({ variant, className })} {...props} > {percentage !== 0 ? ( <span> {percentage > 0 ? <ChevronUp className="mr-px size-2.5" /> : null} {percentage < 0 ? <ChevronDown className="mr-px size-2.5" /> : null} </span> ) : null} {Math.abs(percentage)}% </Badge> ); } const metricCardButtonVariants = cva( "group w-full text-left transition-all rounded-md outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 cursor-pointer", // TODO: discuss if we want rings ); export function MetricCardButton({ children, className, variant, ...props }: React.ComponentProps<"button"> & VariantProps<typeof metricCardVariants>) { return ( <button type="button" data-variant={variant} className={cn( metricCardVariants({ variant, className }), metricCardButtonVariants(), )} {...props} > {children} </button> ); } ================================================ FILE: apps/status-page/src/components/content/process-message.tsx ================================================ import type { AnchorHTMLAttributes, HTMLAttributes } from "react"; import { Fragment, createElement } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; import rehypeReact from "rehype-react"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { unified } from "unified"; export function ProcessMessage({ value }: { value: string }) { const result = unified() .use(remarkParse) .use(remarkRehype) .use(rehypeReact, { createElement, Fragment, jsx, jsxs, components: { ul: (props: HTMLAttributes<HTMLUListElement>) => { return ( <ul className="list-inside list-disc marker:text-muted-foreground/50" {...props} /> ); }, ol: (_props: HTMLAttributes<HTMLOListElement>) => { return ( <ol className="list-inside list-decimal marker:text-muted-foreground/50" /> ); }, a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { return ( <a target="_blank" rel="noreferrer" className="rounded-sm underline outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" {...props} /> ); }, } as { [key: string]: React.ComponentType<unknown> }, }) .processSync(value).result; return result; } ================================================ FILE: apps/status-page/src/components/content/section.tsx ================================================ import { cn } from "@openstatus/ui/lib/utils"; export function Section({ children, className, ...props }: React.ComponentProps<"section">) { return ( <section className={cn("space-y-4", className)} {...props}> {children} </section> ); } export function SectionHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("flex flex-col gap-1.5", className)} {...props}> {children} </div> ); } export function SectionHeaderRow({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn( "flex flex-col gap-1.5 sm:flex-row sm:items-end sm:justify-between", className, )} {...props} > {children} </div> ); } export function SectionDescription({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("text-muted-foreground text-sm", className)} {...props}> {children} </p> ); } export function SectionTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-medium text-lg", className)} {...props}> {children} </p> ); } export function SectionGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("mx-auto w-full max-w-4xl space-y-8 px-4 py-8", className)} {...props} > {children} </div> ); } export function SectionGroupHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div className={cn("space-y-1.5", className)} {...props}> {children} </div> ); } export function SectionGroupTitle({ children, className, ...props }: React.ComponentProps<"p">) { return ( <p className={cn("font-bold text-4xl", className)} {...props}> {children} </p> ); } ================================================ FILE: apps/status-page/src/components/content/timestamp-hover-card.tsx ================================================ import { UTCDate } from "@date-fns/utc"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@openstatus/ui/components/ui/hover-card"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { type HoverCardContentProps, HoverCardPortal, } from "@radix-ui/react-hover-card"; import { format } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns"; import { Check, Copy } from "lucide-react"; import { useEffect, useState } from "react"; export function TimestampHoverCard({ date, side = "right", align = "start", alignOffset = -4, sideOffset, children, onClick, ...props }: React.ComponentProps<typeof HoverCardTrigger> & { date: Date; side?: HoverCardContentProps["side"]; align?: HoverCardContentProps["align"]; alignOffset?: HoverCardContentProps["alignOffset"]; sideOffset?: HoverCardContentProps["sideOffset"]; }) { const [open, setOpen] = useState(false); const isTouch = useMediaQuery("(hover: none)"); const [_, setRerender] = useState(0); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const relative = formatDistanceToNowStrict(date, { addSuffix: true }); const formatted = format(date, "LLL dd, y HH:mm:ss"); const utc = format(new UTCDate(date), "LLL dd, y HH:mm:ss"); useEffect(() => { // only setInterval if open if (!open) return; const interval = setInterval(() => { setRerender((prev) => prev + 1); }, 1000); return () => clearInterval(interval); }, [open]); return ( <HoverCard openDelay={0} closeDelay={0} open={open} onOpenChange={setOpen}> {/* NOTE: the trigger is an `a` tag per default */} <HoverCardTrigger onClick={(e) => { // NOTE: support touch devices if (isTouch) setOpen((prev) => !prev); onClick?.(e); }} {...props} > {children} </HoverCardTrigger> <HoverCardPortal> <HoverCardContent className="z-10 w-auto p-2" {...{ side, align, alignOffset, sideOffset }} > <dl className="flex flex-col gap-1"> <Row value={formatted} label={timezone} /> <Row value={utc} label="UTC" /> {/* <Row value={date.toISOString()} label="ISO" /> */} {/* <Row value={String(date.getTime())} label="Timestamp" /> */} <Row value={relative} label="Relative" /> </dl> </HoverCardContent> </HoverCardPortal> </HoverCard> ); } function Row({ value, label }: { value: string; label: string }) { const { copy, isCopied } = useCopyToClipboard(); return ( <div className="group flex items-center justify-between gap-4 text-sm" onClick={(e) => { e.stopPropagation(); copy(value, { withToast: true }); }} > <dt className="text-muted-foreground">{label}</dt> <dd className="flex items-center gap-1 truncate font-mono"> <span className="invisible group-hover:visible"> {!isCopied ? ( <Copy className="h-3 w-3" /> ) : ( <Check className="h-3 w-3" /> )} </span> {value} </dd> </div> ); } ================================================ FILE: apps/status-page/src/components/date-picker.tsx ================================================ "use client"; import { useState } from "react"; import type { DateRange } from "react-day-picker"; import { Kbd } from "@/components/common/kbd"; import { formatDateForInput } from "@/lib/formatter"; import { Button } from "@openstatus/ui/components/ui/button"; import { Calendar } from "@openstatus/ui/components/ui/calendar"; import { Input } from "@openstatus/ui/components/ui/input"; import { Label } from "@openstatus/ui/components/ui/label"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { endOfDay } from "date-fns"; type DatePickerProps = { range: DateRange; onSelect: (range: DateRange) => void; presets: { id: string; label: string; values: DateRange; shortcut: string }[]; }; export function DatePicker({ range, onSelect, presets }: DatePickerProps) { const [today] = useState(new Date()); const disableBefore = presets[presets.length - 1]?.values?.from; return ( <div> <div className="flex flex-row"> <div className="relative py-4"> <div className="h-full"> <div className="flex flex-col px-1"> <div className="px-3 py-1 font-medium text-muted-foreground text-xs"> Presets </div> {presets.map((preset) => { const isSelected = range.from?.getTime() === preset.values.from?.getTime() && range.to?.getTime() === preset.values.to?.getTime(); return ( <Button key={preset.id} variant={isSelected ? "outline" : "ghost"} size="sm" className="w-full justify-between border border-transparent" onClick={() => { onSelect(preset.values); }} > <span>{preset.label}</span> <Kbd className="font-mono uppercase">{preset.shortcut}</Kbd> </Button> ); })} </div> </div> </div> <Separator orientation="vertical" className="h-auto! w-px" /> <div className="flex flex-1 items-center justify-center"> <Calendar mode="range" selected={range} onSelect={(newDate) => { if (newDate) { onSelect({ ...newDate, to: newDate.to ? endOfDay(newDate.to) : undefined, }); } }} className="p-2" disabled={[ { after: today }, // Dates before today { before: disableBefore ?? today }, // Dates before last action ]} /> </div> </div> <Separator /> <div className="flex flex-col gap-2 px-3 py-4"> <p className="px-1 font-medium text-muted-foreground text-xs"> Custom Range </p> <div className="grid gap-2 sm:grid-cols-2"> <div className="grid w-full gap-1.5"> <Label htmlFor="from" className="px-1"> Start </Label> <Input type="datetime-local" id="from" name="from" min={formatDateForInput(disableBefore ?? today)} max={formatDateForInput(today)} value={range.from ? formatDateForInput(range.from) : ""} onChange={(e) => { const newDate = new Date(e.target.value); if (!Number.isNaN(newDate.getTime())) { onSelect({ ...range, from: newDate }); } }} disabled={!range.from} /> </div> <div className="grid w-full gap-1.5"> <Label htmlFor="to" className="px-1"> End </Label> <Input type="datetime-local" id="to" name="to" min={formatDateForInput(range.from ?? today)} max={formatDateForInput(today)} value={range.to ? formatDateForInput(range.to) : ""} onChange={(e) => { const newDate = new Date(e.target.value); if (!Number.isNaN(newDate.getTime())) { onSelect({ ...range, to: newDate }); } }} disabled={!range.to} /> </div> </div> </div> </div> ); } ================================================ FILE: apps/status-page/src/components/forms/form-card.tsx ================================================ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@openstatus/ui/components/ui/card"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; const formCardVariants = cva( "group relative w-full overflow-hidden py-0 shadow-none gap-4 rounded-lg", { variants: { variant: { default: "", destructive: "border-destructive", info: "border-info", }, defaultVariants: { variant: "default", }, }, }, ); // NOTE: Add a formcardprovider to share the variant prop export function FormCard({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof formCardVariants>) { return ( <Card className={cn(formCardVariants({ variant }), className)} {...props}> {children} </Card> ); } export function FormCardHeader({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardHeader className={cn( "px-4 pt-4 group-has-data-[slot=card-upgrade]:pointer-events-none group-has-data-[slot=card-upgrade]:opacity-50 [.border-b]:pb-4", className, )} {...props} > {children} </CardHeader> ); } export function FormCardTitle({ children }: { children: React.ReactNode }) { return <CardTitle>{children}</CardTitle>; } export function FormCardDescription({ children, }: { children: React.ReactNode; }) { return <CardDescription>{children}</CardDescription>; } export function FormCardContent({ children, className, ...props }: React.ComponentProps<"div">) { return ( <CardContent className={cn( "px-4 group-has-data-[slot=card-upgrade]:pointer-events-none group-has-data-[slot=card-upgrade]:opacity-50", "has-data-[slot=card-content-upgrade]:pointer-events-none has-data-[slot=card-content-upgrade]:opacity-50", className, )} {...props} > {children} </CardContent> ); } export function FormCardSeparator({ ...props }: React.ComponentProps<typeof Separator>) { return <Separator {...props} />; } const formCardFooterVariants = cva( "border-t flex items-center gap-2 pb-4 px-4 [&>:last-child]:ml-auto [.border-t]:pt-4", { variants: { variant: { default: "", destructive: "border-destructive bg-destructive/5", info: "border-info bg-info/5", }, defaultVariants: { variant: "default", }, }, }, ); export function FormCardFooter({ children, className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof formCardFooterVariants>) { return ( <CardFooter className={cn(formCardFooterVariants({ variant }), className)} {...props} > {children} </CardFooter> ); } export function FormCardFooterInfo({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-footer-info" className={cn("text-muted-foreground text-sm", className)} {...props} > {children} </div> ); } export function FormCardGroup({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-group" className={cn("flex flex-col gap-4", className)} {...props} > {children} </div> ); } export function FormCardUpgrade({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-upgrade" className={cn("hidden", className)} {...props} > {children} </div> ); } // NOTE; this is for a very specific case where we don't want to disable the whole content // and instead disable specpfic card content (e.g. for add-ons) export function FormCardContentUpgrade({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-content-upgrade" className={cn("hidden", className)} {...props} > {children} </div> ); } export function FormCardEmpty({ children, className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-empty" className={cn( "pointer-events-none absolute inset-0 z-10 bg-background opacity-70 blur", className, )} {...props} > {children} </div> ); } ================================================ FILE: apps/status-page/src/components/forms/form-email.tsx ================================================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormMessage } from "@openstatus/ui/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ email: z.string().email(), }); export type FormValues = z.infer<typeof schema>; export function FormEmail({ onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: "", }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Confirming...", success: "Confirmed", error: (error) => { console.error(error); if (isTRPCClientError(error)) { form.setError("email", { message: error.message }); return error.message; } if (error instanceof Error) { form.setError("email", { message: error.message }); return error.message; } return "Failed to confirm"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </form> </Form> ); } ================================================ FILE: apps/status-page/src/components/forms/form-manage-subscription.tsx ================================================ "use client"; import { StatusBlankContainer, StatusBlankDescription, StatusBlankTitle, } from "@/components/status-page/status-blank"; import { zodResolver } from "@hookform/resolvers/zod"; import type { RouterOutputs } from "@openstatus/api"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form } from "@openstatus/ui/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; type Page = NonNullable<RouterOutputs["statusPage"]["get"]>; const schema = z.object({ pageComponents: z.array(z.number().int().positive()), subscribeComponents: z.boolean(), }); export type FormValues = z.infer<typeof schema>; export function FormManageSubscription({ page, defaultValues, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { onSubmit: (values: FormValues) => Promise<void>; onSubmitCallback?: () => void; page?: Page | null; defaultValues?: FormValues; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { pageComponents: defaultValues?.pageComponents ?? [], subscribeComponents: defaultValues?.subscribeComponents ?? true, }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Updating subscription...", success: "Subscription updated", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to update subscription"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} className={cn("flex flex-col gap-2", className)} {...props} > <FormField control={form.control} name="subscribeComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2 px-4"> <FormControl> <Checkbox checked={field.value} onCheckedChange={(checked) => { field.onChange(checked); }} /> </FormControl> <FormLabel>Subscribe to specific components</FormLabel> </FormItem> )} /> {form.watch("subscribeComponents") ? ( <> <Separator className="my-2" /> {page?.trackers && page.trackers.length > 0 ? ( page.trackers.map((tracker) => { if (tracker.type === "group") { const groupIds = tracker.components.map((c) => c.id); return ( <div key={tracker.groupId} className="flex flex-col gap-2 px-4" > <FormField control={form.control} name="pageComponents" render={({ field }) => { const allChecked = groupIds.every((id) => field.value?.includes(id), ); const someChecked = groupIds.some((id) => field.value?.includes(id), ); return ( <FormItem className="flex items-center gap-2"> <FormControl> <Checkbox checked={ allChecked ? true : someChecked ? "indeterminate" : false } onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([ ...new Set([...value, ...groupIds]), ]); } else { field.onChange( value.filter( (id) => !groupIds.includes(id), ), ); } }} /> </FormControl> <FormLabel>{tracker.groupName}</FormLabel> </FormItem> ); }} /> {tracker.components.map((component) => ( <FormField key={component.id} control={form.control} name="pageComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2 pl-6"> <FormControl> <Checkbox checked={field.value?.includes(component.id)} onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([...value, component.id]); } else { field.onChange( value.filter( (id) => id !== component.id, ), ); } }} /> </FormControl> <FormLabel>{component.name}</FormLabel> </FormItem> )} /> ))} </div> ); } return ( <FormField key={tracker.component.id} control={form.control} name="pageComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2 px-4"> <FormControl> <Checkbox checked={field.value?.includes( tracker.component.id, )} onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([ ...value, tracker.component.id, ]); } else { field.onChange( value.filter( (id) => id !== tracker.component.id, ), ); } }} /> </FormControl> <FormLabel>{tracker.component.name}</FormLabel> </FormItem> )} /> ); }) ) : ( <StatusBlankContainer className="px-4"> <StatusBlankTitle> No components to subscribe to </StatusBlankTitle> <StatusBlankDescription> This status page has no components to subscribe to. </StatusBlankDescription> </StatusBlankContainer> )} </> ) : null} </form> </Form> ); } ================================================ FILE: apps/status-page/src/components/forms/form-password.tsx ================================================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@openstatus/ui/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; const schema = z.object({ password: z.string().min(1), }); type FormValues = z.infer<typeof schema>; export function FormPassword({ onSubmit, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { onSubmit: (values: FormValues) => Promise<void>; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { password: "", }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Confirming...", success: "Confirmed", error: (error) => { if (isTRPCClientError(error)) { form.setError("password", { message: error.message }); return error.message; } return "Failed to confirm"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>Password</FormLabel> <FormControl> <Input type="password" {...field} /> </FormControl> </FormItem> )} /> </form> </Form> ); } ================================================ FILE: apps/status-page/src/components/forms/form-subscribe-email.tsx ================================================ "use client"; import { StatusBlankContainer, StatusBlankDescription, StatusBlankTitle, } from "@/components/status-page/status-blank"; import { zodResolver } from "@hookform/resolvers/zod"; import type { RouterOutputs } from "@openstatus/api"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Form } from "@openstatus/ui/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, } from "@openstatus/ui/components/ui/form"; import { Input } from "@openstatus/ui/components/ui/input"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { isTRPCClientError } from "@trpc/client"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; type Page = NonNullable<RouterOutputs["statusPage"]["get"]>; const schema = z.object({ email: z.email(), subscribeComponents: z.boolean(), pageComponents: z.array(z.number().int().positive()), }); export type FormValues = z.infer<typeof schema>; export function FormSubscribeEmail({ page, onSubmit, className, ...props }: Omit<React.ComponentProps<"form">, "onSubmit"> & { onSubmit: (values: FormValues) => Promise<void>; onSubmitCallback?: () => void; page?: Page | null; }) { const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { email: "", subscribeComponents: false, pageComponents: [], }, }); const [isPending, startTransition] = useTransition(); function submitAction(values: FormValues) { if (isPending) return; startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Subscribing...", success: "Subscribed", error: (error) => { if (isTRPCClientError(error)) { return error.message; } return "Failed to subscribe"; }, }); await promise; } catch (error) { console.error(error); } }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} className={cn("flex flex-col gap-2", className)} {...props} > <FormField control={form.control} name="email" render={({ field }) => ( <FormItem className="px-2"> <FormLabel className="sr-only">Email</FormLabel> <FormControl> <Input placeholder="subscribe@me.com" {...field} /> </FormControl> </FormItem> )} /> <FormField control={form.control} name="subscribeComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2 px-2"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel>Subscribe to specific components</FormLabel> </FormItem> )} /> {form.watch("subscribeComponents") && ( <> <Separator /> <div className="-my-2 flex max-h-56 flex-col gap-2 overflow-y-auto bg-muted p-2 px-2"> {page?.trackers && page.trackers.length > 0 ? ( page.trackers.map((tracker) => { if (tracker.type === "group") { const groupIds = tracker.components.map((c) => c.id); return ( <div key={tracker.groupId} className="flex flex-col gap-2" > <FormField control={form.control} name="pageComponents" render={({ field }) => { const allChecked = groupIds.every((id) => field.value?.includes(id), ); const someChecked = groupIds.some((id) => field.value?.includes(id), ); return ( <FormItem className="flex items-center gap-2"> <FormControl> <Checkbox checked={ allChecked ? true : someChecked ? "indeterminate" : false } onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([ ...new Set([...value, ...groupIds]), ]); } else { field.onChange( value.filter( (id) => !groupIds.includes(id), ), ); } }} /> </FormControl> <FormLabel>{tracker.groupName}</FormLabel> </FormItem> ); }} /> {tracker.components.map((component) => ( <FormField key={component.id} control={form.control} name="pageComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2 pl-6"> <FormControl> <Checkbox checked={field.value?.includes( component.id, )} onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([ ...value, component.id, ]); } else { field.onChange( value.filter( (id) => id !== component.id, ), ); } }} /> </FormControl> <FormLabel>{component.name}</FormLabel> </FormItem> )} /> ))} </div> ); } return ( <FormField key={tracker.component.id} control={form.control} name="pageComponents" render={({ field }) => ( <FormItem className="flex items-center gap-2"> <FormControl> <Checkbox checked={field.value?.includes( tracker.component.id, )} onCheckedChange={(checked) => { const value = field.value ?? []; if (checked) { field.onChange([ ...value, tracker.component.id, ]); } else { field.onChange( value.filter( (id) => id !== tracker.component.id, ), ); } }} /> </FormControl> <FormLabel>{tracker.component.name}</FormLabel> </FormItem> )} /> ); }) ) : ( <StatusBlankContainer> <StatusBlankTitle> No components to subscribe to </StatusBlankTitle> <StatusBlankDescription> This page has no components to subscribe to. </StatusBlankDescription> </StatusBlankContainer> )} </div> </> )} </form> </Form> ); } ================================================ FILE: apps/status-page/src/components/icons/discord.tsx ================================================ export function DiscordIcon(props: React.ComponentProps<"svg">) { return ( <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" fill="currentColor" {...props} > <title>Discord ); } ================================================ FILE: apps/status-page/src/components/icons/github.tsx ================================================ export function GitHubIcon(props: React.ComponentProps<"svg">) { return ( GitHub ); } ================================================ FILE: apps/status-page/src/components/icons/google.tsx ================================================ export function GoogleIcon(props: React.ComponentProps<"svg">) { return ( Google ); } ================================================ FILE: apps/status-page/src/components/icons/opsgenie.tsx ================================================ export function OpsGenieIcon(props: React.ComponentProps<"svg">) { return ( Opsgenie ); } ================================================ FILE: apps/status-page/src/components/icons/pagerduty.tsx ================================================ export function PagerDutyIcon(props: React.ComponentProps<"svg">) { return ( PagerDuty ); } ================================================ FILE: apps/status-page/src/components/icons/slack.tsx ================================================ export function SlackIcon(props: React.ComponentProps<"svg">) { return ( Slack ); } ================================================ FILE: apps/status-page/src/components/nav/footer.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; import { ThemeDropdown } from "@/components/themes/theme-dropdown"; import { useTRPC } from "@/lib/trpc/client"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { useQuery } from "@tanstack/react-query"; import { Clock } from "lucide-react"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; export function Footer(props: React.ComponentProps<"footer">) { const { domain } = useParams<{ domain: string }>(); const [isMounted, setIsMounted] = useState(false); const trpc = useTRPC(); const { data: page, dataUpdatedAt } = useQuery({ ...trpc.statusPage.get.queryOptions({ slug: domain }), }); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; useEffect(() => { setIsMounted(true); }, []); if (!page) return null; return (
{!page.whiteLabel ? (

powered by{" "} openstatus.dev

) : null}
{isMounted ? ( <> {timezone} ) : ( )}
); } ================================================ FILE: apps/status-page/src/components/nav/header.tsx ================================================ "use client"; import { Link } from "@/components/common/link"; import { type StatusUpdateType, StatusUpdates, } from "@/components/status-page/status-updates"; import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; import { Button } from "@openstatus/ui/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from "@openstatus/ui/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; import { Menu, MessageCircleMore } from "lucide-react"; import NextLink from "next/link"; import { useParams, usePathname } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; type Page = RouterOutputs["statusPage"]["get"]; function useNav() { const pathname = usePathname(); const prefix = usePathnamePrefix(); return [ { label: "Status", href: `/${prefix}`, isActive: pathname === `/${prefix}`, }, { label: "Events", href: `${prefix ? `/${prefix}` : ""}/events`, isActive: pathname.startsWith(`${prefix ? `/${prefix}` : ""}/events`), }, { label: "Monitors", href: `${prefix ? `/${prefix}` : ""}/monitors`, isActive: pathname.startsWith(`${prefix ? `/${prefix}` : ""}/monitors`), }, ]; } function getStatusUpdateTypes(page: Page): StatusUpdateType[] { if (!page) return []; // NOTE: rss or json are not supported because of authentication if (page?.accessType === "email-domain") { return ["email"] as const; } if (page?.workspacePlan === "free") { return ["slack", "rss", "json"] as const; } return ["email", "slack", "rss", "json"] as const; } export function Header(props: React.ComponentProps<"header">) { const trpc = useTRPC(); const { domain } = useParams<{ domain: string }>(); const { data: page } = useQuery({ ...trpc.statusPage.get.queryOptions({ slug: domain }), }); const sendPageSubscriptionMutation = useMutation( trpc.emailRouter.sendPageSubscriptionVerification.mutationOptions({}), ); const subscribeMutation = useMutation( trpc.statusPage.subscribe.mutationOptions({ onSuccess: (data) => { if (!data?.id || !data?.token) return; sendPageSubscriptionMutation.mutate( { id: data.id, token: data.token }, { onError: (error) => { if (isTRPCClientError(error)) { toast.error(error.message); } else { toast.error("Failed to subscribe"); } }, }, ); }, }), ); return (
); } function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { const nav = useNav(); return (
    {nav.map((item) => { return (
  • ); })}
); } function NavMobile({ className, ...props }: React.ComponentProps) { const [open, setOpen] = useState(false); const nav = useNav(); return ( Menu
    {nav.map((item) => { return (
  • ); })}
); } function GetInTouch({ buttonType, className, link, ...props }: React.ComponentProps & { buttonType: "icon" | "text"; link: string; }) { if (buttonType === "text") { return ( ); } return (

Get in touch

); } ================================================ FILE: apps/status-page/src/components/password-wrapper.tsx ================================================ "use client"; import { createProtectedCookieKey } from "@/lib/protected"; import { useParams } from "next/navigation"; import { parseAsString, useQueryState } from "nuqs"; import { useEffect } from "react"; export function PasswordWrapper({ children }: { children?: React.ReactNode }) { const [password, setPassword] = useQueryState("pw", parseAsString); const { domain } = useParams<{ domain: string }>(); useEffect(() => { if (password) { const key = createProtectedCookieKey(domain); document.cookie = `${key}=${password}; path=/; expires=${new Date( Date.now() + 1000 * 60 * 60 * 24 * 30, ).toUTCString()}`; setPassword(null); } }, [password, domain, setPassword]); return children; } ================================================ FILE: apps/status-page/src/components/popover/popover-quantile.tsx ================================================ import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; export function PopoverQuantile({ children, className, ...props }: React.ComponentProps) { return ( {children}

A quantile represents a specific percentile in your dataset.

For example, p50 is the 50th percentile - the point below which 50% of data falls. Higher percentiles include more data and highlight the upper range.

); } ================================================ FILE: apps/status-page/src/components/status-page/floating-button.tsx ================================================ "use client"; import { ThemeSelect } from "@/components/themes/theme-select"; import { THEMES, THEME_KEYS, type ThemeDefinition, generateThemeStyles, } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Label } from "@openstatus/ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, ChevronsUpDown, Settings } from "lucide-react"; import { parseAsString, useQueryState } from "nuqs"; import type React from "react"; import { createContext, useContext, useEffect, useState } from "react"; export const IS_DEV = process.env.NODE_ENV === "development"; export const VARIANT = ["success", "degraded", "error", "info"] as const; export type VariantType = (typeof VARIANT)[number]; export const CARD_TYPE = ["duration", "requests", "manual"] as const; export type CardType = (typeof CARD_TYPE)[number]; export const BAR_TYPE = ["absolute", "manual"] as const; export type BarType = (typeof BAR_TYPE)[number]; export const COMMUNITY_THEME = THEME_KEYS; export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; interface StatusPageContextType { cardType: CardType; setCardType: (cardType: CardType) => void; barType: BarType; setBarType: (barType: BarType) => void; showUptime: boolean; setShowUptime: (showUptime: boolean) => void; communityTheme: CommunityTheme; setCommunityTheme: (communityTheme: CommunityTheme) => void; } const StatusPageContext = createContext(null); export function useStatusPage() { const context = useContext(StatusPageContext); if (!context) { throw new Error("useStatusPage must be used within a StatusPageProvider"); } return context; } export function StatusPageProvider({ children, defaultCardType = "duration", defaultBarType = "absolute", defaultShowUptime = true, defaultCommunityTheme = "default", }: { children: React.ReactNode; defaultCardType?: CardType; defaultBarType?: BarType; defaultShowUptime?: boolean; defaultCommunityTheme?: CommunityTheme; }) { const [cardType, setCardType] = useState(defaultCardType); const [barType, setBarType] = useState(defaultBarType); const [showUptime, setShowUptime] = useState(defaultShowUptime); const [communityTheme, setCommunityTheme] = useState( defaultCommunityTheme, ); const [isMounted, setIsMounted] = useState(false); useEffect(() => { setIsMounted(true); }, []); useEffect(() => { if (isMounted) { recomputeStyles(communityTheme); } }, [communityTheme, isMounted]); return ( {children} ); } export function FloatingButton({ className, pageId, token, }: { className?: string; pageId?: number; token?: string; }) { const { cardType, setCardType, barType, setBarType, showUptime, setShowUptime, communityTheme, setCommunityTheme, } = useStatusPage(); const [display, setDisplay] = useState(false); const [configToken, setConfigToken] = useQueryState( "configuration-token", parseAsString, ); // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { const enabled = localStorage.getItem("configuration-token") === token || configToken === token; const host = window.location.host; if ( (host.includes("localhost") || host.includes("stpg.dev") || host.includes("openstatus.dev") || host.includes("vercel.app")) && enabled ) { setDisplay(true); localStorage.setItem("configuration-token", token); } else if (IS_DEV) { setDisplay(true); } if (configToken) setConfigToken(null); }, [token]); if (!display) return null; return (

Status Page Settings

Configure the status page appearance

No themes found. {COMMUNITY_THEME.map((theme) => ( setCommunityTheme(v as CommunityTheme) } > {THEMES[theme].name} by {THEMES[theme].author.name} ))}
); } export function recomputeStyles( newTheme: CommunityTheme, overrides?: Partial, ) { try { // Only update the text content of existing style tags, don't remove them // This prevents React hydration errors during navigation const allThemeStyles = document.querySelectorAll( "style[id='theme-styles']", ); const newStyles = generateThemeStyles(newTheme, overrides); // Update all style elements with the same content // This way React can manage the DOM without conflicts allThemeStyles.forEach((style) => { style.textContent = newStyles; }); } catch (error) { console.error(error); } } ================================================ FILE: apps/status-page/src/components/status-page/floating-theme.tsx ================================================ "use client"; import { ThemeSelect } from "@/components/themes/theme-select"; import { THEMES, THEME_KEYS } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@openstatus/ui/components/ui/command"; import { Label } from "@openstatus/ui/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, ChevronsUpDown, Palette } from "lucide-react"; import { useEffect } from "react"; import { useState } from "react"; import { useStatusPage } from "./floating-button"; export const COMMUNITY_THEME = THEME_KEYS; export type CommunityTheme = (typeof COMMUNITY_THEME)[number]; export function FloatingTheme({ className }: { className?: string }) { const { communityTheme, setCommunityTheme } = useStatusPage(); const [display, setDisplay] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { const enabled = sessionStorage.getItem("community-theme") === "true"; const host = window.location.host; if ( (host.includes("localhost") || host.includes("stpg.dev") || host.includes("openstatus.dev") || host.includes("vercel.app")) && enabled ) { setDisplay(true); setOpen(true); } }, []); if (!display) return null; return (

Theme Settings

Test community themes on the status page.

No themes found. {COMMUNITY_THEME.map((theme) => ( setCommunityTheme(v as CommunityTheme) } > {THEMES[theme].name} by {THEMES[theme].author.name} ))}
); } ================================================ FILE: apps/status-page/src/components/status-page/messages.ts ================================================ export const messages = { long: { success: "All Systems Operational", degraded: "Degraded Performance", error: "Downtime Performance", info: "Maintenance", empty: "No Data", }, short: { success: "Operational", degraded: "Degraded", error: "Downtime", info: "Maintenance", empty: "No Data", }, }; export const requests = { success: "Normal", degraded: "Degraded", error: "Error", info: "Maintenance", empty: "No Data", }; export const status = { resolved: "Resolved", monitoring: "Monitoring", identified: "Identified", investigating: "Investigating", }; ================================================ FILE: apps/status-page/src/components/status-page/status-banner.tsx ================================================ import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; import { AlertCircleIcon, CheckIcon, TriangleAlertIcon, WrenchIcon, } from "lucide-react"; import { messages } from "./messages"; import { StatusTimestamp } from "./status"; export function StatusBanner({ className, status, }: React.ComponentProps<"div"> & { status?: "success" | "degraded" | "error" | "info"; }) { return (
); } export function StatusBannerContainer({ className, children, status, }: React.ComponentProps<"div"> & { status?: "success" | "degraded" | "error" | "info"; }) { return (
{children}
); } export function StatusBannerMessage({ className, ...props }: React.ComponentProps<"div">) { return (
{messages.long.success} {messages.long.degraded} {messages.long.error} {messages.long.info}
); } export function StatusBannerTitle({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBannerContent({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBannerIcon({ className, ...props }: React.ComponentProps<"div">) { return (
svg]:size-4", "group-data-[status=success]/status-banner:bg-success", "group-data-[status=degraded]/status-banner:bg-warning", "group-data-[status=error]/status-banner:bg-destructive", "group-data-[status=info]/status-banner:bg-info", className, )} {...props} >
); } // Tabs Components export function StatusBannerTabs({ className, children, status, ...props }: React.ComponentProps & { status?: "success" | "degraded" | "error" | "info"; }) { return ( {children} ); } export function StatusBannerTabsList({ className, children, ...props }: React.ComponentProps) { return (
{children}
); } export function StatusBannerTabsTrigger({ className, children, status, ...props }: React.ComponentProps & { status?: "success" | "degraded" | "error" | "info"; }) { return ( {children} ); } // NOTE: tabing into content is not being highlighted export function StatusBannerTabsContent({ className, children, ...props }: React.ComponentProps) { return ( {children} ); } ================================================ FILE: apps/status-page/src/components/status-page/status-blank.tsx ================================================ import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; import Link from "next/link"; export function StatusBlankContainer({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankTitle({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankDescription({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankLink({ children, className, href, ...props }: React.ComponentProps & { href: string }) { return ( ); } export function StatusBlankContent({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankReport({ ...props }: React.ComponentProps<"div">) { return (
); } export function StatusBlankMonitor({ ...props }: React.ComponentProps<"div">) { return ( ); } export function StatusBlankPage({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankPageHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function StatusBlankMonitorUptime({ className, ...props }: React.ComponentProps<"div">) { return (
{Array.from({ length: 30 }).map((_, index) => (
))}
); } export function StatusBlankReportUpdate({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function StatusBlankOverlay({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusBlankEvents({ title = "No reports found", description = "No reports found for this status page.", ...props }: React.ComponentProps & { title?: string; description?: string; }) { return (
{title} {description}
); } export function StatusBlankMonitors({ title = "No public monitors", description = "No public monitors have been added to this page.", ...props }: React.ComponentProps & { title?: string; description?: string; }) { return (
{title} {description}
); } ================================================ FILE: apps/status-page/src/components/status-page/status-charts.tsx ================================================ import { cn } from "@openstatus/ui/lib/utils"; export function StatusChartContent({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusChartHeader({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusChartTitle({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusChartDescription({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } ================================================ FILE: apps/status-page/src/components/status-page/status-events.tsx ================================================ import { ProcessMessage } from "@/components/content/process-message"; import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; import { formatDate, formatDateRange, formatDateTime } from "@/lib/formatter"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceStrict } from "date-fns"; import { Check } from "lucide-react"; import { status } from "./messages"; export function StatusEventGroup({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEvent({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventContent({ className, hoverable = true, children, ...props }: React.ComponentProps<"div"> & { hoverable?: boolean; }) { // TODO: add Link return (
{children}
); } export function StatusEventTitle({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventTitleCheck({ className, children, ...props }: React.ComponentProps<"div">) { return (

Report resolved

); } // TODO: affected monitors export function StatusEventAffected({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventAffectedBadge({ className, children, ...props }: React.ComponentProps<"div">) { return ( {children} ); } export function StatusEventDate({ className, date, ...props }: React.ComponentProps<"div"> & { date: Date; }) { const isFuture = date > new Date(); const distance = formatDistanceStrict(date, new Date(), { addSuffix: true }); return (
{formatDate(date, { month: "short" })}
{" "} {distance}
); } export function StatusEventAside({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventTimelineReport({ className, updates, withDot = true, maxUpdates, reportId, ...props }: React.ComponentProps<"div"> & { // TODO: remove unused props reportId: number; updates: { date: Date; message: string; status: "investigating" | "identified" | "monitoring" | "resolved"; }[]; withDot?: boolean; maxUpdates?: number; }) { const _prefix = usePathnamePrefix(); const sortedUpdates = [...updates].sort( (a, b) => b.date.getTime() - a.date.getTime(), ); const _hasMoreUpdates = maxUpdates && sortedUpdates.length > maxUpdates; const displayedUpdates = maxUpdates ? sortedUpdates.slice(0, maxUpdates) : sortedUpdates; return (
{/* NOTE: make sure they are sorted by date */} {displayedUpdates.map((update, index) => { const updateDate = new Date(update.date); let durationText: string | undefined; if (index === 0) { const startedAt = new Date( sortedUpdates[sortedUpdates.length - 1].date, ); const duration = formatDistanceStrict(startedAt, updateDate); if (duration !== "0 seconds" && update.status === "resolved") { durationText = `(in ${duration})`; } } else { const lastUpdateDate = new Date(displayedUpdates[index - 1].date); const timeFromLast = formatDistanceStrict(updateDate, lastUpdateDate); durationText = `(${timeFromLast} earlier)`; } return ( ); })}
); } export function StatusEventTimelineReportUpdate({ report, duration, withSeparator = true, withDot = true, isLast = false, }: { report: { date: Date; message: string; status: "investigating" | "identified" | "monitoring" | "resolved"; }; withSeparator?: boolean; duration?: string; withDot?: boolean; isLast?: boolean; }) { return (
{withDot ? (
{withSeparator ? : null}
) : null}
{status[report.status]}{" "} ·{" "} {formatDateTime(report.date)} {" "} {duration ? ( {duration} ) : null} {report.message.trim() === "" ? ( - ) : ( )}
); } export function StatusEventTimelineMaintenance({ maintenance, withDot = true, }: { maintenance: { title: string; message: string; from: Date; to: Date; }; withDot?: boolean; }) { const duration = formatDistanceStrict(maintenance.from, maintenance.to); const range = formatDateRange(maintenance.from, maintenance.to); // NOTE: because formatDateRange is sure to return a range, we can split it into two dates const [from, to] = range.split(" - "); return (
{withDot ? (
) : null} {/* NOTE: is always last, no need for className="mb-2" */}
Maintenance{" "} ·{" "} {from} {" - "} {to} {" "} {duration ? ( (for {duration}) ) : null} {maintenance.message.trim() === "" ? ( - ) : ( )}
); } export function StatusEventTimelineTitle({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventTimelineMessage({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEventTimelineDot({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function StatusEventTimelineSeparator({ className, ...props }: React.ComponentProps) { return ( ); } ================================================ FILE: apps/status-page/src/components/status-page/status-feed.tsx ================================================ "use client"; import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; import Link from "next/link"; import { StatusBlankContainer, StatusBlankContent, StatusBlankDescription, StatusBlankLink, StatusBlankReport, StatusBlankTitle, } from "./status-blank"; import { StatusEvent, StatusEventAffected, StatusEventAffectedBadge, StatusEventAside, StatusEventContent, StatusEventDate, StatusEventGroup, StatusEventTimelineMaintenance, StatusEventTimelineReport, StatusEventTitle, } from "./status-events"; type StatusReport = { id: number; title: string; affected: string[]; updates: { date: Date; message: string; status: "investigating" | "identified" | "monitoring" | "resolved"; }[]; }; type Maintenance = { id: number; title: string; message: string; from: Date; to: Date; affected: string[]; }; type UnifiedEvent = { id: number; title: string; type: "report" | "maintenance"; startDate: Date; data: StatusReport | Maintenance; }; export function StatusFeed({ statusReports = [], maintenances = [], ...props }: React.ComponentProps<"div"> & { statusReports?: StatusReport[]; maintenances?: Maintenance[]; showLinks?: boolean; }) { const prefix = usePathnamePrefix(); const unifiedEvents: UnifiedEvent[] = [ ...statusReports.map((report) => ({ id: report.id, title: report.title, type: "report" as const, // FIXME: we have a flicker here when the report is updated startDate: report.updates[report.updates.length - 1]?.date || new Date(), data: report, })), ...maintenances.map((maintenance) => ({ id: maintenance.id, title: maintenance.title, type: "maintenance" as const, startDate: maintenance.from, data: maintenance, })), ].sort((a, b) => b.startDate.getTime() - a.startDate.getTime()); if (unifiedEvents.length === 0) { return (
No recent notifications There have been no reports within the last 7 days. View events history
); } return ( {unifiedEvents.map((event) => { if (event.type === "report") { const report = event.data as StatusReport; return ( {report.title} {report.affected.length > 0 && ( {report.affected.map((affected, index) => ( {affected} ))} )} ); } if (event.type === "maintenance") { const maintenance = event.data as Maintenance; return ( {maintenance.title} {maintenance.affected.length > 0 && ( {maintenance.affected.map((affected, index) => ( {affected} ))} )} ); } return null; })} View events history ); } ================================================ FILE: apps/status-page/src/components/status-page/status-monitor-tabs.tsx ================================================ import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { cn } from "@openstatus/ui/lib/utils"; export function StatusMonitorTabs({ className, ...props }: React.ComponentProps) { return ; } export function StatusMonitorTabsList({ className, ...props }: React.ComponentProps) { return ( ); } export function StatusMonitorTabsTrigger({ className, ...props }: React.ComponentProps) { return ( ); } export function StatusMonitorTabsTriggerLabel({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function StatusMonitorTabsTriggerValue({ className, ...props }: React.ComponentProps<"div">) { return (
); } export function StatusMonitorTabsTriggerValueSkeleton({ className, ...props }: React.ComponentProps) { return ; } export function StatusMonitorTabsContent({ className, ...props }: React.ComponentProps) { return ( ); } ================================================ FILE: apps/status-page/src/components/status-page/status-monitor.tsx ================================================ "use client"; import type { RouterOutputs } from "@openstatus/api"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceToNowStrict } from "date-fns"; import { AlertCircleIcon, CheckIcon, InfoIcon, TriangleAlertIcon, WrenchIcon, } from "lucide-react"; import { useState } from "react"; import type { VariantType } from "./floating-button"; import { StatusTracker, StatusTrackerSkeleton } from "./status-tracker"; // TODO: use status instead of variant type Data = NonNullable< RouterOutputs["statusPage"]["getUptime"] >[number]["data"]; export function StatusMonitor({ className, status = "success", showUptime = true, data = [], monitor, uptime, isLoading = false, ...props }: React.ComponentProps<"div"> & { status?: VariantType; showUptime?: boolean; uptime?: string; monitor: { name: string; description?: string | null; }; data?: Data; isLoading?: boolean; }) { return (
{monitor.name} {monitor.description}
{/* TODO: check if we can improve that cuz its looking ugly */} {showUptime ? ( <> {isLoading ? ( ) : ( {uptime} )} ) : ( )}
{isLoading ? : }
); } export function StatusMonitorTitle({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusMonitorDescription({ onClick, children, ...props }: React.ComponentProps) { const isTouch = useMediaQuery("(hover: none)"); const [open, setOpen] = useState(false); if (!children) return null; return ( { if (isTouch) setOpen((prev) => !prev); onClick?.(e); }} className="rounded-full" {...props} >

{children}

); } export function StatusMonitorIcon({ className, ...props }: React.ComponentProps<"div">) { return (
svg]:size-[9px]", "group-data-[variant=success]/monitor:bg-success", "group-data-[variant=degraded]/monitor:bg-warning", "group-data-[variant=error]/monitor:bg-destructive", "group-data-[variant=info]/monitor:bg-info", className, )} {...props} >
); } export function StatusMonitorFooter({ data, isLoading, }: { data: Data; isLoading?: boolean; }) { return (
{isLoading ? ( ) : data.length > 0 ? ( formatDistanceToNowStrict(new Date(data[0].day), { unit: "day", addSuffix: true, }) ) : ( "-" )}
today
); } export function StatusMonitorUptime({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusMonitorUptimeSkeleton({ className, ...props }: React.ComponentProps) { return ; } export function StatusMonitorStatus({ className, ...props }: React.ComponentProps<"div">) { return (
Operational Degraded Downtime Maintenance
); } ================================================ FILE: apps/status-page/src/components/status-page/status-tracker-group.tsx ================================================ "use client"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { cn } from "@openstatus/ui/lib/utils"; import { useEffect, useState } from "react"; import type { VariantType } from "./floating-button"; import { StatusMonitorIcon, StatusMonitorStatus } from "./status-monitor"; export function StatusTrackerGroup({ children, title, status, className, defaultOpen = false, ...props }: React.ComponentProps & { title: string; status?: VariantType; children?: React.ReactNode; defaultOpen?: boolean; }) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); return ( {title}
{children}
); } ================================================ FILE: apps/status-page/src/components/status-page/status-tracker.tsx ================================================ "use client"; import { Kbd } from "@/components/common/kbd"; import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; import { formatDateRange } from "@/lib/formatter"; import type { RouterOutputs } from "@openstatus/api"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@openstatus/ui/components/ui/hover-card"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceStrict } from "date-fns"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { requests } from "./messages"; import { chartConfig } from "./utils"; type UptimeData = NonNullable< RouterOutputs["statusPage"]["getUptime"] >[number]["data"]; // TODO: keyboard arrow navigation // FIXME: on small screens, avoid pinned state // TODO: only on real mobile devices, use click events // TODO: improve status reports -> add duration and time // TODO: support headless mode -> both card and bar type share only maintenance or degraded mode // TODO: support status page logo + onClick to homepage // TODO: widget type -> current status only | with status history export function StatusTracker({ data }: { data: UptimeData }) { const [pinnedIndex, setPinnedIndex] = useState(null); const [focusedIndex, setFocusedIndex] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const containerRef = useRef(null); const hoverTimeoutRef = useRef(null); const isTouch = useMediaQuery("(hover: none)"); const prefix = usePathnamePrefix(); useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { if ( pinnedIndex !== null && containerRef.current && !containerRef.current.contains(e.target as Node) ) { setPinnedIndex(null); } }; if (pinnedIndex !== null) { document.addEventListener("mousedown", handleOutsideClick); return () => document.removeEventListener("mousedown", handleOutsideClick); } }, [pinnedIndex]); useEffect(() => { if (focusedIndex !== null && containerRef.current) { const buttons = containerRef.current.querySelectorAll('[role="button"]'); const targetButton = buttons[focusedIndex] as HTMLElement; if (targetButton) { targetButton.focus(); } } }, [focusedIndex]); useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }; }, []); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { setPinnedIndex(null); setFocusedIndex(null); setHoveredIndex(null); if (focusedIndex !== null) { const buttons = containerRef.current?.querySelectorAll('[role="button"]'); const button = buttons?.[focusedIndex] as HTMLElement; if (button) { button.blur(); } } if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } return; } if (focusedIndex !== null) { switch (e.key) { case "ArrowLeft": e.preventDefault(); setFocusedIndex((prev) => prev !== null && prev > 0 ? prev - 1 : data.length - 1, ); break; case "ArrowRight": e.preventDefault(); setFocusedIndex((prev) => prev !== null && prev < data.length - 1 ? prev + 1 : 0, ); break; case "ArrowUp": e.preventDefault(); const prevMonitor = containerRef.current?.closest( '[data-slot="status-monitor"]', )?.previousElementSibling; if (prevMonitor) { const prevTracker = prevMonitor.querySelector('[role="toolbar"]'); if (prevTracker) { const buttons = prevTracker.querySelectorAll('[role="button"]'); const button = buttons?.[focusedIndex] as HTMLElement; if (button) { button.focus(); } } } break; case "ArrowDown": e.preventDefault(); const nextMonitor = containerRef.current?.closest( '[data-slot="status-monitor"]', )?.nextElementSibling; if (nextMonitor) { const nextTracker = nextMonitor.querySelector('[role="toolbar"]'); if (nextTracker) { const buttons = nextTracker.querySelectorAll('[role="button"]'); const button = buttons?.[focusedIndex] as HTMLElement; if (button) { button.focus(); } } } break; case "Enter": case "Escape": case " ": e.preventDefault(); handleBarClick(focusedIndex); break; } } }; const handleBarClick = (index: number) => { // Clear any pending hover timeout if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } if (pinnedIndex === index) { setPinnedIndex(null); } else { setPinnedIndex(index); } }; const handleBarFocus = (index: number) => { setFocusedIndex(index); }; const handleBarBlur = (e: React.FocusEvent, _currentIndex: number) => { const relatedTarget = e.relatedTarget as HTMLElement; const isMovingToAnotherBar = relatedTarget && relatedTarget.closest('[role="toolbar"]') === containerRef.current && relatedTarget.getAttribute("role") === "button"; if (!isMovingToAnotherBar) { setFocusedIndex(null); } }; const handleBarMouseEnter = (index: number) => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } setHoveredIndex(index); }; const handleBarMouseLeave = () => { hoverTimeoutRef.current = setTimeout(() => { setHoveredIndex(null); }, 100); }; const handleHoverCardMouseEnter = () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } }; const handleHoverCardMouseLeave = () => { setHoveredIndex(null); }; return (
{data.map((item, index) => { const isPinned = pinnedIndex === index; const isFocused = focusedIndex === index; const isHovered = hoveredIndex === index; return (
handleBarClick(index)} onFocus={() => handleBarFocus(index)} onBlur={(e) => handleBarBlur(e, index)} onMouseEnter={() => handleBarMouseEnter(index)} onMouseLeave={handleBarMouseLeave} tabIndex={ index === data.length - 1 && focusedIndex === null ? 0 : isFocused ? 0 : -1 } role="button" aria-label={`Day ${index + 1} status`} aria-pressed={isPinned} > {/* Render processed bar segments from backend */} {item.bar.map((segment, segmentIndex) => (
))}
{new Date(item.day).toLocaleDateString("default", { day: "numeric", month: "short", year: "numeric", })}
{/* Render processed card data from backend */} {item.card.map((cardItem, cardIndex) => ( ))}
{item.events.length > 0 && ( <>
{item.events.map((event) => { const eventStatus = event.type === "incident" ? "error" : event.type === "report" ? "degraded" : "info"; const content = ( ); // Wrap reports and maintenances with links if ( event.type === "report" || event.type === "maintenance" ) { return ( {content} ); } // Incidents don't have links return content; })}
)} {isPinned && !isTouch && ( <>
Click again to unpin Esc
)}
); })}
); } export function StatusTrackerSkeleton({ className, ...props }: React.ComponentProps) { return ( ); } function StatusTrackerContent({ status, value, }: { status: "success" | "degraded" | "error" | "info" | "empty"; value: string; }) { return (
{requests[status]}
{value}
); } function StatusTrackerEvent({ name, from, to, status, }: { name: string; from?: Date | null; to?: Date | null; status: "success" | "degraded" | "error" | "info" | "empty"; }) { if (!from) return null; return (
{/* NOTE: this is to make the text truncate based on the with of the sibling element */} {/* REMINDER: height needs to be equal the text height */}
{name}
{formatDateRange(from, to ?? undefined)}{" "} {formatDuration({ from, to, name, status })}
); } const formatDuration = ({ from, to, name, }: React.ComponentProps) => { if (!from) return null; if (!to) return "ongoing"; const duration = formatDistanceStrict(from, to); const isMultipleIncidents = name.includes("Downtime ("); if (isMultipleIncidents) return `across ${duration}`; if (duration === "0 seconds") return null; return duration; }; ================================================ FILE: apps/status-page/src/components/status-page/status-updates.tsx ================================================ "use client"; import { FormSubscribeEmail, type FormValues, } from "@/components/forms/form-subscribe-email"; import { getBaseUrl } from "@/lib/base-url"; import type { RouterOutputs } from "@openstatus/api"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, Copy, Inbox } from "lucide-react"; import { useState } from "react"; export type StatusUpdateType = "email" | "rss" | "ssh" | "json" | "slack"; type Page = NonNullable; function getUpdateLink(type: "rss" | "json" | "atom", page?: Page | null) { const baseUrl = getBaseUrl({ slug: page?.slug, customDomain: page?.customDomain, }); return `${baseUrl}/feed/${type}${ page?.accessType === "password" ? `?pw=${page?.password}` : "" }`; } // TODO: use domain instead of openstatus subdomain if available interface StatusUpdatesProps extends React.ComponentProps { types?: StatusUpdateType[]; page?: Page | null; onSubscribe?: (values: FormValues) => Promise | void; } export function StatusUpdates({ className, types = ["rss", "ssh", "json", "slack"], page, onSubscribe, ...props }: StatusUpdatesProps) { const [success, setSuccess] = useState(false); if (types.length === 0) return null; return ( {types.includes("email") ? ( Email ) : null} {types.includes("slack") ? ( Slack ) : null} {types.includes("rss") ? ( RSS ) : null} {types.includes("json") ? ( JSON ) : null} {types.includes("ssh") ? ( SSH ) : null} {success ? ( ) : ( <>

Get email notifications whenever a report has been created or resolved

{ await onSubscribe?.(values); setSuccess(true); }} />
{" "} )}

Get the RSS feed

Get the Atom feed

Get the JSON updates

Get status via SSH

For status updates in Slack, paste the text below into any channel.

); } function CopyInputButton({ value, onClick, ...props }: React.ComponentProps & { value: string; }) { const { copy, isCopied } = useCopyToClipboard(); return (
{ copy(value, { successMessage: "Link copied to clipboard", withToast: true, }); onClick?.(e); }} {...props} />
); } function SuccessMessage() { return (

Check your inbox!

Validate your email to receive updates and you are all set.

); } ================================================ FILE: apps/status-page/src/components/status-page/status.tsx ================================================ import { UTCDate } from "@date-fns/utc"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { format } from "date-fns"; import { AlertCircleIcon, CheckIcon, TriangleAlertIcon, WrenchIcon, } from "lucide-react"; export function Status({ children, className, variant = "success", ...props }: React.ComponentProps<"div"> & { variant?: "success" | "degraded" | "error" | "info"; }) { return (
{children}
); } export function StatusBrand({ src, alt, className, ...props }: React.ComponentProps<"img">) { return ( // biome-ignore lint/a11y/useAltText: {alt} ); } export function StatusHeader({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusTitle({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusDescription({ children, className, }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusContent({ children, className, }: React.ComponentProps<"div">) { return
{children}
; } export function StatusIcon({ className, ...props }: React.ComponentProps<"div">) { return (
svg]:size-4", "group-data-[variant=success]:bg-success", "group-data-[variant=degraded]:bg-warning", "group-data-[variant=error]:bg-destructive", "group-data-[variant=info]:bg-info", className, )} {...props} >
); } export function StatusTimestamp({ date, className, ...props }: React.ComponentProps & { date: Date }) { return ( {format(new UTCDate(date), "LLL dd, y HH:mm (z)")}

{format(date, "LLL dd, y HH:mm (z)")}

); } export function StatusEmptyState({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEmptyStateTitle({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } export function StatusEmptyStateDescription({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } ================================================ FILE: apps/status-page/src/components/status-page/utils.ts ================================================ import type { ChartConfig } from "@openstatus/ui/components/ui/chart"; import { VARIANT, type VariantType } from "./floating-button"; export const chartData = Array.from({ length: 45 }, (_, i) => { const date = new Date(); date.setDate(date.getDate() - i); // Simulate realistic daily status distribution that sums to 1440 minutes let error = 0; let degraded = 0; let success = 1440; // Start with all minutes as ok let info = 0; // Simulate some incidents on certain days if (i === 3) { // Day 3: Major incident for 2 hours (120 minutes) error = 120; success -= error; } else if (i === 16) { // Day 16: Degraded performance for 4 hours (240 minutes) degraded = 240; success -= degraded; } else if (i === 8) { // Day 8: Brief outage (30 minutes) + some degraded performance (60 minutes) error = 30; degraded = 60; success -= error + degraded; } else if (i === 13) { info = 120; success -= info; } else if (i === 22) { // Day 22: Extended degraded performance (6 hours = 360 minutes) degraded = 360; success -= degraded; } else if (Math.random() > 0.85) { // Random minor issues on some days (5-15 minutes of degraded performance) degraded = Math.floor(Math.random() * 10) + 5; success -= degraded; } return { timestamp: date.getTime(), info, degraded, error, success, }; }).reverse(); export type ChartData = (typeof chartData)[number]; export const chartConfig = { success: { label: "success", color: "var(--success)", }, degraded: { label: "degraded", color: "var(--warning)", }, error: { label: "error", color: "var(--destructive)", }, info: { label: "info", color: "var(--info)", }, empty: { label: "empty", color: "var(--muted)", }, } satisfies ChartConfig; export const PRIORITY = { error: 3, degraded: 2, info: 1, success: 0, } as const; // satisfies Record; export function getHighestPriorityStatus(item: ChartData) { const total = item.success + item.degraded + item.info + item.error; if (total === 0) return "empty"; return ( VARIANT.filter((status) => item[status] > 0).sort( (a, b) => PRIORITY[b] - PRIORITY[a], )[0] || "empty" ); } export const PERCENTAGE_PRIORITY = { info: -1, error: 0, degraded: 0.75, success: 0.95, } as const; export function getPercentagePriorityStatus(item: ChartData) { const total = item.success + item.degraded + item.info + item.error; if (total === 0) return "empty"; const percentage = item.success / total; if (percentage >= PERCENTAGE_PRIORITY.success) return "success"; if (percentage >= PERCENTAGE_PRIORITY.degraded) return "degraded"; if (percentage >= PERCENTAGE_PRIORITY.error) return "error"; if (percentage >= PERCENTAGE_PRIORITY.info) return "info"; return "info"; } export function getHighestStatus(items: VariantType[]) { if (items.some((item) => item === "error")) return "error"; if (items.some((item) => item === "degraded")) return "degraded"; if (items.some((item) => item === "info")) return "info"; return "success"; } export function getTotalUptime(item: ChartData[]) { const { ok, total } = item.reduce( (acc, item) => ({ ok: acc.ok + item.success + item.degraded + item.info, total: acc.total + item.success + item.degraded + item.info + item.error, }), { ok: 0, total: 0, }, ); if (total === 0) return 100; return Math.round((ok / total) * 10000) / 100; } export function getManualUptime( items: { from: Date | null; to: Date | null }[], days: number, ) { const duration = items.reduce((acc, item) => { if (!item.from) return acc; return acc + ((item.to || new Date()).getTime() - item.from.getTime()); }, 0); const total = days * 24 * 60 * 60 * 1000; return Math.round(((total - duration) / total) * 10000) / 100; } ================================================ FILE: apps/status-page/src/components/tailwind-indicator.tsx ================================================ export function TailwindIndicator() { if (process.env.NODE_ENV === "production") return null; return (
xs
sm
md
lg
xl
2xl
); } ================================================ FILE: apps/status-page/src/components/themes/theme-dropdown.tsx ================================================ "use client"; import { useTheme } from "next-themes"; import type * as React from "react"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { Laptop, Moon, Sun } from "lucide-react"; import { useState } from "react"; import { useEffect } from "react"; function getThemeIcon(theme?: string | null) { if (theme === "light") return ; if (theme === "dark") return ; if (theme === "system") return ; return null; } export function ThemeDropdown({ className, ...props }: React.ComponentProps) { const { setTheme, theme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) { return ; } return ( {["light", "dark", "system"].map((theme) => ( setTheme(theme)}> {getThemeIcon(theme)} {theme} ))} ); } ================================================ FILE: apps/status-page/src/components/themes/theme-palette-picker.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; import { useSidebar } from "@openstatus/ui/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { Palette } from "lucide-react"; export function ThemePalettePicker() { const { toggleSidebar } = useSidebar(); return ( Toggle Sidebar{" "} + B ); } ================================================ FILE: apps/status-page/src/components/themes/theme-provider.tsx ================================================ "use client"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import type * as React from "react"; export function ThemeProvider({ children, ...props }: React.ComponentProps) { return {children}; } ================================================ FILE: apps/status-page/src/components/themes/theme-select.tsx ================================================ "use client"; import { useTheme } from "next-themes"; import type * as React from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { cn } from "@openstatus/ui/lib/utils"; import { Laptop, Moon, Sun } from "lucide-react"; import { useState } from "react"; import { useEffect } from "react"; export function ThemeSelect({ className, ...props }: React.ComponentProps) { const { setTheme, theme } = useTheme(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) { return ( ); } return ( ); } ================================================ FILE: apps/status-page/src/components/themes/theme-sidebar.tsx ================================================ "use client"; import { searchParamsParsers } from "@/app/(public)/search-params"; import { recomputeStyles } from "@/components/status-page/floating-button"; import { THEMES, type Theme, type ThemeKey, type ThemeVarName, } from "@openstatus/theme-store"; import { Button } from "@openstatus/ui/components/ui/button"; import { ButtonGroup, ButtonGroupText, } from "@openstatus/ui/components/ui/button-group"; import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@openstatus/ui/components/ui/collapsible"; import { InputGroup, InputGroupInput, } from "@openstatus/ui/components/ui/input-group"; import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; import { Label } from "@openstatus/ui/components/ui/label"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { useDebounceCallback } from "@openstatus/ui/hooks/use-debounce-callback"; import { cn } from "@openstatus/ui/lib/utils"; import { Check, ChevronDown, Copy, PanelRightIcon, RotateCcw, } from "lucide-react"; import { useTheme } from "next-themes"; import { useQueryStates } from "nuqs"; import { useEffect, useState } from "react"; type ThemeBuilderColor = { label: string; type: "color"; values: { id: ThemeVarName; label: string }[]; }; type ThemeBuilderCheckbox = { label: string; type: "checkbox"; values: { id: ThemeVarName; label: string }[]; options: { value: string; label: boolean }[]; }; const THEME_BUILDER_INFO = { id: { label: "ID", id: "id", type: "text", }, name: { label: "Name", id: "name", type: "text", }, author: { label: "Author", id: "author.name", type: "text", }, authorUrl: { label: "Link", id: "author.url", type: "text", }, } as const; const THEME_STYLE_BUILDER = { base: { label: "Base Colors", type: "color", values: [ { id: "--foreground", label: "Foreground" }, { id: "--background", label: "Background" }, // consider linking both border and input to the same color { id: "--border", label: "Border" }, { id: "--input", label: "Input" }, ], }, status: { label: "Status Colors", type: "color", values: [ { id: "--success", label: "Operational" }, { id: "--destructive", label: "Error" }, { id: "--warning", label: "Degraded" }, { id: "--info", label: "Maintenance" }, ], }, brand: { label: "Brand Colors", type: "color", values: [ { id: "--primary", label: "Primary" }, { id: "--primary-foreground", label: "Primary Foreground" }, // consider linking both secondary, muted, accent to the same color { id: "--secondary", label: "Secondary" }, { id: "--muted", label: "Muted" }, { id: "--muted-foreground", label: "Muted Foreground" }, { id: "--accent", label: "Accent" }, { id: "--accent-foreground", label: "Accent Foreground" }, ], }, "border-radius": { label: "Border Radius", type: "checkbox", values: [{ id: "--radius", label: "Border Radius" }], options: [ { value: "0rem", label: false }, { value: "0.625rem", label: true }, ], }, } satisfies Record; // Helper function to get nested property value from an object // biome-ignore lint/suspicious/noExplicitAny: function getNestedValue(obj: any, path: string): string | undefined { const keys = path.split("."); let value = obj; for (const key of keys) { if (value === undefined || value === null) return undefined; value = value[key]; } return value; } export function ThemeSidebar(props: React.ComponentProps) { const [{ t, b }, setSearchParams] = useQueryStates(searchParamsParsers); const [newTheme, setNewTheme] = useState(THEMES[t]); const { resolvedTheme, setTheme } = useTheme(); const { copy, isCopied } = useCopyToClipboard(); const [isMounted, setIsMounted] = useState(false); const debouncedNewTheme = useDebounce(newTheme, 100); const { setOpen } = useSidebar(); useEffect(() => { setIsMounted(true); }, []); useEffect(() => { setNewTheme(THEMES[t]); }, [t]); useEffect(() => { if (b) { setOpen(true); setSearchParams({ b: null }); } }, [b, setOpen, setSearchParams]); useEffect(() => { if (!resolvedTheme || !isMounted) return; recomputeStyles(debouncedNewTheme.id as ThemeKey, { ...debouncedNewTheme }); }, [resolvedTheme, isMounted, debouncedNewTheme]); return (
Theme Builder

Reset theme

Information {Object.entries(THEME_BUILDER_INFO).map(([key, config]) => (
))}
Theme Mode
{!isMounted ? ( ) : ( setTheme(value as "light" | "dark") } className="w-full" > Light Dark )}
{Object.entries(THEME_STYLE_BUILDER).map(([key, config], index) => ( {config.label} {config.values.map((value) => { return (
{value.label}
); })}
))}
); } function ThemeValueSelector(props: { config: ThemeBuilderColor | ThemeBuilderCheckbox; id: ThemeVarName; theme: Theme; setTheme: (theme: Theme) => void; isMounted: boolean; }) { const { resolvedTheme } = useTheme(); const handleChange = useDebounceCallback((value: string) => { const mode = resolvedTheme as "light" | "dark"; props.setTheme({ ...props.theme, [mode]: { ...props.theme[mode], [props.id]: value, }, }); }, 100); if (!props.isMounted || !resolvedTheme) return ; const value = props.theme[resolvedTheme as "light" | "dark"][props.id]; if (props.config.type === "color") { return ( ); } if (props.config.type === "checkbox") { const { options } = props.config; const checked = options.find((option) => option.value === value)?.label ?? false; return ( ); } return ( {value} ); } export function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { toggleSidebar } = useSidebar(); return ( Toggle Sidebar{" "} + B ); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-action-bar.tsx ================================================ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import type { Table } from "@tanstack/react-table"; import { Loader, X } from "lucide-react"; import * as React from "react"; import * as ReactDOM from "react-dom"; export interface DataTableActionBarProps extends React.ComponentProps<"div"> { table: Table; visible?: boolean; container?: Element | DocumentFragment | null; } function DataTableActionBar({ table, visible: visibleProp, container: containerProp, children, className, ...props }: DataTableActionBarProps) { const [mounted, setMounted] = React.useState(false); React.useLayoutEffect(() => { setMounted(true); }, []); React.useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { table.toggleAllRowsSelected(false); } } window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [table]); const container = containerProp ?? (mounted ? globalThis.document?.body : null); if (!container) return null; const visible = visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0; return ReactDOM.createPortal(
{visible && (
{children}
)}
, container, ); } interface DataTableActionBarActionProps extends React.ComponentProps { tooltip?: string; isPending?: boolean; } function DataTableActionBarAction({ size = "sm", tooltip, isPending, disabled, className, children, ...props }: DataTableActionBarActionProps) { const trigger = ( ); if (!tooltip) return trigger; return ( {trigger}

{tooltip}

); } interface DataTableActionBarSelectionProps { table: Table; } function DataTableActionBarSelection({ table, }: DataTableActionBarSelectionProps) { const onClearSelection = React.useCallback(() => { table.toggleAllRowsSelected(false); }, [table]); return (
{table.getFilteredSelectedRowModel().rows.length} selected

Clear selection

Esc
); } export { DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection, }; ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-column-header.tsx ================================================ import type { Column } from "@tanstack/react-table"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { cn } from "@openstatus/ui/lib/utils"; interface DataTableColumnHeaderProps extends React.ComponentProps<"button"> { column: Column; title: string; } export function DataTableColumnHeader({ column, title, className, ...props }: DataTableColumnHeaderProps) { if (!column.getCanSort()) { return
{title}
; } return ( ); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-faceted-filter.tsx ================================================ import type { Column } from "@tanstack/react-table"; import { Check, PlusCircle } from "lucide-react"; import type * as React from "react"; import { Badge } from "@openstatus/ui/components/ui/badge"; import { Button } from "@openstatus/ui/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@openstatus/ui/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@openstatus/ui/components/ui/popover"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; interface DataTableFacetedFilterProps { column?: Column; title?: string; options: { label: string; value: string | number; icon?: React.ComponentType<{ className?: string }>; }[]; } export function DataTableFacetedFilter({ column, title, options, }: DataTableFacetedFilterProps) { const facets = column?.getFacetedUniqueValues(); const selectedValues = new Set( column?.getFilterValue() as (string | number)[], ); return ( No results found. {options.map((option) => { const isSelected = selectedValues.has(option.value); return ( { if (isSelected) { selectedValues.delete(option.value); } else { selectedValues.add(option.value); } const filterValues = Array.from(selectedValues); column?.setFilterValue( filterValues.length ? filterValues : undefined, ); }} >
{option.icon && ( )} {option.label} {facets?.get(option.value) && ( {facets.get(option.value)} )}
); })}
{selectedValues.size > 0 && ( <> column?.setFilterValue(undefined)} className="justify-center text-center" > Clear filters )}
); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-pagination.tsx ================================================ import type { Table } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; export interface DataTablePaginationProps { table: Table; } export function DataTablePagination({ table, }: DataTablePaginationProps) { return (
{table.getFilteredSelectedRowModel().rows.length} of{" "} {table.getFilteredRowModel().rows.length} row(s) selected.

Rows per page

Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()}
); } export function DataTablePaginationSimple({ table, }: DataTablePaginationProps) { return (
{table.getFilteredRowModel().rows.length} of{" "} {table.getPreFilteredRowModel().rows.length} row(s) filtered.
); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-skeleton.tsx ================================================ import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; interface DataTableSkeletonProps { /** * Number of rows to render * @default 10 */ rows?: number; } // TODO: add checkbox skeleton (for MonitorTable e.g.) export function DataTableSkeleton({ rows = 3 }: DataTableSkeletonProps) { return ( {new Array(rows).fill(0).map((_, i) => ( ))}
); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-toobar.tsx ================================================ "use client"; import type { Table } from "@tanstack/react-table"; import { X } from "lucide-react"; import { DataTableViewOptions } from "@/components/ui/data-table/data-table-view-options"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; export interface DataTableToolbarProps { table: Table; } export function DataTableToolbar({ table, }: DataTableToolbarProps) { const isFiltered = table.getState().columnFilters.length > 0; return (
table.getColumn("title")?.setFilterValue(event.target.value) } className="h-8 w-[150px] lg:w-[250px]" /> {table.getColumn("status") && ( )} {table.getColumn("tags") && ( )} {isFiltered && ( )}
); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table-view-options.tsx ================================================ "use client"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import type { Table } from "@tanstack/react-table"; import { Settings2 } from "lucide-react"; import { Button } from "@openstatus/ui/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, } from "@openstatus/ui/components/ui/dropdown-menu"; interface DataTableViewOptionsProps { table: Table; } export function DataTableViewOptions({ table, }: DataTableViewOptionsProps) { return ( Toggle columns {table .getAllColumns() .filter( (column) => typeof column.accessorFn !== "undefined" && column.getCanHide(), ) .map((column) => { return ( column.toggleVisibility(!!value)} > {column.id} ); })} ); } ================================================ FILE: apps/status-page/src/components/ui/data-table/data-table.tsx ================================================ "use client"; import { type ColumnDef, type ColumnFiltersState, type PaginationState, type Row, type SortingState, type VisibilityState, flexRender, getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import * as React from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; import { Fragment } from "react"; import type { DataTableActionBarProps } from "./data-table-action-bar"; import type { DataTablePaginationProps } from "./data-table-pagination"; import type { DataTableToolbarProps } from "./data-table-toobar"; export interface DataTableProps { columns: ColumnDef[]; data: TData[]; rowComponent?: React.ComponentType<{ row: Row }>; toolbarComponent?: React.ComponentType>; actionBar?: React.ComponentType>; paginationComponent?: React.ComponentType>; onRowClick?: (row: Row) => void; defaultSorting?: SortingState; defaultColumnVisibility?: VisibilityState; defaultColumnFilters?: ColumnFiltersState; defaultPagination?: PaginationState; autoResetPageIndex?: boolean; /** access the state from the parent component */ columnFilters?: ColumnFiltersState; setColumnFilters?: React.Dispatch>; sorting?: SortingState; setSorting?: React.Dispatch>; pagination?: PaginationState; setPagination?: React.Dispatch>; } export function DataTable({ columns, data, rowComponent, toolbarComponent, actionBar, paginationComponent, onRowClick, defaultSorting = [], defaultColumnVisibility = {}, defaultColumnFilters = [], defaultPagination = { pageIndex: 0, pageSize: 10 }, autoResetPageIndex = true, columnFilters, setColumnFilters, sorting, setSorting, pagination, setPagination, }: DataTableProps) { // biome-ignore lint/suspicious/noExplicitAny: const [globalFilter, setGlobalFilter] = React.useState(); const [rowSelection, setRowSelection] = React.useState({}); const [columnVisibility, setColumnVisibility] = React.useState(defaultColumnVisibility); const [internalPagination, setInternalPagination] = React.useState(defaultPagination); const [internalColumnFilters, setInternalColumnFilters] = React.useState(defaultColumnFilters); const [internalSorting, setInternalSorting] = React.useState(defaultSorting); // Use controlled or uncontrolled column filters const columnFiltersState = columnFilters ?? internalColumnFilters; const setColumnFiltersState = setColumnFilters ?? setInternalColumnFilters; const sortingState = sorting ?? internalSorting; const setSortingState = setSorting ?? setInternalSorting; const paginationState = pagination ?? internalPagination; const setPaginationState = setPagination ?? setInternalPagination; const table = useReactTable({ data, columns, state: { sorting: sortingState, columnVisibility, rowSelection, pagination: paginationState, columnFilters: columnFiltersState, globalFilter, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onSortingChange: setSortingState, onColumnFiltersChange: setColumnFiltersState, onColumnVisibilityChange: setColumnVisibility, onPaginationChange: setPaginationState, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), getExpandedRowModel: getExpandedRowModel(), autoResetPageIndex, // @ts-expect-error as we have an id in the data getRowCanExpand: (row) => Boolean(row.original.id), }); return (
{toolbarComponent ? React.createElement(toolbarComponent, { table }) : null} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} ); })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( onRowClick?.(row)} className="data-[state=selected]:bg-muted/50" > {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ))} {row.getIsExpanded() && ( {rowComponent ? React.createElement(rowComponent, { row }) : null} )} )) ) : ( No results. )} {actionBar ? React.createElement(actionBar, { table }) : null}
{paginationComponent ? React.createElement(paginationComponent, { table }) : null}
); } ================================================ FILE: apps/status-page/src/data/icons.ts ================================================ "use client"; import { Activity, AlertCircle, SearchCheck } from "lucide-react"; export const status = { operational: SearchCheck, investigating: AlertCircle, identified: AlertCircle, monitoring: Activity, } as const; export const icons = { status, }; ================================================ FILE: apps/status-page/src/data/incidents.client.ts ================================================ import { Bookmark, Check, Trash2 } from "lucide-react"; export const actions = [ { id: "acknowledge", label: "Acknowledge", icon: Bookmark, variant: "default" as const, }, { id: "resolve", label: "Resolve", icon: Check, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type IncidentAction = (typeof actions)[number]; export const getActions = ( props: Partial Promise | void>>, ): (IncidentAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/incidents.ts ================================================ export const incidents = [ { id: 1, startedAt: new Date("2025-05-05 12:00:00"), acknowledged: null, resolvedAt: new Date("2025-05-05 14:00:00"), monitor: "OpenStatus API", }, ]; export type Incident = (typeof incidents)[number]; ================================================ FILE: apps/status-page/src/data/invitations.ts ================================================ export const invitations = [ { id: 1, email: "thibault@openstatus.dev", role: "member", createdAt: "2021-01-01", expiresAt: "2021-01-07", acceptedAt: "2021-01-02", }, ]; export type Invitation = (typeof invitations)[number]; ================================================ FILE: apps/status-page/src/data/maintenances.client.ts ================================================ import { Pencil, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Edit", icon: Pencil, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type MaintenanceAction = (typeof actions)[number]; export const getActions = ( props: Partial Promise | void>>, ): (MaintenanceAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/maintenances.ts ================================================ const today = new Date(); const week = new Date(today); week.setDate(week.getDate() + 7); const hour = new Date(week); hour.setHours(hour.getHours() + 1); export const maintenances = [ { id: 1, title: "DB Migration", message: "We are performing a db migration on our system and will be down for a an hour.", startDate: week, endDate: hour, affected: ["OpenStatus API"], }, { id: 2, title: "System Upgrade", message: "We will be upgrading our core infrastructure to improve performance and reliability. Service interruptions may occur.", startDate: new Date("2025-03-01 11:00:00"), endDate: new Date("2025-03-01 15:30:00"), affected: ["OpenStatus API", "OpenStatus Web"], }, ]; export type Maintenance = (typeof maintenances)[number]; ================================================ FILE: apps/status-page/src/data/members.ts ================================================ export const members = [ { id: 1, name: "Maximilian Kaske", email: "max@openstatus.dev", role: "admin", createdAt: "2021-01-01", }, ]; export type Member = (typeof members)[number]; ================================================ FILE: apps/status-page/src/data/metrics.client.ts ================================================ "use client"; import type { MetricCard } from "@/components/content/metric-card"; import { formatDateTime, formatMilliseconds } from "@/lib/formatter"; import type { RouterOutputs } from "@openstatus/api"; import { monitorRegions } from "@openstatus/db/src/schema/constants"; import type { RegionMetric } from "./region-metrics"; import type { Region } from "./regions"; export const STATUS = ["success", "error", "degraded"] as const; export const PERIODS = ["1d", "7d", "14d"] as const; export const REGIONS = monitorRegions as unknown as (typeof monitorRegions)[number][]; export const PERCENTILES = ["p50", "p75", "p90", "p95", "p99"] as const; export const INTERVALS = [5, 15, 30, 60, 120, 240, 480, 1440] as const; export const TRIGGER = ["api", "cron"] as const; const PERCENTILE_MAP = { p50: "p50Latency", p75: "p75Latency", p90: "p90Latency", p95: "p95Latency", p99: "p99Latency", } as const; // FIXME: rename pipe return values export function mapMetrics(metrics: RouterOutputs["tinybird"]["metrics"]) { return metrics.data?.map((metric) => { return { p50: metric.p50Latency, p75: metric.p75Latency, p90: metric.p90Latency, p95: metric.p95Latency, p99: metric.p99Latency, total: metric.count, uptime: (metric.success + metric.degraded) / metric.count, degraded: metric.degraded, error: metric.error, lastTimestamp: metric.lastTimestamp, }; }); } export const metricsCards = { uptime: { label: "UPTIME", variant: "success", }, degraded: { label: "DEGRADED", variant: "warning", }, error: { label: "FAILING", variant: "destructive", }, total: { label: "REQUESTS", variant: "default", }, lastTimestamp: { label: "LAST CHECKED", variant: "ghost", }, p50: { label: "P50", variant: "default", }, p75: { label: "P75", variant: "default", }, p90: { label: "P90", variant: "default", }, p95: { label: "P95", variant: "default", }, p99: { label: "P99", variant: "default", }, } as const satisfies Record< keyof ReturnType[number], { label: string; variant: React.ComponentProps["variant"]; } >; export function mapUptime(status: RouterOutputs["tinybird"]["uptime"]) { return status.data .map((status) => { return { ...status, ok: status.success, interval: formatDateTime(status.interval), total: status.success + status.error + status.degraded, }; }) .reverse(); } /** * Transform Tinybird `metricsRegions` response into RegionMetric[] for UI. */ export function mapRegionMetrics( timeline: RouterOutputs["tinybird"]["metricsRegions"] | undefined, regions: Region[], percentile: (typeof PERCENTILES)[number], ): RegionMetric[] { if (!timeline) return (regions .sort((a, b) => a.localeCompare(b)) .map((region) => ({ region, p50: 0, p90: 0, p99: 0, trend: [] as { latency: number; timestamp: number; [key: string]: number; }[], })) ?? []) as RegionMetric[]; type TimelineRow = (typeof timeline.data)[number]; const map = new Map< Region, { region: Region; p50: number; p90: number; p99: number; trend: { latency: number; timestamp: number; [key: string]: number; }[]; } >(); (timeline.data as TimelineRow[]) .filter((row) => regions.includes(row.region as Region)) .sort((a, b) => a.region.localeCompare(b.region)) .forEach((row) => { const region = row.region as Region; const entry = map.get(region) ?? { region, p50: 0, p90: 0, p99: 0, trend: [], }; entry.trend.push({ latency: row[PERCENTILE_MAP[percentile]] ?? 0, timestamp: row.timestamp, [region]: row[PERCENTILE_MAP[percentile]] ?? 0, }); entry.p50 += row.p50Latency ?? 0; entry.p90 += row.p90Latency ?? 0; entry.p99 += row.p99Latency ?? 0; map.set(region, entry); }); map.forEach((entry) => { const count = entry.trend.length || 1; entry.trend.reverse(); entry.p50 = Math.round(entry.p50 / count); entry.p90 = Math.round(entry.p90 / count); entry.p99 = Math.round(entry.p99 / count); }); return Array.from(map.values()) as RegionMetric[]; } export function mapGlobalMetrics( metrics: RouterOutputs["tinybird"]["globalMetrics"], ) { return metrics.data?.map((metric) => { return { p50: metric.p50Latency, p75: metric.p75Latency, p90: metric.p90Latency, p95: metric.p95Latency, p99: metric.p99Latency, total: metric.count, monitorId: metric.monitorId, }; }); } export type MonitorListMetric = { title: string; key: "degraded" | "error" | "active" | "inactive" | "p95"; value: number | string | undefined; variant: React.ComponentProps["variant"]; }; export const globalCards = [ "active", "degraded", "error", "inactive", "p95", ] as const; export const metricsGlobalCards: Record< (typeof globalCards)[number], { title: string; key: (typeof globalCards)[number]; } > = { active: { title: "Normal", key: "active" as const, }, degraded: { title: "Degraded", key: "degraded" as const, }, error: { title: "Failing", key: "error" as const, }, inactive: { title: "Inactive", key: "inactive" as const, }, p95: { title: "Slowest P95", key: "p95" as const, }, }; /** * Build the metric cards data that is shown on the monitors list page. */ export function getMonitorListMetrics( monitors: RouterOutputs["monitor"]["list"] = [], data: { p95Latency: number; monitorId: string; }[] = [], ): readonly MonitorListMetric[] { const variantMap: Record< (typeof globalCards)[number], React.ComponentProps["variant"] > = { active: "success", degraded: "warning", error: "destructive", inactive: "default", p95: "ghost", } as const; return globalCards.map((key) => { let value: number | string | undefined; switch (key) { case "active": value = monitors.filter( (m) => m.status === "active" && m.active, ).length; break; case "degraded": value = monitors.filter( (m) => m.status === "degraded" && m.active, ).length; break; case "error": value = monitors.filter((m) => m.status === "error" && m.active).length; break; case "inactive": value = monitors.filter((m) => m.active === false).length; break; case "p95": const p95 = data.sort((a, b) => b.p95Latency - a.p95Latency)[0] ?.p95Latency; value = p95 ? formatMilliseconds(p95) : "N/A"; break; } return { title: metricsGlobalCards[key].title, key, value, variant: variantMap[key], } as const; }) as readonly MonitorListMetric[]; } export function mapLatency( latency: RouterOutputs["tinybird"]["metricsLatency"], percentile: (typeof PERCENTILES)[number], ) { return latency.data?.map((metric) => { return { timestamp: formatDateTime(new Date(metric.timestamp)), latency: metric[PERCENTILE_MAP[percentile]], }; }); } export function mapTimingPhases( timingPhases: RouterOutputs["tinybird"]["metricsTimingPhases"], percentile: (typeof PERCENTILES)[number], ) { return timingPhases.data?.map((metric) => { return { timestamp: formatDateTime(new Date(metric.timestamp)), dns: metric[`${percentile}Dns`], ttfb: metric[`${percentile}Ttfb`], transfer: metric[`${percentile}Transfer`], connect: metric[`${percentile}Connect`], tls: metric[`${percentile}Tls`], }; }); } ================================================ FILE: apps/status-page/src/data/monitor-tags.ts ================================================ export const monitorTags = [ { value: "production", label: "Production", color: "bg-green-500", }, { value: "development", label: "Development", color: "bg-blue-500", }, { value: "staging", label: "Staging", color: "bg-yellow-500", }, { value: "testing", label: "Testing", color: "bg-purple-500", }, { value: "api", label: "API", color: "bg-red-500", }, { value: "database", label: "Database", color: "bg-orange-500", }, ]; export type MonitorTag = (typeof monitorTags)[number]; ================================================ FILE: apps/status-page/src/data/monitors.client.ts ================================================ import { Code, Copy, CopyPlus, Pencil, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Edit", icon: Pencil, variant: "default" as const, }, { id: "copy-id", label: "Copy ID", icon: Copy, variant: "default" as const, }, { id: "clone", label: "Clone", icon: CopyPlus, variant: "default" as const, }, { id: "export", label: "Export Code", icon: Code, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type MonitorAction = (typeof actions)[number]; export const getActions = ( props: Partial Promise | void>>, ): (MonitorAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/monitors.ts ================================================ export const monitors = [ { id: 1, name: "OpenStatus Marketing", description: "Marketing website for OpenStatus", public: true, active: true, status: "Normal" as const, url: "https://openstatus.dev", tags: ["Production"], lastIncident: undefined, p50: 110, p90: 200, p99: 250, }, { id: 2, name: "OpenStatus API", description: "API for OpenStatus", public: true, active: true, status: "Normal" as const, url: "https://api.openstatus.dev/v1/ping", tags: ["Production", "API"], lastIncident: undefined, p50: 34, p90: 201, p99: 530, }, { id: 3, name: "OpenStatus App", description: "Dashboard for OpenStatus", public: true, active: true, status: "Failing" as const, url: "https://openstatus.dev/app", tags: ["Production"], lastIncident: "10 minutes ago", p50: 130, p90: 200, p99: 250, }, { id: 4, name: "Lightweight OS", description: "Lightweight Operations System", public: false, active: false, status: "Inactive" as const, url: "https://data-table.openstatus.dev/light", tags: ["Development"], lastIncident: undefined, p50: undefined, p90: undefined, p99: undefined, }, { id: 5, name: "Astro Status Page", description: "Status page for Astro", public: false, active: true, status: "Degraded" as const, url: "https://status.openstat.us", tags: ["Development"], lastIncident: undefined, p50: 130, p90: 201, p99: 250, }, { id: 6, name: "Vercel Edge Ping", description: "Ping for Vercel Edge", public: false, active: true, status: "Normal" as const, url: "https://light.openstatus.dev", tags: ["Staging"], lastIncident: "15 days ago", p50: 30, p90: 240, p99: 400, }, ]; export type Monitor = (typeof monitors)[number]; ================================================ FILE: apps/status-page/src/data/plans.ts ================================================ export const plans = [ { title: "Hobby", id: "hobby", description: "Perfect for personal projects", price: 0, limits: { monitors: 1, regions: 35, periodicity: "10m", "status-pages": 1, members: 1, "notification-channels": 1, "custom-domain": false, "password-protection": false, "status-subscribers": false, "audit-log": false, }, }, { title: "Starter", id: "starter", description: "Perfect for uptime monitoring", price: 30, limits: { monitors: 10, regions: 35, periodicity: "1m", "status-pages": 1, members: Number.POSITIVE_INFINITY, "notification-channels": 10, "custom-domain": true, "password-protection": true, "status-subscribers": true, "audit-log": false, }, }, { title: "Pro", id: "team", description: "Perfect for global synthetic monitoring", price: 100, limits: { monitors: 100, regions: 35, periodicity: "30s", "status-pages": 5, members: Number.POSITIVE_INFINITY, "notification-channels": 20, "custom-domain": true, "password-protection": true, "status-subscribers": true, "audit-log": true, }, }, ]; ================================================ FILE: apps/status-page/src/data/region-metrics.client.ts ================================================ import { Filter, Zap } from "lucide-react"; export const actions = [ { id: "filter", label: "Filter", icon: Filter, variant: "default" as const, }, { id: "trigger", label: "Trigger", icon: Zap, variant: "default" as const, }, ] as const; export type RegionMetricAction = (typeof actions)[number]; export const getActions = ( props: Partial Promise | void>>, ): (RegionMetricAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/region-metrics.ts ================================================ import type { Region } from "./regions"; export const regionMetrics = [ { region: "ams" as const satisfies Region, p50: 100, p90: 150, p99: 200, trend: [{ latency: 100, timestamp: 1716729600 }], }, { region: "fra" as const satisfies Region, p50: 110, p90: 155, p99: 220, trend: [{ latency: 100, timestamp: 1716729600 }], }, { region: "gru" as const satisfies Region, p50: 120, p90: 160, p99: 230, trend: [{ latency: 100, timestamp: 1716729600 }], }, ]; export type RegionMetric = (typeof regionMetrics)[number]; ================================================ FILE: apps/status-page/src/data/region-percentile.ts ================================================ const randomizer = Math.random() * 10; export const regionPercentile = Array.from({ length: 30 }, (_, i) => ({ timestamp: new Date( new Date().setMinutes(new Date().getMinutes() - i), ).toLocaleString("default", { hour: "numeric", minute: "numeric", }), latency: Math.floor(Math.random() * randomizer + 1) * 100, })).map((item, i) => { const baseLatency = item.latency; const randomFactor = () => 0.85 + Math.random() * 0.3; // Random factor between 0.85-1.15 return { ...item, // More realistic percentile distribution with randomness p50: Math.round(baseLatency * 0.7 * randomFactor()), p75: Math.round(baseLatency * 0.85 * randomFactor()), p90: Math.round(baseLatency * 1.1 * randomFactor()), p95: Math.round(baseLatency * 1.3 * randomFactor()), p99: Math.round(baseLatency * 1.8 * randomFactor()), // REMINDER: for error bars error: [4, 5, 6].includes(i) ? 1 : undefined, }; }); export type RegionPercentile = (typeof regionPercentile)[number]; ================================================ FILE: apps/status-page/src/data/regions.ts ================================================ export const regions = [ { code: "ams", location: "Amsterdam, Netherlands", flag: "🇳🇱", continent: "Europe", }, { code: "arn", location: "Stockholm, Sweden", flag: "🇸🇪", continent: "Europe", }, { code: "atl", location: "Atlanta, Georgia, USA", flag: "🇺🇸", continent: "North America", }, { code: "bog", location: "Bogotá, Colombia", flag: "🇨🇴", continent: "South America", }, { code: "bom", location: "Mumbai, India", flag: "🇮🇳", continent: "Asia", }, { code: "bos", location: "Boston, Massachusetts, USA", flag: "🇺🇸", continent: "North America", }, { code: "cdg", location: "Paris, France", flag: "🇫🇷", continent: "Europe", }, { code: "den", location: "Denver, Colorado, USA", flag: "🇺🇸", continent: "North America", }, { code: "dfw", location: "Dallas, Texas, USA", flag: "🇺🇸", continent: "North America", }, { code: "ewr", location: "Secaucus, New Jersey, USA", flag: "🇺🇸", continent: "North America", }, { code: "eze", location: "Ezeiza, Argentina", flag: "🇦🇷", continent: "South America", }, { code: "fra", location: "Frankfurt, Germany", flag: "🇩🇪", continent: "Europe", }, { code: "gdl", location: "Guadalajara, Mexico", flag: "🇲🇽", continent: "North America", }, { code: "gig", location: "Rio de Janeiro, Brazil", flag: "🇧🇷", continent: "South America", }, { code: "gru", location: "Sao Paulo, Brazil", flag: "🇧🇷", continent: "South America", }, { code: "hkg", location: "Hong Kong, Hong Kong", flag: "🇭🇰", continent: "Asia", }, { code: "iad", location: "Ashburn, Virginia, USA", flag: "🇺🇸", continent: "North America", }, { code: "jnb", location: "Johannesburg, South Africa", flag: "🇿🇦", continent: "Africa", }, { code: "lax", location: "Los Angeles, California, USA", flag: "🇺🇸", continent: "North America", }, { code: "lhr", location: "London, United Kingdom", flag: "🇬🇧", continent: "Europe", }, { code: "mad", location: "Madrid, Spain", flag: "🇪🇸", continent: "Europe", }, { code: "mia", location: "Miami, Florida, USA", flag: "🇺🇸", continent: "North America", }, { code: "nrt", location: "Tokyo, Japan", flag: "🇯🇵", continent: "Asia", }, { code: "ord", location: "Chicago, Illinois, USA", flag: "🇺🇸", continent: "North America", }, { code: "otp", location: "Bucharest, Romania", flag: "🇷🇴", continent: "Europe", }, { code: "phx", location: "Phoenix, Arizona, USA", flag: "🇺🇸", continent: "North America", }, { code: "qro", location: "Querétaro, Mexico", flag: "🇲🇽", continent: "North America", }, { code: "scl", location: "Santiago, Chile", flag: "🇨🇱", continent: "South America", }, { code: "sjc", location: "San Jose, California, USA", flag: "🇺🇸", continent: "North America", }, { code: "sea", location: "Seattle, Washington, USA", flag: "🇺🇸", continent: "North America", }, { code: "sin", location: "Singapore, Singapore", flag: "🇸🇬", continent: "Asia", }, { code: "syd", location: "Sydney, Australia", flag: "🇦🇺", continent: "Oceania", }, { code: "waw", location: "Warsaw, Poland", flag: "🇵🇱", continent: "Europe", }, { code: "yul", location: "Montreal, Canada", flag: "🇨🇦", continent: "North America", }, { code: "yyz", location: "Toronto, Canada", flag: "🇨🇦", continent: "North America", }, ] as const; export type Region = (typeof regions)[number]["code"]; export const groupedRegions = regions.reduce( (acc, region) => { const continent = region.continent; if (!acc[continent]) { acc[continent] = []; } acc[continent].push(region.code); return acc; }, {} as Record, ); ================================================ FILE: apps/status-page/src/data/response-logs.ts ================================================ export const responseLogs = [ { id: 1, url: "https://api.openstatus.dev", method: "GET", status: 200 as const, latency: 150, timing: { dns: 10, connect: 20, tls: 30, ttfb: 40, transfer: 50, }, assertions: [], region: "ams" as const, error: false, timestamp: new Date().getTime(), headers: { "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", "Content-Type": "text/html; charset=utf-8", Date: "Sun, 28 Jan 2024 08:50:13 GMT", Server: "Vercel", }, type: "scheduled" as const satisfies "scheduled" | "on-demand", }, { id: 2, url: "https://api.openstatus.dev", method: "GET", status: 500 as const, latency: 150, timing: { dns: 4, connect: 120, tls: 12, ttfb: 20, transfer: 40, }, assertions: [], region: "ams" as const, error: true, timestamp: new Date().getTime(), headers: { "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", "Content-Type": "text/html; charset=utf-8", Date: "Sun, 28 Jan 2024 08:50:13 GMT", Server: "Vercel", }, type: "scheduled" as const satisfies "scheduled" | "on-demand", // error message message: "Environment variable 'NEXT_PUBLIC_TEST_KEY' is missing. Please add and redeploy your project.", }, ]; export type ResponseLog = (typeof responseLogs)[number]; export type Timing = ResponseLog["timing"]; ================================================ FILE: apps/status-page/src/data/status-codes.ts ================================================ export const statusCodes = [ { code: 200 as const, bg: "bg-success", text: "text-success", name: "OK", }, { code: 500 as const, bg: "bg-destructive", text: "text-destructive", name: "Internal Server Error", }, ]; export type StatusCode = (typeof statusCodes)[number]["code"]; ================================================ FILE: apps/status-page/src/data/status-pages.client.ts ================================================ import { Copy, Pencil, Tag, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Edit", icon: Pencil, variant: "default" as const, }, { id: "copy-id", label: "Copy ID", icon: Copy, variant: "default" as const, }, { id: "create-badge", label: "Create Badge", icon: Tag, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusPageAction = (typeof actions)[number]; export const getActions = ( props: Partial Promise | void>>, ): (StatusPageAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/status-pages.ts ================================================ export const statusPages = [ { id: 1, name: "OpenStatus Status", description: "See our uptime history and status reports.", slug: "status", favicon: "https://openstatus.dev/favicon.ico", domain: "status.openstatus.dev", protected: true, showValues: false, // NOTE: the worst status of a report status: "degraded" as const, monitors: [], }, ]; export type StatusPage = (typeof statusPages)[number]; ================================================ FILE: apps/status-page/src/data/status-report-updates.client.ts ================================================ import { Pencil, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Edit", icon: Pencil, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusReportUpdateAction = (typeof actions)[number]; export const getActions = ( props: Partial< Record Promise | void> >, ): (StatusReportUpdateAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/status-reports.client.ts ================================================ import { Pencil, Plus, Trash2 } from "lucide-react"; export const actions = [ { id: "edit", label: "Edit", icon: Pencil, variant: "default" as const, }, { id: "create-update", label: "Create Update", icon: Plus, variant: "default" as const, }, { id: "delete", label: "Delete", icon: Trash2, variant: "destructive" as const, }, ] as const; export type StatusReportUpdateAction = (typeof actions)[number]; export const getActions = ( props: Partial< Record Promise | void> >, ): (StatusReportUpdateAction & { onClick?: () => Promise | void })[] => { return actions.map((action) => ({ ...action, onClick: props[action.id as keyof typeof props], })); }; ================================================ FILE: apps/status-page/src/data/status-reports.ts ================================================ const today = new Date(); const lastHour = new Date(new Date().setHours(new Date().getHours() - 1)); const yesterday = new Date(new Date().setDate(new Date().getDate() - 1)); console.log({ today, lastHour, yesterday }); export const statusReports = [ { id: 1, name: "Downtime API due to hosting provider with 400 errors", startedAt: yesterday, updatedAt: today, status: "operational", updates: [ { id: 2, status: "operational" as const, message: "Everything is under control, we continue to monitor the situation.", date: today, updatedAt: today, monitors: [1], }, { id: 1, status: "investigating" as const, message: "Our hosting provider is having an increase of 400 errors. We are aware of the dependency and will be working on a solution to reduce the risk.", date: lastHour, updatedAt: lastHour, monitors: [1], }, ], affected: ["OpenStatus API"], }, { id: 3, name: "Downtime API due to hosting provider with 400 errors", startedAt: new Date("2025-08-05 12:10:00"), updatedAt: new Date("2025-08-05 12:30:00"), status: "operational", updates: [ { id: 4, status: "operational" as const, message: "Everything is under control, we continue to monitor the situation.", date: new Date("2025-08-06 03:30:00"), updatedAt: new Date("2025-08-06 03:30:00"), monitors: [1], }, { id: 3, status: "monitoring" as const, message: "We are continuing to monitor the situation to ensure that the issue is resolved.", date: new Date("2025-08-05 16:00:00"), updatedAt: new Date("2025-08-05 16:00:00"), monitors: [1], }, { id: 2, status: "identified" as const, message: "We have identified the root cause of the issue. It is due to a configuration error on our part.", date: new Date("2025-08-05 14:00:00"), updatedAt: new Date("2025-08-05 14:00:00"), monitors: [1], }, { id: 1, status: "investigating" as const, message: "Our hosting provider is having an increase of 400 errors. We are working on a solution to reduce the risk.", date: new Date("2025-08-05 12:00:00"), updatedAt: new Date("2025-08-05 12:00:00"), monitors: [1], }, ], affected: ["OpenStatus API"], }, ]; export type StatusReport = (typeof statusReports)[number]; ================================================ FILE: apps/status-page/src/hooks/use-pathname-prefix.ts ================================================ "use client"; import { useTRPC } from "@/lib/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; export function usePathnamePrefix() { const trpc = useTRPC(); const { domain } = useParams<{ domain: string }>(); const { data: page } = useQuery({ ...trpc.statusPage.get.queryOptions({ slug: domain }), }); const [prefix, setPrefix] = useState(""); useEffect(() => { if (typeof window !== "undefined") { const hostnames = window.location.hostname.split("."); const pathnames = window.location.pathname.split("/"); const isCustomDomain = window.location.hostname === page?.customDomain; if ( isCustomDomain || (hostnames.length > 2 && hostnames[0] !== "www" && !window.location.hostname.endsWith(".vercel.app")) ) { setPrefix(""); } else { setPrefix(pathnames[1] || ""); } } }, [page?.customDomain]); return prefix; } ================================================ FILE: apps/status-page/src/instrumentation.ts ================================================ import * as Sentry from "@sentry/nextjs"; export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { await import("../sentry.server.config"); } if (process.env.NEXT_RUNTIME === "edge") { await import("../sentry.edge.config"); } } export const onRequestError = Sentry.captureRequestError; ================================================ FILE: apps/status-page/src/lib/auth/adapter.ts ================================================ import { DrizzleAdapter } from "@auth/drizzle-adapter"; import type { Adapter } from "next-auth/adapters"; import { db } from "@openstatus/db"; import { verificationToken, viewer, viewerAccounts, viewerSession, } from "@openstatus/db/src/schema"; export const adapter: Adapter = { ...DrizzleAdapter(db, { // @ts-ignore usersTable: viewer, // NOTE: only need accounts for external providers // @ts-ignore accountsTable: viewerAccounts, // @ts-ignore sessionsTable: viewerSession, // @ts-ignore verificationTokensTable: verificationToken, }), }; ================================================ FILE: apps/status-page/src/lib/auth/index.ts ================================================ import type { DefaultSession } from "next-auth"; import NextAuth, { AuthError } from "next-auth"; import { db, eq } from "@openstatus/db"; import { viewer } from "@openstatus/db/src/schema"; import { getValidCustomDomain } from "@/lib/domain"; import { getQueryClient, trpc } from "@/lib/trpc/server"; import { headers } from "next/headers"; import { adapter } from "./adapter"; import { ResendProvider } from "./providers"; export type { DefaultSession }; export const { handlers, signIn, signOut, auth } = NextAuth({ debug: process.env.NODE_ENV === "development", adapter, providers: [ResendProvider], callbacks: { async signIn(params) { const _headers = await headers(); const host = _headers.get("host"); if (!host) throw new AuthError("No host found"); const protocol = _headers.get("x-forwarded-proto") || "https"; const req = new Request(`${protocol}://${host}`, { headers: new Headers(_headers), }); const { prefix } = getValidCustomDomain(req); if (!prefix || !params.user.email) return false; const queryClient = getQueryClient(); // NOTE: throws an error if the email domain is not allowed const query = await queryClient.fetchQuery( trpc.statusPage.validateEmailDomain.queryOptions({ slug: prefix, email: params.user.email, }), ); if (!query) return false; if (params.account?.provider === "resend") { // if the user is new, the id is the verification_token and not the viewer id, so we cannot update the viewer if (Number.isNaN(Number(params.user.id))) return true; await db .update(viewer) .set({ updatedAt: new Date() }) .where(eq(viewer.id, Number(params.user.id))) .run(); return true; } return false; }, redirect: async (params) => { return params.url; }, async session(params) { return params.session; }, }, }); ================================================ FILE: apps/status-page/src/lib/auth/providers.ts ================================================ import { getQueryClient, trpc } from "@/lib/trpc/server"; import { EmailClient } from "@openstatus/emails"; import Resend from "next-auth/providers/resend"; import { getValidCustomDomain } from "../domain"; export const ResendProvider = Resend({ apiKey: undefined, async sendVerificationRequest(params) { const url = params.url; const email = params.identifier; const emailClient = new EmailClient({ apiKey: process.env.RESEND_API_KEY ?? "", }); const { prefix } = getValidCustomDomain(params.request); if (!prefix) return; const queryClient = getQueryClient(); const query = await queryClient.fetchQuery( trpc.statusPage.validateEmailDomain.queryOptions({ slug: prefix, email }), ); if (!query) return; await emailClient.sendStatusPageMagicLink({ page: query.page.title, link: url, to: params.identifier, }); }, }); ================================================ FILE: apps/status-page/src/lib/base-url.ts ================================================ export function getBaseUrl({ slug, customDomain, }: { slug?: string; customDomain?: string; }) { if (process.env.NODE_ENV === "development") { return `http://localhost:3000/${slug}`; } if (customDomain) { return `https://${customDomain}`; } return `https://${slug}.openstatus.dev`; } ================================================ FILE: apps/status-page/src/lib/chart.ts ================================================ import type { ChartConfig } from "@openstatus/ui/components/ui/chart"; // Helper to extract item config from a payload. export function getPayloadConfigFromPayload( config: ChartConfig, payload: unknown, key: string, ) { if (typeof payload !== "object" || payload === null) { return undefined; } const payloadPayload = "payload" in payload && typeof payload.payload === "object" && payload.payload !== null ? payload.payload : undefined; let configLabelKey: string = key; if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === "string" ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload ] as string; } return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; } ================================================ FILE: apps/status-page/src/lib/composition.ts ================================================ import * as React from "react"; /** * A utility to compose multiple event handlers into a single event handler. * Run originalEventHandler first, then ourEventHandler unless prevented. */ function composeEventHandlers( originalEventHandler?: (event: E) => void, ourEventHandler?: (event: E) => void, { checkForDefaultPrevented = true } = {}, ) { return function handleEvent(event: E) { originalEventHandler?.(event); if ( checkForDefaultPrevented === false || !(event as unknown as Event).defaultPrevented ) { return ourEventHandler?.(event); } }; } /** * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx */ type PossibleRef = React.Ref | undefined; /** * Set a given ref to a given value. * This utility takes care of different types of refs: callback refs and RefObject(s). */ function setRef(ref: PossibleRef, value: T) { if (typeof ref === "function") { return ref(value); } if (ref !== null && ref !== undefined) { ref.current = value; } } /** * A utility to compose multiple refs together. * Accepts callback refs and RefObject(s). */ function composeRefs(...refs: PossibleRef[]): React.RefCallback { return (node) => { let hasCleanup = false; const cleanups = refs.map((ref) => { const cleanup = setRef(ref, node); if (!hasCleanup && typeof cleanup === "function") { hasCleanup = true; } return cleanup; }); // React <19 will log an error to the console if a callback ref returns a // value. We don't use ref cleanups internally so this will only happen if a // user's ref callback returns a value, which we only expect if they are // using the cleanup functionality added in React 19. if (hasCleanup) { return () => { for (let i = 0; i < cleanups.length; i++) { const cleanup = cleanups[i]; if (typeof cleanup === "function") { cleanup(); } else { setRef(refs[i], null); } } }; } }; } /** * A custom hook that composes multiple refs. * Accepts callback refs and RefObject(s). */ function useComposedRefs(...refs: PossibleRef[]): React.RefCallback { // eslint-disable-next-line react-hooks/exhaustive-deps return React.useCallback(composeRefs(...refs), refs); } export { composeEventHandlers, composeRefs, useComposedRefs }; ================================================ FILE: apps/status-page/src/lib/domain.ts ================================================ import type { NextRequest } from "next/server"; export const getValidSubdomain = (host?: string | null) => { let subdomain: string | null = null; if (!host && typeof window !== "undefined") { // On client side, get the host from window // biome-ignore lint: to fix later host = window.location.host; } // Exclude localhost and IP addresses from being treated as subdomains if ( host?.match(/^(localhost|127\\.0\\.0\\.1|::1|\\d+\\.\\d+\\.\\d+\\.\\d+)/) ) { return null; } // Handle subdomains of localhost (e.g., hello.localhost:3000) if (host?.match(/^([^.]+)\.localhost(:\d+)?$/)) { const match = host.match(/^([^.]+)\.localhost(:\d+)?$/); return match?.[1] || null; } // we should improve here for custom vercel deploy page if (host?.includes(".") && !host.includes(".vercel.app")) { const candidate = host.split(".")[0]; if (candidate && !candidate.includes("www")) { // Valid candidate subdomain = candidate; } } // In case the host is a custom domain if ( host && !( host?.includes("stpg.dev") || host?.includes("openstatus.dev") || host?.endsWith(".vercel.app") ) ) { subdomain = host; } return subdomain; }; export const getValidCustomDomain = (req: NextRequest | Request) => { const url = "nextUrl" in req ? req.nextUrl.clone() : new URL(req.url); const headers = req.headers; const host = headers.get("x-forwarded-host"); let prefix = ""; let type: "hostname" | "pathname"; const hostnames = host?.split(/[.:]/) ?? url.host.split(/[.:]/); const pathnames = url.pathname.split("/"); const subdomain = getValidSubdomain(url.host); console.log({ hostnames, pathnames, host, urlHost: url.host, subdomain, }); if ( hostnames.length > 2 && hostnames[0] !== "www" && !url.host.endsWith(".vercel.app") ) { prefix = hostnames[0].toLowerCase(); type = "hostname"; } else { prefix = pathnames[1].toLowerCase(); type = "pathname"; } if (subdomain !== null) { prefix = subdomain.toLowerCase(); } console.log({ type, prefix }); return { type, prefix }; }; ================================================ FILE: apps/status-page/src/lib/formatter.ts ================================================ import { endOfDay, isSameDay, startOfDay } from "date-fns"; export function formatMilliseconds(ms: number) { if (ms > 1000) { return `${Intl.NumberFormat("en-US", { style: "unit", unit: "second", maximumFractionDigits: 2, }).format(ms / 1000)}`; } return `${Intl.NumberFormat("en-US", { style: "unit", unit: "millisecond", }).format(ms)}`; } export function formatMillisecondsRange(min: number, max: number) { if ((min > 1000 && max > 1000) || (min < 1000 && max < 1000)) { return `${formatNumber(min / 1000)} - ${formatMilliseconds(max)}`; } return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`; } export function formatPercentage(value: number) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value)}`; } export function formatNumber( value: number, options?: Intl.NumberFormatOptions, ) { return `${Intl.NumberFormat("en-US", options).format(value)}`; } // TODO: think of supporting custom formats export function formatDate(date: Date, options?: Intl.DateTimeFormatOptions) { return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", ...options, }); } export function formatDateTime(date: Date) { return date.toLocaleDateString("en-US", { month: "long", day: "numeric", hour: "numeric", minute: "numeric", }); } export function formatTime(date: Date) { return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "numeric", }); } export function formatDateRange(from?: Date, to?: Date) { const sameDay = from && to && isSameDay(from, to); const isFromStartDay = from && startOfDay(from).getTime() === from.getTime(); const isToEndDay = to && endOfDay(to).getTime() === to.getTime(); if (sameDay) { if (from.getTime() === to.getTime()) { return formatDateTime(from); } if (from && to) { return `${formatDateTime(from)} - ${formatTime(to)}`; } } if (from && to) { if (isFromStartDay && isToEndDay) { return `${formatDate(from)} - ${formatDate(to)}`; } return `${formatDateTime(from)} - ${formatDateTime(to)}`; } if (to) { return `Until ${formatDateTime(to)}`; } if (from) { return `Since ${formatDateTime(from)}`; } return "All time"; } export function formatDateForInput(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; } ================================================ FILE: apps/status-page/src/lib/protected.ts ================================================ export function createProtectedCookieKey(value: string) { return `secured-${value}`; } ================================================ FILE: apps/status-page/src/lib/server-actions.ts ================================================ export async function generateServerActionPromise( promise: Promise<{ success: boolean; error?: string; data?: T }>, ): Promise { const { success, data, error } = await promise; if (!success) { throw new Error(error); } return data; } ================================================ FILE: apps/status-page/src/lib/trpc/client.tsx ================================================ "use client"; import { endingLink } from "@/lib/trpc/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createTRPCClient, loggerLink } from "@trpc/client"; import { createTRPCContext } from "@trpc/tanstack-react-query"; import { useState } from "react"; import type { AppRouter } from "@openstatus/api"; export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext(); function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }); } let browserQueryClient: QueryClient | undefined = undefined; function getQueryClient() { if (typeof window === "undefined") { // Server: always make a new query client return makeQueryClient(); } // Browser: make a new query client if we don't already have one // This is very important, so we don't re-make a new client if React // suspends during the initial render. This may not be needed if we // have a suspense boundary BELOW the creation of the query client if (!browserQueryClient) browserQueryClient = makeQueryClient(); return browserQueryClient; } export function TRPCReactProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient(); const [trpcClient] = useState(() => createTRPCClient({ links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), endingLink({ headers: { "x-trpc-source": "client", }, }), ], }), ); return ( {children} ); } ================================================ FILE: apps/status-page/src/lib/trpc/query-client.ts ================================================ import { QueryClient, defaultShouldDehydrateQuery, } from "@tanstack/react-query"; export function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", }, hydrate: {}, }, }); } ================================================ FILE: apps/status-page/src/lib/trpc/server.tsx ================================================ import "server-only"; import type { AppRouter } from "@openstatus/api"; import { HydrationBoundary } from "@tanstack/react-query"; import { dehydrate } from "@tanstack/react-query"; import { createTRPCClient, loggerLink } from "@trpc/client"; import { type TRPCQueryOptions, createTRPCOptionsProxy, } from "@trpc/tanstack-react-query"; import { cookies } from "next/headers"; import { cache } from "react"; import { makeQueryClient } from "./query-client"; import { endingLink } from "./shared"; // IMPORTANT: Create a stable getter for the query client that // will return the same client during the same request. export const getQueryClient = cache(makeQueryClient); export const trpc = createTRPCOptionsProxy({ queryClient: getQueryClient, client: createTRPCClient({ links: [ loggerLink({ enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), endingLink({ headers: { "x-trpc-source": "server", }, fetch: async (url, options) => { const cookieStore = await cookies(); return fetch(url, { ...options, credentials: "include", headers: { ...options?.headers, cookie: cookieStore.toString(), }, }); }, }), ], }), }); export function HydrateClient(props: { children: React.ReactNode }) { const queryClient = getQueryClient(); return ( {props.children} ); } // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any export function prefetch>>( queryOptions: T, ) { const queryClient = getQueryClient(); if (queryOptions.queryKey[1]?.type === "infinite") { // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any void queryClient.prefetchInfiniteQuery(queryOptions as any); } else { void queryClient.prefetchQuery(queryOptions); } } // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any export function batchPrefetch>>( queryOptionsArray: T[], ) { const queryClient = getQueryClient(); for (const queryOptions of queryOptionsArray) { if (queryOptions.queryKey[1]?.type === "infinite") { // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any void queryClient.prefetchInfiniteQuery(queryOptions as any); } else { void queryClient.prefetchQuery(queryOptions); } } } ================================================ FILE: apps/status-page/src/lib/trpc/shared.ts ================================================ import type { HTTPBatchLinkOptions, HTTPHeaders, TRPCLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@openstatus/api"; import superjson from "superjson"; const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // Note: status-page has its own tRPC API routes if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // Vercel return "http://localhost:3000"; // Local dev and Docker (internal calls) }; const lambdas = ["stripeRouter", "emailRouter"]; export const endingLink = (opts?: { fetch?: typeof fetch; headers?: HTTPHeaders | (() => HTTPHeaders | Promise); }) => ((runtime) => { const sharedOpts = { headers: opts?.headers, fetch: opts?.fetch, transformer: superjson, // biome-ignore lint/suspicious/noExplicitAny: FIXME: remove any } satisfies Partial>; const edgeLink = httpBatchLink({ ...sharedOpts, url: `${getBaseUrl()}/api/trpc/edge`, })(runtime); const lambdaLink = httpBatchLink({ ...sharedOpts, url: `${getBaseUrl()}/api/trpc/lambda`, })(runtime); return (ctx) => { const path = ctx.op.path.split(".") as [string, ...string[]]; const endpoint = lambdas.includes(path[0]) ? "lambda" : "edge"; const newCtx = { ...ctx, op: { ...ctx.op, path: path.join(".") }, }; return endpoint === "edge" ? edgeLink(newCtx) : lambdaLink(newCtx); }; }) satisfies TRPCLink; ================================================ FILE: apps/status-page/src/lib/utils.ts ================================================ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================================================ FILE: apps/status-page/src/next-auth.d.ts ================================================ import type { Viewer as DefaultViewerSchema } from "@openstatus/db/src/schema"; declare module "next-auth" { interface User extends DefaultViewerSchema {} } ================================================ FILE: apps/status-page/src/proxy.ts ================================================ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db, sql } from "@openstatus/db"; import { page, selectPageSchema } from "@openstatus/db/src/schema"; import { getValidSubdomain } from "./lib/domain"; import { createProtectedCookieKey } from "./lib/protected"; export default auth(async (req) => { const url = req.nextUrl.clone(); const response = NextResponse.next(); const cookies = req.cookies; const headers = req.headers; const host = headers.get("x-forwarded-host"); let prefix = ""; let type: "hostname" | "pathname"; const hostnames = host?.split(/[.:]/) ?? url.host.split(/[.:]/); const pathnames = url.pathname.split("/"); const subdomain = getValidSubdomain(url.host); console.log({ hostnames, pathnames, host, urlHost: url.host, subdomain, }); if ( hostnames.length > 2 && hostnames[0] !== "www" && !url.host.endsWith(".vercel.app") ) { prefix = hostnames[0].toLowerCase(); type = "hostname"; } else { prefix = pathnames[1].toLowerCase(); type = "pathname"; } if (subdomain !== null) { prefix = subdomain.toLowerCase(); } console.log({ pathname: url.pathname, type, prefix, subdomain }); if (url.pathname === "/" && type !== "hostname" && subdomain === null) { return response; } const query = await db .select() .from(page) .where( sql`lower(${page.slug}) = ${prefix} OR lower(${page.customDomain}) = ${prefix}`, ) .get(); const validation = selectPageSchema.safeParse(query); if (!validation.success) { return response; } const _page = validation.data; console.log({ slug: _page?.slug, customDomain: _page?.customDomain }); if (_page?.accessType === "password") { const protectedCookie = cookies.get(createProtectedCookieKey(_page.slug)); const cookiePassword = protectedCookie ? protectedCookie.value : undefined; const queryPassword = url.searchParams.get("pw"); const password = queryPassword || cookiePassword; if (password !== _page.password && !url.pathname.endsWith("/login")) { const { pathname, origin } = req.nextUrl; // custom domain redirect if (_page.customDomain && host !== `${_page.slug}.stpg.dev`) { const redirect = pathname.replace(`/${_page.customDomain}`, ""); const url = new URL( `https://${_page.customDomain}/login?redirect=${encodeURIComponent( redirect, )}`, ); console.log("redirect to /login", url.toString()); return NextResponse.redirect(url); } const url = new URL( `${origin}${ type === "pathname" ? `/${prefix}` : "" }/login?redirect=${encodeURIComponent(pathname)}`, ); return NextResponse.redirect(url); } if (password === _page.password && url.pathname.endsWith("/login")) { const redirect = url.searchParams.get("redirect"); // custom domain redirect if (_page.customDomain && host !== `${_page.slug}.stpg.dev`) { const url = new URL(`https://${_page.customDomain}${redirect ?? "/"}`); console.log("redirect to /", url.toString()); return NextResponse.redirect(url); } return NextResponse.redirect( new URL( `${req.nextUrl.origin}${ redirect ?? type === "pathname" ? `/${prefix}` : "/" }`, ), ); } } if (_page.accessType === "email-domain") { const { origin, pathname } = req.nextUrl; const email = req.auth?.user?.email; const emailDomain = email?.split("@")[1]; if ( !pathname.endsWith("/login") && (!emailDomain || !_page.authEmailDomains.includes(emailDomain)) ) { const url = new URL( `${origin}${type === "pathname" ? `/${prefix}` : ""}/login`, ); return NextResponse.redirect(url); } if ( pathname.endsWith("/login") && emailDomain && _page.authEmailDomains.includes(emailDomain) ) { const url = new URL( `${origin}${type === "pathname" ? `/${prefix}` : ""}`, ); return NextResponse.redirect(url); } } const proxy = req.headers.get("x-proxy"); console.log({ proxy }); if (proxy) { const rewriteUrl = new URL(`/${prefix}${url.pathname}`, req.url); // Preserve search params from original request rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } console.log({ customDomain: _page.customDomain, host, expectedHost: `${_page.slug}.stpg.dev`, }); if (_page.customDomain && host !== `${_page.slug}.stpg.dev`) { if (pathnames.length > 2 && !subdomain) { const pathname = pathnames.slice(2).join("/"); const rewriteUrl = new URL(`/${_page.slug}/${pathname}`, req.url); rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } if (_page.customDomain && subdomain) { console.log({ url: req.url }); // const vercelURL = process.env.VERCEL_URL || "www.stpg.dev"; // console.log({newUrl: vercelURL}) if (pathnames.length > 2) { const pathname = pathnames.slice(1).join("/"); const rewriteUrl = new URL( `${pathname}`, `https://${_page.slug}.stpg.dev`, ); console.log({ rewriteUrl }); rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } const rewriteUrl = new URL( `${url.pathname}`, `https://${_page.slug}.stpg.dev`, ); console.log({ rewriteUrl }); rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } const rewriteUrl = new URL(`/${_page.slug}`, req.url); console.log({ rewriteUrl }); rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } if (host?.includes("openstatus.dev")) { const rewriteUrl = new URL(`/${prefix}${url.pathname}`, req.url); // Preserve search params from original request rewriteUrl.search = url.search; return NextResponse.rewrite(rewriteUrl); } return response; }); export const config = { matcher: [ "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", ], }; ================================================ FILE: apps/status-page/tsconfig.json ================================================ { "extends": "@openstatus/tsconfig/nextjs.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] }, "plugins": [{ "name": "next" }], "strictNullChecks": true, "strict": true, "moduleResolution": "bundler" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules", "env.ts"] } ================================================ FILE: apps/status-page/turbo.json ================================================ { "$schema": "https://turbo.build/schema.json", "extends": ["//"], "tasks": { "build": { "dependsOn": ["@openstatus/react#build"] } } } ================================================ FILE: apps/web/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel # content-collections .content-collections # Sentry Auth Token .sentryclirc ================================================ FILE: apps/web/README.md ================================================ ## Getting Started First, run the development server: ```bash pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. ================================================ FILE: apps/web/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, "tailwind": { "config": "tailwind.config.js", "css": "src/styles/globals.css", "baseColor": "slate", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } } ================================================ FILE: apps/web/env.ts ================================================ const file = Bun.file("./.env.example"); await Bun.write("./.env", file); ================================================ FILE: apps/web/instrumentation-client.ts ================================================ // This file configures the initialization of Sentry on the client. // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_FRONTEND, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.5, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, replaysOnErrorSampleRate: 1.0, // This sets the sample rate to be 10%. You may want this to be 100% while // in development and sample at a lower rate in production replaysSessionSampleRate: 0.1, // You can remove this option if you're not planning to use the Sentry Session Replay feature: integrations: [ Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), Sentry.captureConsoleIntegration({ levels: ["error"] }), ], }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export const onRequestError = Sentry.captureRequestError; ================================================ FILE: apps/web/next-env.d.ts ================================================ /// /// import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. ================================================ FILE: apps/web/next.config.ts ================================================ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; // REMINDER: avoid Clickjacking attacks by setting the frame-ancestors directive const securityHeaders = [ { key: "Content-Security-Policy", value: "frame-ancestors 'self' https://shoogle.dev", }, ]; /** @type {import('next').NextConfig} */ const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ["@openstatus/ui", "@openstatus/api", "next-mdx-remote"], outputFileTracingIncludes: { "/": [ "./node_modules/.pnpm/@google-cloud/tasks/build/esm/src/**/*.json", "./node_modules/@google-cloud/tasks/build/esm/src/**/*.js", ], }, experimental: { turbopackScopeHoisting: false, // serverMinification:false, }, serverExternalPackages: ["@google-cloud/tasks"], expireTime: 180, // 3 minutes logging: { fetches: { fullUrl: true, }, }, images: { remotePatterns: [ { protocol: "https", hostname: "**.public.blob.vercel-storage.com", }, { protocol: "https", hostname: "screenshot.openstat.us", }, { protocol: "https", hostname: "www.openstatus.dev", }, ], }, async headers() { return [{ source: "/(.*)", headers: securityHeaders }]; }, async redirects() { return [ { source: "/legal/terms", destination: "/terms", permanent: true, }, { source: "/legal/privacy", destination: "/privacy", permanent: true, }, { source: "/features/monitoring", destination: "/uptime-monitoring", permanent: true, }, { source: "/features/status-page", destination: "/status-page", permanent: true, }, { source: "/api-monitoring", destination: "/uptime-monitoring", permanent: true, }, { source: "/monitoring-as-code", destination: "/uptime-monitoring", permanent: true, }, { source: "/private-locations", destination: "/uptime-monitoring", permanent: true, }, { source: "/app/:path*", destination: "https://app.openstatus.dev/", permanent: true, }, ]; }, async rewrites() { return { beforeFiles: [ { source: "/status-page/themes/:path*", destination: "https://www.stpg.dev/:path*", }, { source: "/:path*", has: [ { type: "host", value: "themes.openstatus.dev", }, ], destination: "https://www.stpg.dev/:path*", }, // New design: proxy app routes to external host with slug prefix { source: "/:path*", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "(?[^.]+)\\.(openstatus\\.dev|localhost)", }, ], destination: "https://:slug.stpg.dev/:path*", }, // Handle custom domains (e.g., status.mxkaske.dev) { source: "/:path((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?!.*\\.openstatus\\.dev$)(?!openstatus\\.dev$)$", }, ], destination: "https://www.stpg.dev/:path*", }, // enfore routes to avoid infinite redirects - https://github.com/vercel/vercel/issues/6126#issuecomment-823523122 // testing with https://validator.w3.org/feed/check.cgi { source: "/feed/rss", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?!.*\\.openstatus\\.dev$)(?!openstatus\\.dev$)$", }, ], destination: "https://www.stpg.dev/:domain/feed/rss", }, { source: "/feed/atom", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?!.*\\.openstatus\\.dev$)(?!openstatus\\.dev$)$", }, ], destination: "https://www.stpg.dev/:domain/feed/atom", }, { source: "/feed/rss", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?.+)$", }, ], destination: "https://www.stpg.dev/:domain/feed/rss", }, { source: "/feed/atom", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?.+)$", }, ], destination: "https://www.stpg.dev/:domain/feed/atom", }, { source: "/:path((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|badge|feed|events|monitors|protected|verify).*)", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?.+)$", }, ], destination: "https://www.stpg.dev/:domain*", }, { source: "/:path((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?.+)$", }, ], destination: "https://www.stpg.dev/:domain/:path*", }, // Handle API routes for custom domains { source: "/api/:path*", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?!.*\\.openstatus\\.dev$)(?!openstatus\\.dev$)(?.+)$", }, ], destination: "https://www.stpg.dev/api/:path*", }, // Handle static assets for custom domains { source: "/_next/:path*", has: [ { type: "cookie", key: "sp_mode", value: "new" }, { type: "host", value: "^(?!.*\\.openstatus\\.dev$)(?!openstatus\\.dev$)(?.+)$", }, ], destination: "https://www.stpg.dev/_next/:path*", }, // Markdown content negotiation for AI tools { source: "/:path*", destination: "/api/markdown/:path*", has: [ { type: "header", key: "accept", value: "(.*)text/markdown(.*)", }, ], }, ], }; }, }; // For detailed options, refer to the official documentation: // - Webpack plugin options: https://github.com/getsentry/sentry-webpack-plugin#options // - Next.js Sentry setup guide: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ const sentryConfig = { // Prevent log output unless running in a CI environment (helps reduce noise in logs) silent: !process.env.CI, org: "openstatus", project: "openstatus", authToken: process.env.SENTRY_AUTH_TOKEN, // Upload a larger set of source maps for improved stack trace accuracy (increases build time) widenClientFileUpload: true, // If set to true, transpiles Sentry SDK to be compatible with IE11 (increases bundle size) transpileClientSDK: false, // Tree-shake Sentry logger statements to reduce bundle size webpack: { treeshake: { removeDebugLogging: true, }, }, }; export default withSentryConfig(nextConfig, sentryConfig); ================================================ FILE: apps/web/package.json ================================================ { "name": "@openstatus/web", "version": "1.0.0", "private": true, "scripts": { "env": "bun env.ts", "dev": "next dev", "build:registry": "turbo run registry:build --filter=@openstatus/ui", "build": "pnpm build:registry && next build", "start": "next start", "lint": "next lint", "tsc": "tsc --noEmit" }, "dependencies": { "@auth/core": "0.40.0", "@auth/drizzle-adapter": "1.10.0", "@google-cloud/tasks": "4.0.1", "@hookform/resolvers": "5.1.0", "@libsql/client": "0.15.15", "@openpanel/nextjs": "1.2.0", "@openstatus/analytics": "workspace:*", "@openstatus/api": "workspace:*", "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/error": "workspace:*", "@openstatus/header-analysis": "workspace:*", "@openstatus/icons": "workspace:*", "@openstatus/notification-discord": "workspace:*", "@openstatus/notification-emails": "workspace:*", "@openstatus/notification-ntfy": "workspace:*", "@openstatus/notification-opsgenie": "workspace:*", "@openstatus/notification-pagerduty": "workspace:*", "@openstatus/notification-slack": "workspace:*", "@openstatus/notification-webhook": "workspace:*", "@openstatus/react": "workspace:*", "@openstatus/regions": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", "@openstatus/tracker": "workspace:*", "@openstatus/ui": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", "@sentry/nextjs": "10.31.0", "@stripe/stripe-js": "2.1.6", "@t3-oss/env-nextjs": "0.7.0", "@tailwindcss/container-queries": "0.1.1", "@tailwindcss/typography": "0.5.10", "@tanstack/react-query": "5.81.5", "@tanstack/react-query-devtools": "5.80.7", "@tanstack/react-table": "8.21.3", "@trpc/client": "11.4.4", "@trpc/next": "11.4.4", "@trpc/react-query": "11.4.4", "@trpc/server": "11.4.4", "@upstash/qstash": "2.6.2", "@upstash/redis": "1.22.1", "@vercel/blob": "0.23.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "cobe": "0.6.3", "date-fns": "3.6.0", "date-fns-tz": "2.0.0", "feed": "4.2.2", "gray-matter": "4.0.3", "lucide-react": "0.525.0", "nanoid": "5.0.7", "next": "16.1.6", "next-auth": "5.0.0-beta.29", "next-mdx-remote": "6.0.0", "next-plausible": "3.12.5", "next-themes": "0.4.6", "nuqs": "2.8.5", "random-word-slugs": "0.1.7", "react": "19.2.3", "react-day-picker": "8.10.1", "react-dom": "19.2.3", "react-hook-form": "7.68.0", "react-medium-image-zoom": "5.4.0", "react-tweet": "3.2.1", "reading-time": "1.5.0", "recharts": "2.15.0", "remark-gfm": "4.0.1", "resend": "6.6.0", "sanitize-html": "2.17.0", "schema-dts": "1.1.5", "shiki": "3.23.0", "slugify": "1.6.6", "sonner": "2.0.5", "stripe": "13.8.0", "sugar-high": "0.9.5", "superjson": "2.2.2", "tailwind-merge": "3.3.1", "tailwindcss-animate": "1.0.7", "zod": "4.1.13" }, "devDependencies": { "@openstatus/tsconfig": "workspace:*", "@tailwindcss/postcss": "4.1.11", "@types/node": "24.0.8", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/sanitize-html": "2.16.0", "postcss": "8.4.38", "rehype-autolink-headings": "7.1.0", "rehype-slug": "5.1.0", "tailwindcss": "4.1.11", "typescript": "5.9.3", "unified": "11.0.5" } } ================================================ FILE: apps/web/postcss.config.js ================================================ module.exports = { plugins: { "@tailwindcss/postcss": {}, }, }; ================================================ FILE: apps/web/public/assets/posts/global-latency-monitoring-benchmark-hono-hetzner/hetzner.json ================================================ { "regions": [ "ams", "railway_europe-west4-drams3a", "cdg", "koyeb_par", "fra", "koyeb_fra", "iad", "koyeb_was", "railway_us-east4-eqdc4a", "koyeb_sfo", "railway_us-west2", "lax", "koyeb_sin", "railway_asia-southeast1-eqsg3a", "sin", "koyeb_tyo", "nrt" ], "data": { "regions": [ "koyeb_fra", "cdg", "ams", "railway_europe-west4-drams3a", "fra", "koyeb_par", "koyeb_sfo", "railway_us-west2", "lax", "iad", "koyeb_was", "railway_us-east4-eqdc4a", "koyeb_sin", "railway_asia-southeast1-eqsg3a", "koyeb_tyo", "sin", "nrt" ], "data": [ { "timestamp": "2025-10-16T12:00:00.000Z", "koyeb_tyo": 338, "railway_europe-west4-drams3a": 96, "koyeb_sfo": 243, "koyeb_sin": 344, "koyeb_was": 252, "sin": 396, "nrt": 338, "lax": 197, "ams": 82, "iad": 244, "railway_us-west2": 390, "koyeb_fra": 68, "fra": 67, "cdg": 113, "railway_asia-southeast1-eqsg3a": 348, "koyeb_par": 117, "railway_us-east4-eqdc4a": 241 }, { "timestamp": "2025-10-16T13:00:00.000Z", "fra": 65, "cdg": 114, "koyeb_fra": 67, "ams": 77, "iad": 242, "railway_us-west2": 247, "koyeb_par": 107, "railway_asia-southeast1-eqsg3a": 404, "lax": 210, "railway_europe-west4-drams3a": 96, "nrt": 348, "koyeb_tyo": 352, "sin": 422, "koyeb_sfo": 248, "koyeb_sin": 402, "koyeb_was": 245, "railway_us-east4-eqdc4a": 252 }, { "timestamp": "2025-10-16T14:00:00.000Z", "koyeb_tyo": 366, "nrt": 334, "sin": 418, "railway_asia-southeast1-eqsg3a": 392, "koyeb_was": 245, "koyeb_sin": 394, "koyeb_sfo": 336, "railway_us-west2": 253, "railway_us-east4-eqdc4a": 251, "cdg": 112, "fra": 66, "iad": 242, "ams": 84, "railway_europe-west4-drams3a": 95, "koyeb_par": 116, "lax": 221, "koyeb_fra": 67 }, { "timestamp": "2025-10-16T15:00:00.000Z", "fra": 64, "koyeb_tyo": 334, "cdg": 114, "railway_us-west2": 320, "railway_asia-southeast1-eqsg3a": 420, "lax": 376, "koyeb_was": 252, "koyeb_sfo": 407, "ams": 82, "iad": 242, "koyeb_sin": 467, "sin": 440, "nrt": 319, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 254, "koyeb_par": 116, "koyeb_fra": 64 }, { "timestamp": "2025-10-16T16:00:00.000Z", "koyeb_par": 117, "koyeb_fra": 68, "railway_asia-southeast1-eqsg3a": 396, "sin": 408, "nrt": 436, "railway_us-east4-eqdc4a": 254, "lax": 390, "railway_europe-west4-drams3a": 98, "railway_us-west2": 462, "koyeb_was": 247, "koyeb_sfo": 424, "iad": 250, "koyeb_sin": 394, "ams": 83, "cdg": 114, "fra": 66, "koyeb_tyo": 322 }, { "timestamp": "2025-10-16T17:00:00.000Z", "railway_europe-west4-drams3a": 94, "cdg": 114, "fra": 68, "koyeb_was": 248, "koyeb_sfo": 422, "koyeb_sin": 372, "railway_us-west2": 374, "koyeb_tyo": 313, "iad": 249, "ams": 86, "lax": 378, "nrt": 312, "railway_us-east4-eqdc4a": 254, "sin": 399, "koyeb_par": 116, "koyeb_fra": 67, "railway_asia-southeast1-eqsg3a": 380 }, { "timestamp": "2025-10-16T18:00:00.000Z", "koyeb_fra": 67, "nrt": 426, "koyeb_par": 118, "sin": 380, "railway_europe-west4-drams3a": 92, "railway_us-west2": 361, "koyeb_sin": 369, "koyeb_sfo": 404, "koyeb_was": 248, "lhr": 113, "railway_asia-southeast1-eqsg3a": 372, "fra": 66, "cdg": 114, "ams": 84, "iad": 250, "railway_us-east4-eqdc4a": 249, "lax": 364, "koyeb_tyo": 341 }, { "timestamp": "2025-10-16T19:00:00.000Z", "nrt": 300, "railway_us-west2": 366, "koyeb_fra": 68, "railway_us-east4-eqdc4a": 253, "sin": 370, "railway_europe-west4-drams3a": 96, "koyeb_par": 117, "railway_asia-southeast1-eqsg3a": 374, "ams": 84, "koyeb_tyo": 548, "iad": 247, "lax": 340, "fra": 67, "koyeb_sfo": 338, "cdg": 114, "koyeb_sin": 372, "koyeb_was": 248 }, { "timestamp": "2025-10-16T20:00:00.000Z", "nrt": 307, "railway_asia-southeast1-eqsg3a": 228, "sin": 369, "koyeb_fra": 66, "koyeb_par": 118, "fra": 66, "cdg": 100, "koyeb_tyo": 311, "koyeb_sin": 366, "railway_europe-west4-drams3a": 90, "koyeb_sfo": 308, "railway_us-east4-eqdc4a": 250, "koyeb_was": 243, "railway_us-west2": 302, "ams": 82, "iad": 240, "lax": 307 }, { "timestamp": "2025-10-16T21:00:00.000Z", "ams": 83, "iad": 242, "railway_us-east4-eqdc4a": 244, "koyeb_tyo": 537, "lax": 306, "railway_asia-southeast1-eqsg3a": 376, "koyeb_was": 245, "koyeb_sfo": 320, "koyeb_sin": 366, "fra": 64, "cdg": 113, "railway_us-west2": 303, "koyeb_par": 117, "railway_europe-west4-drams3a": 94, "koyeb_fra": 65, "nrt": 312, "sin": 377 }, { "timestamp": "2025-10-16T22:00:00.000Z", "railway_europe-west4-drams3a": 90, "sin": 372, "koyeb_par": 115, "koyeb_fra": 63, "nrt": 428, "lax": 309, "railway_us-east4-eqdc4a": 249, "koyeb_tyo": 557, "iad": 236, "ams": 80, "railway_asia-southeast1-eqsg3a": 214, "cdg": 84, "fra": 63, "koyeb_was": 245, "koyeb_sfo": 309, "railway_us-west2": 298, "koyeb_sin": 368 }, { "timestamp": "2025-10-16T23:00:00.000Z", "cdg": 112, "fra": 63, "koyeb_tyo": 316, "railway_europe-west4-drams3a": 90, "railway_us-east4-eqdc4a": 246, "lax": 313, "koyeb_sin": 367, "koyeb_sfo": 314, "koyeb_was": 245, "iad": 238, "ams": 80, "sin": 370, "railway_us-west2": 306, "nrt": 326, "railway_asia-southeast1-eqsg3a": 375, "koyeb_fra": 64, "koyeb_par": 116 }, { "timestamp": "2025-10-17T00:00:00.000Z", "cdg": 113, "fra": 63, "railway_us-east4-eqdc4a": 267, "railway_asia-southeast1-eqsg3a": 371, "koyeb_tyo": 458, "koyeb_sfo": 323, "lax": 329, "railway_us-west2": 302, "koyeb_sin": 370, "koyeb_was": 265, "iad": 257, "ams": 81, "sin": 371, "nrt": 307, "koyeb_fra": 63, "railway_europe-west4-drams3a": 94, "koyeb_par": 116 }, { "timestamp": "2025-10-17T01:00:00.000Z", "railway_us-west2": 296, "railway_asia-southeast1-eqsg3a": 387, "koyeb_par": 115, "sin": 386, "koyeb_fra": 64, "nrt": 311, "lax": 318, "koyeb_tyo": 308, "iad": 259, "ams": 82, "cdg": 111, "koyeb_was": 260, "fra": 63, "railway_europe-west4-drams3a": 90, "koyeb_sin": 380, "koyeb_sfo": 323, "railway_us-east4-eqdc4a": 182 }, { "timestamp": "2025-10-17T02:00:00.000Z", "koyeb_tyo": 326, "ams": 77, "iad": 168, "railway_europe-west4-drams3a": 91, "lax": 314, "railway_us-west2": 374, "koyeb_was": 268, "fra": 62, "cdg": 110, "railway_us-east4-eqdc4a": 264, "koyeb_sin": 387, "koyeb_sfo": 320, "railway_asia-southeast1-eqsg3a": 384, "nrt": 316, "sin": 386, "koyeb_par": 114, "koyeb_fra": 62 }, { "timestamp": "2025-10-17T03:00:00.000Z", "nrt": 316, "railway_us-west2": 306, "sin": 388, "railway_europe-west4-drams3a": 90, "koyeb_fra": 62, "koyeb_par": 114, "koyeb_tyo": 326, "railway_us-east4-eqdc4a": 264, "fra": 64, "cdg": 112, "ams": 65, "iad": 262, "railway_asia-southeast1-eqsg3a": 390, "koyeb_sfo": 324, "lax": 318, "koyeb_sin": 378, "koyeb_was": 260 }, { "timestamp": "2025-10-17T04:00:00.000Z", "railway_us-east4-eqdc4a": 264, "koyeb_fra": 65, "koyeb_par": 116, "nrt": 548, "railway_asia-southeast1-eqsg3a": 386, "sin": 392, "railway_europe-west4-drams3a": 83, "ams": 83, "iad": 252, "railway_us-west2": 288, "lax": 374, "koyeb_tyo": 322, "koyeb_sin": 308, "koyeb_sfo": 308, "koyeb_was": 165, "fra": 63, "cdg": 111 }, { "timestamp": "2025-10-17T05:00:00.000Z", "nrt": 324, "koyeb_fra": 64, "sin": 386, "koyeb_par": 115, "railway_us-west2": 264, "railway_asia-southeast1-eqsg3a": 406, "fra": 64, "koyeb_sfo": 282, "cdg": 113, "koyeb_sin": 376, "railway_us-east4-eqdc4a": 258, "koyeb_was": 260, "railway_europe-west4-drams3a": 91, "ams": 81, "iad": 256, "koyeb_tyo": 317, "lax": 346 }, { "timestamp": "2025-10-17T06:00:00.000Z", "railway_asia-southeast1-eqsg3a": 393, "koyeb_was": 261, "koyeb_sin": 390, "koyeb_sfo": 276, "cdg": 112, "fra": 63, "iad": 262, "ams": 84, "koyeb_tyo": 334, "lax": 221, "railway_us-west2": 226, "koyeb_par": 116, "nrt": 530, "koyeb_fra": 64, "sin": 390, "railway_europe-west4-drams3a": 93, "railway_us-east4-eqdc4a": 259 }, { "timestamp": "2025-10-17T07:00:00.000Z", "koyeb_par": 117, "koyeb_fra": 66, "sin": 404, "railway_europe-west4-drams3a": 91, "railway_us-west2": 376, "nrt": 332, "lax": 218, "koyeb_was": 255, "koyeb_sin": 386, "koyeb_sfo": 249, "iad": 252, "ams": 80, "railway_asia-southeast1-eqsg3a": 400, "cdg": 113, "fra": 62, "railway_us-east4-eqdc4a": 265, "koyeb_tyo": 322 }, { "timestamp": "2025-10-17T08:00:00.000Z", "fra": 66, "cdg": 114, "koyeb_tyo": 321, "koyeb_was": 258, "lax": 212, "koyeb_sin": 354, "railway_europe-west4-drams3a": 91, "koyeb_sfo": 252, "ams": 82, "iad": 248, "sin": 424, "railway_us-east4-eqdc4a": 266, "nrt": 324, "railway_asia-southeast1-eqsg3a": 396, "koyeb_par": 116, "koyeb_fra": 66, "railway_us-west2": 214 }, { "timestamp": "2025-10-17T09:00:00.000Z", "cdg": 115, "fra": 64, "koyeb_sfo": 240, "railway_europe-west4-drams3a": 93, "koyeb_sin": 388, "koyeb_was": 257, "lax": 200, "koyeb_tyo": 329, "railway_us-west2": 371, "iad": 261, "ams": 81, "sin": 396, "koyeb_fra": 68, "nrt": 324, "koyeb_par": 114, "railway_us-east4-eqdc4a": 260, "railway_asia-southeast1-eqsg3a": 397 }, { "timestamp": "2025-10-17T10:00:00.000Z", "koyeb_par": 116, "sin": 406, "koyeb_fra": 65, "nrt": 317, "railway_europe-west4-drams3a": 90, "koyeb_was": 256, "fra": 66, "cdg": 113, "railway_us-east4-eqdc4a": 262, "railway_us-west2": 214, "railway_asia-southeast1-eqsg3a": 402, "koyeb_sin": 385, "koyeb_sfo": 244, "lax": 206, "koyeb_tyo": 323, "ams": 85, "iad": 254 }, { "timestamp": "2025-10-17T11:00:00.000Z", "koyeb_was": 254, "koyeb_sin": 387, "ams": 82, "koyeb_sfo": 246, "iad": 254, "railway_asia-southeast1-eqsg3a": 394, "lax": 206, "koyeb_tyo": 559, "fra": 66, "cdg": 113, "railway_us-west2": 366, "koyeb_par": 116, "koyeb_fra": 64, "railway_us-east4-eqdc4a": 263, "railway_europe-west4-drams3a": 94, "nrt": 567, "sin": 395 }, { "timestamp": "2025-10-17T12:00:00.000Z", "fra": 66, "railway_us-east4-eqdc4a": 261, "cdg": 114, "koyeb_was": 256, "koyeb_sfo": 246, "koyeb_sin": 393, "koyeb_tyo": 338, "ams": 88, "iad": 258, "railway_europe-west4-drams3a": 94, "lax": 209, "nrt": 582, "railway_asia-southeast1-eqsg3a": 404, "koyeb_par": 118, "sin": 414, "koyeb_fra": 68, "railway_us-west2": 289 }, { "timestamp": "2025-10-17T13:00:00.000Z", "koyeb_fra": 67, "koyeb_par": 116, "nrt": 352, "railway_europe-west4-drams3a": 94, "sin": 413, "railway_asia-southeast1-eqsg3a": 400, "ams": 84, "iad": 239, "railway_us-west2": 370, "koyeb_sin": 408, "koyeb_sfo": 260, "lax": 212, "koyeb_was": 244, "koyeb_tyo": 344, "railway_us-east4-eqdc4a": 251, "fra": 67, "cdg": 114 }, { "timestamp": "2025-10-17T14:00:00.000Z", "railway_europe-west4-drams3a": 92, "nrt": 330, "koyeb_fra": 68, "sin": 432, "koyeb_par": 117, "iad": 238, "ams": 86, "koyeb_tyo": 354, "lax": 226, "railway_us-east4-eqdc4a": 250, "koyeb_sfo": 248, "cdg": 115, "fra": 64, "koyeb_sin": 418, "railway_us-west2": 375, "koyeb_was": 244, "railway_asia-southeast1-eqsg3a": 410 }, { "timestamp": "2025-10-17T15:00:00.000Z", "koyeb_tyo": 536, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 248, "cdg": 115, "fra": 64, "iad": 245, "ams": 85, "koyeb_was": 248, "lax": 364, "koyeb_sin": 512, "koyeb_sfo": 322, "railway_us-west2": 330, "nrt": 318, "sin": 418, "railway_asia-southeast1-eqsg3a": 462, "koyeb_par": 116, "koyeb_fra": 65 }, { "timestamp": "2025-10-17T16:00:00.000Z", "fra": 64, "cdg": 116, "koyeb_tyo": 316, "koyeb_was": 242, "lax": 355, "koyeb_sfo": 349, "koyeb_sin": 486, "ams": 85, "iad": 239, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 247, "railway_asia-southeast1-eqsg3a": 474, "sin": 474, "railway_us-west2": 338, "nrt": 536, "koyeb_par": 116, "koyeb_fra": 67 }, { "timestamp": "2025-10-17T17:00:00.000Z", "cdg": 114, "koyeb_tyo": 304, "fra": 66, "railway_us-east4-eqdc4a": 252, "lax": 328, "koyeb_was": 238, "koyeb_sin": 442, "railway_us-west2": 450, "iad": 241, "railway_asia-southeast1-eqsg3a": 460, "koyeb_sfo": 322, "ams": 86, "sin": 456, "railway_europe-west4-drams3a": 98, "nrt": 314, "koyeb_par": 116, "koyeb_fra": 66 }, { "timestamp": "2025-10-17T18:00:00.000Z", "nrt": 311, "koyeb_was": 243, "koyeb_sfo": 328, "koyeb_sin": 400, "sin": 388, "railway_us-east4-eqdc4a": 248, "koyeb_tyo": 315, "railway_asia-southeast1-eqsg3a": 404, "railway_europe-west4-drams3a": 92, "railway_us-west2": 302, "koyeb_par": 116, "fra": 66, "koyeb_fra": 66, "cdg": 116, "ams": 85, "iad": 240, "lax": 333 }, { "timestamp": "2025-10-17T19:00:00.000Z", "koyeb_tyo": 558, "railway_us-west2": 369, "ams": 85, "iad": 156, "lax": 309, "railway_europe-west4-drams3a": 91, "fra": 64, "koyeb_was": 161, "cdg": 113, "koyeb_sfo": 333, "koyeb_sin": 395, "nrt": 305, "railway_asia-southeast1-eqsg3a": 393, "sin": 406, "railway_us-east4-eqdc4a": 161, "koyeb_par": 118, "koyeb_fra": 68 }, { "timestamp": "2025-10-17T20:00:00.000Z", "lax": 311, "iad": 152, "ams": 84, "cdg": 115, "fra": 66, "koyeb_par": 114, "railway_europe-west4-drams3a": 94, "koyeb_fra": 66, "railway_asia-southeast1-eqsg3a": 388, "koyeb_tyo": 318, "railway_us-east4-eqdc4a": 226, "railway_us-west2": 364, "sin": 382, "koyeb_was": 154, "nrt": 547, "koyeb_sin": 376, "koyeb_sfo": 354 }, { "timestamp": "2025-10-17T21:00:00.000Z", "railway_europe-west4-drams3a": 86, "koyeb_par": 116, "sin": 382, "railway_us-east4-eqdc4a": 171, "koyeb_fra": 64, "nrt": 549, "railway_us-west2": 376, "lax": 348, "koyeb_tyo": 311, "ams": 84, "iad": 160, "koyeb_was": 162, "fra": 65, "cdg": 114, "railway_asia-southeast1-eqsg3a": 382, "koyeb_sin": 376, "koyeb_sfo": 345 }, { "timestamp": "2025-10-17T22:00:00.000Z", "lax": 370, "railway_us-east4-eqdc4a": 245, "iad": 238, "koyeb_par": 116, "railway_asia-southeast1-eqsg3a": 386, "ams": 83, "koyeb_fra": 63, "cdg": 112, "fra": 62, "railway_us-west2": 383, "koyeb_was": 239, "koyeb_sin": 376, "koyeb_sfo": 398, "railway_europe-west4-drams3a": 90, "koyeb_tyo": 548, "sin": 388, "nrt": 318 }, { "timestamp": "2025-10-17T23:00:00.000Z", "koyeb_sin": 386, "cdg": 114, "koyeb_sfo": 308, "fra": 64, "koyeb_was": 243, "railway_europe-west4-drams3a": 87, "lax": 354, "iad": 232, "railway_us-west2": 350, "koyeb_tyo": 312, "ams": 81, "sin": 386, "koyeb_fra": 62, "koyeb_par": 116, "nrt": 310, "railway_asia-southeast1-eqsg3a": 390, "railway_us-east4-eqdc4a": 244 }, { "timestamp": "2025-10-18T00:00:00.000Z", "cdg": 114, "fra": 64, "koyeb_tyo": 315, "railway_us-east4-eqdc4a": 240, "railway_asia-southeast1-eqsg3a": 374, "railway_us-west2": 242, "lax": 338, "koyeb_was": 239, "koyeb_sin": 382, "koyeb_sfo": 274, "iad": 238, "ams": 83, "sin": 378, "nrt": 540, "railway_europe-west4-drams3a": 90, "koyeb_par": 115, "koyeb_fra": 64 }, { "timestamp": "2025-10-18T01:00:00.000Z", "railway_asia-southeast1-eqsg3a": 383, "railway_us-west2": 378, "fra": 64, "cdg": 96, "koyeb_tyo": 533, "koyeb_sin": 366, "koyeb_sfo": 346, "koyeb_was": 240, "ams": 79, "iad": 237, "lax": 224, "nrt": 312, "sin": 390, "koyeb_fra": 62, "koyeb_par": 114, "railway_europe-west4-drams3a": 90, "railway_us-east4-eqdc4a": 242 }, { "timestamp": "2025-10-18T02:00:00.000Z", "railway_us-west2": 334, "railway_europe-west4-drams3a": 90, "nrt": 315, "koyeb_sin": 304, "koyeb_sfo": 404, "koyeb_was": 245, "sin": 388, "koyeb_tyo": 318, "railway_us-east4-eqdc4a": 250, "koyeb_fra": 62, "cdg": 110, "koyeb_par": 114, "fra": 63, "iad": 256, "ams": 79, "lax": 380, "railway_asia-southeast1-eqsg3a": 388 }, { "timestamp": "2025-10-18T03:00:00.000Z", "fra": 62, "cdg": 110, "railway_asia-southeast1-eqsg3a": 390, "lax": 368, "ams": 70, "koyeb_par": 114, "iad": 214, "railway_us-west2": 383, "koyeb_fra": 61, "koyeb_tyo": 313, "sin": 389, "nrt": 312, "railway_us-east4-eqdc4a": 260, "railway_europe-west4-drams3a": 82, "koyeb_was": 264, "koyeb_sin": 382, "koyeb_sfo": 389 }, { "timestamp": "2025-10-18T04:00:00.000Z", "cdg": 114, "fra": 64, "koyeb_tyo": 445, "railway_europe-west4-drams3a": 90, "lax": 224, "koyeb_sin": 390, "railway_us-west2": 226, "koyeb_sfo": 266, "koyeb_was": 260, "railway_us-east4-eqdc4a": 262, "iad": 257, "ams": 82, "sin": 390, "nrt": 323, "railway_asia-southeast1-eqsg3a": 396, "koyeb_fra": 63, "koyeb_par": 114 }, { "timestamp": "2025-10-18T05:00:00.000Z", "sin": 418, "nrt": 560, "koyeb_tyo": 321, "koyeb_sin": 390, "koyeb_sfo": 258, "koyeb_was": 258, "railway_europe-west4-drams3a": 86, "railway_asia-southeast1-eqsg3a": 394, "fra": 63, "cdg": 113, "railway_us-west2": 377, "railway_us-east4-eqdc4a": 266, "koyeb_fra": 63, "koyeb_par": 115, "lax": 220, "ams": 83, "iad": 250 }, { "timestamp": "2025-10-18T06:00:00.000Z", "railway_us-west2": 225, "cdg": 113, "fra": 61, "koyeb_was": 258, "railway_europe-west4-drams3a": 90, "railway_us-east4-eqdc4a": 263, "koyeb_sfo": 252, "koyeb_sin": 403, "koyeb_tyo": 544, "iad": 251, "ams": 75, "lax": 214, "nrt": 320, "sin": 409, "koyeb_par": 114, "koyeb_fra": 64, "railway_asia-southeast1-eqsg3a": 396 }, { "timestamp": "2025-10-18T07:00:00.000Z", "koyeb_tyo": 545, "nrt": 320, "railway_asia-southeast1-eqsg3a": 396, "koyeb_was": 208, "koyeb_sfo": 248, "koyeb_sin": 417, "sin": 407, "railway_europe-west4-drams3a": 92, "iad": 256, "ams": 79, "lax": 206, "railway_us-west2": 366, "railway_us-east4-eqdc4a": 252, "koyeb_par": 116, "cdg": 112, "fra": 64, "koyeb_fra": 63 }, { "timestamp": "2025-10-18T08:00:00.000Z", "sin": 396, "railway_us-east4-eqdc4a": 258, "koyeb_was": 253, "nrt": 558, "koyeb_sfo": 254, "koyeb_sin": 404, "railway_europe-west4-drams3a": 93, "koyeb_tyo": 548, "fra": 64, "cdg": 114, "railway_asia-southeast1-eqsg3a": 395, "koyeb_par": 115, "railway_us-west2": 358, "koyeb_fra": 63, "lax": 202, "ams": 81, "iad": 254 }, { "timestamp": "2025-10-18T09:00:00.000Z", "railway_us-east4-eqdc4a": 258, "koyeb_was": 252, "koyeb_sin": 426, "sin": 388, "koyeb_sfo": 248, "nrt": 318, "railway_asia-southeast1-eqsg3a": 386, "koyeb_tyo": 324, "railway_us-west2": 370, "koyeb_par": 116, "cdg": 114, "fra": 64, "koyeb_fra": 64, "railway_europe-west4-drams3a": 90, "lax": 206, "iad": 256, "ams": 84 }, { "timestamp": "2025-10-18T10:00:00.000Z", "railway_asia-southeast1-eqsg3a": 400, "koyeb_tyo": 326, "fra": 65, "cdg": 112, "ams": 83, "iad": 258, "koyeb_sfo": 227, "koyeb_sin": 404, "lax": 200, "koyeb_was": 254, "railway_us-east4-eqdc4a": 261, "nrt": 334, "sin": 400, "railway_us-west2": 366, "koyeb_fra": 66, "railway_europe-west4-drams3a": 90, "koyeb_par": 117 }, { "timestamp": "2025-10-18T11:00:00.000Z", "koyeb_sfo": 238, "koyeb_sin": 394, "koyeb_was": 165, "railway_europe-west4-drams3a": 92, "nrt": 332, "railway_us-west2": 370, "koyeb_tyo": 330, "railway_us-east4-eqdc4a": 262, "sin": 406, "ams": 80, "iad": 256, "koyeb_fra": 64, "railway_asia-southeast1-eqsg3a": 397, "lax": 209, "koyeb_par": 116, "fra": 65, "cdg": 114 }, { "timestamp": "2025-10-18T12:00:00.000Z", "koyeb_par": 116, "koyeb_fra": 65, "railway_europe-west4-drams3a": 94, "sin": 396, "nrt": 334, "koyeb_was": 260, "railway_us-west2": 368, "lax": 202, "koyeb_sin": 404, "koyeb_sfo": 241, "iad": 251, "ams": 84, "railway_asia-southeast1-eqsg3a": 390, "cdg": 114, "fra": 63, "railway_us-east4-eqdc4a": 260, "koyeb_tyo": 548 }, { "timestamp": "2025-10-18T13:00:00.000Z", "lax": 209, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 262, "ams": 86, "koyeb_par": 116, "iad": 209, "koyeb_fra": 67, "fra": 64, "cdg": 114, "railway_us-west2": 368, "koyeb_was": 259, "koyeb_sin": 419, "koyeb_sfo": 240, "koyeb_tyo": 322, "sin": 520, "railway_asia-southeast1-eqsg3a": 413, "nrt": 545 }, { "timestamp": "2025-10-18T14:00:00.000Z", "railway_asia-southeast1-eqsg3a": 444, "sin": 436, "koyeb_par": 117, "railway_us-east4-eqdc4a": 260, "koyeb_fra": 66, "nrt": 540, "railway_us-west2": 374, "lax": 218, "koyeb_tyo": 338, "iad": 254, "ams": 84, "koyeb_was": 255, "cdg": 114, "fra": 64, "koyeb_sin": 422, "koyeb_sfo": 248, "railway_europe-west4-drams3a": 89 }, { "timestamp": "2025-10-18T15:00:00.000Z", "koyeb_fra": 64, "railway_asia-southeast1-eqsg3a": 443, "koyeb_par": 117, "cdg": 113, "fra": 65, "railway_us-east4-eqdc4a": 245, "iad": 233, "railway_us-west2": 375, "ams": 84, "lax": 215, "koyeb_sfo": 284, "koyeb_sin": 448, "nrt": 552, "koyeb_was": 242, "sin": 436, "railway_europe-west4-drams3a": 90, "koyeb_tyo": 324 }, { "timestamp": "2025-10-18T16:00:00.000Z", "railway_europe-west4-drams3a": 92, "cdg": 113, "fra": 65, "iad": 232, "ams": 84, "koyeb_par": 116, "lax": 219, "koyeb_fra": 66, "railway_us-west2": 375, "nrt": 344, "koyeb_tyo": 550, "sin": 462, "koyeb_was": 239, "railway_asia-southeast1-eqsg3a": 452, "koyeb_sfo": 248, "railway_us-east4-eqdc4a": 244, "koyeb_sin": 444 }, { "timestamp": "2025-10-18T17:00:00.000Z", "railway_us-west2": 378, "nrt": 544, "koyeb_sin": 384, "koyeb_sfo": 258, "koyeb_was": 238, "railway_asia-southeast1-eqsg3a": 394, "sin": 402, "koyeb_tyo": 547, "koyeb_fra": 66, "railway_us-east4-eqdc4a": 240, "fra": 67, "cdg": 94, "koyeb_par": 117, "ams": 86, "iad": 233, "railway_europe-west4-drams3a": 93, "lax": 228 }, { "timestamp": "2025-10-18T18:00:00.000Z", "railway_us-west2": 371, "cdg": 115, "fra": 67, "railway_europe-west4-drams3a": 91, "koyeb_tyo": 426, "koyeb_sin": 380, "koyeb_sfo": 259, "koyeb_was": 201, "iad": 235, "ams": 87, "lax": 220, "nrt": 312, "sin": 392, "koyeb_fra": 65, "railway_asia-southeast1-eqsg3a": 390, "koyeb_par": 115, "railway_us-east4-eqdc4a": 246 }, { "timestamp": "2025-10-18T19:00:00.000Z", "koyeb_was": 240, "koyeb_sfo": 263, "koyeb_sin": 382, "railway_us-west2": 377, "railway_europe-west4-drams3a": 92, "sin": 392, "nrt": 552, "koyeb_tyo": 310, "koyeb_par": 117, "koyeb_fra": 66, "lax": 207, "iad": 234, "ams": 86, "cdg": 115, "fra": 66, "railway_asia-southeast1-eqsg3a": 284, "railway_us-east4-eqdc4a": 245 }, { "timestamp": "2025-10-18T20:00:00.000Z", "koyeb_fra": 67, "koyeb_par": 116, "railway_asia-southeast1-eqsg3a": 384, "railway_us-west2": 372, "railway_us-east4-eqdc4a": 241, "sin": 384, "nrt": 312, "koyeb_sfo": 250, "koyeb_sin": 382, "lax": 216, "koyeb_was": 241, "ams": 82, "iad": 237, "railway_europe-west4-drams3a": 94, "fra": 66, "cdg": 114, "koyeb_tyo": 314 }, { "timestamp": "2025-10-18T21:00:00.000Z", "lax": 214, "iad": 235, "koyeb_fra": 66, "ams": 82, "koyeb_par": 116, "cdg": 114, "fra": 65, "railway_asia-southeast1-eqsg3a": 382, "railway_us-east4-eqdc4a": 244, "railway_europe-west4-drams3a": 92, "koyeb_sfo": 250, "koyeb_sin": 380, "koyeb_was": 242, "sin": 386, "railway_us-west2": 374, "koyeb_tyo": 316, "nrt": 309 }, { "timestamp": "2025-10-18T22:00:00.000Z", "koyeb_was": 237, "koyeb_sfo": 256, "railway_us-west2": 293, "sin": 384, "koyeb_sin": 308, "railway_asia-southeast1-eqsg3a": 382, "nrt": 326, "koyeb_tyo": 547, "koyeb_par": 116, "koyeb_fra": 64, "fra": 62, "cdg": 114, "railway_us-east4-eqdc4a": 245, "lax": 308, "railway_europe-west4-drams3a": 91, "ams": 83, "iad": 235 }, { "timestamp": "2025-10-18T23:00:00.000Z", "lax": 222, "koyeb_tyo": 548, "ams": 80, "iad": 234, "fra": 62, "cdg": 113, "koyeb_was": 238, "koyeb_sfo": 263, "koyeb_sin": 382, "railway_us-east4-eqdc4a": 238, "railway_europe-west4-drams3a": 90, "railway_asia-southeast1-eqsg3a": 306, "railway_us-west2": 368, "koyeb_par": 114, "sin": 384, "koyeb_fra": 62, "nrt": 308 }, { "timestamp": "2025-10-19T00:00:00.000Z", "railway_us-east4-eqdc4a": 250, "koyeb_tyo": 318, "railway_europe-west4-drams3a": 90, "iad": 248, "ams": 79, "lax": 217, "koyeb_was": 243, "cdg": 114, "fra": 63, "koyeb_sin": 382, "koyeb_sfo": 263, "railway_us-west2": 372, "nrt": 550, "koyeb_par": 115, "sin": 386, "railway_asia-southeast1-eqsg3a": 382, "koyeb_fra": 61 }, { "timestamp": "2025-10-19T01:00:00.000Z", "railway_europe-west4-drams3a": 74, "nrt": 315, "sin": 390, "koyeb_fra": 63, "koyeb_par": 116, "koyeb_tyo": 320, "cdg": 113, "fra": 61, "railway_us-east4-eqdc4a": 264, "iad": 254, "ams": 79, "koyeb_sfo": 258, "lax": 221, "koyeb_sin": 384, "railway_asia-southeast1-eqsg3a": 312, "railway_us-west2": 375, "koyeb_was": 260 }, { "timestamp": "2025-10-19T02:00:00.000Z", "railway_us-east4-eqdc4a": 264, "iad": 253, "ams": 76, "lax": 234, "railway_europe-west4-drams3a": 90, "koyeb_fra": 61, "koyeb_par": 115, "cdg": 112, "fra": 62, "koyeb_tyo": 316, "nrt": 434, "koyeb_sfo": 260, "koyeb_sin": 386, "railway_asia-southeast1-eqsg3a": 382, "koyeb_was": 256, "railway_us-west2": 232, "sin": 385 }, { "timestamp": "2025-10-19T03:00:00.000Z", "nrt": 548, "koyeb_tyo": 310, "railway_europe-west4-drams3a": 86, "sin": 390, "koyeb_was": 260, "koyeb_sin": 308, "koyeb_sfo": 294, "railway_us-west2": 378, "cdg": 112, "fra": 63, "koyeb_par": 115, "iad": 252, "ams": 82, "railway_asia-southeast1-eqsg3a": 390, "railway_us-east4-eqdc4a": 167, "koyeb_fra": 62, "lax": 223 }, { "timestamp": "2025-10-19T04:00:00.000Z", "koyeb_sin": 388, "koyeb_sfo": 258, "koyeb_was": 264, "sin": 392, "nrt": 438, "railway_asia-southeast1-eqsg3a": 390, "koyeb_tyo": 321, "koyeb_fra": 62, "koyeb_par": 115, "fra": 63, "cdg": 114, "railway_us-east4-eqdc4a": 170, "lax": 228, "railway_us-west2": 374, "railway_europe-west4-drams3a": 90, "ams": 82, "iad": 254 }, { "timestamp": "2025-10-19T05:00:00.000Z", "koyeb_par": 115, "koyeb_fra": 62, "lax": 222, "railway_asia-southeast1-eqsg3a": 396, "ams": 84, "iad": 209, "fra": 62, "cdg": 113, "railway_us-east4-eqdc4a": 260, "koyeb_was": 263, "koyeb_sfo": 258, "koyeb_sin": 394, "railway_us-west2": 378, "railway_europe-west4-drams3a": 92, "sin": 390, "nrt": 328, "koyeb_tyo": 328 }, { "timestamp": "2025-10-19T06:00:00.000Z", "sin": 408, "railway_us-east4-eqdc4a": 271, "nrt": 322, "railway_us-west2": 373, "koyeb_tyo": 554, "koyeb_sin": 392, "koyeb_sfo": 250, "koyeb_was": 260, "railway_europe-west4-drams3a": 91, "fra": 63, "cdg": 114, "railway_asia-southeast1-eqsg3a": 404, "koyeb_fra": 63, "koyeb_par": 116, "lax": 216, "ams": 82, "iad": 252 }, { "timestamp": "2025-10-19T07:00:00.000Z", "ams": 83, "iad": 252, "lax": 214, "koyeb_par": 116, "railway_asia-southeast1-eqsg3a": 390, "fra": 63, "cdg": 114, "koyeb_fra": 62, "koyeb_tyo": 326, "railway_europe-west4-drams3a": 92, "railway_us-west2": 372, "nrt": 318, "railway_us-east4-eqdc4a": 258, "koyeb_was": 264, "koyeb_sin": 400, "sin": 394, "koyeb_sfo": 252 }, { "timestamp": "2025-10-19T08:00:00.000Z", "ams": 84, "iad": 250, "lax": 211, "koyeb_tyo": 328, "railway_asia-southeast1-eqsg3a": 396, "koyeb_was": 254, "koyeb_sfo": 244, "koyeb_sin": 401, "fra": 65, "cdg": 114, "railway_us-west2": 364, "railway_us-east4-eqdc4a": 258, "koyeb_par": 116, "railway_europe-west4-drams3a": 90, "koyeb_fra": 65, "nrt": 334, "sin": 398 }, { "timestamp": "2025-10-19T09:00:00.000Z", "sin": 406, "nrt": 307, "koyeb_par": 116, "koyeb_fra": 65, "railway_europe-west4-drams3a": 92, "railway_us-east4-eqdc4a": 252, "railway_asia-southeast1-eqsg3a": 402, "fra": 64, "cdg": 114, "railway_us-west2": 362, "koyeb_tyo": 318, "koyeb_was": 256, "lax": 205, "koyeb_sfo": 254, "koyeb_sin": 398, "ams": 85, "iad": 260 }, { "timestamp": "2025-10-19T10:00:00.000Z", "koyeb_sfo": 244, "koyeb_sin": 244, "koyeb_was": 253, "railway_us-west2": 354, "sin": 394, "railway_europe-west4-drams3a": 92, "nrt": 322, "koyeb_tyo": 326, "railway_asia-southeast1-eqsg3a": 401, "koyeb_fra": 64, "koyeb_par": 117, "lax": 200, "ams": 84, "iad": 256, "fra": 66, "cdg": 114, "railway_us-east4-eqdc4a": 264 }, { "timestamp": "2025-10-19T11:00:00.000Z", "koyeb_tyo": 569, "sin": 402, "nrt": 336, "railway_europe-west4-drams3a": 96, "koyeb_sin": 390, "railway_us-east4-eqdc4a": 256, "koyeb_sfo": 245, "koyeb_was": 262, "fra": 65, "cdg": 114, "railway_asia-southeast1-eqsg3a": 359, "lax": 209, "ams": 76, "iad": 162, "koyeb_fra": 66, "koyeb_par": 117, "railway_us-west2": 368 }, { "timestamp": "2025-10-19T12:00:00.000Z", "sin": 394, "railway_europe-west4-drams3a": 92, "railway_us-west2": 366, "nrt": 338, "koyeb_sfo": 244, "koyeb_sin": 396, "koyeb_was": 254, "koyeb_tyo": 440, "cdg": 94, "fra": 66, "koyeb_fra": 66, "koyeb_par": 117, "railway_us-east4-eqdc4a": 264, "lax": 208, "railway_asia-southeast1-eqsg3a": 399, "iad": 258, "ams": 84 }, { "timestamp": "2025-10-19T13:00:00.000Z", "iad": 164, "ams": 84, "koyeb_fra": 66, "lax": 352, "koyeb_par": 119, "railway_asia-southeast1-eqsg3a": 398, "cdg": 116, "fra": 64, "koyeb_sfo": 245, "koyeb_sin": 406, "railway_europe-west4-drams3a": 86, "koyeb_was": 260, "nrt": 326, "railway_us-west2": 360, "railway_us-east4-eqdc4a": 262, "koyeb_tyo": 336, "sin": 446 }, { "timestamp": "2025-10-19T14:00:00.000Z", "koyeb_tyo": 336, "nrt": 558, "railway_europe-west4-drams3a": 94, "sin": 422, "railway_us-west2": 370, "koyeb_was": 258, "koyeb_sin": 398, "koyeb_sfo": 251, "railway_us-east4-eqdc4a": 240, "cdg": 114, "fra": 66, "iad": 152, "ams": 85, "koyeb_par": 119, "railway_asia-southeast1-eqsg3a": 412, "lax": 206, "koyeb_fra": 65 }, { "timestamp": "2025-10-19T15:00:00.000Z", "iad": 235, "ams": 83, "railway_europe-west4-drams3a": 94, "lax": 201, "railway_us-west2": 370, "railway_us-east4-eqdc4a": 241, "koyeb_fra": 66, "cdg": 116, "fra": 64, "koyeb_par": 119, "koyeb_tyo": 320, "nrt": 320, "koyeb_sfo": 381, "koyeb_sin": 400, "railway_asia-southeast1-eqsg3a": 434, "sin": 490, "koyeb_was": 241 }, { "timestamp": "2025-10-19T16:00:00.000Z", "koyeb_was": 238, "koyeb_sin": 432, "koyeb_sfo": 253, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 246, "nrt": 544, "koyeb_tyo": 314, "sin": 451, "koyeb_par": 119, "iad": 236, "ams": 80, "railway_us-west2": 378, "koyeb_fra": 69, "lax": 348, "railway_asia-southeast1-eqsg3a": 428, "cdg": 80, "fra": 66 }, { "timestamp": "2025-10-19T17:00:00.000Z", "koyeb_fra": 68, "koyeb_par": 117, "cdg": 114, "railway_europe-west4-drams3a": 94, "fra": 68, "iad": 236, "ams": 83, "lax": 354, "koyeb_sfo": 257, "nrt": 310, "koyeb_sin": 439, "railway_us-east4-eqdc4a": 242, "koyeb_was": 242, "sin": 430, "railway_asia-southeast1-eqsg3a": 434, "railway_us-west2": 381, "koyeb_tyo": 542 }, { "timestamp": "2025-10-19T18:00:00.000Z", "lax": 280, "koyeb_tyo": 316, "iad": 234, "ams": 84, "cdg": 115, "koyeb_sin": 427, "fra": 68, "koyeb_sfo": 249, "koyeb_was": 238, "railway_asia-southeast1-eqsg3a": 420, "railway_europe-west4-drams3a": 93, "railway_us-west2": 379, "sin": 422, "koyeb_fra": 67, "koyeb_par": 119, "nrt": 307, "railway_us-east4-eqdc4a": 244 }, { "timestamp": "2025-10-19T19:00:00.000Z", "sin": 404, "nrt": 312, "railway_us-east4-eqdc4a": 238, "koyeb_par": 119, "koyeb_fra": 68, "railway_asia-southeast1-eqsg3a": 388, "cdg": 114, "railway_europe-west4-drams3a": 94, "fra": 67, "koyeb_tyo": 551, "railway_us-west2": 378, "lax": 352, "koyeb_was": 239, "koyeb_sfo": 246, "koyeb_sin": 410, "iad": 234, "ams": 87 }, { "timestamp": "2025-10-19T20:00:00.000Z", "fra": 67, "railway_asia-southeast1-eqsg3a": 320, "cdg": 116, "koyeb_tyo": 310, "lax": 208, "koyeb_sin": 236, "koyeb_sfo": 384, "ams": 84, "koyeb_was": 239, "iad": 234, "sin": 400, "nrt": 532, "railway_us-west2": 376, "railway_us-east4-eqdc4a": 244, "koyeb_fra": 66, "railway_europe-west4-drams3a": 95, "koyeb_par": 116 }, { "timestamp": "2025-10-19T21:00:00.000Z", "railway_asia-southeast1-eqsg3a": 391, "railway_us-east4-eqdc4a": 247, "sin": 388, "koyeb_par": 117, "koyeb_fra": 66, "nrt": 309, "lax": 206, "koyeb_tyo": 306, "ams": 83, "iad": 204, "railway_us-west2": 378, "fra": 64, "cdg": 114, "koyeb_was": 243, "koyeb_sfo": 385, "railway_europe-west4-drams3a": 90, "koyeb_sin": 379 }, { "timestamp": "2025-10-19T22:00:00.000Z", "fra": 62, "cdg": 113, "koyeb_sin": 369, "koyeb_sfo": 386, "railway_us-west2": 374, "koyeb_was": 160, "lax": 206, "railway_europe-west4-drams3a": 89, "koyeb_tyo": 310, "ams": 80, "iad": 234, "railway_us-east4-eqdc4a": 244, "sin": 385, "railway_asia-southeast1-eqsg3a": 378, "koyeb_fra": 65, "koyeb_par": 116, "nrt": 308 }, { "timestamp": "2025-10-19T23:00:00.000Z", "railway_asia-southeast1-eqsg3a": 386, "koyeb_fra": 63, "koyeb_par": 116, "nrt": 306, "railway_us-east4-eqdc4a": 180, "sin": 384, "ams": 82, "iad": 233, "koyeb_sin": 373, "koyeb_sfo": 299, "lax": 210, "koyeb_was": 218, "railway_us-west2": 365, "koyeb_tyo": 438, "fra": 62, "cdg": 98, "railway_europe-west4-drams3a": 88 }, { "timestamp": "2025-10-20T00:00:00.000Z", "koyeb_sfo": 388, "koyeb_sin": 378, "koyeb_was": 242, "railway_us-west2": 245, "railway_asia-southeast1-eqsg3a": 376, "nrt": 316, "sin": 372, "koyeb_tyo": 314, "koyeb_fra": 62, "ams": 80, "iad": 236, "koyeb_par": 114, "railway_europe-west4-drams3a": 90, "lax": 211, "railway_us-east4-eqdc4a": 246, "fra": 62, "cdg": 113 }, { "timestamp": "2025-10-20T01:00:00.000Z", "fra": 63, "cdg": 112, "koyeb_par": 114, "koyeb_fra": 63, "railway_us-east4-eqdc4a": 262, "railway_europe-west4-drams3a": 92, "lax": 214, "ams": 79, "iad": 254, "railway_us-west2": 258, "sin": 371, "koyeb_was": 256, "nrt": 314, "koyeb_sfo": 267, "railway_asia-southeast1-eqsg3a": 377, "koyeb_sin": 378, "koyeb_tyo": 314 }, { "timestamp": "2025-10-20T02:00:00.000Z", "lax": 347, "koyeb_tyo": 439, "ams": 78, "iad": 260, "railway_us-west2": 245, "fra": 61, "railway_europe-west4-drams3a": 84, "cdg": 113, "koyeb_sfo": 250, "koyeb_sin": 382, "koyeb_was": 260, "railway_us-east4-eqdc4a": 265, "railway_asia-southeast1-eqsg3a": 381, "sin": 384, "koyeb_fra": 62, "koyeb_par": 114, "nrt": 322 }, { "timestamp": "2025-10-20T03:00:00.000Z", "railway_asia-southeast1-eqsg3a": 388, "nrt": 318, "sin": 389, "koyeb_fra": 62, "koyeb_par": 114, "railway_us-east4-eqdc4a": 256, "koyeb_tyo": 548, "fra": 62, "cdg": 112, "ams": 81, "iad": 256, "railway_us-west2": 248, "koyeb_sfo": 240, "railway_europe-west4-drams3a": 88, "lax": 207, "koyeb_sin": 387, "koyeb_was": 266 }, { "timestamp": "2025-10-20T04:00:00.000Z", "railway_us-west2": 252, "nrt": 556, "sin": 384, "koyeb_fra": 62, "railway_us-east4-eqdc4a": 164, "koyeb_par": 116, "railway_asia-southeast1-eqsg3a": 385, "cdg": 100, "koyeb_sin": 386, "fra": 63, "koyeb_sfo": 252, "koyeb_was": 262, "railway_europe-west4-drams3a": 92, "koyeb_tyo": 552, "iad": 156, "ams": 82, "lax": 210 }, { "timestamp": "2025-10-20T05:00:00.000Z", "koyeb_fra": 63, "nrt": 318, "koyeb_par": 114, "railway_asia-southeast1-eqsg3a": 387, "sin": 390, "railway_us-east4-eqdc4a": 263, "iad": 250, "ams": 80, "railway_europe-west4-drams3a": 94, "lax": 220, "koyeb_tyo": 316, "koyeb_sfo": 246, "koyeb_sin": 390, "koyeb_was": 262, "railway_us-west2": 260, "cdg": 112, "fra": 64 }, { "timestamp": "2025-10-20T06:00:00.000Z", "koyeb_was": 260, "koyeb_sin": 388, "koyeb_sfo": 263, "cdg": 113, "fra": 62, "railway_europe-west4-drams3a": 90, "iad": 248, "railway_us-west2": 368, "ams": 81, "koyeb_tyo": 324, "lax": 334, "koyeb_par": 118, "nrt": 316, "koyeb_fra": 63, "sin": 392, "railway_us-east4-eqdc4a": 262, "railway_asia-southeast1-eqsg3a": 392 }, { "timestamp": "2025-10-20T07:00:00.000Z", "nrt": 612, "sin": 477, "iad": 264, "ams": 94, "lax": 388, "cdg": 62, "fra": 72 }, { "timestamp": "2025-10-20T08:00:00.000Z", "railway_us-west2": 384, "koyeb_fra": 76, "koyeb_par": 138, "sin": 844, "railway_europe-west4-drams3a": 107, "nrt": 578, "railway_us-east4-eqdc4a": 282, "lax": 359, "koyeb_sin": 896, "koyeb_sfo": 452, "koyeb_was": 286, "railway_asia-southeast1-eqsg3a": 488, "ams": 97, "iad": 276, "fra": 72, "cdg": 142, "koyeb_tyo": 587 }, { "timestamp": "2025-10-20T09:00:00.000Z", "railway_asia-southeast1-eqsg3a": 319, "fra": 48, "cdg": 71, "lax": 191, "ams": 59, "iad": 153, "koyeb_fra": 52, "koyeb_par": 74, "koyeb_tyo": 303, "sin": 331, "railway_us-west2": 207, "railway_us-east4-eqdc4a": 157, "nrt": 313, "railway_europe-west4-drams3a": 66, "koyeb_sin": 318, "koyeb_sfo": 229, "koyeb_was": 156 }, { "timestamp": "2025-10-20T10:00:00.000Z", "lax": 206, "koyeb_tyo": 319, "ams": 85, "iad": 256, "railway_us-east4-eqdc4a": 263, "fra": 66, "cdg": 116, "koyeb_sin": 396, "koyeb_sfo": 244, "koyeb_was": 165, "railway_us-west2": 362, "railway_europe-west4-drams3a": 88, "railway_asia-southeast1-eqsg3a": 402, "sin": 424, "koyeb_fra": 64, "koyeb_par": 108, "nrt": 331 }, { "timestamp": "2025-10-20T11:00:00.000Z", "lax": 208, "iad": 252, "koyeb_tyo": 338, "ams": 82, "koyeb_sfo": 240, "cdg": 114, "fra": 67, "koyeb_sin": 390, "koyeb_was": 264, "railway_us-east4-eqdc4a": 261, "railway_asia-southeast1-eqsg3a": 402, "railway_europe-west4-drams3a": 93, "railway_us-west2": 286, "koyeb_fra": 66, "sin": 407, "koyeb_par": 118, "nrt": 333 }, { "timestamp": "2025-10-20T12:00:00.000Z", "railway_us-east4-eqdc4a": 251, "ams": 84, "iad": 248, "railway_asia-southeast1-eqsg3a": 452, "railway_us-west2": 370, "lax": 280, "koyeb_fra": 66, "koyeb_par": 119, "fra": 66, "cdg": 113, "koyeb_tyo": 352, "koyeb_sin": 406, "nrt": 349, "railway_europe-west4-drams3a": 88, "koyeb_sfo": 300, "koyeb_was": 256, "sin": 458 }, { "timestamp": "2025-10-20T13:00:00.000Z", "koyeb_fra": 66, "koyeb_par": 118, "nrt": 343, "sin": 562, "railway_europe-west4-drams3a": 96, "koyeb_sin": 522, "koyeb_sfo": 252, "koyeb_was": 156, "railway_us-west2": 370, "railway_asia-southeast1-eqsg3a": 525, "fra": 65, "cdg": 115, "ams": 84, "iad": 244, "railway_us-east4-eqdc4a": 247, "lax": 207, "koyeb_tyo": 347 }, { "timestamp": "2025-10-20T14:00:00.000Z", "nrt": 340, "koyeb_was": 250, "koyeb_sin": 418, "koyeb_sfo": 250, "sin": 456, "railway_us-west2": 368, "railway_asia-southeast1-eqsg3a": 432, "koyeb_tyo": 354, "koyeb_par": 118, "koyeb_fra": 64, "cdg": 115, "railway_europe-west4-drams3a": 94, "fra": 67, "iad": 247, "ams": 84, "lax": 214, "railway_us-east4-eqdc4a": 206 }, { "timestamp": "2025-10-20T15:00:00.000Z", "railway_asia-southeast1-eqsg3a": 428, "koyeb_was": 161, "fra": 67, "cdg": 115, "koyeb_sin": 457, "railway_us-east4-eqdc4a": 258, "koyeb_sfo": 243, "koyeb_tyo": 317, "ams": 88, "iad": 151, "lax": 210, "nrt": 559, "koyeb_par": 108, "sin": 532, "koyeb_fra": 68, "railway_us-west2": 264, "railway_europe-west4-drams3a": 92 }, { "timestamp": "2025-10-20T16:00:00.000Z", "railway_europe-west4-drams3a": 93, "fra": 67, "cdg": 87, "koyeb_tyo": 535, "koyeb_sin": 461, "koyeb_sfo": 259, "ams": 89, "koyeb_was": 256, "iad": 159, "lax": 215, "railway_us-east4-eqdc4a": 256, "nrt": 316, "sin": 476, "koyeb_fra": 68, "koyeb_par": 119, "railway_asia-southeast1-eqsg3a": 444, "railway_us-west2": 255 }, { "timestamp": "2025-10-20T18:00:00.000Z", "lax": 221, "koyeb_tyo": 337, "railway_asia-southeast1-eqsg3a": 443, "iad": 249, "ams": 81, "cdg": 114, "fra": 69, "koyeb_was": 253, "koyeb_sfo": 251, "koyeb_sin": 456, "railway_us-west2": 243, "sin": 497, "railway_us-east4-eqdc4a": 255, "railway_europe-west4-drams3a": 95, "koyeb_par": 119, "koyeb_fra": 66, "nrt": 307 }, { "timestamp": "2025-10-20T19:00:00.000Z", "railway_asia-southeast1-eqsg3a": 422, "koyeb_tyo": 434, "ams": 85, "iad": 252, "lax": 217, "fra": 66, "cdg": 115, "koyeb_was": 206, "koyeb_sfo": 253, "koyeb_sin": 415, "railway_us-west2": 249, "nrt": 302, "railway_us-east4-eqdc4a": 258, "railway_europe-west4-drams3a": 90, "koyeb_par": 117, "sin": 440, "koyeb_fra": 66 }, { "timestamp": "2025-10-20T20:00:00.000Z", "iad": 149, "ams": 61, "railway_europe-west4-drams3a": 68, "lax": 198, "koyeb_fra": 48, "railway_us-east4-eqdc4a": 157, "cdg": 73, "fra": 51, "koyeb_par": 78, "koyeb_tyo": 301, "railway_us-west2": 231, "nrt": 299, "koyeb_sfo": 232, "koyeb_sin": 231, "sin": 296, "railway_asia-southeast1-eqsg3a": 237, "koyeb_was": 155 }, { "timestamp": "2025-10-20T21:00:00.000Z", "railway_europe-west4-drams3a": 67, "sin": 286, "koyeb_par": 74, "nrt": 304, "koyeb_fra": 50, "railway_us-east4-eqdc4a": 153, "cdg": 74, "fra": 52, "koyeb_was": 150, "koyeb_sin": 231, "koyeb_sfo": 237, "koyeb_tyo": 302, "lax": 204, "iad": 239, "railway_asia-southeast1-eqsg3a": 234, "ams": 60, "railway_us-west2": 235 }, { "timestamp": "2025-10-20T22:00:00.000Z", "fra": 61, "cdg": 76, "railway_asia-southeast1-eqsg3a": 248, "railway_us-east4-eqdc4a": 246, "koyeb_par": 115, "koyeb_fra": 61, "lax": 214, "ams": 65, "iad": 335, "sin": 344, "koyeb_tyo": 316, "nrt": 306, "koyeb_was": 156, "koyeb_sin": 388, "koyeb_sfo": 263, "railway_us-west2": 235, "railway_europe-west4-drams3a": 85 }, { "timestamp": "2025-10-20T23:00:00.000Z", "sin": 391, "railway_us-west2": 251, "nrt": 320, "railway_asia-southeast1-eqsg3a": 398, "koyeb_par": 115, "koyeb_fra": 63, "koyeb_tyo": 542, "cdg": 113, "fra": 63, "railway_europe-west4-drams3a": 88, "lax": 356, "koyeb_was": 252, "railway_us-east4-eqdc4a": 253, "iad": 246, "ams": 82, "koyeb_sin": 394, "koyeb_sfo": 390 }, { "timestamp": "2025-10-21T00:00:00.000Z", "railway_us-west2": 240, "koyeb_par": 116, "railway_us-east4-eqdc4a": 259, "koyeb_fra": 62, "nrt": 318, "railway_europe-west4-drams3a": 92, "sin": 388, "ams": 80, "iad": 248, "railway_asia-southeast1-eqsg3a": 240, "koyeb_was": 252, "lax": 366, "koyeb_sin": 388, "koyeb_sfo": 390, "koyeb_tyo": 555, "fra": 62, "cdg": 112 }, { "timestamp": "2025-10-21T01:00:00.000Z", "fra": 61, "cdg": 112, "railway_asia-southeast1-eqsg3a": 399, "ams": 80, "iad": 247, "koyeb_par": 115, "koyeb_fra": 63, "lax": 355, "koyeb_tyo": 314, "nrt": 432, "sin": 408, "railway_us-west2": 240, "railway_europe-west4-drams3a": 88, "koyeb_was": 250, "koyeb_sin": 388, "koyeb_sfo": 328, "railway_us-east4-eqdc4a": 252 }, { "timestamp": "2025-10-21T02:00:00.000Z", "koyeb_fra": 64, "sin": 406, "koyeb_par": 115, "nrt": 314, "railway_us-west2": 242, "railway_asia-southeast1-eqsg3a": 398, "railway_us-east4-eqdc4a": 250, "cdg": 112, "railway_europe-west4-drams3a": 91, "fra": 62, "koyeb_sfo": 396, "koyeb_sin": 396, "koyeb_was": 212, "lax": 204, "koyeb_tyo": 323, "iad": 245, "ams": 78 }, { "timestamp": "2025-10-21T03:00:00.000Z", "sin": 432, "koyeb_fra": 62, "koyeb_par": 91, "nrt": 326, "railway_us-east4-eqdc4a": 262, "railway_europe-west4-drams3a": 86, "railway_asia-southeast1-eqsg3a": 408, "fra": 63, "cdg": 114, "koyeb_sin": 406, "koyeb_sfo": 398, "koyeb_was": 256, "lax": 362, "koyeb_tyo": 556, "railway_us-west2": 244, "ams": 81, "iad": 252 }, { "timestamp": "2025-10-21T04:00:00.000Z", "railway_asia-southeast1-eqsg3a": 448, "koyeb_fra": 62, "railway_us-west2": 243, "koyeb_par": 114, "railway_us-east4-eqdc4a": 264, "nrt": 558, "sin": 448, "ams": 78, "iad": 243, "koyeb_sin": 418, "lax": 284, "koyeb_sfo": 396, "koyeb_was": 260, "koyeb_tyo": 452, "fra": 63, "railway_europe-west4-drams3a": 90, "cdg": 111 }, { "timestamp": "2025-10-21T05:00:00.000Z", "fra": 62, "cdg": 113, "railway_europe-west4-drams3a": 93, "ams": 80, "iad": 254, "koyeb_fra": 63, "koyeb_par": 116, "lax": 358, "railway_us-east4-eqdc4a": 247, "railway_us-west2": 386, "nrt": 522, "koyeb_tyo": 317, "railway_asia-southeast1-eqsg3a": 538, "sin": 513, "koyeb_sin": 465, "koyeb_sfo": 392, "koyeb_was": 206 }, { "timestamp": "2025-10-21T06:00:00.000Z", "railway_us-west2": 406, "sin": 451, "railway_us-east4-eqdc4a": 211, "koyeb_par": 115, "koyeb_fra": 65, "railway_europe-west4-drams3a": 92, "nrt": 400, "cdg": 103, "fra": 64, "koyeb_was": 252, "koyeb_sfo": 390, "koyeb_sin": 458, "lax": 358, "koyeb_tyo": 332, "railway_asia-southeast1-eqsg3a": 460, "iad": 252, "ams": 82 }, { "timestamp": "2025-10-21T07:00:00.000Z", "fra": 65, "cdg": 115, "koyeb_par": 117, "koyeb_fra": 64, "railway_europe-west4-drams3a": 94, "lax": 352, "ams": 80, "iad": 254, "sin": 562, "railway_asia-southeast1-eqsg3a": 569, "nrt": 542, "koyeb_was": 253, "koyeb_sfo": 384, "koyeb_sin": 570, "railway_us-east4-eqdc4a": 214, "koyeb_tyo": 444, "railway_us-west2": 318 }, { "timestamp": "2025-10-21T08:00:00.000Z", "fra": 66, "cdg": 114, "koyeb_fra": 65, "koyeb_par": 117, "railway_asia-southeast1-eqsg3a": 436, "lax": 355, "railway_us-east4-eqdc4a": 261, "ams": 81, "iad": 254, "sin": 452, "nrt": 550, "railway_europe-west4-drams3a": 92, "koyeb_tyo": 336, "koyeb_sfo": 386, "koyeb_sin": 452, "railway_us-west2": 263, "koyeb_was": 262 }, { "timestamp": "2025-10-21T09:00:00.000Z", "koyeb_tyo": 318, "lax": 205, "railway_europe-west4-drams3a": 94, "ams": 86, "iad": 249, "fra": 67, "cdg": 114, "koyeb_was": 255, "koyeb_sfo": 362, "koyeb_sin": 486, "railway_asia-southeast1-eqsg3a": 482, "railway_us-east4-eqdc4a": 211, "railway_us-west2": 252, "sin": 530, "koyeb_par": 117, "koyeb_fra": 66, "nrt": 556 }, { "timestamp": "2025-10-21T10:00:00.000Z", "koyeb_was": 258, "koyeb_sfo": 268, "koyeb_sin": 523, "railway_asia-southeast1-eqsg3a": 490, "sin": 480, "nrt": 550, "koyeb_tyo": 328, "railway_us-east4-eqdc4a": 262, "koyeb_par": 116, "koyeb_fra": 66, "lax": 208, "iad": 160, "ams": 84, "cdg": 113, "fra": 65, "railway_us-west2": 256, "railway_europe-west4-drams3a": 95 }, { "timestamp": "2025-10-21T11:00:00.000Z", "koyeb_sfo": 248, "koyeb_sin": 474, "koyeb_was": 263, "nrt": 340, "koyeb_tyo": 553, "sin": 512, "railway_asia-southeast1-eqsg3a": 454, "railway_us-east4-eqdc4a": 164, "railway_us-west2": 248, "ams": 84, "railway_europe-west4-drams3a": 95, "iad": 251, "koyeb_fra": 64, "koyeb_par": 118, "lax": 216, "fra": 66, "cdg": 115 }, { "timestamp": "2025-10-21T12:00:00.000Z", "koyeb_par": 116, "koyeb_fra": 68, "nrt": 343, "railway_europe-west4-drams3a": 96, "sin": 494, "iad": 254, "ams": 84, "railway_asia-southeast1-eqsg3a": 465, "railway_us-west2": 394, "lax": 194, "koyeb_was": 262, "koyeb_sfo": 248, "koyeb_sin": 466, "koyeb_tyo": 344, "railway_us-east4-eqdc4a": 271, "cdg": 114, "fra": 67 }, { "timestamp": "2025-10-21T13:00:00.000Z", "cdg": 106, "fra": 67, "railway_us-east4-eqdc4a": 256, "railway_europe-west4-drams3a": 97, "koyeb_par": 116, "lax": 356, "koyeb_fra": 66, "iad": 248, "ams": 86, "sin": 534, "koyeb_tyo": 406, "nrt": 428, "koyeb_was": 250, "koyeb_sin": 532, "railway_asia-southeast1-eqsg3a": 489, "koyeb_sfo": 384, "railway_us-west2": 257 }, { "timestamp": "2025-10-21T14:00:00.000Z", "sin": 662, "koyeb_par": 120, "railway_asia-southeast1-eqsg3a": 659, "koyeb_fra": 66, "nrt": 577, "railway_us-east4-eqdc4a": 254, "fra": 64, "cdg": 114, "koyeb_was": 252, "koyeb_sin": 582, "koyeb_sfo": 386, "koyeb_tyo": 559, "railway_europe-west4-drams3a": 96, "lax": 360, "ams": 85, "iad": 252, "railway_us-west2": 410 }, { "timestamp": "2025-10-21T15:00:00.000Z", "koyeb_par": 120, "railway_us-west2": 246, "koyeb_fra": 66, "cdg": 106, "fra": 67, "lax": 354, "iad": 249, "ams": 85, "railway_asia-southeast1-eqsg3a": 522, "koyeb_was": 254, "sin": 611, "koyeb_sin": 665, "railway_europe-west4-drams3a": 94, "koyeb_sfo": 382, "nrt": 594, "railway_us-east4-eqdc4a": 260, "koyeb_tyo": 642 }, { "timestamp": "2025-10-21T16:00:00.000Z", "railway_asia-southeast1-eqsg3a": 566, "koyeb_par": 118, "koyeb_fra": 68, "railway_us-west2": 315, "fra": 68, "cdg": 114, "ams": 86, "iad": 160, "lax": 354, "nrt": 568, "koyeb_was": 209, "koyeb_sfo": 382, "sin": 545, "koyeb_sin": 540, "koyeb_tyo": 611, "railway_us-east4-eqdc4a": 258, "railway_europe-west4-drams3a": 100 }, { "timestamp": "2025-10-21T17:00:00.000Z", "railway_us-east4-eqdc4a": 258, "railway_asia-southeast1-eqsg3a": 450, "koyeb_sin": 458, "koyeb_sfo": 399, "koyeb_was": 256, "nrt": 564, "railway_us-west2": 250, "koyeb_tyo": 592, "sin": 469, "ams": 86, "iad": 250, "koyeb_fra": 66, "lax": 342, "koyeb_par": 120, "fra": 68, "cdg": 114, "railway_europe-west4-drams3a": 96 }, { "timestamp": "2025-10-21T18:00:00.000Z", "koyeb_tyo": 573, "railway_us-east4-eqdc4a": 260, "railway_us-west2": 238, "nrt": 570, "koyeb_sfo": 390, "railway_asia-southeast1-eqsg3a": 398, "koyeb_sin": 430, "sin": 462, "koyeb_was": 250, "iad": 256, "ams": 86, "railway_europe-west4-drams3a": 99, "lax": 350, "koyeb_fra": 67, "cdg": 114, "koyeb_par": 119, "fra": 64 }, { "timestamp": "2025-10-21T19:00:00.000Z", "cdg": 114, "fra": 67, "iad": 249, "railway_us-west2": 233, "ams": 88, "koyeb_par": 120, "railway_asia-southeast1-eqsg3a": 404, "lax": 347, "koyeb_fra": 64, "nrt": 576, "koyeb_tyo": 566, "railway_europe-west4-drams3a": 97, "sin": 404, "koyeb_was": 254, "railway_us-east4-eqdc4a": 256, "koyeb_sin": 362, "koyeb_sfo": 379 }, { "timestamp": "2025-10-21T20:00:00.000Z", "railway_europe-west4-drams3a": 95, "koyeb_fra": 66, "sin": 385, "koyeb_par": 117, "nrt": 467, "railway_us-west2": 248, "railway_us-east4-eqdc4a": 232, "lax": 211, "koyeb_tyo": 544, "iad": 247, "ams": 84, "cdg": 115, "fra": 67, "koyeb_sfo": 366, "koyeb_sin": 402, "railway_asia-southeast1-eqsg3a": 403, "koyeb_was": 250 }, { "timestamp": "2025-10-21T21:00:00.000Z", "railway_us-west2": 241, "cdg": 114, "fra": 65, "koyeb_tyo": 310, "railway_europe-west4-drams3a": 86, "koyeb_was": 246, "lax": 340, "koyeb_sin": 386, "koyeb_sfo": 371, "iad": 242, "ams": 84, "sin": 414, "railway_us-east4-eqdc4a": 254, "nrt": 547, "koyeb_par": 119, "railway_asia-southeast1-eqsg3a": 411, "koyeb_fra": 65 }, { "timestamp": "2025-10-21T22:00:00.000Z", "railway_us-west2": 251, "koyeb_tyo": 547, "railway_us-east4-eqdc4a": 250, "koyeb_was": 252, "sin": 392, "koyeb_sfo": 385, "koyeb_sin": 380, "railway_europe-west4-drams3a": 90, "nrt": 317, "lax": 356, "iad": 247, "railway_asia-southeast1-eqsg3a": 388, "ams": 80, "cdg": 114, "fra": 62, "koyeb_par": 117, "koyeb_fra": 62 }, { "timestamp": "2025-10-21T23:00:00.000Z", "cdg": 112, "fra": 62, "railway_europe-west4-drams3a": 90, "koyeb_fra": 64, "lax": 352, "koyeb_par": 116, "railway_us-west2": 394, "iad": 246, "ams": 82, "sin": 394, "railway_asia-southeast1-eqsg3a": 381, "koyeb_tyo": 317, "nrt": 550, "koyeb_sin": 396, "koyeb_sfo": 323, "koyeb_was": 248, "railway_us-east4-eqdc4a": 256 }, { "timestamp": "2025-10-22T00:00:00.000Z", "koyeb_sfo": 383, "koyeb_sin": 392, "railway_asia-southeast1-eqsg3a": 392, "lax": 358, "koyeb_was": 247, "ams": 67, "iad": 248, "railway_us-east4-eqdc4a": 255, "fra": 62, "cdg": 112, "koyeb_tyo": 318, "koyeb_fra": 62, "koyeb_par": 114, "sin": 394, "nrt": 306, "railway_europe-west4-drams3a": 92, "railway_us-west2": 382 }, { "timestamp": "2025-10-22T01:00:00.000Z", "koyeb_fra": 64, "nrt": 309, "railway_us-west2": 389, "koyeb_par": 106, "sin": 395, "railway_europe-west4-drams3a": 88, "railway_us-east4-eqdc4a": 248, "koyeb_sfo": 384, "koyeb_sin": 398, "koyeb_was": 246, "fra": 64, "railway_asia-southeast1-eqsg3a": 396, "cdg": 112, "ams": 58, "iad": 246, "lax": 354, "koyeb_tyo": 312 }, { "timestamp": "2025-10-22T02:00:00.000Z", "railway_europe-west4-drams3a": 88, "railway_us-east4-eqdc4a": 252, "koyeb_was": 250, "koyeb_sin": 396, "cdg": 112, "koyeb_sfo": 386, "fra": 62, "iad": 248, "koyeb_tyo": 332, "ams": 80, "lax": 358, "nrt": 324, "koyeb_par": 115, "sin": 434, "koyeb_fra": 62, "railway_us-west2": 269, "railway_asia-southeast1-eqsg3a": 417 }, { "timestamp": "2025-10-22T03:00:00.000Z", "railway_us-west2": 327, "iad": 257, "ams": 68, "railway_europe-west4-drams3a": 89, "koyeb_tyo": 314, "lax": 358, "koyeb_was": 256, "koyeb_sfo": 389, "koyeb_sin": 438, "cdg": 112, "fra": 63, "railway_us-east4-eqdc4a": 267, "railway_asia-southeast1-eqsg3a": 424, "nrt": 546, "koyeb_par": 114, "koyeb_fra": 62, "sin": 457 }, { "timestamp": "2025-10-22T04:00:00.000Z", "koyeb_tyo": 316, "nrt": 322, "railway_europe-west4-drams3a": 94, "sin": 500, "koyeb_was": 260, "koyeb_sfo": 390, "koyeb_sin": 444, "railway_us-west2": 256, "cdg": 112, "fra": 65, "iad": 251, "railway_asia-southeast1-eqsg3a": 446, "ams": 80, "lax": 220, "koyeb_par": 114, "koyeb_fra": 62, "railway_us-east4-eqdc4a": 169 }, { "timestamp": "2025-10-22T05:00:00.000Z", "lax": 208, "iad": 250, "ams": 82, "railway_us-west2": 398, "cdg": 74, "fra": 65, "koyeb_par": 114, "koyeb_fra": 62, "railway_asia-southeast1-eqsg3a": 466, "koyeb_tyo": 324, "railway_europe-west4-drams3a": 90, "sin": 574, "railway_us-east4-eqdc4a": 214, "koyeb_was": 254, "nrt": 553, "koyeb_sfo": 393, "koyeb_sin": 519 }, { "timestamp": "2025-10-22T06:00:00.000Z", "railway_asia-southeast1-eqsg3a": 452, "koyeb_tyo": 324, "koyeb_sin": 599, "koyeb_sfo": 398, "sin": 568, "koyeb_was": 252, "nrt": 352, "railway_us-west2": 409, "lax": 363, "railway_us-east4-eqdc4a": 258, "ams": 83, "iad": 250, "fra": 64, "cdg": 112, "railway_europe-west4-drams3a": 85, "koyeb_fra": 66, "koyeb_par": 115 }, { "timestamp": "2025-10-22T07:00:00.000Z", "railway_europe-west4-drams3a": 95, "fra": 65, "cdg": 112, "railway_us-west2": 276, "ams": 82, "iad": 250, "railway_us-east4-eqdc4a": 258, "koyeb_par": 116, "lax": 362, "koyeb_fra": 66, "koyeb_tyo": 554, "nrt": 550, "sin": 540, "railway_asia-southeast1-eqsg3a": 467, "koyeb_was": 254, "koyeb_sin": 546, "koyeb_sfo": 400 }, { "timestamp": "2025-10-22T08:00:00.000Z", "railway_us-west2": 240, "sin": 474, "nrt": 556, "koyeb_par": 116, "koyeb_fra": 66, "railway_asia-southeast1-eqsg3a": 454, "koyeb_tyo": 554, "fra": 64, "cdg": 115, "railway_europe-west4-drams3a": 94, "lax": 358, "ams": 84, "railway_us-east4-eqdc4a": 254, "koyeb_was": 260, "iad": 251, "koyeb_sfo": 392, "koyeb_sin": 456 }, { "timestamp": "2025-10-22T09:00:00.000Z", "koyeb_tyo": 440, "lax": 354, "railway_us-east4-eqdc4a": 258, "ams": 83, "iad": 156, "fra": 64, "cdg": 112, "koyeb_sin": 516, "railway_asia-southeast1-eqsg3a": 452, "railway_us-west2": 382, "koyeb_sfo": 252, "koyeb_was": 254, "railway_europe-west4-drams3a": 96, "sin": 535, "nrt": 388, "koyeb_fra": 65, "koyeb_par": 116 }, { "timestamp": "2025-10-22T10:00:00.000Z", "railway_us-west2": 398, "nrt": 318, "koyeb_fra": 66, "koyeb_par": 114, "sin": 601, "railway_europe-west4-drams3a": 97, "iad": 253, "railway_asia-southeast1-eqsg3a": 485, "ams": 85, "railway_us-east4-eqdc4a": 261, "koyeb_tyo": 552, "lax": 346, "koyeb_sin": 532, "koyeb_sfo": 377, "koyeb_was": 256, "cdg": 98, "fra": 65 }, { "timestamp": "2025-10-22T11:00:00.000Z", "koyeb_tyo": 569, "cdg": 113, "fra": 64, "iad": 250, "ams": 83, "railway_us-west2": 232, "koyeb_was": 256, "koyeb_sfo": 327, "koyeb_sin": 497, "lax": 212, "railway_europe-west4-drams3a": 94, "railway_us-east4-eqdc4a": 258, "railway_asia-southeast1-eqsg3a": 474, "nrt": 572, "sin": 578, "koyeb_par": 116, "koyeb_fra": 67 }, { "timestamp": "2025-10-22T12:00:00.000Z", "koyeb_fra": 66, "iad": 254, "ams": 83, "koyeb_par": 116, "lax": 346, "railway_us-west2": 398, "railway_europe-west4-drams3a": 96, "railway_us-east4-eqdc4a": 257, "cdg": 114, "fra": 65, "koyeb_sfo": 386, "koyeb_sin": 608, "koyeb_was": 260, "railway_asia-southeast1-eqsg3a": 599, "nrt": 570, "sin": 620, "koyeb_tyo": 392 }, { "timestamp": "2025-10-22T13:00:00.000Z", "koyeb_was": 262, "nrt": 352, "koyeb_sin": 828, "koyeb_sfo": 395, "sin": 625, "railway_us-west2": 410, "railway_europe-west4-drams3a": 94, "koyeb_tyo": 393, "koyeb_par": 117, "railway_us-east4-eqdc4a": 264, "koyeb_fra": 66, "railway_asia-southeast1-eqsg3a": 610, "cdg": 114, "fra": 66, "iad": 260, "ams": 85, "lax": 356 }, { "timestamp": "2025-10-22T14:00:00.000Z", "railway_us-east4-eqdc4a": 276, "fra": 67, "cdg": 114, "koyeb_par": 118, "koyeb_fra": 67, "railway_asia-southeast1-eqsg3a": 612, "lax": 207, "railway_us-west2": 330, "ams": 86, "iad": 275, "sin": 628, "koyeb_was": 266, "railway_europe-west4-drams3a": 97, "koyeb_sin": 718, "koyeb_sfo": 394, "nrt": 570, "koyeb_tyo": 579 }, { "timestamp": "2025-10-22T15:00:00.000Z", "railway_us-west2": 244, "koyeb_sfo": 383, "koyeb_sin": 604, "koyeb_was": 264, "sin": 626, "railway_asia-southeast1-eqsg3a": 568, "koyeb_tyo": 584, "nrt": 548, "lax": 346, "koyeb_fra": 67, "ams": 88, "iad": 268, "railway_europe-west4-drams3a": 92, "koyeb_par": 116, "fra": 66, "cdg": 114, "railway_us-east4-eqdc4a": 272 }, { "timestamp": "2025-10-22T16:00:00.000Z", "railway_europe-west4-drams3a": 97, "sin": 654, "nrt": 564, "koyeb_fra": 67, "koyeb_par": 116, "railway_us-east4-eqdc4a": 280, "koyeb_tyo": 567, "cdg": 115, "fra": 65, "railway_us-west2": 264, "lax": 360, "koyeb_sin": 608, "iad": 260, "koyeb_sfo": 397, "ams": 84, "railway_asia-southeast1-eqsg3a": 652, "koyeb_was": 282 }, { "timestamp": "2025-10-22T17:00:00.000Z", "railway_us-east4-eqdc4a": 266, "railway_us-west2": 255, "fra": 66, "cdg": 114, "ams": 86, "iad": 262, "koyeb_fra": 67, "lax": 352, "railway_europe-west4-drams3a": 94, "koyeb_par": 117, "railway_asia-southeast1-eqsg3a": 532, "nrt": 505, "koyeb_tyo": 558, "sin": 487, "koyeb_sfo": 392, "koyeb_sin": 534, "koyeb_was": 266 }, { "timestamp": "2025-10-22T18:00:00.000Z", "nrt": 562, "koyeb_tyo": 544, "sin": 456, "railway_europe-west4-drams3a": 88, "koyeb_was": 268, "railway_us-east4-eqdc4a": 265, "koyeb_sin": 434, "koyeb_sfo": 395, "cdg": 114, "fra": 68, "railway_us-west2": 257, "koyeb_par": 116, "railway_asia-southeast1-eqsg3a": 440, "iad": 258, "ams": 87, "koyeb_fra": 69, "lax": 358 }, { "timestamp": "2025-10-22T19:00:00.000Z", "lax": 356, "railway_us-west2": 244, "iad": 260, "ams": 89, "koyeb_par": 116, "cdg": 114, "fra": 66, "railway_us-east4-eqdc4a": 272, "koyeb_fra": 69, "railway_asia-southeast1-eqsg3a": 426, "railway_europe-west4-drams3a": 96, "koyeb_tyo": 304, "sin": 439, "koyeb_was": 266, "koyeb_sin": 416, "koyeb_sfo": 384, "nrt": 547 }, { "timestamp": "2025-10-22T20:00:00.000Z", "railway_us-east4-eqdc4a": 268, "sin": 423, "nrt": 306, "koyeb_par": 117, "koyeb_fra": 66, "railway_asia-southeast1-eqsg3a": 392, "koyeb_tyo": 536, "railway_us-west2": 382, "cdg": 115, "fra": 66, "railway_europe-west4-drams3a": 95, "lax": 358, "koyeb_was": 266, "iad": 266, "ams": 89, "koyeb_sin": 408, "koyeb_sfo": 392 }, { "timestamp": "2025-10-22T21:00:00.000Z", "koyeb_par": 117, "koyeb_fra": 66, "sin": 406, "railway_asia-southeast1-eqsg3a": 400, "railway_us-west2": 251, "nrt": 424, "koyeb_was": 254, "lax": 358, "koyeb_sfo": 394, "koyeb_sin": 394, "railway_europe-west4-drams3a": 94, "iad": 250, "ams": 84, "railway_us-east4-eqdc4a": 256, "cdg": 112, "fra": 64, "koyeb_tyo": 537 }, { "timestamp": "2025-10-22T22:00:00.000Z", "lax": 354, "railway_asia-southeast1-eqsg3a": 384, "koyeb_sin": 398, "koyeb_sfo": 395, "ams": 80, "iad": 254, "koyeb_was": 257, "fra": 62, "cdg": 114, "koyeb_tyo": 534, "railway_us-west2": 244, "koyeb_fra": 63, "koyeb_par": 116, "sin": 400, "railway_us-east4-eqdc4a": 232, "nrt": 314, "railway_europe-west4-drams3a": 92 }, { "timestamp": "2025-10-22T23:00:00.000Z", "nrt": 309, "koyeb_fra": 64, "koyeb_par": 114, "sin": 404, "railway_europe-west4-drams3a": 92, "railway_us-west2": 250, "koyeb_sin": 396, "railway_asia-southeast1-eqsg3a": 242, "koyeb_sfo": 315, "fra": 62, "koyeb_was": 245, "cdg": 114, "ams": 79, "iad": 234, "koyeb_tyo": 307, "railway_us-east4-eqdc4a": 254, "lax": 340 }, { "timestamp": "2025-10-23T00:00:00.000Z", "koyeb_par": 114, "nrt": 430, "koyeb_fra": 62, "sin": 396, "railway_us-east4-eqdc4a": 252, "railway_asia-southeast1-eqsg3a": 403, "koyeb_was": 250, "koyeb_sin": 392, "railway_europe-west4-drams3a": 93, "koyeb_sfo": 242, "cdg": 112, "fra": 61, "iad": 246, "ams": 81, "railway_us-west2": 262, "koyeb_tyo": 314, "lax": 336 }, { "timestamp": "2025-10-23T01:00:00.000Z", "railway_asia-southeast1-eqsg3a": 408, "koyeb_par": 114, "railway_us-west2": 250, "fra": 62, "cdg": 112, "koyeb_fra": 62, "ams": 81, "iad": 250, "lax": 210, "nrt": 444, "railway_us-east4-eqdc4a": 256, "koyeb_was": 258, "sin": 420, "koyeb_sin": 401, "koyeb_sfo": 242, "railway_europe-west4-drams3a": 89, "koyeb_tyo": 328 }, { "timestamp": "2025-10-23T02:00:00.000Z", "sin": 422, "nrt": 335, "railway_asia-southeast1-eqsg3a": 398, "koyeb_par": 114, "koyeb_fra": 62, "koyeb_tyo": 320, "cdg": 112, "fra": 62, "lax": 214, "railway_europe-west4-drams3a": 76, "iad": 248, "ams": 80, "koyeb_was": 258, "koyeb_sfo": 249, "railway_us-west2": 259, "railway_us-east4-eqdc4a": 254, "koyeb_sin": 411 }, { "timestamp": "2025-10-23T03:00:00.000Z", "lax": 295, "koyeb_par": 114, "railway_us-east4-eqdc4a": 248, "koyeb_fra": 63, "iad": 252, "ams": 81, "cdg": 111, "fra": 62, "railway_us-west2": 250, "railway_europe-west4-drams3a": 84, "railway_asia-southeast1-eqsg3a": 399, "koyeb_was": 252, "koyeb_sfo": 388, "koyeb_sin": 430, "sin": 434, "koyeb_tyo": 337, "nrt": 334 }, { "timestamp": "2025-10-23T04:00:00.000Z", "ams": 78, "iad": 254, "koyeb_was": 257, "lax": 282, "koyeb_sin": 531, "railway_europe-west4-drams3a": 79, "koyeb_sfo": 242, "koyeb_tyo": 316, "railway_us-east4-eqdc4a": 258, "fra": 62, "cdg": 105, "koyeb_par": 110, "koyeb_fra": 62, "nrt": 444, "railway_us-west2": 367, "railway_asia-southeast1-eqsg3a": 514, "sin": 524 }, { "timestamp": "2025-10-23T05:00:00.000Z", "iad": 256, "ams": 80, "koyeb_was": 256, "koyeb_sfo": 262, "railway_us-west2": 395, "koyeb_sin": 466, "lax": 211, "railway_asia-southeast1-eqsg3a": 466, "railway_us-east4-eqdc4a": 260, "koyeb_tyo": 327, "cdg": 112, "fra": 62, "koyeb_par": 116, "koyeb_fra": 61, "railway_europe-west4-drams3a": 90, "nrt": 324, "sin": 508 }, { "timestamp": "2025-10-23T06:00:00.000Z", "railway_europe-west4-drams3a": 94, "koyeb_fra": 64, "koyeb_par": 116, "ams": 82, "iad": 249, "lax": 354, "railway_us-east4-eqdc4a": 254, "fra": 64, "cdg": 114, "koyeb_sfo": 278, "koyeb_sin": 584, "koyeb_was": 248, "railway_us-west2": 401, "nrt": 330, "railway_asia-southeast1-eqsg3a": 480, "sin": 558, "koyeb_tyo": 336 }, { "timestamp": "2025-10-23T07:00:00.000Z", "railway_us-west2": 401, "nrt": 331, "sin": 500, "koyeb_fra": 64, "railway_asia-southeast1-eqsg3a": 470, "koyeb_par": 115, "railway_us-east4-eqdc4a": 253, "railway_europe-west4-drams3a": 90, "fra": 65, "koyeb_tyo": 355, "cdg": 112, "ams": 68, "koyeb_sfo": 394, "iad": 248, "koyeb_sin": 486, "koyeb_was": 250, "lax": 218 }, { "timestamp": "2025-10-23T08:00:00.000Z", "railway_asia-southeast1-eqsg3a": 450, "sin": 544, "nrt": 560, "koyeb_fra": 64, "koyeb_par": 116, "fra": 65, "cdg": 114, "koyeb_sin": 451, "koyeb_sfo": 254, "koyeb_was": 254, "railway_us-west2": 398, "koyeb_tyo": 568, "lax": 211, "railway_europe-west4-drams3a": 87, "railway_us-east4-eqdc4a": 256, "ams": 83, "iad": 253 }, { "timestamp": "2025-10-23T09:00:00.000Z", "cdg": 115, "koyeb_fra": 65, "fra": 67, "koyeb_par": 117, "railway_us-east4-eqdc4a": 262, "railway_us-west2": 261, "lax": 210, "railway_asia-southeast1-eqsg3a": 454, "iad": 252, "ams": 85, "sin": 470, "koyeb_sin": 561, "koyeb_sfo": 247, "koyeb_was": 252, "nrt": 547, "railway_europe-west4-drams3a": 92, "koyeb_tyo": 507 }, { "timestamp": "2025-10-23T10:00:00.000Z", "sin": 570, "nrt": 564, "railway_europe-west4-drams3a": 91, "railway_us-west2": 398, "railway_us-east4-eqdc4a": 260, "koyeb_fra": 65, "koyeb_par": 116, "koyeb_tyo": 556, "fra": 65, "cdg": 115, "railway_asia-southeast1-eqsg3a": 476, "lax": 210, "ams": 86, "iad": 253, "koyeb_sin": 478, "koyeb_sfo": 324, "koyeb_was": 256 }, { "timestamp": "2025-10-23T11:00:00.000Z", "railway_us-east4-eqdc4a": 258, "sin": 571, "railway_us-west2": 403, "koyeb_tyo": 341, "nrt": 517, "koyeb_sin": 472, "koyeb_sfo": 386, "koyeb_was": 258, "railway_asia-southeast1-eqsg3a": 438, "cdg": 116, "fra": 65, "railway_europe-west4-drams3a": 96, "lax": 207, "koyeb_fra": 66, "koyeb_par": 114, "iad": 254, "ams": 77 }, { "timestamp": "2025-10-23T12:00:00.000Z", "sin": 578, "nrt": 340, "railway_us-west2": 392, "koyeb_par": 117, "koyeb_fra": 64, "railway_europe-west4-drams3a": 98, "fra": 65, "cdg": 115, "railway_asia-southeast1-eqsg3a": 480, "koyeb_tyo": 342, "railway_us-east4-eqdc4a": 262, "koyeb_was": 252, "koyeb_sin": 476, "koyeb_sfo": 322, "lax": 199, "ams": 86, "iad": 256 }, { "timestamp": "2025-10-23T13:00:00.000Z", "nrt": 343, "sin": 571, "railway_europe-west4-drams3a": 91, "railway_us-east4-eqdc4a": 268, "koyeb_fra": 67, "koyeb_par": 117, "railway_us-west2": 407, "koyeb_tyo": 343, "cdg": 116, "fra": 66, "railway_asia-southeast1-eqsg3a": 475, "iad": 258, "ams": 84, "koyeb_sin": 474, "koyeb_sfo": 371, "koyeb_was": 261, "lax": 336 } ] }, "metricsByRegion": [ { "region": "sin", "count": 10134, "ok": 10134, "p50Latency": 409, "p75Latency": 561, "p90Latency": 754, "p95Latency": 1006, "p99Latency": 2088 }, { "region": "lhr", "count": 1, "ok": 1, "p50Latency": 113, "p75Latency": 113, "p90Latency": 113, "p95Latency": 113, "p99Latency": 113 }, { "region": "koyeb_fra", "count": 10140, "ok": 10140, "p50Latency": 65, "p75Latency": 69, "p90Latency": 75, "p95Latency": 79, "p99Latency": 91 }, { "region": "koyeb_was", "count": 10140, "ok": 10140, "p50Latency": 249, "p75Latency": 267, "p90Latency": 289, "p95Latency": 297, "p99Latency": 319 }, { "region": "koyeb_sin", "count": 10139, "ok": 10139, "p50Latency": 393, "p75Latency": 467, "p90Latency": 655, "p95Latency": 1072, "p99Latency": 2241 }, { "region": "railway_us-east4-eqdc4a", "count": 10138, "ok": 10138, "p50Latency": 251, "p75Latency": 270, "p90Latency": 292, "p95Latency": 301, "p99Latency": 336 }, { "region": "railway_us-west2", "count": 10138, "ok": 10138, "p50Latency": 309, "p75Latency": 393, "p90Latency": 436, "p95Latency": 467, "p99Latency": 540 }, { "region": "iad", "count": 10136, "ok": 10136, "p50Latency": 246, "p75Latency": 264, "p90Latency": 286, "p95Latency": 294, "p99Latency": 346 }, { "region": "ams", "count": 10141, "ok": 10141, "p50Latency": 82, "p75Latency": 89, "p90Latency": 96, "p95Latency": 101, "p99Latency": 112 }, { "region": "koyeb_sfo", "count": 10140, "ok": 10140, "p50Latency": 313, "p75Latency": 405, "p90Latency": 436, "p95Latency": 476, "p99Latency": 636 }, { "region": "railway_asia-southeast1-eqsg3a", "count": 10138, "ok": 10138, "p50Latency": 396, "p75Latency": 459, "p90Latency": 615, "p95Latency": 1033, "p99Latency": 2230 }, { "region": "koyeb_tyo", "count": 10138, "ok": 10138, "p50Latency": 338, "p75Latency": 580, "p90Latency": 608, "p95Latency": 655, "p99Latency": 1651 }, { "region": "nrt", "count": 10140, "ok": 10140, "p50Latency": 334, "p75Latency": 579, "p90Latency": 605, "p95Latency": 646, "p99Latency": 1703 }, { "region": "cdg", "count": 10182, "ok": 10182, "p50Latency": 113, "p75Latency": 117, "p90Latency": 121, "p95Latency": 130, "p99Latency": 140 }, { "region": "lax", "count": 10137, "ok": 10137, "p50Latency": 297, "p75Latency": 367, "p90Latency": 399, "p95Latency": 418, "p99Latency": 549 }, { "region": "koyeb_par", "count": 10140, "ok": 10140, "p50Latency": 116, "p75Latency": 120, "p90Latency": 139, "p95Latency": 148, "p99Latency": 162 }, { "region": "railway_europe-west4-drams3a", "count": 10137, "ok": 10137, "p50Latency": 92, "p75Latency": 100, "p90Latency": 110, "p95Latency": 120, "p99Latency": 130 }, { "region": "fra", "count": 10141, "ok": 10141, "p50Latency": 64, "p75Latency": 69, "p90Latency": 75, "p95Latency": 79, "p99Latency": 89 } ] } ================================================ FILE: apps/web/public/assets/posts/hono-vercel-fluid-compute/hono-cold.json ================================================ { "regions": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "scl", "sjc", "sea", "sin", "syd", "yul", "yyz" ], "data": { "regions": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "scl", "sea", "sin", "sjc", "syd", "yul", "yyz" ], "data": [ { "timestamp": "2025-08-18T16:00:00.000Z", "yyz": 132, "lhr": 327, "bos": 102, "cdg": 167, "fra": 142, "iad": 113, "ams": 207, "jnb": 386, "gdl": 479, "dfw": 259, "sjc": 151, "phx": 174, "lax": 193, "otp": 218, "eze": 283, "bog": 434, "mad": 345, "syd": 263, "nrt": 215, "mia": 158, "bom": 303, "ewr": 131, "sin": 502, "scl": 347, "gru": 207, "gig": 286, "atl": 346, "yul": 114, "sea": 209, "arn": 433, "hkg": 336, "den": 236, "ord": 183 }, { "timestamp": "2025-08-18T17:00:00.000Z", "gru": 192, "gig": 376, "scl": 296, "sea": 216, "arn": 233, "hkg": 518, "den": 300, "ord": 124, "yul": 116, "atl": 155, "nrt": 458, "mia": 143, "mad": 353, "syd": 355, "sin": 436, "ewr": 100, "bom": 307, "gdl": 605, "dfw": 238, "sjc": 366, "phx": 208, "iad": 152, "ams": 200, "jnb": 382, "lax": 135, "eze": 266, "otp": 249, "bog": 357, "lhr": 249, "yyz": 158, "cdg": 299, "fra": 198, "bos": 101 }, { "timestamp": "2025-08-18T18:00:00.000Z", "bog": 383, "eze": 289, "otp": 251, "lax": 169, "phx": 180, "sjc": 132, "dfw": 170, "gdl": 565, "jnb": 371, "ams": 197, "iad": 463, "fra": 164, "cdg": 160, "bos": 305, "lhr": 169, "yyz": 150, "ord": 106, "den": 303, "hkg": 346, "arn": 201, "sea": 194, "yul": 111, "atl": 170, "gig": 383, "gru": 401, "scl": 344, "sin": 425, "ewr": 211, "bom": 535, "mia": 238, "nrt": 265, "syd": 319, "mad": 339 }, { "timestamp": "2025-08-18T19:00:00.000Z", "bom": 289, "ewr": 126, "sin": 511, "syd": 312, "mad": 333, "mia": 160, "nrt": 224, "atl": 157, "yul": 109, "ord": 214, "den": 243, "hkg": 336, "arn": 202, "sea": 218, "scl": 362, "gig": 220, "gru": 209, "bos": 119, "fra": 391, "cdg": 173, "yyz": 125, "lhr": 147, "bog": 331, "otp": 312, "eze": 263, "lax": 146, "jnb": 381, "ams": 206, "iad": 99, "phx": 184, "sjc": 142, "dfw": 266, "gdl": 590 }, { "timestamp": "2025-08-18T20:00:00.000Z", "dfw": 251, "gdl": 475, "phx": 159, "sjc": 114, "ams": 185, "iad": 157, "jnb": 352, "otp": 272, "eze": 261, "lax": 161, "bog": 391, "lhr": 155, "yyz": 127, "fra": 239, "cdg": 152, "bos": 124, "gru": 278, "gig": 361, "scl": 496, "arn": 248, "sea": 175, "ord": 142, "den": 279, "hkg": 275, "atl": 190, "yul": 227, "nrt": 455, "mia": 151, "mad": 345, "syd": 326, "sin": 348, "bom": 604, "ewr": 189 }, { "timestamp": "2025-08-18T21:00:00.000Z", "mad": 332, "syd": 301, "nrt": 216, "mia": 132, "ewr": 183, "bom": 321, "sin": 449, "scl": 338, "gru": 193, "gig": 353, "yul": 114, "atl": 169, "arn": 212, "sea": 169, "ord": 128, "den": 453, "hkg": 334, "yyz": 152, "lhr": 160, "bos": 108, "fra": 319, "cdg": 158, "ams": 196, "iad": 104, "jnb": 366, "dfw": 164, "gdl": 704, "phx": 188, "sjc": 145, "eze": 322, "otp": 278, "lax": 133, "bog": 399 }, { "timestamp": "2025-08-18T22:00:00.000Z", "bos": 122, "cdg": 496, "fra": 154, "yyz": 151, "lhr": 158, "bog": 425, "lax": 138, "eze": 494, "otp": 298, "jnb": 348, "iad": 132, "ams": 208, "sjc": 119, "phx": 170, "gdl": 504, "dfw": 163, "ewr": 164, "bom": 644, "sin": 318, "syd": 263, "mad": 360, "mia": 237, "nrt": 323, "yul": 109, "atl": 156, "hkg": 333, "den": 246, "ord": 169, "sea": 190, "arn": 217, "scl": 312, "gig": 355, "gru": 194 }, { "timestamp": "2025-08-18T23:00:00.000Z", "hkg": 348, "den": 229, "ord": 139, "sea": 165, "arn": 223, "atl": 125, "yul": 123, "gig": 215, "gru": 229, "scl": 537, "sin": 322, "bom": 252, "ewr": 116, "mia": 246, "nrt": 217, "syd": 380, "mad": 339, "bog": 351, "lax": 153, "otp": 260, "eze": 311, "sjc": 141, "phx": 192, "gdl": 595, "dfw": 144, "jnb": 349, "iad": 73, "ams": 182, "cdg": 316, "fra": 151, "bos": 95, "lhr": 154, "yyz": 149 }, { "timestamp": "2025-08-19T00:00:00.000Z", "bom": 305, "ewr": 162, "sin": 592, "syd": 314, "mad": 329, "mia": 135, "nrt": 216, "atl": 107, "yul": 136, "den": 226, "ord": 130, "hkg": 360, "arn": 201, "sea": 195, "scl": 337, "gig": 185, "gru": 215, "bos": 124, "fra": 153, "cdg": 144, "yyz": 120, "lhr": 170, "bog": 349, "otp": 240, "eze": 484, "lax": 141, "jnb": 332, "ams": 192, "iad": 108, "phx": 207, "sjc": 133, "dfw": 187, "gdl": 452 }, { "timestamp": "2025-08-19T01:00:00.000Z", "arn": 210, "sea": 155, "ord": 128, "den": 236, "hkg": 339, "yul": 136, "atl": 173, "gru": 193, "gig": 266, "scl": 336, "sin": 448, "ewr": 117, "bom": 274, "nrt": 381, "mia": 192, "mad": 338, "syd": 303, "eze": 295, "otp": 272, "lax": 138, "bog": 384, "dfw": 181, "gdl": 593, "phx": 179, "sjc": 136, "ams": 198, "iad": 89, "jnb": 362, "fra": 144, "cdg": 327, "bos": 134, "lhr": 142, "yyz": 132 }, { "timestamp": "2025-08-19T02:00:00.000Z", "bog": 517, "lax": 142, "eze": 485, "otp": 238, "jnb": 351, "iad": 243, "ams": 216, "sjc": 147, "phx": 194, "gdl": 584, "dfw": 236, "bos": 113, "cdg": 169, "fra": 158, "yyz": 144, "lhr": 144, "yul": 156, "atl": 177, "hkg": 340, "ord": 134, "den": 257, "sea": 189, "arn": 238, "scl": 541, "gig": 357, "gru": 192, "ewr": 59, "bom": 298, "sin": 340, "syd": 347, "mad": 324, "mia": 183, "nrt": 270 }, { "timestamp": "2025-08-19T03:00:00.000Z", "syd": 251, "mad": 318, "mia": 242, "nrt": 212, "bom": 275, "ewr": 137, "sin": 574, "scl": 501, "gig": 344, "gru": 223, "atl": 307, "yul": 113, "hkg": 334, "den": 271, "ord": 145, "sea": 202, "arn": 260, "yyz": 134, "lhr": 150, "bos": 136, "cdg": 384, "fra": 143, "jnb": 328, "iad": 104, "ams": 192, "sjc": 134, "phx": 194, "gdl": 740, "dfw": 187, "bog": 490, "lax": 135, "otp": 247, "eze": 298 }, { "timestamp": "2025-08-19T04:00:00.000Z", "gdl": 596, "dfw": 191, "sjc": 143, "phx": 184, "iad": 198, "ams": 200, "jnb": 358, "lax": 183, "eze": 303, "otp": 279, "bog": 345, "lhr": 154, "yyz": 158, "cdg": 155, "fra": 149, "bos": 139, "gru": 354, "gig": 513, "scl": 501, "sea": 186, "arn": 216, "hkg": 279, "ord": 183, "den": 308, "yul": 114, "atl": 139, "nrt": 221, "mia": 226, "mad": 331, "syd": 303, "sin": 732, "ewr": 768, "bom": 293 }, { "timestamp": "2025-08-19T05:00:00.000Z", "gru": 200, "gig": 453, "scl": 342, "sea": 172, "arn": 244, "hkg": 298, "den": 271, "ord": 79, "yul": 103, "atl": 199, "nrt": 213, "mia": 113, "mad": 352, "syd": 307, "sin": 666, "ewr": 185, "bom": 577, "gdl": 479, "dfw": 176, "sjc": 258, "phx": 222, "iad": 132, "ams": 182, "jnb": 359, "lax": 140, "eze": 294, "otp": 268, "bog": 240, "lhr": 160, "yyz": 143, "cdg": 214, "fra": 262, "bos": 118 }, { "timestamp": "2025-08-19T06:00:00.000Z", "jnb": 381, "ams": 187, "iad": 50, "phx": 190, "sjc": 129, "dfw": 198, "gdl": 623, "bog": 456, "eze": 493, "otp": 323, "lax": 164, "yyz": 177, "lhr": 164, "bos": 111, "fra": 151, "cdg": 146, "scl": 382, "gig": 223, "gru": 149, "yul": 106, "atl": 461, "den": 279, "ord": 80, "hkg": 338, "arn": 260, "sea": 173, "syd": 302, "mad": 335, "mia": 123, "nrt": 411, "ewr": 51, "bom": 284, "sin": 426 }, { "timestamp": "2025-08-19T07:00:00.000Z", "bom": 569, "ewr": 135, "sin": 564, "syd": 296, "mad": 336, "mia": 195, "nrt": 456, "atl": 178, "yul": 99, "ord": 182, "den": 321, "hkg": 331, "arn": 253, "sea": 228, "scl": 330, "gig": 211, "gru": 160, "bos": 99, "fra": 148, "cdg": 146, "yyz": 133, "lhr": 167, "bog": 468, "otp": 268, "eze": 294, "lax": 149, "jnb": 357, "ams": 200, "iad": 129, "phx": 180, "sjc": 131, "dfw": 162, "gdl": 591 }, { "timestamp": "2025-08-19T08:00:00.000Z", "yyz": 140, "lhr": 148, "bos": 112, "fra": 153, "cdg": 314, "jnb": 374, "ams": 184, "iad": 103, "phx": 197, "sjc": 126, "dfw": 442, "gdl": 814, "bog": 676, "otp": 527, "eze": 290, "lax": 143, "syd": 264, "mad": 335, "mia": 200, "nrt": 280, "bom": 581, "ewr": 126, "sin": 368, "scl": 343, "gig": 365, "gru": 162, "atl": 164, "yul": 96, "ord": 135, "den": 295, "hkg": 334, "arn": 209, "sea": 194 }, { "timestamp": "2025-08-19T09:00:00.000Z", "gig": 205, "gru": 398, "scl": 343, "ord": 170, "den": 332, "hkg": 275, "arn": 397, "sea": 184, "yul": 112, "atl": 123, "mia": 150, "nrt": 260, "syd": 301, "mad": 328, "sin": 402, "ewr": 119, "bom": 542, "phx": 190, "sjc": 117, "dfw": 209, "gdl": 736, "jnb": 383, "ams": 222, "iad": 118, "bog": 462, "eze": 265, "otp": 299, "lax": 141, "lhr": 151, "yyz": 143, "fra": 358, "cdg": 339, "bos": 90 }, { "timestamp": "2025-08-19T10:00:00.000Z", "scl": 342, "gig": 219, "gru": 197, "yul": 109, "atl": 116, "hkg": 328, "den": 294, "ord": 198, "sea": 176, "arn": 513, "syd": 265, "mad": 340, "mia": 140, "nrt": 215, "ewr": 128, "bom": 352, "sin": 606, "jnb": 369, "iad": 124, "ams": 185, "sjc": 127, "phx": 359, "gdl": 500, "dfw": 163, "bog": 382, "lax": 240, "eze": 295, "otp": 251, "yyz": 123, "lhr": 150, "bos": 112, "cdg": 257, "fra": 148 }, { "timestamp": "2025-08-19T11:00:00.000Z", "lhr": 164, "yyz": 138, "cdg": 286, "fra": 172, "bos": 90, "sjc": 121, "phx": 189, "gdl": 516, "dfw": 186, "jnb": 344, "iad": 105, "ams": 189, "bog": 472, "lax": 127, "otp": 275, "eze": 303, "mia": 147, "nrt": 322, "syd": 298, "mad": 328, "sin": 690, "bom": 327, "ewr": 55, "gig": 546, "gru": 164, "scl": 339, "hkg": 343, "den": 286, "ord": 130, "sea": 170, "arn": 390, "atl": 160, "yul": 125 }, { "timestamp": "2025-08-19T12:00:00.000Z", "sea": 165, "arn": 1156, "hkg": 321, "den": 244, "ord": 142, "yul": 141, "atl": 120, "gru": 213, "gig": 400, "scl": 566, "sin": 426, "ewr": 140, "bom": 309, "nrt": 259, "mia": 188, "mad": 721, "syd": 299, "lax": 160, "eze": 612, "otp": 242, "bog": 389, "gdl": 597, "dfw": 183, "sjc": 135, "phx": 203, "iad": 125, "ams": 219, "jnb": 347, "cdg": 160, "fra": 149, "bos": 135, "lhr": 161, "yyz": 118 }, { "timestamp": "2025-08-19T13:00:00.000Z", "bos": 135, "cdg": 159, "fra": 150, "yyz": 144, "lhr": 202, "lax": 140, "otp": 274, "eze": 691, "bog": 344, "iad": 111, "ams": 185, "jnb": 353, "gdl": 698, "dfw": 221, "sjc": 128, "phx": 191, "bom": 318, "ewr": 126, "sin": 593, "mad": 329, "syd": 303, "nrt": 570, "mia": 152, "atl": 170, "yul": 119, "sea": 192, "arn": 280, "hkg": 359, "den": 235, "ord": 165, "scl": 367, "gru": 333, "gig": 356 }, { "timestamp": "2025-08-19T14:00:00.000Z", "mia": 146, "nrt": 265, "syd": 342, "mad": 345, "sin": 344, "ewr": 70, "bom": 335, "gig": 187, "gru": 219, "scl": 490, "ord": 202, "den": 278, "hkg": 345, "arn": 252, "sea": 183, "yul": 110, "atl": 321, "lhr": 244, "yyz": 143, "fra": 167, "cdg": 220, "bos": 404, "phx": 231, "sjc": 134, "dfw": 283, "gdl": 487, "jnb": 360, "ams": 182, "iad": 186, "bog": 408, "eze": 472, "otp": 248, "lax": 140 }, { "timestamp": "2025-08-19T15:00:00.000Z", "jnb": 368, "ams": 208, "iad": 124, "phx": 222, "sjc": 146, "dfw": 172, "gdl": 505, "bog": 502, "otp": 295, "eze": 266, "lax": 157, "yyz": 139, "lhr": 153, "bos": 248, "fra": 281, "cdg": 686, "scl": 299, "gig": 387, "gru": 210, "atl": 112, "yul": 109, "ord": 160, "den": 254, "hkg": 329, "arn": 226, "sea": 182, "syd": 271, "mad": 332, "mia": 184, "nrt": 258, "bom": 290, "ewr": 129, "sin": 445 }, { "timestamp": "2025-08-19T16:00:00.000Z", "lhr": 166, "yyz": 115, "cdg": 160, "fra": 155, "bos": 123, "sjc": 184, "phx": 298, "gdl": 806, "dfw": 415, "jnb": 355, "iad": 111, "ams": 211, "bog": 380, "lax": 150, "otp": 298, "eze": 499, "mia": 229, "nrt": 554, "syd": 304, "mad": 342, "sin": 455, "bom": 275, "ewr": 122, "gig": 383, "gru": 340, "scl": 362, "hkg": 264, "ord": 236, "den": 551, "sea": 166, "arn": 243, "atl": 139, "yul": 121 }, { "timestamp": "2025-08-19T17:00:00.000Z", "hkg": 342, "den": 238, "ord": 244, "sea": 207, "arn": 216, "yul": 129, "atl": 179, "gig": 206, "gru": 166, "scl": 348, "sin": 345, "ewr": 133, "bom": 270, "mia": 160, "nrt": 375, "syd": 308, "mad": 344, "bog": 405, "lax": 167, "eze": 307, "otp": 238, "sjc": 127, "phx": 180, "gdl": 655, "dfw": 247, "jnb": 396, "iad": 214, "ams": 204, "cdg": 152, "fra": 154, "bos": 90, "lhr": 148, "yyz": 137 }, { "timestamp": "2025-08-19T18:00:00.000Z", "eze": 308, "otp": 279, "lax": 179, "bog": 349, "ams": 220, "iad": 109, "jnb": 352, "dfw": 251, "gdl": 499, "phx": 183, "sjc": 124, "bos": 108, "fra": 152, "cdg": 164, "yyz": 239, "lhr": 184, "yul": 128, "atl": 164, "arn": 209, "sea": 176, "den": 227, "ord": 134, "hkg": 341, "scl": 643, "gru": 191, "gig": 410, "ewr": 135, "bom": 282, "sin": 429, "mad": 343, "syd": 300, "nrt": 460, "mia": 193 }, { "timestamp": "2025-08-19T19:00:00.000Z", "fra": 185, "cdg": 174, "bos": 164, "lhr": 160, "yyz": 138, "bog": 233, "otp": 316, "eze": 566, "lax": 156, "phx": 211, "sjc": 128, "dfw": 174, "gdl": 509, "jnb": 364, "ams": 187, "iad": 226, "sin": 725, "bom": 289, "ewr": 181, "mia": 192, "nrt": 257, "syd": 280, "mad": 445, "ord": 143, "den": 262, "hkg": 285, "arn": 210, "sea": 192, "atl": 151, "yul": 103, "gig": 204, "gru": 216, "scl": 339 }, { "timestamp": "2025-08-19T20:00:00.000Z", "yul": 115, "atl": 175, "arn": 221, "sea": 170, "den": 253, "ord": 128, "hkg": 276, "scl": 299, "gru": 207, "gig": 390, "ewr": 127, "bom": 301, "sin": 413, "mad": 342, "syd": 313, "nrt": 455, "mia": 243, "eze": 303, "otp": 278, "lax": 131, "bog": 371, "ams": 229, "iad": 109, "jnb": 354, "dfw": 246, "gdl": 474, "phx": 237, "sjc": 125, "bos": 404, "fra": 152, "cdg": 336, "yyz": 126, "lhr": 279 }, { "timestamp": "2025-08-19T21:00:00.000Z", "yyz": 205, "lhr": 153, "bos": 106, "fra": 154, "cdg": 155, "ams": 189, "iad": 134, "jnb": 351, "dfw": 256, "gdl": 594, "phx": 224, "sjc": 123, "otp": 227, "eze": 388, "lax": 130, "bog": 476, "mad": 373, "syd": 300, "nrt": 268, "mia": 191, "bom": 291, "ewr": 114, "sin": 389, "scl": 287, "gru": 224, "gig": 206, "atl": 123, "yul": 110, "arn": 220, "sea": 192, "ord": 136, "den": 294, "hkg": 450 }, { "timestamp": "2025-08-19T22:00:00.000Z", "mia": 187, "nrt": 292, "syd": 309, "mad": 369, "sin": 469, "bom": 272, "ewr": 134, "gig": 297, "gru": 425, "scl": 344, "hkg": 341, "ord": 193, "den": 302, "sea": 200, "arn": 250, "atl": 122, "yul": 128, "lhr": 188, "yyz": 163, "cdg": 173, "fra": 154, "bos": 131, "sjc": 131, "phx": 237, "gdl": 604, "dfw": 215, "jnb": 367, "iad": 155, "ams": 245, "bog": 747, "lax": 155, "otp": 267, "eze": 484 }, { "timestamp": "2025-08-19T23:00:00.000Z", "fra": 145, "cdg": 157, "bos": 97, "lhr": 179, "yyz": 127, "bog": 598, "otp": 289, "eze": 260, "lax": 133, "phx": 200, "sjc": 121, "dfw": 222, "gdl": 580, "jnb": 397, "ams": 200, "iad": 106, "sin": 588, "bom": 282, "ewr": 122, "mia": 120, "nrt": 262, "syd": 318, "mad": 364, "ord": 154, "den": 288, "hkg": 331, "arn": 197, "sea": 191, "atl": 104, "yul": 127, "gig": 389, "gru": 186, "scl": 395 }, { "timestamp": "2025-08-20T00:00:00.000Z", "bos": 85, "cdg": 149, "fra": 148, "yyz": 141, "lhr": 164, "bog": 338, "lax": 140, "eze": 301, "otp": 266, "jnb": 346, "iad": 197, "ams": 205, "sjc": 131, "phx": 193, "gdl": 591, "dfw": 236, "ewr": 114, "bom": 290, "sin": 286, "syd": 307, "mad": 664, "mia": 186, "nrt": 221, "yul": 111, "atl": 200, "hkg": 336, "den": 258, "ord": 96, "sea": 166, "arn": 217, "scl": 294, "gig": 205, "gru": 194 }, { "timestamp": "2025-08-20T01:00:00.000Z", "ewr": 182, "bom": 291, "sin": 649, "mad": 400, "syd": 301, "nrt": 444, "mia": 120, "yul": 119, "atl": 133, "arn": 210, "sea": 189, "ord": 167, "den": 258, "hkg": 352, "scl": 364, "gru": 177, "gig": 311, "bos": 133, "fra": 145, "cdg": 148, "yyz": 152, "lhr": 175, "eze": 306, "otp": 260, "lax": 171, "bog": 522, "ams": 209, "iad": 97, "jnb": 378, "dfw": 161, "gdl": 634, "phx": 181, "sjc": 143 }, { "timestamp": "2025-08-20T02:00:00.000Z", "yyz": 146, "lhr": 149, "bos": 107, "cdg": 156, "fra": 341, "iad": 41, "ams": 213, "jnb": 358, "gdl": 496, "dfw": 227, "sjc": 117, "phx": 182, "lax": 172, "otp": 283, "eze": 289, "bog": 510, "mad": 347, "syd": 306, "nrt": 495, "mia": 117, "bom": 304, "ewr": 164, "sin": 917, "scl": 418, "gru": 201, "gig": 389, "atl": 220, "yul": 133, "sea": 163, "arn": 213, "hkg": 452, "ord": 142, "den": 242 }, { "timestamp": "2025-08-20T03:00:00.000Z", "gig": 408, "gru": 192, "scl": 362, "hkg": 481, "den": 267, "ord": 193, "sea": 205, "arn": 222, "atl": 194, "yul": 136, "mia": 231, "nrt": 286, "syd": 303, "mad": 356, "sin": 1133, "bom": 303, "ewr": 194, "sjc": 126, "phx": 182, "gdl": 585, "dfw": 151, "jnb": 374, "iad": 106, "ams": 207, "bog": 353, "lax": 148, "otp": 271, "eze": 297, "lhr": 135, "yyz": 187, "cdg": 164, "fra": 156, "bos": 321 }, { "timestamp": "2025-08-20T04:00:00.000Z", "jnb": 369, "ams": 201, "iad": 142, "phx": 225, "sjc": 118, "dfw": 164, "gdl": 606, "bog": 390, "otp": 255, "eze": 348, "lax": 122, "yyz": 136, "lhr": 142, "bos": 122, "fra": 197, "cdg": 168, "scl": 529, "gig": 205, "gru": 226, "atl": 233, "yul": 139, "ord": 167, "den": 245, "hkg": 282, "arn": 205, "sea": 184, "syd": 264, "mad": 317, "mia": 167, "nrt": 498, "bom": 322, "ewr": 135, "sin": 471 }, { "timestamp": "2025-08-20T05:00:00.000Z", "sjc": 146, "phx": 187, "gdl": 611, "dfw": 160, "jnb": 385, "iad": 258, "ams": 230, "bog": 495, "lax": 140, "otp": 242, "eze": 505, "lhr": 177, "yyz": 128, "cdg": 173, "fra": 152, "bos": 106, "gig": 363, "gru": 170, "scl": 296, "hkg": 401, "den": 263, "ord": 98, "sea": 213, "arn": 253, "atl": 159, "yul": 129, "mia": 177, "nrt": 270, "syd": 351, "mad": 334, "sin": 804, "bom": 508, "ewr": 122 }, { "timestamp": "2025-08-20T06:00:00.000Z", "den": 248, "ord": 131, "hkg": 282, "arn": 229, "sea": 176, "yul": 130, "atl": 141, "gig": 216, "gru": 184, "scl": 336, "sin": 627, "ewr": 193, "bom": 295, "mia": 239, "nrt": 275, "syd": 347, "mad": 358, "bog": 782, "eze": 299, "otp": 282, "lax": 138, "phx": 182, "sjc": 121, "dfw": 145, "gdl": 657, "jnb": 388, "ams": 206, "iad": 138, "fra": 145, "cdg": 156, "bos": 113, "lhr": 152, "yyz": 131 }, { "timestamp": "2025-08-20T07:00:00.000Z", "bos": 98, "fra": 204, "cdg": 332, "yyz": 141, "lhr": 161, "eze": 303, "otp": 280, "lax": 155, "bog": 500, "ams": 189, "iad": 172, "jnb": 351, "dfw": 222, "gdl": 662, "phx": 185, "sjc": 157, "ewr": 128, "bom": 298, "sin": 283, "mad": 320, "syd": 261, "nrt": 228, "mia": 202, "yul": 95, "atl": 118, "arn": 458, "sea": 189, "ord": 135, "den": 232, "hkg": 397, "scl": 352, "gru": 480, "gig": 363 }, { "timestamp": "2025-08-20T08:00:00.000Z", "yyz": 133, "lhr": 429, "bos": 140, "fra": 153, "cdg": 308, "jnb": 350, "ams": 198, "iad": 217, "phx": 184, "sjc": 158, "dfw": 199, "gdl": 363, "bog": 378, "eze": 491, "otp": 301, "lax": 158, "syd": 298, "mad": 357, "mia": 237, "nrt": 273, "ewr": 125, "bom": 540, "sin": 452, "scl": 350, "gig": 376, "gru": 168, "yul": 100, "atl": 192, "ord": 184, "den": 239, "hkg": 330, "arn": 351, "sea": 165 }, { "timestamp": "2025-08-20T09:00:00.000Z", "mad": 379, "syd": 314, "nrt": 238, "mia": 159, "ewr": 134, "bom": 343, "sin": 380, "scl": 341, "gru": 174, "gig": 192, "yul": 160, "atl": 134, "sea": 197, "arn": 421, "hkg": 338, "den": 266, "ord": 147, "yyz": 136, "lhr": 165, "bos": 167, "cdg": 526, "fra": 155, "iad": 54, "ams": 201, "jnb": 399, "gdl": 848, "dfw": 211, "sjc": 124, "phx": 203, "lax": 132, "eze": 245, "otp": 284, "bog": 393 }, { "timestamp": "2025-08-20T10:00:00.000Z", "lhr": 150, "yyz": 132, "fra": 158, "cdg": 296, "bos": 95, "dfw": 199, "gdl": 586, "phx": 207, "sjc": 118, "ams": 207, "iad": 162, "jnb": 362, "otp": 245, "eze": 490, "lax": 141, "bog": 496, "nrt": 210, "mia": 181, "mad": 352, "syd": 302, "sin": 422, "bom": 312, "ewr": 176, "gru": 227, "gig": 393, "scl": 398, "arn": 393, "sea": 196, "ord": 160, "den": 251, "hkg": 267, "atl": 297, "yul": 95 }, { "timestamp": "2025-08-20T11:00:00.000Z", "ord": 87, "den": 258, "hkg": 338, "arn": 352, "sea": 171, "atl": 172, "yul": 128, "gig": 204, "gru": 182, "scl": 307, "sin": 468, "bom": 510, "ewr": 190, "mia": 179, "nrt": 220, "syd": 362, "mad": 335, "bog": 520, "otp": 287, "eze": 307, "lax": 153, "phx": 182, "sjc": 113, "dfw": 202, "gdl": 452, "jnb": 390, "ams": 212, "iad": 174, "fra": 157, "cdg": 160, "bos": 181, "lhr": 318, "yyz": 156 }, { "timestamp": "2025-08-20T12:00:00.000Z", "atl": 120, "yul": 100, "sea": 187, "arn": 244, "hkg": 337, "ord": 148, "den": 234, "scl": 334, "gru": 167, "gig": 248, "bom": 323, "ewr": 200, "sin": 356, "mad": 343, "syd": 344, "nrt": 304, "mia": 229, "lax": 176, "otp": 308, "eze": 505, "bog": 460, "iad": 98, "ams": 176, "jnb": 379, "gdl": 496, "dfw": 147, "sjc": 136, "phx": 188, "bos": 134, "cdg": 165, "fra": 139, "yyz": 124, "lhr": 159 }, { "timestamp": "2025-08-20T13:00:00.000Z", "bog": 480, "otp": 270, "eze": 399, "lax": 165, "phx": 186, "sjc": 119, "dfw": 281, "gdl": 498, "jnb": 354, "ams": 194, "iad": 97, "fra": 148, "cdg": 155, "bos": 187, "lhr": 151, "yyz": 150, "ord": 104, "den": 238, "hkg": 354, "arn": 237, "sea": 193, "atl": 213, "yul": 150, "gig": 457, "gru": 185, "scl": 525, "sin": 438, "bom": 322, "ewr": 207, "mia": 170, "nrt": 266, "syd": 304, "mad": 336 }, { "timestamp": "2025-08-20T14:00:00.000Z", "yul": 132, "atl": 196, "hkg": 282, "den": 261, "ord": 173, "sea": 208, "arn": 391, "scl": 542, "gig": 520, "gru": 288, "ewr": 197, "bom": 317, "sin": 501, "syd": 349, "mad": 341, "mia": 239, "nrt": 267, "bog": 488, "lax": 147, "eze": 523, "otp": 316, "jnb": 363, "iad": 95, "ams": 309, "sjc": 129, "phx": 183, "gdl": 645, "dfw": 281, "bos": 110, "cdg": 390, "fra": 160, "yyz": 145, "lhr": 162 }, { "timestamp": "2025-08-20T15:00:00.000Z", "yyz": 145, "lhr": 255, "bos": 114, "cdg": 185, "fra": 163, "iad": 48, "ams": 205, "jnb": 414, "gdl": 626, "dfw": 206, "sjc": 118, "phx": 646, "lax": 170, "eze": 452, "otp": 270, "bog": 394, "mad": 346, "syd": 306, "nrt": 268, "mia": 122, "ewr": 188, "bom": 666, "sin": 271, "scl": 383, "gru": 205, "gig": 204, "yul": 121, "atl": 236, "sea": 191, "arn": 193, "hkg": 274, "den": 235, "ord": 138 }, { "timestamp": "2025-08-20T16:00:00.000Z", "ord": 130, "den": 226, "hkg": 786, "arn": 207, "sea": 168, "atl": 250, "yul": 113, "gig": 208, "gru": 164, "scl": 308, "sin": 447, "bom": 295, "ewr": 114, "mia": 226, "nrt": 231, "syd": 257, "mad": 341, "bog": 520, "otp": 260, "eze": 294, "lax": 155, "phx": 209, "sjc": 121, "dfw": 277, "gdl": 619, "jnb": 358, "ams": 195, "iad": 103, "fra": 144, "cdg": 295, "bos": 103, "lhr": 152, "yyz": 145 }, { "timestamp": "2025-08-20T17:00:00.000Z", "lax": 523, "otp": 352, "eze": 387, "bog": 465, "gdl": 634, "dfw": 151, "sjc": 125, "phx": 189, "iad": 98, "ams": 183, "jnb": 373, "cdg": 292, "fra": 158, "bos": 122, "lhr": 430, "yyz": 136, "sea": 158, "arn": 205, "hkg": 354, "den": 249, "ord": 180, "atl": 182, "yul": 134, "gru": 218, "gig": 190, "scl": 394, "sin": 503, "bom": 331, "ewr": 190, "nrt": 676, "mia": 172, "mad": 333, "syd": 258 }, { "timestamp": "2025-08-20T18:00:00.000Z", "cdg": 160, "fra": 156, "bos": 152, "lhr": 162, "yyz": 117, "bog": 408, "lax": 136, "eze": 268, "otp": 387, "sjc": 119, "phx": 187, "gdl": 618, "dfw": 167, "jnb": 396, "iad": 102, "ams": 181, "sin": 347, "ewr": 69, "bom": 319, "mia": 176, "nrt": 243, "syd": 300, "mad": 320, "hkg": 335, "ord": 210, "den": 210, "sea": 146, "arn": 224, "yul": 121, "atl": 137, "gig": 235, "gru": 164, "scl": 498 }, { "timestamp": "2025-08-20T19:00:00.000Z", "yul": 117, "atl": 199, "sea": 208, "arn": 210, "hkg": 277, "den": 229, "ord": 202, "scl": 349, "gru": 183, "gig": 198, "ewr": 297, "bom": 300, "sin": 416, "mad": 345, "syd": 307, "nrt": 270, "mia": 178, "lax": 149, "eze": 351, "otp": 317, "bog": 417, "iad": 162, "ams": 211, "jnb": 384, "gdl": 591, "dfw": 168, "sjc": 152, "phx": 200, "bos": 113, "cdg": 160, "fra": 161, "yyz": 137, "lhr": 152 }, { "timestamp": "2025-08-20T20:00:00.000Z", "scl": 344, "gig": 353, "gru": 204, "yul": 141, "atl": 222, "ord": 117, "den": 266, "hkg": 269, "arn": 221, "sea": 171, "syd": 307, "mad": 345, "mia": 175, "nrt": 271, "ewr": 182, "bom": 296, "sin": 314, "jnb": 357, "ams": 194, "iad": 194, "phx": 160, "sjc": 126, "dfw": 157, "gdl": 644, "bog": 382, "eze": 313, "otp": 272, "lax": 163, "yyz": 195, "lhr": 201, "bos": 88, "fra": 150, "cdg": 146 }, { "timestamp": "2025-08-20T21:00:00.000Z", "gig": 377, "gru": 214, "scl": 343, "hkg": 274, "den": 222, "ord": 132, "sea": 158, "arn": 232, "yul": 97, "atl": 117, "mia": 172, "nrt": 216, "syd": 254, "mad": 323, "sin": 376, "ewr": 202, "bom": 288, "sjc": 144, "phx": 204, "gdl": 488, "dfw": 203, "jnb": 346, "iad": 164, "ams": 194, "bog": 384, "lax": 149, "eze": 273, "otp": 334, "lhr": 162, "yyz": 140, "cdg": 332, "fra": 138, "bos": 107 }, { "timestamp": "2025-08-20T22:00:00.000Z", "scl": 363, "gru": 180, "gig": 239, "atl": 173, "yul": 122, "arn": 241, "sea": 233, "den": 291, "ord": 154, "hkg": 270, "mad": 494, "syd": 380, "nrt": 274, "mia": 177, "bom": 297, "ewr": 126, "sin": 353, "ams": 188, "iad": 140, "jnb": 366, "dfw": 204, "gdl": 500, "phx": 226, "sjc": 120, "otp": 239, "eze": 324, "lax": 147, "bog": 394, "yyz": 148, "lhr": 162, "bos": 144, "fra": 151, "cdg": 163 }, { "timestamp": "2025-08-20T23:00:00.000Z", "lhr": 163, "yyz": 124, "fra": 151, "cdg": 163, "bos": 104, "phx": 185, "sjc": 121, "dfw": 190, "gdl": 455, "jnb": 380, "ams": 199, "iad": 154, "bog": 373, "otp": 246, "eze": 379, "lax": 163, "mia": 177, "nrt": 313, "syd": 302, "mad": 346, "sin": 336, "bom": 296, "ewr": 434, "gig": 378, "gru": 154, "scl": 321, "ord": 161, "den": 233, "hkg": 331, "arn": 243, "sea": 201, "atl": 152, "yul": 108 }, { "timestamp": "2025-08-21T00:00:00.000Z", "bos": 149, "fra": 154, "cdg": 165, "yyz": 144, "lhr": 155, "eze": 262, "otp": 257, "lax": 150, "bog": 423, "ams": 176, "iad": 384, "jnb": 397, "dfw": 181, "gdl": 496, "phx": 179, "sjc": 116, "ewr": 121, "bom": 305, "sin": 341, "mad": 332, "syd": 299, "nrt": 237, "mia": 184, "yul": 127, "atl": 139, "arn": 237, "sea": 166, "ord": 133, "den": 224, "hkg": 263, "scl": 399, "gru": 330, "gig": 165 }, { "timestamp": "2025-08-21T01:00:00.000Z", "ewr": 55, "bom": 291, "sin": 437, "syd": 313, "mad": 340, "mia": 224, "nrt": 235, "yul": 116, "atl": 159, "hkg": 544, "den": 232, "ord": 138, "sea": 175, "arn": 213, "scl": 392, "gig": 200, "gru": 186, "bos": 113, "cdg": 293, "fra": 162, "yyz": 139, "lhr": 150, "bog": 414, "lax": 140, "eze": 402, "otp": 277, "jnb": 385, "iad": 300, "ams": 218, "sjc": 137, "phx": 179, "gdl": 628, "dfw": 207 }, { "timestamp": "2025-08-21T02:00:00.000Z", "fra": 153, "cdg": 150, "bos": 106, "lhr": 164, "yyz": 146, "bog": 488, "otp": 271, "eze": 515, "lax": 135, "phx": 190, "sjc": 116, "dfw": 502, "gdl": 505, "jnb": 407, "ams": 189, "iad": 64, "sin": 844, "bom": 292, "ewr": 123, "mia": 174, "nrt": 224, "syd": 269, "mad": 332, "ord": 139, "den": 463, "hkg": 346, "arn": 233, "sea": 223, "atl": 179, "yul": 105, "gig": 381, "gru": 184, "scl": 297 }, { "timestamp": "2025-08-21T03:00:00.000Z", "scl": 291, "gru": 179, "gig": 194, "yul": 99, "atl": 199, "arn": 201, "sea": 197, "ord": 165, "den": 257, "hkg": 327, "mad": 323, "syd": 346, "nrt": 457, "mia": 143, "ewr": 101, "bom": 619, "sin": 710, "ams": 202, "iad": 43, "jnb": 356, "dfw": 202, "gdl": 647, "phx": 183, "sjc": 124, "eze": 530, "otp": 271, "lax": 137, "bog": 304, "yyz": 659, "lhr": 186, "bos": 122, "fra": 171, "cdg": 161 }, { "timestamp": "2025-08-21T04:00:00.000Z", "gig": 268, "gru": 183, "scl": 316, "hkg": 344, "ord": 135, "den": 234, "sea": 177, "arn": 216, "yul": 111, "atl": 142, "mia": 198, "nrt": 239, "syd": 446, "mad": 351, "sin": 494, "ewr": 180, "bom": 321, "sjc": 136, "phx": 229, "gdl": 606, "dfw": 187, "jnb": 374, "iad": 488, "ams": 227, "bog": 304, "lax": 151, "eze": 297, "otp": 283, "lhr": 161, "yyz": 143, "cdg": 157, "fra": 161, "bos": 115 }, { "timestamp": "2025-08-21T05:00:00.000Z", "ord": 160, "den": 215, "hkg": 275, "arn": 267, "sea": 185, "yul": 118, "atl": 235, "gig": 167, "gru": 170, "scl": 509, "sin": 948, "ewr": 66, "bom": 518, "mia": 151, "nrt": 267, "syd": 302, "mad": 318, "bog": 508, "eze": 261, "otp": 288, "lax": 146, "phx": 222, "sjc": 128, "dfw": 167, "gdl": 491, "jnb": 365, "ams": 197, "iad": 47, "fra": 151, "cdg": 155, "bos": 142, "lhr": 188, "yyz": 116 }, { "timestamp": "2025-08-21T06:00:00.000Z", "bog": 516, "lax": 147, "otp": 274, "eze": 269, "jnb": 372, "iad": 95, "ams": 198, "sjc": 127, "phx": 198, "gdl": 621, "dfw": 299, "bos": 126, "cdg": 179, "fra": 154, "yyz": 174, "lhr": 156, "atl": 144, "yul": 104, "hkg": 383, "den": 280, "ord": 149, "sea": 203, "arn": 211, "scl": 326, "gig": 194, "gru": 174, "bom": 580, "ewr": 236, "sin": 644, "syd": 345, "mad": 321, "mia": 121, "nrt": 498 }, { "timestamp": "2025-08-21T07:00:00.000Z", "cdg": 180, "fra": 145, "bos": 104, "lhr": 156, "yyz": 123, "bog": 303, "lax": 154, "otp": 252, "eze": 593, "sjc": 152, "phx": 240, "gdl": 636, "dfw": 409, "jnb": 391, "iad": 128, "ams": 239, "sin": 551, "bom": 629, "ewr": 116, "mia": 135, "nrt": 496, "syd": 261, "mad": 332, "hkg": 351, "den": 207, "ord": 186, "sea": 144, "arn": 350, "atl": 202, "yul": 126, "gig": 348, "gru": 188, "scl": 288 }, { "timestamp": "2025-08-21T08:00:00.000Z", "bos": 98, "fra": 151, "cdg": 294, "yyz": 133, "lhr": 197, "bog": 353, "eze": 292, "otp": 283, "lax": 163, "jnb": 355, "ams": 153, "iad": 140, "phx": 212, "sjc": 157, "dfw": 237, "gdl": 752, "ewr": 129, "bom": 299, "sin": 605, "syd": 261, "mad": 331, "mia": 136, "nrt": 309, "yul": 125, "atl": 157, "ord": 78, "den": 255, "hkg": 340, "arn": 417, "sea": 202, "scl": 356, "gig": 178, "gru": 234 }, { "timestamp": "2025-08-21T09:00:00.000Z", "scl": 299, "gig": 277, "gru": 185, "atl": 110, "yul": 95, "den": 461, "ord": 139, "hkg": 271, "arn": 349, "sea": 176, "syd": 313, "mad": 333, "mia": 165, "nrt": 270, "bom": 514, "ewr": 120, "sin": 385, "jnb": 343, "ams": 218, "iad": 129, "phx": 183, "sjc": 124, "dfw": 232, "gdl": 523, "bog": 471, "otp": 247, "eze": 295, "lax": 127, "yyz": 127, "lhr": 177, "bos": 110, "fra": 159, "cdg": 419 }, { "timestamp": "2025-08-21T10:00:00.000Z", "yyz": 133, "lhr": 309, "bos": 110, "fra": 165, "cdg": 159, "ams": 192, "iad": 135, "jnb": 378, "dfw": 144, "gdl": 499, "phx": 190, "sjc": 132, "otp": 284, "eze": 415, "lax": 148, "bog": 357, "mad": 339, "syd": 307, "nrt": 275, "mia": 123, "bom": 302, "ewr": 71, "sin": 594, "scl": 398, "gru": 259, "gig": 212, "atl": 152, "yul": 103, "arn": 256, "sea": 185, "den": 232, "ord": 133, "hkg": 348 }, { "timestamp": "2025-08-21T11:00:00.000Z", "gru": 189, "gig": 184, "scl": 292, "arn": 353, "sea": 231, "den": 232, "ord": 181, "hkg": 372, "yul": 118, "atl": 129, "nrt": 306, "mia": 190, "mad": 338, "syd": 273, "sin": 282, "ewr": 190, "bom": 325, "dfw": 204, "gdl": 501, "phx": 206, "sjc": 122, "ams": 194, "iad": 101, "jnb": 365, "eze": 327, "otp": 276, "lax": 240, "bog": 318, "lhr": 298, "yyz": 135, "fra": 347, "cdg": 378, "bos": 102 }, { "timestamp": "2025-08-21T12:00:00.000Z", "sin": 384, "ewr": 59, "bom": 330, "nrt": 260, "mia": 151, "mad": 358, "syd": 307, "arn": 208, "sea": 209, "ord": 187, "den": 278, "hkg": 350, "yul": 119, "atl": 128, "gru": 186, "gig": 532, "scl": 554, "fra": 230, "cdg": 364, "bos": 127, "lhr": 230, "yyz": 136, "eze": 324, "otp": 276, "lax": 152, "bog": 489, "dfw": 387, "gdl": 486, "phx": 202, "sjc": 132, "ams": 202, "iad": 171, "jnb": 354 }, { "timestamp": "2025-08-21T13:00:00.000Z", "otp": 254, "eze": 319, "lax": 147, "bog": 457, "ams": 192, "iad": 167, "jnb": 523, "dfw": 189, "gdl": 543, "phx": 215, "sjc": 118, "bos": 109, "fra": 185, "cdg": 169, "yyz": 156, "lhr": 140, "atl": 161, "yul": 134, "arn": 216, "sea": 191, "ord": 141, "den": 210, "hkg": 358, "scl": 364, "gru": 410, "gig": 394, "bom": 316, "ewr": 246, "sin": 358, "mad": 486, "syd": 306, "nrt": 217, "mia": 170 }, { "timestamp": "2025-08-21T14:00:00.000Z", "yyz": 287, "lhr": 152, "bos": 192, "fra": 137, "cdg": 171, "ams": 235, "iad": 202, "jnb": 397, "dfw": 161, "gdl": 471, "phx": 179, "sjc": 160, "otp": 326, "eze": 265, "lax": 159, "bog": 314, "mad": 329, "syd": 303, "nrt": 317, "mia": 148, "bom": 358, "ewr": 272, "sin": 438, "scl": 339, "gru": 179, "gig": 587, "atl": 210, "yul": 96, "arn": 231, "sea": 170, "den": 239, "ord": 135, "hkg": 270 }, { "timestamp": "2025-08-21T15:00:00.000Z", "gru": 187, "gig": 247, "scl": 348, "arn": 202, "sea": 186, "den": 239, "ord": 184, "hkg": 370, "yul": 106, "atl": 111, "nrt": 480, "mia": 160, "mad": 339, "syd": 339, "sin": 454, "ewr": 127, "bom": 306, "dfw": 565, "gdl": 585, "phx": 190, "sjc": 121, "ams": 210, "iad": 145, "jnb": 397, "eze": 258, "otp": 312, "lax": 292, "bog": 452, "lhr": 165, "yyz": 240, "fra": 153, "cdg": 287, "bos": 139 }, { "timestamp": "2025-08-21T16:00:00.000Z", "phx": 205, "sjc": 131, "dfw": 210, "gdl": 506, "jnb": 373, "ams": 233, "iad": 130, "bog": 308, "eze": 415, "otp": 255, "lax": 277, "lhr": 243, "yyz": 138, "fra": 168, "cdg": 171, "bos": 107, "gig": 207, "gru": 248, "scl": 354, "ord": 134, "den": 223, "hkg": 335, "arn": 234, "sea": 177, "yul": 328, "atl": 155, "mia": 136, "nrt": 463, "syd": 330, "mad": 333, "sin": 495, "ewr": 177, "bom": 328 }, { "timestamp": "2025-08-21T17:00:00.000Z", "syd": 311, "mad": 355, "mia": 201, "nrt": 276, "bom": 309, "ewr": 339, "sin": 457, "scl": 336, "gig": 391, "gru": 213, "atl": 128, "yul": 129, "ord": 221, "den": 247, "hkg": 283, "arn": 240, "sea": 198, "yyz": 125, "lhr": 149, "bos": 103, "fra": 197, "cdg": 277, "jnb": 344, "ams": 213, "iad": 359, "phx": 206, "sjc": 232, "dfw": 156, "gdl": 631, "bog": 448, "otp": 320, "eze": 310, "lax": 163 }, { "timestamp": "2025-08-21T18:00:00.000Z", "ams": 217, "iad": 136, "jnb": 346, "dfw": 158, "gdl": 642, "phx": 180, "sjc": 147, "otp": 274, "eze": 257, "lax": 151, "bog": 467, "yyz": 139, "lhr": 173, "bos": 112, "fra": 142, "cdg": 158, "scl": 290, "gru": 244, "gig": 362, "atl": 166, "yul": 108, "arn": 185, "sea": 184, "ord": 176, "den": 417, "hkg": 332, "mad": 329, "syd": 341, "nrt": 219, "mia": 132, "bom": 311, "ewr": 149, "sin": 344 }, { "timestamp": "2025-08-21T19:00:00.000Z", "ewr": 129, "bom": 316, "sin": 407, "mad": 763, "syd": 322, "nrt": 221, "mia": 208, "yul": 121, "atl": 139, "arn": 228, "sea": 208, "den": 213, "ord": 106, "hkg": 357, "scl": 292, "gru": 317, "gig": 367, "bos": 140, "fra": 140, "cdg": 145, "yyz": 116, "lhr": 155, "eze": 323, "otp": 283, "lax": 180, "bog": 479, "ams": 202, "iad": 419, "jnb": 375, "dfw": 199, "gdl": 527, "phx": 199, "sjc": 129 }, { "timestamp": "2025-08-21T20:00:00.000Z", "sin": 836, "bom": 330, "ewr": 121, "nrt": 264, "mia": 887, "mad": 341, "syd": 301, "sea": 173, "arn": 211, "hkg": 329, "ord": 187, "den": 269, "atl": 118, "yul": 95, "gru": 195, "gig": 392, "scl": 303, "cdg": 161, "fra": 158, "bos": 128, "lhr": 155, "yyz": 132, "lax": 136, "otp": 289, "eze": 301, "bog": 573, "gdl": 541, "dfw": 198, "sjc": 140, "phx": 189, "iad": 181, "ams": 193, "jnb": 663 }, { "timestamp": "2025-08-21T21:00:00.000Z", "iad": 129, "ams": 213, "jnb": 365, "gdl": 504, "dfw": 458, "sjc": 128, "phx": 185, "lax": 164, "otp": 236, "eze": 263, "bog": 387, "yyz": 137, "lhr": 142, "bos": 140, "cdg": 158, "fra": 151, "scl": 300, "gru": 192, "gig": 346, "atl": 138, "yul": 105, "sea": 194, "arn": 239, "hkg": 326, "den": 278, "ord": 202, "mad": 357, "syd": 299, "nrt": 254, "mia": 187, "bom": 273, "ewr": 123, "sin": 427 }, { "timestamp": "2025-08-21T22:00:00.000Z", "lhr": 212, "yyz": 131, "cdg": 147, "fra": 157, "bos": 103, "gdl": 502, "dfw": 159, "sjc": 117, "phx": 188, "iad": 76, "ams": 197, "jnb": 359, "lax": 153, "otp": 270, "eze": 275, "bog": 439, "nrt": 266, "mia": 120, "mad": 319, "syd": 336, "sin": 319, "bom": 306, "ewr": 116, "gru": 196, "gig": 216, "scl": 342, "sea": 168, "arn": 209, "hkg": 329, "den": 283, "ord": 190, "atl": 139, "yul": 119 }, { "timestamp": "2025-08-21T23:00:00.000Z", "sea": 199, "arn": 205, "hkg": 341, "ord": 148, "den": 253, "yul": 109, "atl": 155, "gru": 180, "gig": 474, "scl": 381, "sin": 443, "ewr": 162, "bom": 299, "nrt": 324, "mia": 155, "mad": 333, "syd": 477, "lax": 141, "eze": 323, "otp": 254, "bog": 506, "gdl": 529, "dfw": 220, "sjc": 140, "phx": 201, "iad": 103, "ams": 186, "jnb": 405, "cdg": 157, "fra": 376, "bos": 168, "lhr": 154, "yyz": 120 }, { "timestamp": "2025-08-22T00:00:00.000Z", "yyz": 133, "lhr": 150, "bos": 113, "fra": 190, "cdg": 149, "ams": 180, "iad": 134, "jnb": 392, "dfw": 182, "gdl": 502, "phx": 291, "sjc": 121, "eze": 269, "otp": 282, "lax": 144, "bog": 394, "mad": 346, "syd": 259, "nrt": 227, "mia": 181, "ewr": 134, "bom": 293, "sin": 585, "scl": 288, "gru": 210, "gig": 242, "yul": 100, "atl": 154, "arn": 206, "sea": 195, "ord": 155, "den": 298, "hkg": 329 }, { "timestamp": "2025-08-22T01:00:00.000Z", "phx": 182, "sjc": 133, "dfw": 249, "gdl": 485, "jnb": 374, "ams": 233, "iad": 198, "bog": 478, "otp": 256, "eze": 289, "lax": 143, "lhr": 141, "yyz": 133, "fra": 150, "cdg": 168, "bos": 106, "gig": 349, "gru": 175, "scl": 496, "den": 272, "ord": 396, "hkg": 386, "arn": 204, "sea": 178, "atl": 143, "yul": 118, "mia": 161, "nrt": 221, "syd": 300, "mad": 331, "sin": 978, "bom": 302, "ewr": 183 }, { "timestamp": "2025-08-22T02:00:00.000Z", "nrt": 458, "mia": 154, "mad": 318, "syd": 306, "sin": 886, "bom": 290, "ewr": 316, "gru": 370, "gig": 410, "scl": 340, "arn": 223, "sea": 217, "den": 233, "ord": 182, "hkg": 352, "atl": 119, "yul": 83, "lhr": 153, "yyz": 153, "fra": 145, "cdg": 172, "bos": 128, "dfw": 229, "gdl": 587, "phx": 192, "sjc": 113, "ams": 311, "iad": 151, "jnb": 388, "otp": 358, "eze": 506, "lax": 145, "bog": 356 }, { "timestamp": "2025-08-22T03:00:00.000Z", "ams": 194, "iad": 113, "jnb": 344, "dfw": 157, "gdl": 475, "phx": 203, "sjc": 115, "eze": 460, "otp": 230, "lax": 166, "bog": 345, "yyz": 138, "lhr": 161, "bos": 101, "fra": 154, "cdg": 168, "scl": 496, "gru": 186, "gig": 355, "yul": 81, "atl": 146, "arn": 244, "sea": 199, "den": 232, "ord": 182, "hkg": 638, "mad": 328, "syd": 274, "nrt": 215, "mia": 107, "ewr": 186, "bom": 317, "sin": 621 }, { "timestamp": "2025-08-22T04:00:00.000Z", "lax": 144, "otp": 287, "eze": 493, "bog": 461, "iad": 380, "ams": 191, "jnb": 351, "gdl": 726, "dfw": 207, "sjc": 155, "phx": 185, "bos": 96, "cdg": 172, "fra": 148, "yyz": 120, "lhr": 153, "atl": 186, "yul": 93, "sea": 199, "arn": 216, "hkg": 411, "den": 221, "ord": 134, "scl": 287, "gru": 239, "gig": 236, "bom": 393, "ewr": 184, "sin": 449, "mad": 319, "syd": 263, "nrt": 421, "mia": 160 }, { "timestamp": "2025-08-22T05:00:00.000Z", "sin": 453, "ewr": 61, "bom": 300, "nrt": 504, "mia": 177, "mad": 367, "syd": 315, "sea": 189, "arn": 226, "hkg": 427, "den": 228, "ord": 129, "yul": 106, "atl": 142, "gru": 206, "gig": 198, "scl": 342, "cdg": 184, "fra": 143, "bos": 136, "lhr": 149, "yyz": 162, "lax": 132, "eze": 296, "otp": 240, "bog": 300, "gdl": 473, "dfw": 190, "sjc": 125, "phx": 176, "iad": 125, "ams": 195, "jnb": 378 }, { "timestamp": "2025-08-22T06:00:00.000Z", "ewr": 115, "bom": 661, "sin": 395, "syd": 307, "mad": 315, "mia": 194, "nrt": 281, "yul": 92, "atl": 139, "den": 234, "ord": 128, "hkg": 326, "arn": 240, "sea": 170, "scl": 393, "gig": 229, "gru": 160, "bos": 118, "fra": 385, "cdg": 153, "yyz": 119, "lhr": 147, "bog": 505, "eze": 294, "otp": 299, "lax": 159, "jnb": 398, "ams": 212, "iad": 36, "phx": 201, "sjc": 115, "dfw": 238, "gdl": 480 }, { "timestamp": "2025-08-22T07:00:00.000Z", "bog": 473, "otp": 283, "eze": 404, "lax": 145, "phx": 185, "sjc": 126, "dfw": 236, "gdl": 651, "jnb": 364, "ams": 204, "iad": 116, "fra": 144, "cdg": 158, "bos": 140, "lhr": 167, "yyz": 119, "den": 247, "ord": 138, "hkg": 272, "arn": 590, "sea": 161, "atl": 153, "yul": 107, "gig": 347, "gru": 200, "scl": 305, "sin": 718, "bom": 300, "ewr": 117, "mia": 133, "nrt": 522, "syd": 271, "mad": 635 }, { "timestamp": "2025-08-22T08:00:00.000Z", "jnb": 360, "ams": 212, "iad": 211, "phx": 180, "sjc": 131, "dfw": 183, "gdl": 500, "bog": 457, "otp": 303, "eze": 308, "lax": 161, "yyz": 104, "lhr": 190, "bos": 111, "fra": 149, "cdg": 278, "scl": 341, "gig": 376, "gru": 216, "atl": 194, "yul": 89, "ord": 135, "den": 262, "hkg": 262, "arn": 363, "sea": 175, "syd": 294, "mad": 341, "mia": 110, "nrt": 220, "bom": 304, "ewr": 197, "sin": 310 }, { "timestamp": "2025-08-22T09:00:00.000Z", "mia": 152, "nrt": 287, "syd": 421, "mad": 330, "sin": 433, "ewr": 126, "bom": 300, "gig": 249, "gru": 220, "scl": 340, "ord": 130, "den": 276, "hkg": 345, "arn": 187, "sea": 201, "yul": 114, "atl": 174, "lhr": 156, "yyz": 116, "fra": 160, "cdg": 181, "bos": 89, "phx": 195, "sjc": 118, "dfw": 198, "gdl": 630, "jnb": 352, "ams": 190, "iad": 506, "bog": 347, "eze": 304, "otp": 291, "lax": 142 }, { "timestamp": "2025-08-22T10:00:00.000Z", "dfw": 169, "gdl": 860, "phx": 192, "sjc": 127, "ams": 185, "iad": 114, "jnb": 360, "eze": 299, "otp": 238, "lax": 142, "bog": 379, "lhr": 162, "yyz": 263, "fra": 170, "cdg": 552, "bos": 113, "gru": 180, "gig": 392, "scl": 334, "arn": 224, "sea": 175, "ord": 132, "den": 241, "hkg": 275, "yul": 140, "atl": 149, "nrt": 723, "mia": 138, "mad": 323, "syd": 308, "sin": 472, "ewr": 114, "bom": 327 }, { "timestamp": "2025-08-22T11:00:00.000Z", "yyz": 133, "lhr": 172, "bos": 134, "fra": 162, "cdg": 150, "jnb": 381, "ams": 186, "iad": 132, "phx": 180, "sjc": 143, "dfw": 190, "gdl": 666, "bog": 660, "otp": 276, "eze": 489, "lax": 143, "syd": 296, "mad": 366, "mia": 186, "nrt": 270, "bom": 301, "ewr": 130, "sin": 433, "scl": 343, "gig": 220, "gru": 189, "atl": 133, "yul": 125, "den": 248, "ord": 141, "hkg": 429, "arn": 220, "sea": 188 }, { "timestamp": "2025-08-22T12:00:00.000Z", "lhr": 161, "yyz": 111, "cdg": 165, "fra": 443, "bos": 115, "sjc": 130, "phx": 188, "gdl": 669, "dfw": 218, "jnb": 376, "iad": 508, "ams": 208, "bog": 508, "lax": 165, "eze": 307, "otp": 236, "mia": 154, "nrt": 279, "syd": 305, "mad": 344, "sin": 776, "ewr": 123, "bom": 325, "gig": 230, "gru": 212, "scl": 450, "hkg": 348, "ord": 181, "den": 225, "sea": 170, "arn": 215, "yul": 107, "atl": 166 }, { "timestamp": "2025-08-22T13:00:00.000Z", "hkg": 362, "den": 232, "ord": 148, "sea": 178, "arn": 315, "atl": 438, "yul": 107, "gig": 392, "gru": 373, "scl": 535, "sin": 463, "bom": 315, "ewr": 193, "mia": 186, "nrt": 276, "syd": 311, "mad": 336, "bog": 363, "lax": 151, "otp": 272, "eze": 405, "sjc": 145, "phx": 183, "gdl": 482, "dfw": 191, "jnb": 545, "iad": 244, "ams": 198, "cdg": 296, "fra": 134, "bos": 174, "lhr": 135, "yyz": 132 }, { "timestamp": "2025-08-22T14:00:00.000Z", "bom": 548, "ewr": 166, "sin": 428, "syd": 339, "mad": 340, "mia": 144, "nrt": 269, "atl": 151, "yul": 95, "hkg": 264, "den": 237, "ord": 177, "sea": 207, "arn": 203, "scl": 352, "gig": 176, "gru": 182, "bos": 95, "cdg": 169, "fra": 153, "yyz": 124, "lhr": 155, "bog": 351, "lax": 155, "otp": 308, "eze": 297, "jnb": 381, "iad": 167, "ams": 218, "sjc": 144, "phx": 157, "gdl": 596, "dfw": 454 }, { "timestamp": "2025-08-22T15:00:00.000Z", "sjc": 139, "phx": 164, "gdl": 484, "dfw": 205, "jnb": 364, "iad": 140, "ams": 227, "bog": 330, "lax": 265, "otp": 251, "eze": 286, "lhr": 304, "yyz": 172, "cdg": 168, "fra": 151, "bos": 100, "gig": 194, "gru": 198, "scl": 389, "hkg": 329, "ord": 182, "den": 219, "sea": 199, "arn": 204, "atl": 157, "yul": 92, "mia": 111, "nrt": 402, "syd": 320, "mad": 339, "sin": 552, "bom": 311, "ewr": 66 }, { "timestamp": "2025-08-22T16:00:00.000Z", "jnb": 488, "ams": 194, "iad": 159, "phx": 198, "sjc": 267, "dfw": 121, "gdl": 485, "bog": 567, "eze": 326, "otp": 275, "lax": 151, "yyz": 186, "lhr": 167, "bos": 86, "fra": 140, "cdg": 276, "scl": 330, "gig": 237, "gru": 183, "yul": 91, "atl": 117, "den": 317, "ord": 140, "hkg": 352, "arn": 186, "sea": 223, "syd": 321, "mad": 371, "mia": 133, "nrt": 274, "ewr": 150, "bom": 293, "sin": 347 }, { "timestamp": "2025-08-22T17:00:00.000Z", "ewr": 137, "bom": 400, "sin": 351, "mad": 325, "syd": 288, "nrt": 436, "mia": 220, "yul": 121, "atl": 135, "arn": 195, "sea": 421, "den": 500, "ord": 179, "hkg": 278, "scl": 288, "gru": 261, "gig": 351, "bos": 121, "fra": 170, "cdg": 333, "yyz": 124, "lhr": 174, "eze": 325, "otp": 281, "lax": 152, "bog": 454, "ams": 197, "iad": 207, "jnb": 402, "dfw": 195, "gdl": 487, "phx": 189, "sjc": 291 }, { "timestamp": "2025-08-22T18:00:00.000Z", "cdg": 289, "fra": 149, "bos": 104, "lhr": 151, "yyz": 148, "lax": 172, "otp": 331, "eze": 351, "bog": 418, "gdl": 487, "dfw": 219, "sjc": 116, "phx": 184, "iad": 136, "ams": 200, "jnb": 784, "sin": 496, "bom": 308, "ewr": 187, "nrt": 394, "mia": 151, "mad": 337, "syd": 314, "sea": 202, "arn": 222, "hkg": 632, "ord": 187, "den": 471, "atl": 162, "yul": 100, "gru": 186, "gig": 178, "scl": 319 }, { "timestamp": "2025-08-22T19:00:00.000Z", "sin": 380, "bom": 309, "ewr": 161, "mia": 120, "nrt": 440, "syd": 318, "mad": 492, "den": 221, "ord": 168, "hkg": 354, "arn": 260, "sea": 336, "atl": 158, "yul": 117, "gig": 551, "gru": 192, "scl": 316, "fra": 190, "cdg": 167, "bos": 105, "lhr": 161, "yyz": 128, "bog": 418, "otp": 315, "eze": 490, "lax": 172, "phx": 189, "sjc": 158, "dfw": 154, "gdl": 664, "jnb": 548, "ams": 213, "iad": 121 }, { "timestamp": "2025-08-22T20:00:00.000Z", "bos": 82, "cdg": 171, "fra": 152, "yyz": 136, "lhr": 153, "bog": 373, "lax": 141, "otp": 288, "eze": 290, "jnb": 403, "iad": 148, "ams": 248, "sjc": 115, "phx": 195, "gdl": 590, "dfw": 178, "bom": 306, "ewr": 146, "sin": 380, "syd": 270, "mad": 363, "mia": 199, "nrt": 274, "atl": 147, "yul": 84, "hkg": 371, "ord": 185, "den": 239, "sea": 212, "arn": 205, "scl": 327, "gig": 180, "gru": 430 }, { "timestamp": "2025-08-22T21:00:00.000Z", "sea": 201, "arn": 201, "hkg": 733, "den": 237, "ord": 191, "atl": 155, "yul": 87, "gru": 200, "gig": 171, "scl": 398, "sin": 389, "bom": 301, "ewr": 190, "nrt": 266, "mia": 179, "mad": 330, "syd": 481, "lax": 159, "otp": 248, "eze": 512, "bog": 414, "gdl": 495, "dfw": 131, "sjc": 139, "phx": 193, "iad": 128, "ams": 200, "jnb": 393, "cdg": 195, "fra": 154, "bos": 141, "lhr": 149, "yyz": 127 }, { "timestamp": "2025-08-22T22:00:00.000Z", "dfw": 169, "gdl": 594, "phx": 175, "sjc": 174, "ams": 204, "iad": 119, "jnb": 360, "eze": 462, "otp": 298, "lax": 152, "bog": 387, "lhr": 559, "yyz": 159, "fra": 149, "cdg": 154, "bos": 77, "gru": 233, "gig": 204, "scl": 302, "arn": 205, "sea": 192, "den": 246, "ord": 134, "hkg": 653, "yul": 109, "atl": 207, "nrt": 451, "mia": 138, "mad": 339, "syd": 263, "sin": 502, "ewr": 126, "bom": 300 }, { "timestamp": "2025-08-22T23:00:00.000Z", "iad": 115, "ams": 191, "jnb": 622, "gdl": 648, "dfw": 222, "sjc": 196, "phx": 182, "lax": 160, "eze": 286, "otp": 242, "bog": 466, "yyz": 150, "lhr": 168, "bos": 97, "cdg": 177, "fra": 146, "scl": 368, "gru": 224, "gig": 389, "yul": 98, "atl": 153, "sea": 171, "arn": 288, "hkg": 270, "ord": 127, "den": 211, "mad": 354, "syd": 309, "nrt": 267, "mia": 523, "ewr": 115, "bom": 292, "sin": 442 }, { "timestamp": "2025-08-23T00:00:00.000Z", "fra": 140, "cdg": 145, "bos": 115, "lhr": 153, "yyz": 124, "bog": 306, "eze": 268, "otp": 272, "lax": 175, "phx": 228, "sjc": 127, "dfw": 209, "gdl": 835, "jnb": 377, "ams": 188, "iad": 112, "sin": 306, "ewr": 125, "bom": 299, "mia": 165, "nrt": 277, "syd": 312, "mad": 315, "den": 207, "ord": 203, "hkg": 280, "arn": 204, "sea": 448, "yul": 93, "atl": 146, "gig": 203, "gru": 192, "scl": 347 }, { "timestamp": "2025-08-23T01:00:00.000Z", "yul": 109, "atl": 137, "arn": 183, "sea": 203, "ord": 208, "den": 245, "hkg": 285, "scl": 337, "gru": 165, "gig": 367, "ewr": 245, "bom": 286, "sin": 691, "mad": 388, "syd": 297, "nrt": 445, "mia": 147, "eze": 325, "otp": 249, "lax": 294, "bog": 507, "ams": 231, "iad": 101, "jnb": 363, "dfw": 157, "gdl": 658, "phx": 187, "sjc": 133, "bos": 140, "fra": 155, "cdg": 156, "yyz": 126, "lhr": 147 }, { "timestamp": "2025-08-23T02:00:00.000Z", "iad": 187, "ams": 200, "jnb": 359, "gdl": 484, "dfw": 199, "sjc": 130, "phx": 191, "lax": 154, "otp": 239, "eze": 341, "bog": 557, "yyz": 127, "lhr": 160, "bos": 116, "cdg": 173, "fra": 158, "scl": 364, "gru": 182, "gig": 176, "atl": 154, "yul": 102, "sea": 214, "arn": 202, "hkg": 329, "ord": 95, "den": 290, "mad": 410, "syd": 273, "nrt": 333, "mia": 165, "bom": 328, "ewr": 132, "sin": 412 }, { "timestamp": "2025-08-23T03:00:00.000Z", "scl": 333, "gig": 177, "gru": 196, "atl": 176, "yul": 95, "den": 239, "ord": 132, "hkg": 343, "arn": 227, "sea": 157, "syd": 261, "mad": 358, "mia": 161, "nrt": 439, "bom": 295, "ewr": 219, "sin": 346, "jnb": 401, "ams": 198, "iad": 77, "phx": 174, "sjc": 128, "dfw": 192, "gdl": 579, "bog": 422, "otp": 243, "eze": 264, "lax": 179, "yyz": 120, "lhr": 151, "bos": 91, "fra": 146, "cdg": 154 }, { "timestamp": "2025-08-23T04:00:00.000Z", "gru": 203, "gig": 387, "scl": 380, "sea": 168, "arn": 280, "hkg": 336, "den": 227, "ord": 187, "atl": 140, "yul": 99, "nrt": 273, "mia": 192, "mad": 367, "syd": 366, "sin": 582, "bom": 316, "ewr": 123, "gdl": 642, "dfw": 198, "sjc": 115, "phx": 194, "iad": 126, "ams": 252, "jnb": 390, "lax": 176, "otp": 266, "eze": 496, "bog": 468, "lhr": 151, "yyz": 156, "cdg": 165, "fra": 154, "bos": 105 }, { "timestamp": "2025-08-23T05:00:00.000Z", "mad": 325, "syd": 318, "nrt": 268, "mia": 179, "bom": 323, "ewr": 130, "sin": 688, "scl": 341, "gru": 186, "gig": 239, "atl": 182, "yul": 91, "sea": 195, "arn": 196, "hkg": 357, "den": 245, "ord": 137, "yyz": 233, "lhr": 173, "bos": 92, "cdg": 149, "fra": 169, "iad": 169, "ams": 208, "jnb": 433, "gdl": 607, "dfw": 198, "sjc": 117, "phx": 345, "lax": 161, "otp": 308, "eze": 603, "bog": 380 }, { "timestamp": "2025-08-23T06:00:00.000Z", "lhr": 165, "yyz": 156, "fra": 152, "cdg": 172, "bos": 100, "dfw": 166, "gdl": 581, "phx": 173, "sjc": 112, "ams": 261, "iad": 125, "jnb": 385, "eze": 328, "otp": 233, "lax": 154, "bog": 547, "nrt": 261, "mia": 148, "mad": 337, "syd": 257, "sin": 384, "ewr": 126, "bom": 302, "gru": 163, "gig": 159, "scl": 347, "arn": 206, "sea": 177, "den": 327, "hkg": 349, "yul": 105, "atl": 157 }, { "timestamp": "2025-08-23T07:00:00.000Z", "cdg": 150, "fra": 159, "bos": 118, "lhr": 166, "yyz": 114, "lax": 147, "eze": 261, "otp": 277, "bog": 234, "gdl": 616, "dfw": 201, "sjc": 149, "phx": 217, "iad": 69, "ams": 209, "jnb": 418, "sin": 564, "ewr": 113, "bom": 565, "nrt": 346, "mia": 149, "mad": 532, "syd": 302, "sea": 196, "arn": 199, "hkg": 269, "ord": 179, "den": 204, "yul": 110, "atl": 181, "gru": 155, "gig": 245, "scl": 387 }, { "timestamp": "2025-08-23T08:00:00.000Z", "scl": 310, "gig": 355, "gru": 203, "atl": 138, "yul": 120, "den": 228, "ord": 184, "hkg": 367, "arn": 178, "sea": 182, "syd": 307, "mad": 322, "mia": 182, "nrt": 266, "bom": 325, "ewr": 139, "sin": 351, "jnb": 384, "ams": 258, "iad": 182, "phx": 202, "sjc": 117, "dfw": 221, "gdl": 407, "bog": 399, "otp": 272, "eze": 289, "lax": 171, "yyz": 137, "lhr": 135, "bos": 75, "fra": 147, "cdg": 145 }, { "timestamp": "2025-08-23T09:00:00.000Z", "fra": 332, "cdg": 203, "bos": 101, "lhr": 150, "yyz": 123, "eze": 334, "otp": 247, "lax": 141, "bog": 358, "dfw": 206, "gdl": 580, "phx": 186, "sjc": 215, "ams": 202, "iad": 187, "jnb": 361, "sin": 350, "ewr": 201, "bom": 565, "nrt": 272, "mia": 194, "mad": 342, "syd": 259, "arn": 222, "sea": 234, "den": 249, "ord": 186, "hkg": 394, "yul": 111, "atl": 112, "gru": 199, "gig": 207, "scl": 334 }, { "timestamp": "2025-08-23T10:00:00.000Z", "bom": 283, "ewr": 268, "sin": 320, "mad": 346, "syd": 306, "nrt": 266, "mia": 158, "atl": 175, "yul": 89, "sea": 164, "arn": 215, "hkg": 354, "ord": 131, "den": 219, "scl": 310, "gru": 207, "gig": 200, "bos": 114, "cdg": 155, "fra": 149, "yyz": 123, "lhr": 361, "lax": 167, "otp": 254, "eze": 294, "bog": 507, "iad": 187, "ams": 259, "jnb": 385, "gdl": 511, "dfw": 195, "sjc": 118, "phx": 192 }, { "timestamp": "2025-08-23T11:00:00.000Z", "bos": 131, "fra": 144, "cdg": 161, "yyz": 154, "lhr": 155, "bog": 457, "otp": 266, "eze": 484, "lax": 168, "jnb": 384, "ams": 197, "iad": 363, "phx": 164, "sjc": 126, "dfw": 153, "gdl": 529, "bom": 346, "ewr": 129, "sin": 422, "syd": 262, "mad": 358, "mia": 145, "nrt": 222, "atl": 110, "yul": 82, "den": 217, "ord": 182, "hkg": 270, "arn": 212, "sea": 192, "scl": 539, "gig": 250, "gru": 171 }, { "timestamp": "2025-08-23T12:00:00.000Z", "yyz": 389, "lhr": 434, "bos": 103, "cdg": 169, "fra": 180, "iad": 135, "ams": 186, "jnb": 454, "gdl": 618, "dfw": 188, "sjc": 131, "phx": 199, "lax": 144, "otp": 282, "eze": 308, "bog": 552, "mad": 344, "syd": 310, "nrt": 261, "mia": 174, "bom": 317, "ewr": 130, "sin": 501, "scl": 335, "gru": 162, "gig": 201, "atl": 164, "yul": 105, "sea": 208, "arn": 213, "hkg": 360, "ord": 139, "den": 255 }, { "timestamp": "2025-08-23T13:00:00.000Z", "gig": 227, "gru": 236, "scl": 504, "hkg": 272, "den": 494, "ord": 129, "sea": 197, "arn": 382, "atl": 175, "yul": 96, "mia": 160, "nrt": 264, "syd": 308, "mad": 326, "sin": 437, "bom": 590, "ewr": 181, "sjc": 119, "phx": 182, "gdl": 583, "dfw": 190, "jnb": 396, "iad": 127, "ams": 249, "bog": 360, "lax": 165, "otp": 297, "eze": 318, "lhr": 146, "yyz": 133, "cdg": 507, "fra": 447, "bos": 115 }, { "timestamp": "2025-08-23T14:00:00.000Z", "scl": 345, "gru": 194, "gig": 214, "yul": 93, "atl": 157, "arn": 204, "sea": 178, "den": 245, "ord": 80, "hkg": 344, "mad": 343, "syd": 329, "nrt": 227, "mia": 136, "ewr": 123, "bom": 314, "sin": 396, "ams": 188, "iad": 147, "jnb": 406, "dfw": 156, "gdl": 585, "phx": 179, "sjc": 130, "eze": 295, "otp": 247, "lax": 156, "bog": 400, "yyz": 189, "lhr": 152, "bos": 107, "fra": 222, "cdg": 276 }, { "timestamp": "2025-08-23T15:00:00.000Z", "gru": 165, "gig": 367, "scl": 332, "sea": 203, "arn": 194, "hkg": 631, "ord": 187, "den": 219, "yul": 93, "atl": 103, "nrt": 266, "mia": 141, "mad": 330, "syd": 298, "sin": 435, "ewr": 118, "bom": 293, "gdl": 448, "dfw": 229, "sjc": 125, "phx": 210, "iad": 140, "ams": 202, "jnb": 363, "lax": 168, "eze": 331, "otp": 254, "bog": 469, "lhr": 175, "yyz": 127, "cdg": 157, "fra": 141, "bos": 122 }, { "timestamp": "2025-08-23T16:00:00.000Z", "atl": 168, "yul": 101, "hkg": 360, "ord": 153, "den": 248, "sea": 185, "arn": 273, "scl": 363, "gig": 179, "gru": 196, "bom": 293, "ewr": 148, "sin": 584, "syd": 304, "mad": 332, "mia": 152, "nrt": 298, "bog": 302, "lax": 151, "otp": 286, "eze": 281, "jnb": 405, "iad": 97, "ams": 216, "sjc": 125, "phx": 202, "gdl": 496, "dfw": 203, "bos": 116, "cdg": 353, "fra": 137, "yyz": 121, "lhr": 150 }, { "timestamp": "2025-08-23T17:00:00.000Z", "cdg": 158, "fra": 151, "bos": 82, "lhr": 161, "yyz": 123, "lax": 142, "otp": 285, "eze": 241, "bog": 440, "gdl": 586, "dfw": 204, "sjc": 142, "phx": 224, "iad": 127, "ams": 205, "jnb": 362, "sin": 370, "bom": 318, "ewr": 119, "nrt": 311, "mia": 156, "mad": 379, "syd": 299, "sea": 190, "arn": 240, "hkg": 334, "den": 214, "ord": 128, "atl": 144, "yul": 129, "gru": 194, "gig": 235, "scl": 334 }, { "timestamp": "2025-08-23T18:00:00.000Z", "bog": 387, "lax": 157, "eze": 296, "otp": 304, "sjc": 133, "phx": 195, "gdl": 487, "dfw": 141, "jnb": 386, "iad": 150, "ams": 205, "cdg": 157, "fra": 152, "bos": 124, "lhr": 156, "yyz": 129, "hkg": 570, "ord": 433, "den": 249, "sea": 220, "arn": 213, "yul": 96, "atl": 140, "gig": 286, "gru": 172, "scl": 343, "sin": 356, "ewr": 118, "bom": 296, "mia": 166, "nrt": 258, "syd": 361, "mad": 326 }, { "timestamp": "2025-08-23T19:00:00.000Z", "arn": 530, "sea": 323, "den": 212, "ord": 80, "hkg": 332, "yul": 110, "atl": 134, "gru": 178, "gig": 244, "scl": 392, "sin": 377, "ewr": 119, "bom": 542, "nrt": 264, "mia": 145, "mad": 342, "syd": 303, "eze": 465, "otp": 264, "lax": 167, "bog": 438, "dfw": 146, "gdl": 530, "phx": 196, "sjc": 134, "ams": 211, "iad": 111, "jnb": 420, "fra": 155, "cdg": 296, "bos": 81, "lhr": 152, "yyz": 141 }, { "timestamp": "2025-08-23T20:00:00.000Z", "yul": 102, "atl": 172, "hkg": 332, "den": 245, "ord": 167, "sea": 192, "arn": 200, "scl": 513, "gig": 231, "gru": 207, "ewr": 125, "bom": 324, "sin": 465, "syd": 299, "mad": 338, "mia": 170, "nrt": 261, "bog": 478, "lax": 204, "eze": 269, "otp": 284, "jnb": 359, "iad": 540, "ams": 193, "sjc": 121, "phx": 197, "gdl": 593, "dfw": 206, "bos": 102, "cdg": 172, "fra": 210, "yyz": 130, "lhr": 245 }, { "timestamp": "2025-08-23T21:00:00.000Z", "yyz": 137, "lhr": 154, "bos": 92, "cdg": 157, "fra": 151, "iad": 41, "ams": 198, "jnb": 407, "gdl": 470, "dfw": 244, "sjc": 126, "phx": 207, "lax": 156, "eze": 491, "otp": 316, "bog": 538, "mad": 312, "syd": 314, "nrt": 275, "mia": 141, "ewr": 117, "bom": 292, "sin": 389, "scl": 339, "gru": 218, "gig": 359, "yul": 104, "atl": 171, "sea": 186, "arn": 229, "hkg": 291, "den": 232, "ord": 213 }, { "timestamp": "2025-08-23T22:00:00.000Z", "nrt": 264, "mia": 146, "mad": 376, "syd": 324, "sin": 361, "bom": 331, "ewr": 130, "gru": 151, "gig": 239, "scl": 286, "arn": 219, "sea": 197, "ord": 145, "den": 236, "hkg": 558, "atl": 149, "yul": 82, "lhr": 152, "yyz": 136, "fra": 150, "cdg": 174, "bos": 85, "dfw": 204, "gdl": 535, "phx": 195, "sjc": 126, "ams": 245, "iad": 145, "jnb": 347, "otp": 241, "eze": 538, "lax": 149, "bog": 236 }, { "timestamp": "2025-08-23T23:00:00.000Z", "yyz": 134, "lhr": 160, "bos": 103, "cdg": 166, "fra": 165, "jnb": 381, "iad": 104, "ams": 198, "sjc": 126, "phx": 198, "gdl": 479, "dfw": 143, "bog": 359, "lax": 150, "otp": 251, "eze": 269, "syd": 298, "mad": 345, "mia": 192, "nrt": 258, "bom": 301, "ewr": 203, "sin": 433, "scl": 507, "gig": 175, "gru": 196, "atl": 147, "yul": 119, "hkg": 278, "ord": 131, "den": 252, "sea": 186, "arn": 221 }, { "timestamp": "2025-08-24T00:00:00.000Z", "lhr": 229, "yyz": 128, "fra": 151, "cdg": 147, "bos": 107, "phx": 185, "sjc": 133, "dfw": 200, "gdl": 615, "jnb": 475, "ams": 230, "iad": 191, "bog": 708, "eze": 251, "otp": 298, "lax": 148, "mia": 177, "nrt": 267, "syd": 321, "mad": 346, "sin": 365, "ewr": 126, "bom": 304, "gig": 397, "gru": 175, "scl": 338, "den": 242, "ord": 128, "hkg": 274, "arn": 248, "sea": 177, "yul": 114, "atl": 149 }, { "timestamp": "2025-08-24T01:00:00.000Z", "ord": 165, "den": 257, "hkg": 343, "arn": 194, "sea": 187, "atl": 148, "yul": 112, "gig": 230, "gru": 220, "scl": 333, "sin": 441, "bom": 296, "ewr": 60, "mia": 185, "nrt": 457, "syd": 299, "mad": 330, "bog": 456, "otp": 250, "eze": 230, "lax": 172, "phx": 209, "sjc": 121, "dfw": 123, "gdl": 468, "jnb": 359, "ams": 190, "iad": 206, "fra": 152, "cdg": 174, "bos": 110, "lhr": 161, "yyz": 136 }, { "timestamp": "2025-08-24T02:00:00.000Z", "lax": 163, "otp": 256, "eze": 656, "bog": 577, "iad": 494, "ams": 184, "jnb": 390, "gdl": 491, "dfw": 217, "sjc": 121, "phx": 248, "bos": 97, "cdg": 164, "fra": 159, "yyz": 128, "lhr": 141, "atl": 151, "yul": 101, "sea": 171, "arn": 211, "hkg": 297, "ord": 152, "den": 232, "scl": 306, "gru": 198, "gig": 224, "bom": 335, "ewr": 120, "sin": 544, "mad": 347, "syd": 315, "nrt": 261, "mia": 149 }, { "timestamp": "2025-08-24T03:00:00.000Z", "nrt": 265, "mia": 121, "mad": 347, "syd": 305, "sin": 599, "bom": 322, "ewr": 125, "gru": 186, "gig": 182, "scl": 347, "sea": 195, "arn": 237, "hkg": 330, "den": 468, "ord": 191, "atl": 138, "yul": 81, "lhr": 140, "yyz": 255, "cdg": 154, "fra": 136, "bos": 107, "gdl": 500, "dfw": 213, "sjc": 120, "phx": 196, "iad": 137, "ams": 180, "jnb": 382, "lax": 255, "otp": 277, "eze": 296, "bog": 484 }, { "timestamp": "2025-08-24T04:00:00.000Z", "gru": 488, "gig": 204, "scl": 300, "sea": 216, "arn": 223, "hkg": 341, "ord": 123, "den": 229, "yul": 98, "atl": 192, "nrt": 290, "mia": 123, "mad": 432, "syd": 308, "sin": 745, "ewr": 155, "bom": 298, "gdl": 1142, "dfw": 194, "sjc": 127, "phx": 190, "iad": 174, "ams": 160, "jnb": 363, "lax": 156, "eze": 314, "otp": 263, "bog": 369, "lhr": 166, "yyz": 137, "cdg": 169, "fra": 188, "bos": 102 }, { "timestamp": "2025-08-24T05:00:00.000Z", "yyz": 127, "lhr": 154, "bos": 109, "cdg": 163, "fra": 156, "iad": 106, "ams": 249, "jnb": 377, "gdl": 487, "dfw": 190, "sjc": 133, "phx": 207, "lax": 137, "otp": 337, "eze": 326, "bog": 370, "mad": 336, "syd": 304, "nrt": 225, "mia": 183, "bom": 307, "ewr": 127, "sin": 575, "scl": 478, "gru": 168, "gig": 379, "atl": 148, "yul": 118, "sea": 235, "arn": 232, "hkg": 362, "ord": 132, "den": 206 }, { "timestamp": "2025-08-24T06:00:00.000Z", "bom": 387, "ewr": 120, "sin": 396, "syd": 284, "mad": 329, "mia": 151, "nrt": 275, "atl": 117, "yul": 98, "den": 269, "ord": 112, "hkg": 334, "arn": 225, "sea": 173, "scl": 285, "gig": 234, "gru": 196, "bos": 156, "fra": 138, "cdg": 160, "yyz": 135, "lhr": 170, "bog": 439, "otp": 281, "eze": 254, "lax": 134, "jnb": 355, "ams": 207, "iad": 166, "phx": 188, "sjc": 116, "dfw": 206, "gdl": 665 }, { "timestamp": "2025-08-24T07:00:00.000Z", "bog": 384, "eze": 348, "otp": 265, "lax": 150, "phx": 200, "sjc": 118, "dfw": 348, "gdl": 629, "jnb": 344, "ams": 242, "iad": 197, "fra": 141, "cdg": 170, "bos": 99, "lhr": 146, "yyz": 136, "den": 255, "ord": 144, "hkg": 264, "arn": 239, "sea": 232, "yul": 112, "atl": 135, "gig": 340, "gru": 339, "scl": 373, "sin": 496, "ewr": 116, "bom": 287, "mia": 207, "nrt": 219, "syd": 301, "mad": 344 }, { "timestamp": "2025-08-24T08:00:00.000Z", "cdg": 159, "fra": 144, "bos": 98, "lhr": 149, "yyz": 138, "bog": 417, "lax": 141, "eze": 475, "otp": 248, "sjc": 120, "phx": 184, "gdl": 592, "dfw": 161, "jnb": 388, "iad": 131, "ams": 190, "sin": 496, "ewr": 151, "bom": 558, "mia": 207, "nrt": 457, "syd": 305, "mad": 326, "hkg": 350, "ord": 125, "den": 249, "sea": 160, "arn": 218, "yul": 88, "atl": 143, "gig": 211, "gru": 188, "scl": 406 }, { "timestamp": "2025-08-24T09:00:00.000Z", "atl": 122, "yul": 106, "hkg": 334, "ord": 123, "den": 238, "sea": 183, "arn": 196, "scl": 334, "gig": 202, "gru": 204, "bom": 279, "ewr": 185, "sin": 695, "syd": 345, "mad": 333, "mia": 175, "nrt": 225, "bog": 384, "lax": 168, "otp": 286, "eze": 302, "jnb": 363, "iad": 168, "ams": 212, "sjc": 118, "phx": 178, "gdl": 576, "dfw": 225, "bos": 104, "cdg": 315, "fra": 159, "yyz": 124, "lhr": 274 }, { "timestamp": "2025-08-24T10:00:00.000Z", "ams": 187, "iad": 146, "jnb": 410, "dfw": 181, "gdl": 459, "phx": 179, "sjc": 115, "otp": 237, "eze": 293, "lax": 147, "bog": 724, "yyz": 124, "lhr": 150, "bos": 114, "fra": 142, "cdg": 177, "scl": 393, "gru": 233, "gig": 373, "atl": 178, "yul": 101, "arn": 198, "sea": 194, "den": 239, "ord": 94, "hkg": 461, "mad": 350, "syd": 305, "nrt": 273, "mia": 148, "bom": 297, "ewr": 295, "sin": 503 }, { "timestamp": "2025-08-24T11:00:00.000Z", "nrt": 258, "mia": 132, "mad": 334, "syd": 260, "sin": 528, "ewr": 176, "bom": 308, "gru": 190, "gig": 198, "scl": 533, "arn": 232, "sea": 166, "den": 186, "ord": 170, "hkg": 362, "yul": 86, "atl": 158, "lhr": 154, "yyz": 141, "fra": 142, "cdg": 444, "bos": 93, "dfw": 159, "gdl": 585, "phx": 194, "sjc": 125, "ams": 203, "iad": 203, "jnb": 363, "eze": 316, "otp": 313, "lax": 148, "bog": 363 }, { "timestamp": "2025-08-24T12:00:00.000Z", "gru": 186, "gig": 348, "scl": 331, "arn": 199, "sea": 179, "ord": 131, "den": 265, "hkg": 333, "atl": 196, "yul": 98, "nrt": 278, "mia": 151, "mad": 355, "syd": 348, "sin": 364, "bom": 592, "ewr": 136, "dfw": 200, "gdl": 517, "phx": 197, "sjc": 129, "ams": 207, "iad": 192, "jnb": 410, "otp": 241, "eze": 474, "lax": 160, "bog": 391, "lhr": 153, "yyz": 130, "fra": 141, "cdg": 289, "bos": 206 }, { "timestamp": "2025-08-24T13:00:00.000Z", "lax": 143, "otp": 287, "eze": 478, "bog": 351, "gdl": 749, "dfw": 187, "sjc": 140, "phx": 209, "iad": 130, "ams": 191, "jnb": 342, "cdg": 420, "fra": 183, "bos": 109, "lhr": 148, "yyz": 136, "sea": 234, "arn": 352, "hkg": 333, "ord": 131, "den": 248, "atl": 114, "yul": 107, "gru": 185, "gig": 207, "scl": 548, "sin": 610, "bom": 319, "ewr": 176, "nrt": 276, "mia": 206, "mad": 341, "syd": 302 }, { "timestamp": "2025-08-24T14:00:00.000Z", "atl": 172, "yul": 105, "ord": 153, "den": 264, "hkg": 300, "arn": 217, "sea": 188, "scl": 383, "gig": 245, "gru": 191, "bom": 297, "ewr": 126, "sin": 499, "syd": 462, "mad": 355, "mia": 149, "nrt": 223, "bog": 521, "otp": 243, "eze": 313, "lax": 138, "jnb": 384, "ams": 190, "iad": 151, "phx": 198, "sjc": 132, "dfw": 178, "gdl": 512, "bos": 90, "fra": 149, "cdg": 274, "yyz": 117, "lhr": 154 }, { "timestamp": "2025-08-24T15:00:00.000Z", "yyz": 162, "lhr": 351, "bos": 88, "fra": 142, "cdg": 199, "jnb": 363, "ams": 191, "iad": 229, "phx": 197, "sjc": 128, "dfw": 174, "gdl": 488, "bog": 383, "eze": 269, "otp": 250, "lax": 144, "syd": 345, "mad": 349, "mia": 182, "nrt": 229, "ewr": 122, "bom": 301, "sin": 447, "scl": 354, "gig": 339, "gru": 363, "yul": 107, "atl": 211, "den": 241, "ord": 127, "hkg": 349, "arn": 206, "sea": 172 }, { "timestamp": "2025-08-24T16:00:00.000Z", "fra": 148, "cdg": 485, "bos": 82, "lhr": 154, "yyz": 128, "eze": 302, "otp": 302, "lax": 140, "bog": 325, "dfw": 132, "gdl": 594, "phx": 200, "sjc": 122, "ams": 184, "iad": 148, "jnb": 390, "sin": 413, "ewr": 129, "bom": 316, "nrt": 257, "mia": 127, "mad": 354, "syd": 331, "arn": 218, "sea": 209, "den": 284, "ord": 152, "hkg": 498, "yul": 109, "atl": 144, "gru": 170, "gig": 428, "scl": 331 }, { "timestamp": "2025-08-24T17:00:00.000Z", "bog": 441, "otp": 287, "eze": 326, "lax": 153, "jnb": 393, "ams": 177, "iad": 47, "phx": 178, "sjc": 452, "dfw": 228, "gdl": 583, "bos": 96, "fra": 161, "cdg": 150, "yyz": 163, "lhr": 197, "atl": 180, "yul": 88, "ord": 183, "den": 284, "hkg": 461, "arn": 214, "sea": 225, "scl": 334, "gig": 234, "gru": 218, "bom": 297, "ewr": 185, "sin": 514, "syd": 312, "mad": 357, "mia": 168, "nrt": 455 }, { "timestamp": "2025-08-24T18:00:00.000Z", "sea": 193, "arn": 345, "hkg": 269, "ord": 156, "den": 224, "atl": 182, "yul": 102, "gru": 324, "gig": 393, "scl": 314, "sin": 354, "bom": 308, "ewr": 134, "nrt": 740, "mia": 157, "mad": 345, "syd": 302, "lax": 143, "otp": 286, "eze": 323, "bog": 520, "gdl": 469, "dfw": 123, "sjc": 116, "phx": 180, "iad": 135, "ams": 215, "jnb": 337, "cdg": 149, "fra": 148, "bos": 92, "lhr": 174, "yyz": 125 }, { "timestamp": "2025-08-24T19:00:00.000Z", "lhr": 144, "yyz": 119, "cdg": 179, "fra": 154, "bos": 94, "gdl": 497, "dfw": 161, "sjc": 128, "phx": 190, "iad": 129, "ams": 193, "jnb": 370, "lax": 166, "eze": 340, "otp": 257, "bog": 393, "nrt": 543, "mia": 154, "mad": 334, "syd": 470, "sin": 470, "ewr": 58, "bom": 312, "gru": 162, "gig": 216, "scl": 343, "sea": 191, "arn": 199, "hkg": 348, "den": 268, "ord": 213, "yul": 101, "atl": 175 }, { "timestamp": "2025-08-24T20:00:00.000Z", "gdl": 595, "dfw": 200, "sjc": 132, "phx": 306, "iad": 296, "ams": 216, "jnb": 379, "lax": 181, "otp": 303, "eze": 461, "bog": 505, "lhr": 155, "yyz": 150, "cdg": 159, "fra": 149, "bos": 125, "gru": 165, "gig": 250, "scl": 337, "sea": 202, "arn": 204, "hkg": 279, "ord": 198, "den": 279, "atl": 151, "yul": 118, "nrt": 266, "mia": 151, "mad": 362, "syd": 298, "sin": 324, "bom": 320, "ewr": 125 }, { "timestamp": "2025-08-24T21:00:00.000Z", "mad": 329, "syd": 309, "nrt": 254, "mia": 188, "ewr": 132, "bom": 301, "sin": 358, "scl": 288, "gru": 181, "gig": 226, "yul": 92, "atl": 156, "sea": 210, "arn": 185, "hkg": 285, "ord": 138, "den": 555, "yyz": 161, "lhr": 169, "bos": 104, "cdg": 166, "fra": 142, "iad": 117, "ams": 200, "jnb": 373, "gdl": 470, "dfw": 532, "sjc": 123, "phx": 177, "lax": 138, "eze": 444, "otp": 271, "bog": 503 }, { "timestamp": "2025-08-24T22:00:00.000Z", "fra": 154, "cdg": 166, "bos": 123, "lhr": 162, "yyz": 193, "bog": 382, "otp": 277, "eze": 269, "lax": 150, "phx": 198, "sjc": 142, "dfw": 185, "gdl": 593, "jnb": 571, "ams": 199, "iad": 133, "sin": 425, "bom": 321, "ewr": 125, "mia": 174, "nrt": 232, "syd": 310, "mad": 348, "den": 267, "ord": 142, "hkg": 410, "arn": 213, "sea": 200, "atl": 137, "yul": 97, "gig": 358, "gru": 377, "scl": 572 }, { "timestamp": "2025-08-24T23:00:00.000Z", "yul": 97, "atl": 127, "den": 214, "ord": 135, "hkg": 334, "arn": 245, "sea": 175, "scl": 362, "gig": 389, "gru": 210, "ewr": 148, "bom": 301, "sin": 432, "syd": 312, "mad": 351, "mia": 133, "nrt": 284, "bog": 424, "eze": 275, "otp": 282, "lax": 200, "jnb": 348, "ams": 203, "iad": 98, "phx": 195, "sjc": 130, "dfw": 200, "gdl": 620, "bos": 104, "fra": 159, "cdg": 404, "yyz": 145, "lhr": 166 }, { "timestamp": "2025-08-25T00:00:00.000Z", "lhr": 146, "yyz": 129, "cdg": 156, "fra": 213, "bos": 114, "gdl": 498, "dfw": 232, "sjc": 120, "phx": 226, "iad": 139, "ams": 193, "jnb": 373, "lax": 155, "eze": 312, "otp": 221, "bog": 511, "nrt": 319, "mia": 124, "mad": 331, "syd": 272, "sin": 432, "ewr": 143, "bom": 266, "gru": 187, "gig": 395, "scl": 317, "sea": 226, "arn": 193, "hkg": 403, "ord": 127, "den": 232, "yul": 98, "atl": 165 }, { "timestamp": "2025-08-25T01:00:00.000Z", "scl": 540, "gru": 189, "gig": 203, "atl": 126, "yul": 104, "sea": 169, "arn": 208, "hkg": 343, "ord": 128, "den": 216, "mad": 330, "syd": 303, "nrt": 271, "mia": 193, "bom": 279, "ewr": 190, "sin": 767, "iad": 338, "ams": 207, "jnb": 383, "gdl": 580, "dfw": 274, "sjc": 108, "phx": 199, "lax": 173, "otp": 320, "eze": 304, "bog": 375, "yyz": 224, "lhr": 156, "bos": 400, "cdg": 172, "fra": 145 }, { "timestamp": "2025-08-25T02:00:00.000Z", "gru": 231, "gig": 383, "scl": 353, "arn": 208, "sea": 184, "den": 566, "ord": 90, "hkg": 377, "atl": 205, "yul": 96, "nrt": 279, "mia": 156, "mad": 352, "syd": 312, "sin": 684, "bom": 317, "ewr": 78, "dfw": 232, "gdl": 461, "phx": 220, "sjc": 125, "ams": 222, "iad": 135, "jnb": 354, "otp": 240, "eze": 275, "lax": 165, "bog": 494, "lhr": 166, "yyz": 137, "fra": 143, "cdg": 156, "bos": 114 }, { "timestamp": "2025-08-25T03:00:00.000Z", "yyz": 124, "lhr": 166, "bos": 100, "fra": 187, "cdg": 202, "ams": 204, "iad": 199, "jnb": 415, "dfw": 192, "gdl": 634, "phx": 182, "sjc": 134, "eze": 321, "otp": 275, "lax": 169, "bog": 378, "mad": 353, "syd": 332, "nrt": 277, "mia": 128, "ewr": 79, "bom": 305, "sin": 354, "scl": 368, "gru": 296, "gig": 243, "yul": 96, "atl": 278, "arn": 226, "sea": 192, "den": 221, "ord": 158, "hkg": 263 }, { "timestamp": "2025-08-25T04:00:00.000Z", "ams": 220, "iad": 180, "jnb": 573, "dfw": 214, "gdl": 592, "phx": 198, "sjc": 157, "otp": 317, "eze": 267, "lax": 134, "bog": 307, "yyz": 120, "lhr": 157, "bos": 107, "fra": 161, "cdg": 148, "scl": 345, "gru": 178, "gig": 325, "atl": 148, "yul": 96, "arn": 244, "sea": 173, "ord": 150, "den": 235, "hkg": 285, "mad": 333, "syd": 266, "nrt": 311, "mia": 198, "bom": 294, "ewr": 122, "sin": 839 }, { "timestamp": "2025-08-25T05:00:00.000Z", "ewr": 116, "bom": 602, "sin": 702, "mad": 330, "syd": 344, "nrt": 215, "mia": 109, "yul": 97, "atl": 139, "arn": 194, "sea": 187, "den": 269, "ord": 127, "hkg": 332, "scl": 326, "gru": 179, "gig": 228, "bos": 93, "fra": 153, "cdg": 148, "yyz": 150, "lhr": 147, "eze": 309, "otp": 252, "lax": 185, "bog": 355, "ams": 368, "iad": 173, "jnb": 366, "dfw": 204, "gdl": 472, "phx": 181, "sjc": 121 }, { "timestamp": "2025-08-25T06:00:00.000Z", "cdg": 179, "fra": 155, "bos": 145, "lhr": 138, "yyz": 154, "bog": 500, "lax": 171, "eze": 336, "otp": 249, "sjc": 123, "phx": 181, "gdl": 615, "dfw": 208, "jnb": 368, "iad": 172, "ams": 198, "sin": 425, "ewr": 112, "bom": 556, "mia": 149, "nrt": 213, "syd": 309, "mad": 334, "hkg": 346, "den": 205, "ord": 129, "sea": 175, "arn": 209, "yul": 95, "atl": 113, "gig": 440, "gru": 207, "scl": 549 }, { "timestamp": "2025-08-25T07:00:00.000Z", "sin": 429, "ewr": 116, "bom": 565, "mia": 240, "nrt": 267, "syd": 259, "mad": 339, "hkg": 280, "ord": 135, "den": 243, "sea": 192, "arn": 328, "yul": 111, "atl": 128, "gig": 243, "gru": 320, "scl": 479, "cdg": 162, "fra": 278, "bos": 102, "lhr": 163, "yyz": 127, "bog": 443, "lax": 149, "eze": 278, "otp": 262, "sjc": 117, "phx": 196, "gdl": 630, "dfw": 181, "jnb": 378, "iad": 105, "ams": 195 }, { "timestamp": "2025-08-25T08:00:00.000Z", "bom": 310, "ewr": 124, "sin": 727, "syd": 300, "mad": 324, "mia": 149, "nrt": 456, "atl": 201, "yul": 103, "den": 228, "ord": 123, "hkg": 276, "arn": 262, "sea": 203, "scl": 349, "gig": 384, "gru": 239, "bos": 95, "fra": 145, "cdg": 469, "yyz": 142, "lhr": 189, "bog": 361, "otp": 238, "eze": 227, "lax": 143, "jnb": 408, "ams": 190, "iad": 105, "phx": 212, "sjc": 123, "dfw": 242, "gdl": 339 }, { "timestamp": "2025-08-25T09:00:00.000Z", "syd": 355, "mad": 346, "mia": 135, "nrt": 268, "bom": 586, "ewr": 178, "sin": 756, "scl": 354, "gig": 228, "gru": 210, "atl": 129, "yul": 98, "hkg": 429, "den": 261, "ord": 298, "sea": 172, "arn": 371, "yyz": 111, "lhr": 158, "bos": 136, "cdg": 632, "fra": 149, "jnb": 362, "iad": 195, "ams": 191, "sjc": 126, "phx": 487, "gdl": 611, "dfw": 249, "bog": 376, "lax": 137, "otp": 258, "eze": 276 }, { "timestamp": "2025-08-25T10:00:00.000Z", "lhr": 150, "yyz": 120, "fra": 146, "cdg": 307, "bos": 185, "phx": 182, "sjc": 144, "dfw": 234, "gdl": 587, "jnb": 383, "ams": 235, "iad": 199, "bog": 355, "eze": 289, "otp": 272, "lax": 161, "mia": 139, "nrt": 302, "syd": 352, "mad": 354, "sin": 412, "ewr": 56, "bom": 296, "gig": 346, "gru": 181, "scl": 530, "ord": 169, "den": 197, "hkg": 392, "arn": 352, "sea": 196, "yul": 104, "atl": 131 }, { "timestamp": "2025-08-25T11:00:00.000Z", "jnb": 443, "ams": 179, "iad": 177, "phx": 182, "sjc": 117, "dfw": 167, "gdl": 511, "bog": 414, "eze": 277, "otp": 286, "lax": 135, "yyz": 154, "lhr": 149, "bos": 140, "fra": 186, "cdg": 164, "scl": 401, "gig": 355, "gru": 192, "yul": 88, "atl": 118, "ord": 123, "den": 307, "hkg": 337, "arn": 224, "sea": 161, "syd": 301, "mad": 319, "mia": 161, "nrt": 259, "ewr": 62, "bom": 293, "sin": 560 }, { "timestamp": "2025-08-25T12:00:00.000Z", "gdl": 679, "dfw": 122, "sjc": 124, "phx": 204, "iad": 206, "ams": 187, "jnb": 376, "lax": 132, "eze": 645, "otp": 246, "bog": 511, "lhr": 156, "yyz": 517, "cdg": 171, "fra": 163, "bos": 134, "gru": 522, "gig": 493, "scl": 763, "sea": 166, "arn": 201, "hkg": 273, "ord": 139, "den": 242, "yul": 113, "atl": 135, "nrt": 301, "mia": 141, "mad": 337, "syd": 304, "sin": 411, "ewr": 185, "bom": 319 }, { "timestamp": "2025-08-25T13:00:00.000Z", "gig": 492, "gru": 208, "scl": 390, "den": 244, "ord": 127, "hkg": 278, "arn": 383, "sea": 182, "yul": 109, "atl": 93, "mia": 163, "nrt": 265, "syd": 343, "mad": 345, "sin": 584, "ewr": 121, "bom": 302, "phx": 187, "sjc": 123, "dfw": 227, "gdl": 496, "jnb": 358, "ams": 205, "iad": 218, "bog": 323, "eze": 453, "otp": 359, "lax": 215, "lhr": 159, "yyz": 138, "fra": 342, "cdg": 174, "bos": 132 }, { "timestamp": "2025-08-25T14:00:00.000Z", "bog": 509, "lax": 158, "otp": 310, "eze": 615, "sjc": 162, "phx": 198, "gdl": 478, "dfw": 237, "jnb": 419, "iad": 135, "ams": 201, "cdg": 313, "fra": 153, "bos": 111, "lhr": 150, "yyz": 135, "hkg": 338, "den": 267, "ord": 150, "sea": 207, "arn": 219, "atl": 235, "yul": 146, "gig": 387, "gru": 209, "scl": 342, "sin": 502, "bom": 284, "ewr": 125, "mia": 191, "nrt": 261, "syd": 298, "mad": 357 }, { "timestamp": "2025-08-25T15:00:00.000Z", "bom": 309, "ewr": 113, "sin": 467, "mad": 327, "syd": 309, "nrt": 214, "mia": 157, "atl": 217, "yul": 118, "sea": 178, "arn": 222, "hkg": 330, "ord": 149, "den": 229, "scl": 285, "gru": 255, "gig": 375, "bos": 109, "cdg": 157, "fra": 148, "yyz": 157, "lhr": 145, "lax": 158, "otp": 254, "eze": 229, "bog": 310, "iad": 146, "ams": 216, "jnb": 410, "gdl": 577, "dfw": 461, "sjc": 125, "phx": 208 } ] }, "metricsByRegion": [ { "region": "mad", "count": 168, "ok": 168, "p50Latency": 340, "p75Latency": 353, "p90Latency": 372, "p95Latency": 440, "p99Latency": 683 }, { "region": "bos", "count": 168, "ok": 168, "p50Latency": 111, "p75Latency": 131, "p90Latency": 150, "p95Latency": 186, "p99Latency": 401 }, { "region": "sin", "count": 168, "ok": 168, "p50Latency": 444, "p75Latency": 574, "p90Latency": 712, "p95Latency": 794, "p99Latency": 958 }, { "region": "lhr", "count": 169, "ok": 169, "p50Latency": 159, "p75Latency": 170, "p90Latency": 243, "p95Latency": 307, "p99Latency": 431 }, { "region": "yul", "count": 168, "ok": 168, "p50Latency": 108, "p75Latency": 119, "p90Latency": 131, "p95Latency": 140, "p99Latency": 182 }, { "region": "dfw", "count": 168, "ok": 168, "p50Latency": 199, "p75Latency": 227, "p90Latency": 268, "p95Latency": 401, "p99Latency": 512 }, { "region": "phx", "count": 168, "ok": 168, "p50Latency": 192, "p75Latency": 204, "p90Latency": 224, "p95Latency": 239, "p99Latency": 401 }, { "region": "gig", "count": 168, "ok": 168, "p50Latency": 250, "p75Latency": 376, "p90Latency": 398, "p95Latency": 468, "p99Latency": 548 }, { "region": "bog", "count": 168, "ok": 168, "p50Latency": 416, "p75Latency": 488, "p90Latency": 520, "p95Latency": 571, "p99Latency": 732 }, { "region": "sea", "count": 168, "ok": 168, "p50Latency": 190, "p75Latency": 202, "p90Latency": 217, "p95Latency": 232, "p99Latency": 364 }, { "region": "syd", "count": 168, "ok": 168, "p50Latency": 306, "p75Latency": 318, "p90Latency": 347, "p95Latency": 365, "p99Latency": 472 }, { "region": "gru", "count": 168, "ok": 168, "p50Latency": 196, "p75Latency": 220, "p90Latency": 326, "p95Latency": 376, "p99Latency": 483 }, { "region": "scl", "count": 168, "ok": 168, "p50Latency": 344, "p75Latency": 392, "p90Latency": 517, "p95Latency": 541, "p99Latency": 595 }, { "region": "ewr", "count": 168, "ok": 168, "p50Latency": 129, "p75Latency": 181, "p90Latency": 200, "p95Latency": 246, "p99Latency": 370 }, { "region": "mia", "count": 168, "ok": 168, "p50Latency": 161, "p75Latency": 187, "p90Latency": 221, "p95Latency": 238, "p99Latency": 337 }, { "region": "iad", "count": 168, "ok": 168, "p50Latency": 136, "p75Latency": 183, "p90Latency": 233, "p95Latency": 374, "p99Latency": 507 }, { "region": "ams", "count": 168, "ok": 168, "p50Latency": 200, "p75Latency": 212, "p90Latency": 233, "p95Latency": 249, "p99Latency": 310 }, { "region": "arn", "count": 168, "ok": 168, "p50Latency": 222, "p75Latency": 246, "p90Latency": 352, "p95Latency": 396, "p99Latency": 550 }, { "region": "atl", "count": 168, "ok": 168, "p50Latency": 154, "p75Latency": 178, "p90Latency": 210, "p95Latency": 236, "p99Latency": 376 }, { "region": "yyz", "count": 168, "ok": 168, "p50Latency": 136, "p75Latency": 150, "p90Latency": 175, "p95Latency": 230, "p99Latency": 431 }, { "region": "sjc", "count": 168, "ok": 168, "p50Latency": 128, "p75Latency": 139, "p90Latency": 156, "p95Latency": 181, "p99Latency": 316 }, { "region": "den", "count": 168, "ok": 168, "p50Latency": 246, "p75Latency": 270, "p90Latency": 304, "p95Latency": 458, "p99Latency": 552 }, { "region": "nrt", "count": 168, "ok": 168, "p50Latency": 270, "p75Latency": 320, "p90Latency": 457, "p95Latency": 498, "p99Latency": 692 }, { "region": "cdg", "count": 168, "ok": 168, "p50Latency": 169, "p75Latency": 277, "p90Latency": 343, "p95Latency": 436, "p99Latency": 578 }, { "region": "lax", "count": 168, "ok": 168, "p50Latency": 152, "p75Latency": 165, "p90Latency": 179, "p95Latency": 211, "p99Latency": 293 }, { "region": "ord", "count": 167, "ok": 167, "p50Latency": 144, "p75Latency": 181, "p90Latency": 195, "p95Latency": 212, "p99Latency": 331 }, { "region": "gdl", "count": 168, "ok": 168, "p50Latency": 582, "p75Latency": 619, "p90Latency": 664, "p95Latency": 739, "p99Latency": 852 }, { "region": "bom", "count": 168, "ok": 168, "p50Latency": 308, "p75Latency": 328, "p90Latency": 560, "p95Latency": 589, "p99Latency": 650 }, { "region": "eze", "count": 168, "ok": 168, "p50Latency": 308, "p75Latency": 415, "p90Latency": 497, "p95Latency": 535, "p99Latency": 649 }, { "region": "hkg", "count": 168, "ok": 168, "p50Latency": 336, "p75Latency": 357, "p90Latency": 429, "p95Latency": 535, "p99Latency": 679 }, { "region": "fra", "count": 168, "ok": 168, "p50Latency": 153, "p75Latency": 163, "p90Latency": 216, "p95Latency": 338, "p99Latency": 408 }, { "region": "otp", "count": 168, "ok": 168, "p50Latency": 273, "p75Latency": 288, "p90Latency": 315, "p95Latency": 325, "p99Latency": 368 }, { "region": "jnb", "count": 168, "ok": 168, "p50Latency": 374, "p75Latency": 391, "p90Latency": 414, "p95Latency": 483, "p99Latency": 636 } ] } ================================================ FILE: apps/web/public/assets/posts/hono-vercel-fluid-compute/hono-warm.json ================================================ { "regions": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "scl", "sjc", "sea", "sin", "syd", "yul", "yyz" ], "data": { "regions": [ "ams", "arn", "atl", "bog", "bom", "bos", "cdg", "den", "dfw", "ewr", "eze", "fra", "gdl", "gig", "gru", "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "otp", "phx", "scl", "sea", "sin", "sjc", "syd", "yul", "yyz" ], "data": [ { "timestamp": "2025-08-18T15:00:00.000Z", "ewr": 134, "bom": 340, "scl": 508, "hkg": 386, "eze": 450, "mad": 328, "sin": 503, "arn": 197, "bos": 136, "iad": 132, "mia": 234, "nrt": 288, "gig": 347, "atl": 144, "cdg": 255, "sjc": 136, "dfw": 176, "ams": 197, "otp": 276, "lax": 320, "syd": 428, "gru": 263, "den": 293, "ord": 175, "sea": 155, "yul": 131, "lhr": 145, "yyz": 228, "fra": 218, "phx": 190, "gdl": 502, "jnb": 610, "bog": 417 }, { "timestamp": "2025-08-18T15:30:00.000Z", "bos": 113, "iad": 195, "mad": 353, "sin": 415, "arn": 202, "eze": 456, "ewr": 140, "bom": 296, "scl": 491, "hkg": 385, "yyz": 167, "lhr": 206, "fra": 170, "jnb": 494, "phx": 213, "gdl": 587, "bog": 423, "syd": 340, "gru": 278, "yul": 116, "ord": 140, "den": 233, "sea": 165, "cdg": 160, "ams": 183, "sjc": 205, "dfw": 259, "lax": 159, "otp": 283, "mia": 265, "nrt": 430, "gig": 348, "atl": 146 }, { "timestamp": "2025-08-18T16:00:00.000Z", "cdg": 169, "ams": 193, "dfw": 174, "sjc": 149, "lax": 166, "otp": 302, "nrt": 357, "mia": 168, "gig": 351, "atl": 160, "yyz": 147, "lhr": 335, "fra": 172, "jnb": 514, "gdl": 644, "phx": 196, "bog": 395, "syd": 420, "gru": 231, "yul": 111, "sea": 180, "den": 232, "ord": 170, "eze": 423, "bom": 302, "ewr": 144, "scl": 498, "hkg": 455, "bos": 124, "iad": 140, "mad": 358, "sin": 413, "arn": 199 }, { "timestamp": "2025-08-18T16:30:00.000Z", "hkg": 337, "scl": 382, "bom": 292, "ewr": 173, "eze": 458, "arn": 209, "sin": 488, "mad": 414, "iad": 135, "bos": 118, "atl": 120, "gig": 350, "nrt": 461, "mia": 211, "otp": 350, "lax": 183, "dfw": 180, "sjc": 175, "ams": 212, "cdg": 149, "sea": 188, "den": 307, "ord": 186, "yul": 122, "gru": 261, "syd": 428, "bog": 445, "gdl": 601, "phx": 197, "jnb": 384, "fra": 162, "lhr": 156, "yyz": 327 }, { "timestamp": "2025-08-18T17:00:00.000Z", "gig": 300, "atl": 149, "nrt": 349, "mia": 186, "dfw": 194, "sjc": 139, "ams": 181, "lax": 162, "otp": 279, "cdg": 147, "gru": 328, "sea": 172, "den": 233, "ord": 178, "yul": 188, "syd": 297, "gdl": 538, "phx": 195, "jnb": 501, "bog": 396, "lhr": 142, "yyz": 153, "fra": 183, "scl": 522, "hkg": 330, "ewr": 158, "bom": 285, "eze": 386, "arn": 217, "mad": 366, "sin": 470, "iad": 138, "bos": 120 }, { "timestamp": "2025-08-18T17:30:00.000Z", "bos": 212, "iad": 128, "mad": 329, "sin": 505, "arn": 212, "eze": 454, "bom": 321, "ewr": 166, "scl": 493, "hkg": 343, "lhr": 156, "yyz": 159, "fra": 155, "phx": 204, "gdl": 521, "jnb": 497, "bog": 423, "syd": 373, "gru": 208, "den": 256, "ord": 172, "sea": 182, "yul": 124, "cdg": 154, "sjc": 151, "dfw": 189, "ams": 180, "otp": 309, "lax": 175, "mia": 173, "nrt": 368, "gig": 360, "atl": 141 }, { "timestamp": "2025-08-18T18:00:00.000Z", "otp": 282, "lax": 319, "sjc": 129, "dfw": 211, "ams": 213, "cdg": 156, "atl": 145, "gig": 361, "mia": 184, "nrt": 486, "bog": 501, "phx": 224, "gdl": 528, "jnb": 519, "fra": 257, "lhr": 225, "yyz": 177, "ord": 146, "den": 232, "sea": 187, "yul": 110, "gru": 302, "syd": 333, "eze": 456, "hkg": 345, "scl": 522, "ewr": 172, "bom": 282, "iad": 175, "bos": 127, "arn": 212, "sin": 485, "mad": 411 }, { "timestamp": "2025-08-18T18:30:00.000Z", "ewr": 163, "bom": 281, "scl": 500, "hkg": 343, "eze": 479, "mad": 346, "sin": 487, "arn": 258, "bos": 139, "iad": 131, "mia": 162, "nrt": 488, "gig": 378, "atl": 159, "cdg": 156, "ams": 178, "sjc": 151, "dfw": 242, "lax": 170, "otp": 306, "syd": 374, "gru": 345, "yul": 138, "ord": 194, "den": 232, "sea": 172, "yyz": 139, "lhr": 152, "fra": 178, "jnb": 478, "phx": 193, "gdl": 611, "bog": 435 }, { "timestamp": "2025-08-18T19:00:00.000Z", "mia": 189, "nrt": 360, "atl": 132, "gig": 357, "cdg": 244, "otp": 287, "lax": 152, "ams": 180, "sjc": 166, "dfw": 198, "syd": 371, "yul": 140, "ord": 154, "den": 243, "sea": 178, "gru": 213, "fra": 180, "yyz": 158, "lhr": 169, "bog": 471, "jnb": 444, "phx": 333, "gdl": 590, "bom": 283, "ewr": 162, "hkg": 350, "scl": 616, "eze": 485, "sin": 416, "mad": 341, "arn": 221, "bos": 110, "iad": 155 }, { "timestamp": "2025-08-18T19:30:00.000Z", "eze": 490, "ewr": 174, "bom": 282, "scl": 492, "hkg": 356, "bos": 137, "iad": 160, "mad": 384, "sin": 458, "arn": 203, "cdg": 158, "ams": 237, "dfw": 187, "sjc": 139, "otp": 328, "lax": 157, "nrt": 451, "mia": 272, "gig": 373, "atl": 171, "yyz": 144, "lhr": 167, "fra": 173, "jnb": 501, "gdl": 535, "phx": 195, "bog": 405, "syd": 424, "gru": 335, "yul": 178, "sea": 184, "den": 235, "ord": 170 }, { "timestamp": "2025-08-18T20:00:00.000Z", "eze": 400, "scl": 528, "hkg": 337, "bom": 282, "ewr": 132, "iad": 178, "bos": 122, "arn": 216, "mad": 362, "sin": 451, "dfw": 174, "sjc": 132, "ams": 220, "otp": 275, "lax": 157, "cdg": 178, "gig": 354, "atl": 158, "nrt": 403, "mia": 166, "gdl": 571, "phx": 193, "jnb": 504, "bog": 458, "lhr": 172, "yyz": 215, "fra": 165, "gru": 189, "sea": 166, "ord": 206, "den": 218, "yul": 126, "syd": 439 }, { "timestamp": "2025-08-18T20:30:00.000Z", "bos": 161, "iad": 140, "sin": 442, "mad": 346, "arn": 193, "eze": 456, "bom": 316, "ewr": 120, "hkg": 334, "scl": 495, "fra": 248, "lhr": 150, "yyz": 141, "bog": 453, "phx": 196, "gdl": 595, "jnb": 432, "syd": 378, "ord": 158, "den": 236, "sea": 156, "yul": 114, "gru": 340, "cdg": 149, "lax": 176, "otp": 272, "sjc": 142, "dfw": 184, "ams": 217, "mia": 221, "nrt": 357, "atl": 153, "gig": 371 }, { "timestamp": "2025-08-18T21:00:00.000Z", "ewr": 193, "bom": 265, "scl": 503, "hkg": 441, "eze": 449, "mad": 346, "sin": 456, "arn": 203, "bos": 121, "iad": 142, "nrt": 415, "mia": 179, "gig": 349, "atl": 155, "cdg": 154, "ams": 173, "dfw": 206, "sjc": 128, "otp": 266, "lax": 173, "syd": 368, "gru": 204, "yul": 123, "sea": 177, "ord": 169, "den": 335, "yyz": 151, "lhr": 168, "fra": 180, "jnb": 370, "gdl": 587, "phx": 187, "bog": 459 }, { "timestamp": "2025-08-18T21:30:00.000Z", "yul": 112, "sea": 170, "den": 221, "ord": 161, "gru": 193, "syd": 421, "bog": 405, "jnb": 426, "gdl": 563, "phx": 188, "fra": 150, "yyz": 136, "lhr": 140, "atl": 142, "gig": 365, "nrt": 401, "mia": 183, "lax": 235, "otp": 279, "ams": 178, "dfw": 170, "sjc": 132, "cdg": 147, "arn": 207, "sin": 426, "mad": 355, "iad": 140, "bos": 123, "hkg": 340, "scl": 499, "bom": 305, "ewr": 187, "eze": 475 }, { "timestamp": "2025-08-18T22:00:00.000Z", "eze": 474, "ewr": 138, "bom": 271, "hkg": 452, "scl": 494, "bos": 119, "iad": 153, "sin": 430, "mad": 332, "arn": 195, "cdg": 194, "lax": 209, "otp": 278, "ams": 227, "sjc": 124, "dfw": 169, "mia": 150, "nrt": 428, "atl": 151, "gig": 358, "fra": 226, "yyz": 145, "lhr": 146, "bog": 433, "jnb": 456, "phx": 222, "gdl": 578, "syd": 466, "yul": 105, "den": 255, "ord": 166, "sea": 162, "gru": 192 }, { "timestamp": "2025-08-18T22:30:00.000Z", "iad": 158, "bos": 129, "arn": 192, "mad": 346, "sin": 414, "eze": 459, "scl": 492, "hkg": 351, "ewr": 136, "bom": 280, "jnb": 461, "gdl": 566, "phx": 193, "bog": 405, "yyz": 136, "lhr": 179, "fra": 149, "gru": 205, "yul": 130, "sea": 174, "den": 221, "ord": 160, "syd": 396, "ams": 181, "dfw": 179, "sjc": 136, "otp": 275, "lax": 153, "cdg": 145, "gig": 295, "atl": 179, "nrt": 417, "mia": 187 }, { "timestamp": "2025-08-18T23:00:00.000Z", "hkg": 359, "scl": 352, "bom": 283, "ewr": 137, "eze": 451, "arn": 216, "sin": 407, "mad": 352, "iad": 136, "bos": 110, "atl": 226, "gig": 358, "mia": 169, "nrt": 414, "lax": 163, "otp": 294, "sjc": 134, "dfw": 229, "ams": 178, "cdg": 146, "den": 225, "ord": 197, "sea": 173, "yul": 128, "gru": 214, "syd": 458, "bog": 415, "phx": 240, "gdl": 574, "jnb": 456, "fra": 160, "lhr": 290, "yyz": 160 }, { "timestamp": "2025-08-18T23:30:00.000Z", "dfw": 210, "sjc": 128, "ams": 172, "lax": 194, "otp": 302, "cdg": 164, "gig": 356, "atl": 153, "nrt": 432, "mia": 170, "gdl": 550, "phx": 195, "jnb": 453, "bog": 474, "lhr": 137, "yyz": 179, "fra": 158, "gru": 283, "sea": 166, "ord": 175, "den": 231, "yul": 112, "syd": 342, "eze": 423, "scl": 489, "hkg": 312, "ewr": 154, "bom": 275, "iad": 166, "bos": 129, "arn": 203, "mad": 325, "sin": 413 }, { "timestamp": "2025-08-19T00:00:00.000Z", "mia": 187, "nrt": 425, "atl": 206, "gig": 358, "cdg": 173, "otp": 278, "lax": 165, "ams": 185, "sjc": 131, "dfw": 199, "syd": 375, "yul": 114, "den": 255, "ord": 142, "sea": 167, "gru": 219, "fra": 160, "yyz": 142, "lhr": 140, "bog": 491, "jnb": 439, "phx": 195, "gdl": 581, "bom": 277, "ewr": 172, "hkg": 317, "scl": 505, "eze": 298, "sin": 439, "mad": 336, "arn": 205, "bos": 131, "iad": 149 }, { "timestamp": "2025-08-19T00:30:00.000Z", "scl": 437, "hkg": 359, "bom": 264, "ewr": 128, "eze": 383, "arn": 188, "mad": 397, "sin": 562, "iad": 132, "bos": 198, "gig": 353, "atl": 240, "nrt": 418, "mia": 179, "dfw": 202, "sjc": 129, "ams": 206, "lax": 174, "otp": 317, "cdg": 147, "gru": 327, "sea": 166, "ord": 146, "den": 223, "yul": 120, "syd": 442, "gdl": 554, "phx": 190, "jnb": 354, "bog": 467, "lhr": 161, "yyz": 150, "fra": 161 }, { "timestamp": "2025-08-19T01:00:00.000Z", "atl": 205, "gig": 367, "nrt": 398, "mia": 181, "otp": 277, "lax": 177, "dfw": 221, "sjc": 131, "ams": 186, "cdg": 177, "sea": 164, "ord": 144, "den": 244, "yul": 147, "gru": 202, "syd": 437, "bog": 491, "gdl": 564, "phx": 202, "jnb": 510, "fra": 153, "lhr": 162, "yyz": 145, "hkg": 511, "scl": 497, "ewr": 180, "bom": 287, "eze": 458, "arn": 188, "sin": 530, "mad": 365, "iad": 271, "bos": 118 }, { "timestamp": "2025-08-19T01:30:00.000Z", "cdg": 149, "ams": 180, "sjc": 126, "dfw": 192, "lax": 173, "otp": 291, "mia": 177, "nrt": 351, "gig": 355, "atl": 196, "yyz": 132, "lhr": 144, "fra": 155, "jnb": 436, "phx": 210, "gdl": 483, "bog": 447, "syd": 417, "gru": 198, "yul": 118, "den": 239, "ord": 155, "sea": 189, "eze": 287, "bom": 273, "ewr": 123, "scl": 528, "hkg": 319, "bos": 119, "iad": 133, "mad": 342, "sin": 505, "arn": 194 }, { "timestamp": "2025-08-19T02:00:00.000Z", "iad": 136, "bos": 124, "arn": 192, "sin": 511, "mad": 342, "eze": 453, "hkg": 323, "scl": 525, "ewr": 126, "bom": 289, "bog": 399, "jnb": 357, "phx": 205, "gdl": 522, "fra": 180, "yyz": 136, "lhr": 156, "yul": 109, "ord": 178, "den": 244, "sea": 229, "gru": 289, "syd": 416, "lax": 194, "otp": 270, "ams": 217, "sjc": 174, "dfw": 201, "cdg": 151, "atl": 219, "gig": 350, "mia": 176, "nrt": 426 }, { "timestamp": "2025-08-19T02:30:00.000Z", "ewr": 122, "bom": 270, "hkg": 356, "scl": 445, "eze": 451, "sin": 411, "mad": 336, "arn": 187, "bos": 113, "iad": 118, "mia": 223, "nrt": 422, "atl": 337, "gig": 354, "cdg": 150, "otp": 274, "lax": 152, "ams": 200, "sjc": 205, "dfw": 182, "syd": 353, "yul": 124, "den": 231, "ord": 146, "sea": 175, "gru": 207, "fra": 248, "yyz": 178, "lhr": 147, "bog": 410, "jnb": 352, "phx": 199, "gdl": 651 }, { "timestamp": "2025-08-19T03:00:00.000Z", "mia": 195, "nrt": 421, "gig": 361, "atl": 216, "cdg": 148, "ams": 183, "sjc": 144, "dfw": 196, "lax": 181, "otp": 302, "syd": 382, "gru": 285, "yul": 121, "den": 296, "ord": 146, "sea": 169, "yyz": 149, "lhr": 137, "fra": 163, "jnb": 430, "phx": 216, "gdl": 650, "bog": 392, "bom": 288, "ewr": 142, "scl": 446, "hkg": 390, "eze": 445, "mad": 348, "sin": 454, "arn": 201, "bos": 116, "iad": 119 }, { "timestamp": "2025-08-19T03:30:00.000Z", "eze": 450, "ewr": 177, "bom": 279, "hkg": 481, "scl": 457, "bos": 124, "iad": 128, "sin": 486, "mad": 390, "arn": 240, "cdg": 139, "lax": 162, "otp": 290, "ams": 182, "dfw": 179, "sjc": 131, "nrt": 326, "mia": 182, "atl": 323, "gig": 344, "fra": 160, "yyz": 149, "lhr": 148, "bog": 407, "jnb": 350, "gdl": 616, "phx": 249, "syd": 333, "yul": 118, "sea": 156, "ord": 203, "den": 277, "gru": 298 }, { "timestamp": "2025-08-19T04:00:00.000Z", "dfw": 163, "sjc": 136, "ams": 170, "lax": 155, "otp": 300, "cdg": 144, "gig": 374, "atl": 662, "nrt": 430, "mia": 203, "gdl": 509, "phx": 218, "jnb": 433, "bog": 462, "lhr": 147, "yyz": 140, "fra": 166, "gru": 280, "sea": 167, "ord": 230, "den": 279, "yul": 129, "syd": 378, "eze": 450, "scl": 435, "hkg": 333, "ewr": 128, "bom": 344, "iad": 184, "bos": 116, "arn": 187, "mad": 341, "sin": 419 }, { "timestamp": "2025-08-19T04:30:00.000Z", "fra": 167, "lhr": 154, "yyz": 147, "bog": 421, "phx": 197, "gdl": 574, "jnb": 495, "syd": 417, "ord": 169, "den": 275, "sea": 195, "yul": 144, "gru": 279, "cdg": 141, "otp": 358, "lax": 164, "sjc": 152, "dfw": 175, "ams": 178, "mia": 194, "nrt": 427, "atl": 518, "gig": 352, "bos": 114, "iad": 129, "sin": 439, "mad": 330, "arn": 277, "eze": 449, "ewr": 176, "bom": 322, "hkg": 318, "scl": 540 }, { "timestamp": "2025-08-19T05:00:00.000Z", "gru": 255, "sea": 176, "den": 314, "ord": 169, "yul": 136, "syd": 365, "gdl": 620, "phx": 205, "jnb": 428, "bog": 384, "lhr": 155, "yyz": 163, "fra": 179, "gig": 354, "atl": 613, "nrt": 311, "mia": 228, "dfw": 187, "sjc": 171, "ams": 237, "lax": 175, "otp": 290, "cdg": 160, "arn": 240, "mad": 351, "sin": 423, "iad": 185, "bos": 126, "scl": 502, "hkg": 448, "ewr": 136, "bom": 319, "eze": 381 }, { "timestamp": "2025-08-19T05:30:00.000Z", "bom": 293, "ewr": 176, "hkg": 318, "scl": 490, "eze": 448, "sin": 448, "mad": 348, "arn": 202, "bos": 129, "iad": 151, "nrt": 436, "mia": 183, "atl": 645, "gig": 373, "cdg": 154, "otp": 303, "lax": 187, "dfw": 201, "sjc": 130, "ams": 178, "syd": 446, "sea": 187, "ord": 139, "den": 293, "yul": 140, "gru": 338, "fra": 172, "lhr": 148, "yyz": 138, "bog": 387, "gdl": 576, "phx": 206, "jnb": 450 }, { "timestamp": "2025-08-19T06:00:00.000Z", "eze": 469, "scl": 494, "hkg": 322, "ewr": 169, "bom": 284, "iad": 146, "bos": 113, "arn": 230, "mad": 376, "sin": 444, "ams": 194, "sjc": 147, "dfw": 164, "otp": 297, "lax": 167, "cdg": 171, "gig": 357, "atl": 372, "mia": 207, "nrt": 267, "jnb": 503, "phx": 207, "gdl": 618, "bog": 396, "yyz": 152, "lhr": 136, "fra": 175, "gru": 277, "yul": 131, "den": 300, "ord": 174, "sea": 176, "syd": 294 }, { "timestamp": "2025-08-19T06:30:00.000Z", "bos": 115, "iad": 140, "sin": 437, "mad": 346, "arn": 205, "eze": 457, "ewr": 134, "bom": 289, "hkg": 489, "scl": 490, "fra": 172, "yyz": 157, "lhr": 187, "bog": 390, "jnb": 497, "gdl": 581, "phx": 350, "syd": 443, "yul": 128, "sea": 163, "den": 298, "ord": 155, "gru": 264, "cdg": 147, "lax": 163, "otp": 275, "ams": 247, "dfw": 190, "sjc": 146, "nrt": 344, "mia": 183, "atl": 273, "gig": 349 }, { "timestamp": "2025-08-19T07:00:00.000Z", "syd": 377, "yul": 125, "ord": 141, "den": 297, "sea": 169, "gru": 302, "fra": 254, "yyz": 144, "lhr": 200, "bog": 698, "jnb": 488, "phx": 212, "gdl": 618, "mia": 203, "nrt": 417, "atl": 229, "gig": 353, "cdg": 163, "otp": 312, "lax": 160, "ams": 179, "sjc": 179, "dfw": 196, "sin": 659, "mad": 365, "arn": 240, "bos": 125, "iad": 140, "bom": 298, "ewr": 159, "hkg": 358, "scl": 493, "eze": 383 }, { "timestamp": "2025-08-19T07:30:00.000Z", "bos": 117, "iad": 140, "mad": 357, "sin": 441, "arn": 268, "eze": 388, "ewr": 130, "bom": 291, "scl": 493, "hkg": 324, "yyz": 150, "lhr": 155, "fra": 154, "jnb": 489, "gdl": 618, "phx": 282, "bog": 674, "syd": 416, "gru": 328, "yul": 125, "sea": 170, "den": 288, "ord": 159, "cdg": 272, "ams": 182, "dfw": 186, "sjc": 151, "otp": 280, "lax": 150, "nrt": 423, "mia": 179, "gig": 349, "atl": 222 }, { "timestamp": "2025-08-19T08:00:00.000Z", "cdg": 221, "ams": 180, "sjc": 152, "dfw": 209, "otp": 313, "lax": 204, "mia": 168, "nrt": 345, "gig": 208, "atl": 143, "yyz": 192, "lhr": 151, "fra": 158, "jnb": 524, "phx": 260, "gdl": 556, "bog": 658, "syd": 291, "gru": 211, "yul": 111, "ord": 168, "den": 285, "sea": 165, "eze": 446, "bom": 281, "ewr": 145, "scl": 488, "hkg": 363, "bos": 109, "iad": 152, "mad": 403, "sin": 430, "arn": 201 }, { "timestamp": "2025-08-19T08:30:00.000Z", "lhr": 151, "yyz": 153, "fra": 263, "gdl": 566, "phx": 218, "jnb": 521, "bog": 451, "syd": 372, "gru": 267, "sea": 165, "ord": 147, "den": 330, "yul": 138, "cdg": 271, "dfw": 202, "sjc": 150, "ams": 516, "lax": 153, "otp": 276, "nrt": 433, "mia": 156, "gig": 345, "atl": 134, "bos": 114, "iad": 138, "mad": 345, "sin": 433, "arn": 195, "eze": 536, "bom": 297, "ewr": 155, "scl": 534, "hkg": 349 }, { "timestamp": "2025-08-19T09:00:00.000Z", "gig": 358, "atl": 149, "mia": 177, "nrt": 422, "sjc": 139, "dfw": 195, "ams": 180, "otp": 290, "lax": 155, "cdg": 145, "gru": 224, "ord": 169, "den": 298, "sea": 186, "yul": 121, "syd": 389, "phx": 206, "gdl": 547, "jnb": 499, "bog": 455, "lhr": 139, "yyz": 141, "fra": 174, "scl": 486, "hkg": 374, "ewr": 136, "bom": 284, "eze": 459, "arn": 204, "mad": 350, "sin": 523, "iad": 149, "bos": 111 }, { "timestamp": "2025-08-19T09:30:00.000Z", "jnb": 414, "phx": 199, "gdl": 511, "bog": 455, "yyz": 141, "lhr": 210, "fra": 150, "gru": 277, "yul": 105, "den": 333, "ord": 156, "sea": 180, "syd": 385, "ams": 182, "sjc": 163, "dfw": 192, "lax": 165, "otp": 292, "cdg": 163, "gig": 358, "atl": 136, "mia": 160, "nrt": 426, "iad": 142, "bos": 112, "arn": 196, "mad": 430, "sin": 458, "eze": 453, "scl": 492, "hkg": 476, "ewr": 161, "bom": 295 }, { "timestamp": "2025-08-19T10:00:00.000Z", "arn": 202, "mad": 346, "sin": 435, "iad": 167, "bos": 113, "scl": 495, "hkg": 453, "ewr": 140, "bom": 298, "eze": 449, "gru": 194, "yul": 121, "den": 290, "ord": 173, "sea": 194, "syd": 352, "jnb": 435, "phx": 202, "gdl": 610, "bog": 491, "yyz": 247, "lhr": 166, "fra": 174, "gig": 385, "atl": 125, "mia": 173, "nrt": 427, "ams": 178, "sjc": 134, "dfw": 191, "lax": 165, "otp": 275, "cdg": 163 }, { "timestamp": "2025-08-19T10:30:00.000Z", "scl": 493, "hkg": 345, "ewr": 135, "bom": 291, "eze": 456, "arn": 209, "mad": 335, "sin": 454, "iad": 141, "bos": 123, "gig": 353, "atl": 130, "nrt": 448, "mia": 205, "dfw": 181, "sjc": 149, "ams": 178, "otp": 277, "lax": 170, "cdg": 141, "gru": 225, "sea": 177, "den": 297, "ord": 173, "yul": 124, "syd": 380, "gdl": 582, "phx": 204, "jnb": 503, "bog": 452, "lhr": 142, "yyz": 152, "fra": 167 }, { "timestamp": "2025-08-19T11:00:00.000Z", "bos": 121, "iad": 136, "mad": 438, "sin": 433, "arn": 212, "eze": 411, "bom": 359, "ewr": 146, "scl": 375, "hkg": 322, "lhr": 169, "yyz": 172, "fra": 170, "phx": 213, "gdl": 635, "jnb": 574, "bog": 399, "syd": 392, "gru": 216, "den": 289, "ord": 139, "sea": 169, "yul": 117, "cdg": 154, "sjc": 139, "dfw": 199, "ams": 201, "lax": 186, "otp": 295, "mia": 180, "nrt": 358, "gig": 348, "atl": 124 }, { "timestamp": "2025-08-19T11:30:00.000Z", "fra": 158, "lhr": 149, "yyz": 211, "bog": 474, "gdl": 612, "phx": 238, "jnb": 511, "syd": 325, "sea": 167, "ord": 168, "den": 286, "yul": 134, "gru": 340, "cdg": 182, "otp": 370, "lax": 155, "dfw": 174, "sjc": 213, "ams": 197, "nrt": 455, "mia": 181, "atl": 136, "gig": 359, "bos": 120, "iad": 142, "sin": 503, "mad": 345, "arn": 202, "eze": 452, "bom": 291, "ewr": 126, "hkg": 487, "scl": 517 }, { "timestamp": "2025-08-19T12:00:00.000Z", "atl": 137, "gig": 372, "nrt": 415, "mia": 156, "lax": 216, "otp": 281, "dfw": 166, "sjc": 155, "ams": 196, "cdg": 144, "sea": 169, "den": 286, "ord": 147, "yul": 129, "gru": 279, "syd": 418, "bog": 404, "gdl": 635, "phx": 196, "jnb": 512, "fra": 286, "lhr": 202, "yyz": 141, "hkg": 321, "scl": 509, "ewr": 168, "bom": 306, "eze": 482, "arn": 197, "sin": 412, "mad": 391, "iad": 148, "bos": 116 }, { "timestamp": "2025-08-19T12:30:00.000Z", "eze": 397, "hkg": 452, "scl": 501, "ewr": 166, "bom": 295, "iad": 141, "bos": 115, "arn": 214, "sin": 430, "mad": 345, "otp": 284, "lax": 159, "dfw": 182, "sjc": 155, "ams": 173, "cdg": 283, "atl": 143, "gig": 373, "nrt": 370, "mia": 166, "bog": 442, "gdl": 548, "phx": 197, "jnb": 441, "fra": 165, "lhr": 138, "yyz": 137, "sea": 207, "den": 338, "ord": 181, "yul": 144, "gru": 233, "syd": 371 }, { "timestamp": "2025-08-19T13:00:00.000Z", "cdg": 247, "lax": 156, "otp": 268, "ams": 281, "dfw": 175, "sjc": 134, "nrt": 373, "mia": 176, "atl": 150, "gig": 298, "fra": 193, "yyz": 241, "lhr": 147, "bog": 450, "jnb": 444, "gdl": 619, "phx": 233, "syd": 445, "yul": 116, "sea": 171, "den": 236, "ord": 178, "gru": 303, "eze": 457, "bom": 295, "ewr": 124, "hkg": 410, "scl": 501, "bos": 116, "iad": 151, "sin": 414, "mad": 354, "arn": 204 }, { "timestamp": "2025-08-19T13:30:00.000Z", "bom": 291, "ewr": 167, "hkg": 396, "scl": 518, "eze": 469, "sin": 562, "mad": 351, "arn": 196, "bos": 122, "iad": 142, "nrt": 283, "mia": 201, "atl": 141, "gig": 353, "cdg": 186, "otp": 313, "lax": 171, "ams": 189, "dfw": 201, "sjc": 127, "syd": 292, "yul": 119, "sea": 213, "den": 246, "ord": 147, "gru": 225, "fra": 202, "yyz": 146, "lhr": 163, "bog": 392, "jnb": 363, "gdl": 593, "phx": 204 }, { "timestamp": "2025-08-19T14:00:00.000Z", "syd": 441, "gru": 189, "ord": 193, "den": 266, "sea": 183, "yul": 173, "lhr": 226, "yyz": 146, "fra": 156, "phx": 207, "gdl": 595, "jnb": 463, "bog": 408, "mia": 171, "nrt": 390, "gig": 361, "atl": 145, "cdg": 149, "sjc": 138, "dfw": 182, "ams": 205, "otp": 278, "lax": 294, "mad": 336, "sin": 440, "arn": 198, "bos": 152, "iad": 194, "ewr": 163, "bom": 299, "scl": 494, "hkg": 365, "eze": 456 }, { "timestamp": "2025-08-19T14:30:00.000Z", "bos": 130, "iad": 187, "mad": 348, "sin": 459, "arn": 231, "eze": 472, "ewr": 195, "bom": 317, "scl": 496, "hkg": 420, "lhr": 147, "yyz": 142, "fra": 227, "phx": 218, "gdl": 595, "jnb": 532, "bog": 432, "syd": 316, "gru": 250, "ord": 170, "den": 253, "sea": 194, "yul": 122, "cdg": 205, "sjc": 133, "dfw": 196, "ams": 186, "lax": 168, "otp": 303, "mia": 169, "nrt": 424, "gig": 352, "atl": 130 }, { "timestamp": "2025-08-19T15:00:00.000Z", "jnb": 443, "phx": 200, "gdl": 646, "bog": 418, "yyz": 150, "lhr": 161, "fra": 154, "gru": 191, "yul": 146, "ord": 169, "den": 252, "sea": 167, "syd": 294, "ams": 177, "sjc": 143, "dfw": 188, "otp": 291, "lax": 150, "cdg": 244, "gig": 226, "atl": 132, "mia": 167, "nrt": 257, "iad": 162, "bos": 230, "arn": 236, "mad": 403, "sin": 428, "eze": 476, "scl": 497, "hkg": 320, "bom": 310, "ewr": 135 }, { "timestamp": "2025-08-19T15:30:00.000Z", "arn": 213, "mad": 347, "sin": 445, "iad": 206, "bos": 121, "scl": 446, "hkg": 364, "bom": 290, "ewr": 165, "eze": 448, "gru": 347, "yul": 121, "ord": 195, "den": 251, "sea": 162, "syd": 424, "jnb": 380, "phx": 194, "gdl": 603, "bog": 433, "yyz": 158, "lhr": 194, "fra": 168, "gig": 363, "atl": 143, "mia": 194, "nrt": 431, "ams": 212, "sjc": 130, "dfw": 191, "lax": 147, "otp": 279, "cdg": 157 }, { "timestamp": "2025-08-19T16:00:00.000Z", "bos": 119, "iad": 161, "mad": 333, "sin": 416, "arn": 196, "eze": 450, "bom": 307, "ewr": 144, "scl": 499, "hkg": 534, "lhr": 258, "yyz": 151, "fra": 185, "phx": 229, "gdl": 597, "jnb": 497, "bog": 419, "syd": 417, "gru": 342, "ord": 146, "den": 245, "sea": 205, "yul": 121, "cdg": 157, "sjc": 195, "dfw": 205, "ams": 185, "lax": 147, "otp": 274, "mia": 176, "nrt": 419, "gig": 398, "atl": 128 }, { "timestamp": "2025-08-19T16:30:00.000Z", "fra": 150, "lhr": 158, "yyz": 145, "bog": 479, "gdl": 612, "phx": 197, "jnb": 429, "syd": 375, "sea": 178, "den": 260, "ord": 173, "yul": 106, "gru": 205, "cdg": 146, "otp": 292, "lax": 149, "dfw": 179, "sjc": 142, "ams": 215, "nrt": 445, "mia": 171, "atl": 144, "gig": 374, "bos": 134, "iad": 144, "sin": 432, "mad": 334, "arn": 315, "eze": 474, "bom": 297, "ewr": 189, "hkg": 324, "scl": 496 }, { "timestamp": "2025-08-19T17:00:00.000Z", "atl": 130, "gig": 347, "mia": 173, "nrt": 433, "lax": 152, "otp": 284, "sjc": 129, "dfw": 227, "ams": 183, "cdg": 145, "den": 239, "ord": 180, "sea": 161, "yul": 124, "gru": 287, "syd": 422, "bog": 388, "phx": 198, "gdl": 642, "jnb": 464, "fra": 159, "lhr": 184, "yyz": 136, "hkg": 526, "scl": 510, "ewr": 130, "bom": 281, "eze": 375, "arn": 202, "sin": 451, "mad": 410, "iad": 159, "bos": 126 }, { "timestamp": "2025-08-19T17:30:00.000Z", "yul": 134, "sea": 277, "den": 279, "ord": 143, "gru": 208, "syd": 296, "bog": 459, "jnb": 354, "gdl": 564, "phx": 237, "fra": 176, "yyz": 208, "lhr": 152, "atl": 162, "gig": 375, "nrt": 389, "mia": 166, "otp": 279, "lax": 148, "ams": 177, "dfw": 312, "sjc": 146, "cdg": 144, "arn": 196, "sin": 444, "mad": 375, "iad": 186, "bos": 131, "hkg": 468, "scl": 507, "ewr": 172, "bom": 285, "eze": 460 }, { "timestamp": "2025-08-19T18:00:00.000Z", "iad": 132, "bos": 116, "arn": 199, "sin": 477, "mad": 361, "eze": 448, "hkg": 370, "scl": 496, "ewr": 140, "bom": 300, "bog": 497, "jnb": 357, "gdl": 625, "phx": 203, "fra": 163, "yyz": 141, "lhr": 208, "yul": 125, "sea": 186, "den": 263, "ord": 187, "gru": 201, "syd": 415, "otp": 271, "lax": 194, "ams": 188, "dfw": 207, "sjc": 137, "cdg": 158, "atl": 143, "gig": 358, "nrt": 344, "mia": 178 }, { "timestamp": "2025-08-19T18:30:00.000Z", "hkg": 495, "scl": 498, "ewr": 175, "bom": 279, "eze": 485, "arn": 214, "sin": 467, "mad": 357, "iad": 128, "bos": 187, "atl": 152, "gig": 378, "nrt": 430, "mia": 196, "lax": 171, "otp": 306, "dfw": 187, "sjc": 148, "ams": 186, "cdg": 149, "sea": 169, "ord": 154, "den": 263, "yul": 123, "gru": 300, "syd": 413, "bog": 397, "gdl": 584, "phx": 207, "jnb": 498, "fra": 246, "lhr": 139, "yyz": 138 }, { "timestamp": "2025-08-19T19:00:00.000Z", "bos": 119, "iad": 166, "sin": 489, "mad": 375, "arn": 198, "eze": 458, "bom": 271, "ewr": 248, "hkg": 485, "scl": 499, "fra": 154, "lhr": 155, "yyz": 144, "bog": 445, "phx": 194, "gdl": 541, "jnb": 468, "syd": 452, "ord": 209, "den": 248, "sea": 173, "yul": 139, "gru": 293, "cdg": 152, "otp": 295, "lax": 150, "sjc": 128, "dfw": 169, "ams": 192, "mia": 183, "nrt": 419, "atl": 138, "gig": 346 }, { "timestamp": "2025-08-19T19:30:00.000Z", "eze": 453, "bom": 371, "ewr": 128, "hkg": 375, "scl": 523, "bos": 124, "iad": 159, "sin": 460, "mad": 335, "arn": 196, "cdg": 173, "lax": 165, "otp": 306, "ams": 171, "dfw": 170, "sjc": 145, "nrt": 347, "mia": 169, "atl": 196, "gig": 350, "fra": 155, "yyz": 151, "lhr": 158, "bog": 403, "jnb": 517, "gdl": 626, "phx": 220, "syd": 432, "yul": 119, "sea": 166, "ord": 303, "den": 286, "gru": 230 }, { "timestamp": "2025-08-19T20:00:00.000Z", "arn": 235, "sin": 493, "mad": 348, "iad": 251, "bos": 120, "hkg": 331, "scl": 526, "ewr": 157, "bom": 278, "eze": 384, "yul": 125, "sea": 173, "den": 244, "ord": 152, "gru": 198, "syd": 373, "bog": 398, "jnb": 478, "gdl": 579, "phx": 199, "fra": 152, "yyz": 143, "lhr": 223, "atl": 173, "gig": 389, "nrt": 357, "mia": 196, "otp": 281, "lax": 154, "ams": 222, "dfw": 202, "sjc": 133, "cdg": 195 }, { "timestamp": "2025-08-19T20:30:00.000Z", "bog": 481, "jnb": 370, "gdl": 567, "phx": 292, "fra": 157, "yyz": 150, "lhr": 157, "yul": 126, "sea": 187, "den": 280, "ord": 144, "gru": 201, "syd": 335, "lax": 158, "otp": 288, "ams": 171, "dfw": 253, "sjc": 124, "cdg": 150, "atl": 182, "gig": 368, "nrt": 422, "mia": 202, "iad": 180, "bos": 114, "arn": 206, "sin": 532, "mad": 343, "eze": 480, "hkg": 331, "scl": 490, "ewr": 302, "bom": 336 }, { "timestamp": "2025-08-19T21:00:00.000Z", "cdg": 148, "ams": 180, "dfw": 191, "sjc": 168, "otp": 279, "lax": 148, "nrt": 433, "mia": 205, "gig": 365, "atl": 173, "yyz": 157, "lhr": 151, "fra": 174, "jnb": 469, "gdl": 644, "phx": 199, "bog": 393, "syd": 385, "gru": 203, "yul": 132, "sea": 172, "ord": 170, "den": 247, "eze": 462, "bom": 271, "ewr": 171, "scl": 493, "hkg": 389, "bos": 114, "iad": 159, "mad": 353, "sin": 424, "arn": 200 }, { "timestamp": "2025-08-19T21:30:00.000Z", "bom": 265, "ewr": 154, "scl": 447, "hkg": 334, "eze": 446, "mad": 345, "sin": 428, "arn": 197, "bos": 133, "iad": 224, "nrt": 431, "mia": 221, "gig": 357, "atl": 164, "cdg": 211, "ams": 174, "dfw": 187, "sjc": 142, "lax": 161, "otp": 285, "syd": 423, "gru": 281, "yul": 111, "sea": 165, "ord": 176, "den": 267, "yyz": 196, "lhr": 145, "fra": 162, "jnb": 481, "gdl": 609, "phx": 280, "bog": 425 }, { "timestamp": "2025-08-19T22:00:00.000Z", "mad": 341, "sin": 421, "arn": 209, "bos": 126, "iad": 179, "bom": 311, "ewr": 159, "scl": 494, "hkg": 328, "eze": 451, "syd": 420, "gru": 334, "ord": 149, "den": 273, "sea": 162, "yul": 114, "lhr": 221, "yyz": 142, "fra": 165, "phx": 212, "gdl": 643, "jnb": 510, "bog": 441, "mia": 237, "nrt": 433, "gig": 371, "atl": 195, "cdg": 244, "sjc": 126, "dfw": 177, "ams": 179, "lax": 152, "otp": 300 }, { "timestamp": "2025-08-19T22:30:00.000Z", "lhr": 162, "yyz": 155, "fra": 156, "phx": 208, "gdl": 575, "jnb": 460, "bog": 465, "syd": 313, "gru": 206, "ord": 235, "den": 245, "sea": 167, "yul": 129, "cdg": 141, "sjc": 134, "dfw": 175, "ams": 243, "otp": 275, "lax": 172, "mia": 188, "nrt": 426, "gig": 352, "atl": 177, "bos": 131, "iad": 137, "mad": 355, "sin": 475, "arn": 240, "eze": 455, "bom": 285, "ewr": 186, "scl": 485, "hkg": 468 }, { "timestamp": "2025-08-19T23:00:00.000Z", "cdg": 184, "otp": 277, "lax": 147, "sjc": 131, "dfw": 187, "ams": 172, "mia": 243, "nrt": 392, "atl": 195, "gig": 305, "fra": 183, "lhr": 138, "yyz": 160, "bog": 476, "phx": 207, "gdl": 584, "jnb": 380, "syd": 422, "ord": 175, "den": 248, "sea": 165, "yul": 123, "gru": 227, "eze": 451, "bom": 264, "ewr": 175, "hkg": 361, "scl": 496, "bos": 113, "iad": 166, "sin": 428, "mad": 328, "arn": 208 }, { "timestamp": "2025-08-19T23:30:00.000Z", "bom": 268, "ewr": 138, "hkg": 339, "scl": 499, "eze": 467, "sin": 450, "mad": 328, "arn": 195, "bos": 117, "iad": 180, "mia": 218, "nrt": 404, "atl": 219, "gig": 371, "cdg": 142, "lax": 176, "otp": 290, "sjc": 126, "dfw": 200, "ams": 173, "syd": 365, "ord": 219, "den": 241, "sea": 283, "yul": 137, "gru": 244, "fra": 152, "lhr": 141, "yyz": 156, "bog": 419, "phx": 206, "gdl": 630, "jnb": 490 }, { "timestamp": "2025-08-20T00:00:00.000Z", "eze": 381, "ewr": 177, "bom": 283, "hkg": 317, "scl": 500, "bos": 144, "iad": 170, "sin": 414, "mad": 459, "arn": 193, "cdg": 142, "lax": 159, "otp": 283, "ams": 193, "sjc": 124, "dfw": 226, "mia": 218, "nrt": 295, "atl": 180, "gig": 370, "fra": 162, "yyz": 146, "lhr": 142, "bog": 400, "jnb": 495, "phx": 268, "gdl": 571, "syd": 341, "yul": 139, "den": 236, "ord": 185, "sea": 166, "gru": 281 }, { "timestamp": "2025-08-20T00:30:00.000Z", "nrt": 361, "mia": 179, "gig": 351, "atl": 237, "cdg": 156, "ams": 187, "dfw": 210, "sjc": 135, "lax": 155, "otp": 304, "syd": 416, "gru": 205, "yul": 123, "sea": 192, "ord": 141, "den": 261, "yyz": 152, "lhr": 152, "fra": 156, "jnb": 362, "gdl": 504, "phx": 196, "bog": 393, "bom": 262, "ewr": 153, "scl": 493, "hkg": 355, "eze": 456, "mad": 458, "sin": 484, "arn": 201, "bos": 122, "iad": 159 }, { "timestamp": "2025-08-20T01:00:00.000Z", "ewr": 190, "bom": 265, "hkg": 325, "scl": 486, "eze": 474, "sin": 641, "mad": 478, "arn": 210, "bos": 122, "iad": 167, "nrt": 433, "mia": 185, "atl": 241, "gig": 354, "cdg": 187, "otp": 285, "lax": 173, "ams": 182, "dfw": 189, "sjc": 137, "syd": 290, "yul": 132, "sea": 174, "ord": 171, "den": 247, "gru": 227, "fra": 160, "yyz": 195, "lhr": 134, "bog": 474, "jnb": 505, "gdl": 631, "phx": 218 }, { "timestamp": "2025-08-20T01:30:00.000Z", "iad": 134, "bos": 136, "arn": 203, "sin": 520, "mad": 417, "eze": 415, "hkg": 356, "scl": 723, "ewr": 169, "bom": 274, "bog": 418, "jnb": 502, "gdl": 500, "phx": 296, "fra": 164, "yyz": 152, "lhr": 148, "yul": 125, "sea": 176, "den": 227, "ord": 257, "gru": 204, "syd": 392, "lax": 152, "otp": 271, "ams": 182, "dfw": 194, "sjc": 213, "cdg": 143, "atl": 234, "gig": 357, "nrt": 422, "mia": 235 }, { "timestamp": "2025-08-20T02:00:00.000Z", "cdg": 142, "ams": 188, "dfw": 202, "sjc": 143, "lax": 161, "otp": 266, "nrt": 437, "mia": 179, "gig": 362, "atl": 222, "yyz": 227, "lhr": 141, "fra": 275, "jnb": 442, "gdl": 564, "phx": 193, "bog": 487, "syd": 416, "gru": 222, "yul": 124, "sea": 170, "ord": 147, "den": 234, "eze": 490, "bom": 278, "ewr": 133, "scl": 498, "hkg": 349, "bos": 123, "iad": 191, "mad": 334, "sin": 573, "arn": 198 }, { "timestamp": "2025-08-20T02:30:00.000Z", "atl": 234, "gig": 366, "mia": 188, "nrt": 366, "otp": 311, "lax": 165, "sjc": 136, "dfw": 185, "ams": 194, "cdg": 145, "den": 224, "ord": 154, "sea": 158, "yul": 129, "gru": 215, "syd": 385, "bog": 416, "phx": 270, "gdl": 603, "jnb": 466, "fra": 174, "lhr": 161, "yyz": 144, "hkg": 356, "scl": 504, "ewr": 133, "bom": 297, "eze": 456, "arn": 216, "sin": 480, "mad": 335, "iad": 165, "bos": 118 }, { "timestamp": "2025-08-20T03:00:00.000Z", "scl": 491, "hkg": 316, "bom": 272, "ewr": 159, "eze": 449, "arn": 189, "mad": 341, "sin": 417, "iad": 134, "bos": 125, "gig": 296, "atl": 290, "mia": 177, "nrt": 423, "sjc": 130, "dfw": 210, "ams": 225, "lax": 152, "otp": 273, "cdg": 143, "gru": 198, "den": 234, "ord": 169, "sea": 180, "yul": 115, "syd": 309, "phx": 202, "gdl": 632, "jnb": 447, "bog": 485, "lhr": 154, "yyz": 145, "fra": 153 }, { "timestamp": "2025-08-20T03:30:00.000Z", "nrt": 284, "mia": 192, "atl": 330, "gig": 354, "cdg": 143, "otp": 310, "lax": 153, "ams": 203, "dfw": 205, "sjc": 126, "syd": 417, "yul": 138, "sea": 168, "ord": 140, "den": 228, "gru": 188, "fra": 171, "yyz": 145, "lhr": 174, "bog": 464, "jnb": 375, "gdl": 612, "phx": 203, "bom": 391, "ewr": 177, "hkg": 382, "scl": 494, "eze": 451, "sin": 417, "mad": 337, "arn": 201, "bos": 119, "iad": 183 }, { "timestamp": "2025-08-20T04:00:00.000Z", "jnb": 459, "phx": 206, "gdl": 633, "bog": 389, "yyz": 239, "lhr": 142, "fra": 188, "gru": 184, "yul": 136, "ord": 414, "den": 240, "sea": 169, "syd": 345, "ams": 212, "sjc": 136, "dfw": 164, "otp": 313, "lax": 154, "cdg": 144, "gig": 354, "atl": 295, "mia": 211, "nrt": 430, "iad": 219, "bos": 121, "arn": 208, "mad": 324, "sin": 436, "eze": 454, "scl": 431, "hkg": 394, "bom": 288, "ewr": 147 }, { "timestamp": "2025-08-20T04:30:00.000Z", "arn": 217, "sin": 413, "mad": 332, "iad": 142, "bos": 109, "hkg": 511, "scl": 498, "ewr": 188, "bom": 281, "eze": 452, "yul": 146, "sea": 186, "den": 233, "ord": 164, "gru": 263, "syd": 375, "bog": 418, "jnb": 477, "gdl": 498, "phx": 201, "fra": 150, "yyz": 171, "lhr": 193, "atl": 242, "gig": 344, "nrt": 426, "mia": 175, "otp": 439, "lax": 146, "ams": 233, "dfw": 206, "sjc": 136, "cdg": 154 }, { "timestamp": "2025-08-20T05:00:00.000Z", "eze": 448, "scl": 498, "hkg": 348, "bom": 445, "ewr": 142, "iad": 343, "bos": 116, "arn": 198, "mad": 333, "sin": 401, "sjc": 127, "dfw": 188, "ams": 176, "lax": 142, "otp": 315, "cdg": 145, "gig": 350, "atl": 300, "mia": 197, "nrt": 421, "phx": 265, "gdl": 566, "jnb": 465, "bog": 397, "lhr": 144, "yyz": 170, "fra": 165, "gru": 212, "den": 234, "ord": 158, "sea": 162, "yul": 118, "syd": 379 }, { "timestamp": "2025-08-20T05:30:00.000Z", "bos": 155, "iad": 154, "sin": 421, "mad": 334, "arn": 203, "eze": 452, "bom": 290, "ewr": 185, "hkg": 361, "scl": 501, "fra": 151, "lhr": 162, "yyz": 155, "bog": 411, "gdl": 567, "phx": 202, "jnb": 457, "syd": 319, "sea": 165, "den": 241, "ord": 188, "yul": 125, "gru": 229, "cdg": 176, "otp": 273, "lax": 156, "dfw": 192, "sjc": 133, "ams": 177, "nrt": 420, "mia": 181, "atl": 260, "gig": 353 }, { "timestamp": "2025-08-20T06:00:00.000Z", "atl": 192, "gig": 374, "mia": 173, "nrt": 364, "otp": 283, "lax": 170, "sjc": 130, "dfw": 170, "ams": 187, "cdg": 181, "den": 230, "ord": 150, "sea": 167, "yul": 114, "gru": 187, "syd": 419, "bog": 483, "phx": 223, "gdl": 627, "jnb": 391, "fra": 156, "lhr": 144, "yyz": 164, "hkg": 389, "scl": 496, "ewr": 152, "bom": 286, "eze": 453, "arn": 327, "sin": 457, "mad": 351, "iad": 165, "bos": 150 }, { "timestamp": "2025-08-20T06:30:00.000Z", "mad": 329, "sin": 525, "arn": 361, "bos": 149, "iad": 135, "bom": 337, "ewr": 128, "scl": 520, "hkg": 329, "eze": 474, "syd": 414, "gru": 283, "ord": 152, "den": 226, "sea": 191, "yul": 115, "lhr": 159, "yyz": 213, "fra": 163, "phx": 227, "gdl": 624, "jnb": 505, "bog": 405, "mia": 208, "nrt": 436, "gig": 346, "atl": 179, "cdg": 189, "sjc": 140, "dfw": 188, "ams": 197, "lax": 174, "otp": 285 }, { "timestamp": "2025-08-20T07:00:00.000Z", "eze": 450, "ewr": 130, "bom": 357, "hkg": 365, "scl": 504, "bos": 125, "iad": 126, "sin": 469, "mad": 374, "arn": 254, "cdg": 210, "otp": 317, "lax": 149, "ams": 172, "dfw": 216, "sjc": 162, "nrt": 412, "mia": 210, "atl": 157, "gig": 346, "fra": 269, "yyz": 174, "lhr": 177, "bog": 483, "jnb": 473, "gdl": 599, "phx": 225, "syd": 364, "yul": 119, "sea": 159, "ord": 181, "den": 242, "gru": 272 }, { "timestamp": "2025-08-20T07:30:00.000Z", "iad": 157, "bos": 137, "arn": 255, "mad": 374, "sin": 408, "eze": 439, "scl": 496, "hkg": 398, "ewr": 170, "bom": 304, "jnb": 501, "phx": 198, "gdl": 628, "bog": 411, "yyz": 179, "lhr": 140, "fra": 152, "gru": 221, "yul": 134, "ord": 174, "den": 232, "sea": 171, "syd": 346, "ams": 183, "sjc": 139, "dfw": 227, "lax": 174, "otp": 289, "cdg": 225, "gig": 344, "atl": 189, "mia": 203, "nrt": 444 }, { "timestamp": "2025-08-20T08:00:00.000Z", "eze": 456, "ewr": 149, "bom": 306, "scl": 501, "hkg": 537, "bos": 128, "iad": 167, "mad": 346, "sin": 403, "arn": 233, "cdg": 184, "ams": 211, "sjc": 124, "dfw": 205, "otp": 274, "lax": 232, "mia": 209, "nrt": 368, "gig": 349, "atl": 185, "yyz": 159, "lhr": 148, "fra": 166, "jnb": 452, "phx": 193, "gdl": 500, "bog": 392, "syd": 376, "gru": 238, "yul": 122, "ord": 153, "den": 251, "sea": 171 }, { "timestamp": "2025-08-20T08:30:00.000Z", "nrt": 427, "mia": 212, "atl": 149, "gig": 353, "cdg": 266, "otp": 303, "lax": 148, "ams": 264, "dfw": 200, "sjc": 131, "syd": 376, "yul": 120, "sea": 175, "den": 233, "ord": 177, "gru": 268, "fra": 162, "yyz": 151, "lhr": 357, "bog": 497, "jnb": 520, "gdl": 586, "phx": 201, "bom": 292, "ewr": 130, "hkg": 395, "scl": 496, "eze": 455, "sin": 486, "mad": 359, "arn": 205, "bos": 124, "iad": 149 }, { "timestamp": "2025-08-20T09:00:00.000Z", "ewr": 140, "bom": 318, "scl": 492, "hkg": 422, "eze": 452, "mad": 386, "sin": 456, "arn": 200, "bos": 110, "iad": 132, "nrt": 353, "mia": 180, "gig": 356, "atl": 164, "cdg": 157, "ams": 208, "dfw": 233, "sjc": 132, "lax": 153, "otp": 303, "syd": 413, "gru": 209, "yul": 118, "sea": 175, "den": 225, "ord": 155, "yyz": 160, "lhr": 152, "fra": 187, "jnb": 501, "gdl": 596, "phx": 199, "bog": 400 }, { "timestamp": "2025-08-20T09:30:00.000Z", "otp": 296, "lax": 157, "dfw": 199, "sjc": 146, "ams": 176, "cdg": 143, "atl": 153, "gig": 349, "nrt": 438, "mia": 212, "bog": 417, "gdl": 568, "phx": 214, "jnb": 394, "fra": 153, "lhr": 171, "yyz": 166, "sea": 173, "den": 234, "ord": 166, "yul": 146, "gru": 190, "syd": 414, "eze": 475, "hkg": 476, "scl": 498, "ewr": 125, "bom": 356, "iad": 174, "bos": 115, "arn": 202, "sin": 427, "mad": 346 }, { "timestamp": "2025-08-20T10:00:00.000Z", "bos": 130, "iad": 134, "mad": 388, "sin": 433, "arn": 195, "eze": 450, "bom": 330, "ewr": 161, "scl": 511, "hkg": 339, "lhr": 148, "yyz": 142, "fra": 165, "gdl": 614, "phx": 202, "jnb": 529, "bog": 436, "syd": 312, "gru": 207, "sea": 197, "ord": 200, "den": 252, "yul": 128, "cdg": 196, "dfw": 170, "sjc": 144, "ams": 178, "otp": 281, "lax": 159, "nrt": 432, "mia": 205, "gig": 343, "atl": 166 }, { "timestamp": "2025-08-20T10:30:00.000Z", "gig": 348, "atl": 199, "mia": 231, "nrt": 297, "sjc": 139, "dfw": 176, "ams": 210, "lax": 186, "otp": 293, "cdg": 200, "gru": 303, "ord": 173, "den": 233, "sea": 189, "yul": 127, "syd": 332, "phx": 196, "gdl": 645, "jnb": 537, "bog": 541, "lhr": 179, "yyz": 134, "fra": 165, "scl": 530, "hkg": 419, "ewr": 131, "bom": 331, "eze": 456, "arn": 186, "mad": 634, "sin": 456, "iad": 294, "bos": 121 }, { "timestamp": "2025-08-20T11:00:00.000Z", "hkg": 474, "scl": 432, "bom": 335, "ewr": 182, "eze": 456, "arn": 199, "sin": 437, "mad": 354, "iad": 203, "bos": 127, "atl": 226, "gig": 299, "mia": 234, "nrt": 436, "otp": 285, "lax": 177, "sjc": 141, "dfw": 166, "ams": 195, "cdg": 148, "ord": 199, "den": 231, "sea": 179, "yul": 132, "gru": 229, "syd": 316, "bog": 495, "phx": 199, "gdl": 543, "jnb": 461, "fra": 187, "lhr": 175, "yyz": 158 }, { "timestamp": "2025-08-20T11:30:00.000Z", "cdg": 339, "ams": 176, "sjc": 199, "dfw": 191, "lax": 168, "otp": 296, "mia": 188, "nrt": 460, "gig": 441, "atl": 152, "yyz": 157, "lhr": 191, "fra": 209, "jnb": 385, "phx": 282, "gdl": 598, "bog": 392, "syd": 430, "gru": 279, "yul": 124, "ord": 183, "den": 238, "sea": 218, "eze": 462, "bom": 325, "ewr": 127, "scl": 500, "hkg": 412, "bos": 160, "iad": 170, "mad": 342, "sin": 409, "arn": 220 }, { "timestamp": "2025-08-20T12:00:00.000Z", "yul": 122, "sea": 170, "ord": 170, "den": 252, "gru": 239, "syd": 439, "bog": 395, "jnb": 487, "gdl": 614, "phx": 203, "fra": 161, "yyz": 164, "lhr": 146, "atl": 149, "gig": 367, "nrt": 463, "mia": 234, "lax": 159, "otp": 298, "ams": 178, "dfw": 182, "sjc": 141, "cdg": 143, "arn": 192, "sin": 412, "mad": 343, "iad": 187, "bos": 117, "hkg": 422, "scl": 503, "bom": 322, "ewr": 156, "eze": 482 }, { "timestamp": "2025-08-20T12:30:00.000Z", "iad": 186, "bos": 126, "arn": 198, "mad": 339, "sin": 432, "eze": 448, "scl": 510, "hkg": 331, "ewr": 168, "bom": 317, "jnb": 465, "phx": 198, "gdl": 543, "bog": 475, "yyz": 166, "lhr": 146, "fra": 184, "gru": 300, "yul": 126, "den": 227, "ord": 174, "sea": 196, "syd": 330, "ams": 192, "sjc": 132, "dfw": 174, "lax": 159, "otp": 284, "cdg": 163, "gig": 362, "atl": 202, "mia": 196, "nrt": 444 }, { "timestamp": "2025-08-20T13:00:00.000Z", "eze": 411, "hkg": 527, "scl": 498, "bom": 335, "ewr": 266, "iad": 186, "bos": 112, "arn": 201, "sin": 496, "mad": 338, "otp": 326, "lax": 168, "sjc": 145, "dfw": 177, "ams": 189, "cdg": 158, "atl": 184, "gig": 365, "mia": 223, "nrt": 365, "bog": 408, "phx": 198, "gdl": 606, "jnb": 426, "fra": 196, "lhr": 145, "yyz": 150, "ord": 186, "den": 222, "sea": 171, "yul": 125, "gru": 335, "syd": 292 }, { "timestamp": "2025-08-20T13:30:00.000Z", "bos": 118, "iad": 131, "mad": 406, "sin": 436, "arn": 192, "eze": 458, "bom": 355, "ewr": 165, "scl": 495, "hkg": 331, "lhr": 150, "yyz": 220, "fra": 163, "gdl": 632, "phx": 188, "jnb": 405, "bog": 464, "syd": 364, "gru": 258, "sea": 168, "ord": 164, "den": 234, "yul": 119, "cdg": 154, "dfw": 207, "sjc": 129, "ams": 181, "lax": 194, "otp": 272, "nrt": 453, "mia": 301, "gig": 353, "atl": 162 }, { "timestamp": "2025-08-20T14:00:00.000Z", "arn": 211, "sin": 424, "mad": 360, "iad": 161, "bos": 119, "hkg": 348, "scl": 368, "ewr": 158, "bom": 314, "eze": 452, "yul": 138, "den": 221, "ord": 145, "sea": 188, "gru": 202, "syd": 364, "bog": 458, "jnb": 445, "phx": 283, "gdl": 613, "fra": 176, "yyz": 149, "lhr": 206, "atl": 227, "gig": 384, "mia": 202, "nrt": 355, "lax": 180, "otp": 282, "ams": 176, "sjc": 135, "dfw": 209, "cdg": 159 }, { "timestamp": "2025-08-20T14:30:00.000Z", "mia": 184, "nrt": 424, "gig": 364, "atl": 206, "cdg": 153, "ams": 221, "sjc": 133, "dfw": 178, "otp": 276, "lax": 150, "syd": 286, "gru": 224, "yul": 114, "ord": 132, "den": 238, "sea": 156, "yyz": 140, "lhr": 136, "fra": 233, "jnb": 414, "phx": 194, "gdl": 619, "bog": 433, "bom": 488, "ewr": 155, "scl": 542, "hkg": 313, "eze": 381, "mad": 322, "sin": 424, "arn": 201, "bos": 162, "iad": 146 }, { "timestamp": "2025-08-20T15:00:00.000Z", "eze": 452, "ewr": 151, "bom": 303, "scl": 442, "hkg": 335, "bos": 130, "iad": 186, "mad": 368, "sin": 462, "arn": 214, "cdg": 153, "ams": 182, "dfw": 223, "sjc": 134, "lax": 207, "otp": 282, "nrt": 360, "mia": 181, "gig": 367, "atl": 149, "yyz": 147, "lhr": 170, "fra": 211, "jnb": 463, "gdl": 606, "phx": 195, "bog": 395, "syd": 418, "gru": 195, "yul": 110, "sea": 163, "den": 228, "ord": 147 }, { "timestamp": "2025-08-20T15:30:00.000Z", "iad": 198, "bos": 116, "arn": 206, "sin": 498, "mad": 422, "eze": 452, "hkg": 324, "scl": 514, "ewr": 157, "bom": 314, "bog": 408, "jnb": 499, "phx": 238, "gdl": 608, "fra": 151, "yyz": 157, "lhr": 359, "yul": 119, "den": 276, "ord": 168, "sea": 199, "gru": 224, "syd": 386, "otp": 271, "lax": 176, "ams": 185, "sjc": 131, "dfw": 192, "cdg": 147, "atl": 218, "gig": 376, "mia": 191, "nrt": 388 }, { "timestamp": "2025-08-20T16:00:00.000Z", "arn": 185, "sin": 430, "mad": 378, "iad": 325, "bos": 129, "hkg": 387, "scl": 380, "bom": 321, "ewr": 130, "eze": 449, "ord": 149, "den": 234, "sea": 158, "yul": 144, "gru": 294, "syd": 309, "bog": 429, "phx": 207, "gdl": 599, "jnb": 404, "fra": 157, "lhr": 141, "yyz": 137, "atl": 147, "gig": 372, "mia": 194, "nrt": 391, "otp": 307, "lax": 180, "sjc": 162, "dfw": 193, "ams": 206, "cdg": 188 }, { "timestamp": "2025-08-20T16:30:00.000Z", "bog": 447, "phx": 204, "gdl": 622, "jnb": 502, "fra": 162, "lhr": 183, "yyz": 162, "ord": 151, "den": 216, "sea": 175, "yul": 108, "gru": 223, "syd": 413, "lax": 175, "otp": 276, "sjc": 139, "dfw": 188, "ams": 203, "cdg": 151, "atl": 163, "gig": 356, "mia": 182, "nrt": 591, "iad": 150, "bos": 135, "arn": 195, "sin": 420, "mad": 345, "eze": 437, "hkg": 464, "scl": 498, "bom": 425, "ewr": 124 }, { "timestamp": "2025-08-20T17:00:00.000Z", "iad": 361, "bos": 126, "arn": 223, "sin": 458, "mad": 347, "eze": 469, "hkg": 371, "scl": 496, "bom": 298, "ewr": 140, "bog": 394, "gdl": 636, "phx": 194, "jnb": 438, "fra": 220, "lhr": 155, "yyz": 147, "sea": 167, "den": 228, "ord": 161, "yul": 125, "gru": 282, "syd": 442, "lax": 173, "otp": 329, "dfw": 174, "sjc": 141, "ams": 225, "cdg": 177, "atl": 172, "gig": 363, "nrt": 858, "mia": 181 }, { "timestamp": "2025-08-20T17:30:00.000Z", "sin": 421, "mad": 345, "arn": 243, "bos": 114, "iad": 183, "bom": 313, "ewr": 133, "hkg": 415, "scl": 502, "eze": 455, "syd": 412, "sea": 160, "den": 230, "ord": 206, "yul": 112, "gru": 195, "fra": 174, "lhr": 141, "yyz": 138, "bog": 493, "gdl": 552, "phx": 206, "jnb": 454, "nrt": 457, "mia": 216, "atl": 149, "gig": 354, "cdg": 173, "lax": 169, "otp": 277, "dfw": 197, "sjc": 133, "ams": 187 }, { "timestamp": "2025-08-20T18:00:00.000Z", "bos": 111, "iad": 419, "sin": 456, "mad": 336, "arn": 185, "eze": 452, "ewr": 131, "bom": 298, "hkg": 327, "scl": 515, "fra": 196, "lhr": 139, "yyz": 159, "bog": 388, "phx": 207, "gdl": 586, "jnb": 439, "syd": 295, "ord": 148, "den": 219, "sea": 190, "yul": 130, "gru": 196, "cdg": 194, "lax": 194, "otp": 287, "sjc": 140, "dfw": 228, "ams": 181, "mia": 179, "nrt": 474, "atl": 154, "gig": 374 }, { "timestamp": "2025-08-20T18:30:00.000Z", "eze": 466, "ewr": 131, "bom": 308, "hkg": 326, "scl": 497, "bos": 120, "iad": 295, "sin": 438, "mad": 332, "arn": 200, "cdg": 148, "otp": 302, "lax": 157, "ams": 186, "dfw": 209, "sjc": 146, "nrt": 427, "mia": 254, "atl": 164, "gig": 361, "fra": 155, "yyz": 162, "lhr": 139, "bog": 462, "jnb": 445, "gdl": 581, "phx": 188, "syd": 381, "yul": 125, "sea": 175, "ord": 152, "den": 235, "gru": 219 }, { "timestamp": "2025-08-20T19:00:00.000Z", "yul": 110, "sea": 172, "den": 235, "ord": 167, "gru": 190, "syd": 375, "bog": 390, "jnb": 365, "gdl": 595, "phx": 266, "fra": 198, "yyz": 198, "lhr": 152, "atl": 152, "gig": 360, "nrt": 428, "mia": 211, "lax": 174, "otp": 396, "ams": 207, "dfw": 269, "sjc": 158, "cdg": 174, "arn": 204, "sin": 410, "mad": 410, "iad": 325, "bos": 147, "hkg": 339, "scl": 516, "ewr": 195, "bom": 309, "eze": 447 }, { "timestamp": "2025-08-20T19:30:00.000Z", "arn": 186, "mad": 343, "sin": 439, "iad": 150, "bos": 119, "scl": 510, "hkg": 366, "ewr": 134, "bom": 360, "eze": 448, "gru": 309, "yul": 123, "ord": 161, "den": 230, "sea": 164, "syd": 291, "jnb": 447, "phx": 193, "gdl": 550, "bog": 393, "yyz": 145, "lhr": 141, "fra": 158, "gig": 379, "atl": 182, "mia": 177, "nrt": 423, "ams": 171, "sjc": 126, "dfw": 213, "otp": 285, "lax": 152, "cdg": 139 }, { "timestamp": "2025-08-20T20:00:00.000Z", "gru": 269, "yul": 143, "ord": 244, "den": 265, "sea": 186, "syd": 392, "jnb": 455, "phx": 189, "gdl": 691, "bog": 437, "yyz": 164, "lhr": 141, "fra": 159, "gig": 364, "atl": 158, "mia": 242, "nrt": 343, "ams": 211, "sjc": 127, "dfw": 174, "otp": 320, "lax": 407, "cdg": 185, "arn": 193, "mad": 344, "sin": 433, "iad": 190, "bos": 108, "scl": 461, "hkg": 334, "ewr": 167, "bom": 294, "eze": 470 }, { "timestamp": "2025-08-20T20:30:00.000Z", "gig": 354, "atl": 167, "nrt": 356, "mia": 181, "dfw": 194, "sjc": 132, "ams": 170, "lax": 153, "otp": 269, "cdg": 171, "gru": 215, "sea": 174, "ord": 172, "den": 222, "yul": 123, "syd": 426, "gdl": 603, "phx": 199, "jnb": 455, "bog": 422, "lhr": 179, "yyz": 143, "fra": 158, "scl": 504, "hkg": 334, "ewr": 162, "bom": 303, "eze": 456, "arn": 194, "mad": 355, "sin": 419, "iad": 279, "bos": 117 }, { "timestamp": "2025-08-20T21:00:00.000Z", "scl": 505, "hkg": 460, "ewr": 180, "bom": 289, "eze": 459, "arn": 207, "mad": 330, "sin": 452, "iad": 437, "bos": 116, "gig": 350, "atl": 158, "mia": 219, "nrt": 415, "sjc": 134, "dfw": 199, "ams": 177, "lax": 150, "otp": 270, "cdg": 169, "gru": 194, "den": 248, "ord": 162, "sea": 169, "yul": 113, "syd": 370, "phx": 202, "gdl": 564, "jnb": 449, "bog": 392, "lhr": 153, "yyz": 146, "fra": 154 }, { "timestamp": "2025-08-20T21:30:00.000Z", "sjc": 169, "dfw": 166, "ams": 187, "otp": 276, "lax": 152, "cdg": 167, "gig": 355, "atl": 147, "mia": 214, "nrt": 434, "phx": 218, "gdl": 632, "jnb": 453, "bog": 421, "lhr": 188, "yyz": 151, "fra": 148, "gru": 195, "den": 241, "ord": 178, "sea": 163, "yul": 119, "syd": 330, "eze": 390, "scl": 497, "hkg": 560, "ewr": 147, "bom": 282, "iad": 206, "bos": 129, "arn": 200, "mad": 340, "sin": 434 }, { "timestamp": "2025-08-20T22:00:00.000Z", "arn": 198, "mad": 368, "sin": 473, "iad": 282, "bos": 106, "scl": 507, "hkg": 340, "bom": 292, "ewr": 134, "eze": 446, "gru": 190, "yul": 110, "sea": 175, "den": 237, "ord": 150, "syd": 340, "jnb": 465, "gdl": 638, "phx": 210, "bog": 388, "yyz": 138, "lhr": 139, "fra": 154, "gig": 385, "atl": 142, "nrt": 373, "mia": 173, "ams": 177, "dfw": 169, "sjc": 143, "otp": 282, "lax": 176, "cdg": 149 }, { "timestamp": "2025-08-20T22:30:00.000Z", "jnb": 520, "gdl": 638, "phx": 194, "bog": 460, "yyz": 195, "lhr": 166, "fra": 157, "gru": 213, "yul": 127, "sea": 171, "den": 239, "ord": 197, "syd": 330, "ams": 177, "dfw": 208, "sjc": 143, "lax": 208, "otp": 309, "cdg": 155, "gig": 355, "atl": 154, "nrt": 420, "mia": 211, "iad": 140, "bos": 117, "arn": 202, "mad": 337, "sin": 443, "eze": 450, "scl": 509, "hkg": 431, "bom": 304, "ewr": 144 }, { "timestamp": "2025-08-20T23:00:00.000Z", "lhr": 142, "yyz": 136, "fra": 148, "phx": 205, "gdl": 515, "jnb": 396, "bog": 474, "syd": 426, "gru": 205, "ord": 149, "den": 226, "sea": 169, "yul": 124, "cdg": 157, "sjc": 144, "dfw": 174, "ams": 193, "otp": 293, "lax": 159, "mia": 180, "nrt": 353, "gig": 373, "atl": 176, "bos": 146, "iad": 370, "mad": 344, "sin": 424, "arn": 187, "eze": 387, "bom": 312, "ewr": 144, "scl": 464, "hkg": 394 }, { "timestamp": "2025-08-20T23:30:00.000Z", "mad": 333, "sin": 448, "arn": 213, "bos": 118, "iad": 282, "bom": 396, "ewr": 138, "scl": 506, "hkg": 460, "eze": 451, "syd": 369, "gru": 199, "ord": 137, "den": 231, "sea": 161, "yul": 120, "lhr": 147, "yyz": 148, "fra": 150, "phx": 194, "gdl": 560, "jnb": 365, "bog": 513, "mia": 174, "nrt": 425, "gig": 374, "atl": 188, "cdg": 155, "sjc": 172, "dfw": 208, "ams": 174, "lax": 155, "otp": 316 }, { "timestamp": "2025-08-21T00:00:00.000Z", "cdg": 144, "otp": 275, "lax": 162, "ams": 173, "dfw": 183, "sjc": 138, "nrt": 425, "mia": 181, "atl": 172, "gig": 352, "fra": 152, "yyz": 177, "lhr": 157, "bog": 481, "jnb": 366, "gdl": 589, "phx": 195, "syd": 319, "yul": 113, "sea": 168, "ord": 142, "den": 229, "gru": 193, "eze": 447, "ewr": 134, "bom": 294, "hkg": 341, "scl": 453, "bos": 129, "iad": 423, "sin": 414, "mad": 322, "arn": 190 }, { "timestamp": "2025-08-21T00:30:00.000Z", "ewr": 145, "bom": 329, "hkg": 356, "scl": 500, "eze": 450, "sin": 534, "mad": 343, "arn": 197, "bos": 101, "iad": 227, "nrt": 362, "mia": 172, "atl": 156, "gig": 351, "cdg": 161, "lax": 147, "otp": 263, "ams": 173, "dfw": 186, "sjc": 130, "syd": 420, "yul": 110, "sea": 194, "ord": 164, "den": 223, "gru": 206, "fra": 189, "yyz": 131, "lhr": 144, "bog": 386, "jnb": 365, "gdl": 622, "phx": 206 }, { "timestamp": "2025-08-21T01:00:00.000Z", "mia": 209, "nrt": 359, "atl": 200, "gig": 302, "cdg": 147, "lax": 152, "otp": 278, "ams": 203, "sjc": 136, "dfw": 203, "syd": 333, "yul": 113, "den": 226, "ord": 166, "sea": 288, "gru": 209, "fra": 165, "yyz": 138, "lhr": 141, "bog": 506, "jnb": 447, "phx": 208, "gdl": 620, "ewr": 183, "bom": 346, "hkg": 471, "scl": 432, "eze": 458, "sin": 454, "mad": 376, "arn": 203, "bos": 125, "iad": 250 }, { "timestamp": "2025-08-21T01:30:00.000Z", "syd": 411, "sea": 173, "den": 234, "ord": 136, "yul": 105, "gru": 280, "fra": 168, "lhr": 182, "yyz": 159, "bog": 478, "gdl": 607, "phx": 195, "jnb": 511, "nrt": 434, "mia": 191, "atl": 227, "gig": 389, "cdg": 149, "otp": 283, "lax": 213, "dfw": 244, "sjc": 130, "ams": 202, "sin": 558, "mad": 346, "arn": 205, "bos": 115, "iad": 174, "ewr": 157, "bom": 291, "hkg": 357, "scl": 513, "eze": 385 }, { "timestamp": "2025-08-21T02:00:00.000Z", "fra": 151, "lhr": 149, "yyz": 131, "bog": 404, "phx": 218, "gdl": 576, "jnb": 458, "syd": 314, "ord": 189, "den": 226, "sea": 161, "yul": 118, "gru": 191, "cdg": 142, "otp": 269, "lax": 168, "sjc": 162, "dfw": 202, "ams": 169, "mia": 150, "nrt": 418, "atl": 249, "gig": 348, "bos": 104, "iad": 213, "sin": 564, "mad": 361, "arn": 203, "eze": 566, "bom": 303, "ewr": 123, "hkg": 356, "scl": 497 }, { "timestamp": "2025-08-21T02:30:00.000Z", "eze": 473, "ewr": 138, "bom": 282, "scl": 505, "hkg": 324, "bos": 123, "iad": 188, "mad": 338, "sin": 419, "arn": 206, "cdg": 152, "ams": 177, "dfw": 175, "sjc": 133, "lax": 157, "otp": 273, "nrt": 423, "mia": 193, "gig": 350, "atl": 247, "yyz": 157, "lhr": 236, "fra": 152, "jnb": 531, "gdl": 612, "phx": 195, "bog": 413, "syd": 416, "gru": 193, "yul": 119, "sea": 163, "den": 224, "ord": 136 }, { "timestamp": "2025-08-21T03:00:00.000Z", "gru": 204, "yul": 188, "sea": 172, "ord": 152, "den": 229, "syd": 414, "jnb": 521, "gdl": 630, "phx": 190, "bog": 413, "yyz": 140, "lhr": 144, "fra": 201, "gig": 347, "atl": 386, "nrt": 352, "mia": 160, "ams": 182, "dfw": 168, "sjc": 138, "otp": 264, "lax": 150, "cdg": 137, "arn": 212, "mad": 344, "sin": 447, "iad": 134, "bos": 108, "scl": 506, "hkg": 317, "ewr": 127, "bom": 321, "eze": 431 }, { "timestamp": "2025-08-21T03:30:00.000Z", "gig": 347, "atl": 278, "mia": 170, "nrt": 375, "sjc": 131, "dfw": 195, "ams": 177, "lax": 152, "otp": 260, "cdg": 175, "gru": 270, "ord": 155, "den": 217, "sea": 192, "yul": 142, "syd": 379, "phx": 188, "gdl": 594, "jnb": 387, "bog": 390, "lhr": 144, "yyz": 160, "fra": 198, "scl": 504, "hkg": 339, "ewr": 139, "bom": 336, "eze": 444, "arn": 198, "mad": 372, "sin": 465, "iad": 176, "bos": 105 }, { "timestamp": "2025-08-21T04:00:00.000Z", "scl": 503, "hkg": 330, "ewr": 186, "bom": 286, "eze": 446, "arn": 197, "mad": 325, "sin": 450, "iad": 168, "bos": 113, "gig": 351, "atl": 323, "mia": 162, "nrt": 247, "sjc": 138, "dfw": 172, "ams": 232, "lax": 153, "otp": 281, "cdg": 148, "gru": 211, "ord": 140, "den": 235, "sea": 155, "yul": 112, "syd": 403, "phx": 194, "gdl": 547, "jnb": 380, "bog": 402, "lhr": 215, "yyz": 144, "fra": 159 }, { "timestamp": "2025-08-21T04:30:00.000Z", "atl": 240, "gig": 376, "nrt": 445, "mia": 216, "otp": 275, "lax": 179, "dfw": 173, "sjc": 157, "ams": 235, "cdg": 165, "sea": 168, "den": 220, "ord": 174, "yul": 110, "gru": 202, "syd": 440, "bog": 437, "gdl": 543, "phx": 194, "jnb": 504, "fra": 172, "lhr": 175, "yyz": 136, "hkg": 323, "scl": 454, "ewr": 140, "bom": 304, "eze": 460, "arn": 197, "sin": 400, "mad": 466, "iad": 156, "bos": 137 }, { "timestamp": "2025-08-21T05:00:00.000Z", "hkg": 388, "scl": 506, "ewr": 121, "bom": 288, "eze": 417, "arn": 210, "sin": 437, "mad": 354, "iad": 126, "bos": 99, "atl": 237, "gig": 319, "mia": 161, "nrt": 432, "otp": 330, "lax": 162, "sjc": 134, "dfw": 169, "ams": 179, "cdg": 149, "ord": 144, "den": 235, "sea": 198, "yul": 106, "gru": 336, "syd": 338, "bog": 433, "phx": 213, "gdl": 580, "jnb": 519, "fra": 156, "lhr": 170, "yyz": 230 }, { "timestamp": "2025-08-21T05:30:00.000Z", "lax": 187, "otp": 261, "sjc": 163, "dfw": 178, "ams": 229, "cdg": 176, "atl": 130, "gig": 348, "mia": 156, "nrt": 367, "bog": 396, "phx": 189, "gdl": 648, "jnb": 457, "fra": 208, "lhr": 240, "yyz": 145, "ord": 140, "den": 231, "sea": 172, "yul": 109, "gru": 184, "syd": 380, "eze": 452, "hkg": 334, "scl": 500, "ewr": 151, "bom": 307, "iad": 146, "bos": 96, "arn": 192, "sin": 403, "mad": 350 }, { "timestamp": "2025-08-21T06:00:00.000Z", "iad": 138, "bos": 117, "arn": 302, "sin": 415, "mad": 331, "eze": 454, "hkg": 356, "scl": 500, "bom": 297, "ewr": 130, "bog": 400, "jnb": 476, "phx": 193, "gdl": 598, "fra": 179, "yyz": 190, "lhr": 153, "yul": 113, "den": 233, "ord": 148, "sea": 169, "gru": 205, "syd": 428, "lax": 154, "otp": 282, "ams": 187, "sjc": 130, "dfw": 177, "cdg": 157, "atl": 122, "gig": 346, "mia": 155, "nrt": 246 }, { "timestamp": "2025-08-21T06:30:00.000Z", "yul": 112, "den": 236, "ord": 141, "sea": 174, "gru": 285, "syd": 323, "bog": 433, "jnb": 456, "phx": 192, "gdl": 612, "fra": 221, "yyz": 145, "lhr": 151, "atl": 129, "gig": 426, "mia": 194, "nrt": 436, "otp": 277, "lax": 165, "ams": 240, "sjc": 128, "dfw": 168, "cdg": 248, "arn": 197, "sin": 432, "mad": 382, "iad": 145, "bos": 161, "hkg": 318, "scl": 509, "bom": 324, "ewr": 183, "eze": 446 }, { "timestamp": "2025-08-21T07:00:00.000Z", "fra": 154, "lhr": 168, "yyz": 157, "bog": 399, "phx": 273, "gdl": 585, "jnb": 358, "syd": 366, "den": 223, "ord": 135, "sea": 165, "yul": 112, "gru": 224, "cdg": 268, "lax": 161, "otp": 314, "sjc": 131, "dfw": 187, "ams": 197, "mia": 177, "nrt": 428, "atl": 135, "gig": 248, "bos": 117, "iad": 136, "sin": 425, "mad": 334, "arn": 246, "eze": 455, "bom": 346, "ewr": 155, "hkg": 332, "scl": 496 }, { "timestamp": "2025-08-21T07:30:00.000Z", "sin": 452, "mad": 341, "arn": 183, "bos": 181, "iad": 141, "bom": 318, "ewr": 153, "hkg": 368, "scl": 498, "eze": 438, "syd": 357, "den": 235, "ord": 158, "sea": 165, "yul": 112, "gru": 203, "fra": 170, "lhr": 206, "yyz": 222, "bog": 480, "phx": 204, "gdl": 593, "jnb": 444, "mia": 192, "nrt": 426, "atl": 140, "gig": 287, "cdg": 155, "otp": 270, "lax": 159, "sjc": 142, "dfw": 180, "ams": 174 }, { "timestamp": "2025-08-21T08:00:00.000Z", "bos": 117, "iad": 135, "sin": 417, "mad": 335, "arn": 189, "eze": 452, "ewr": 135, "bom": 295, "hkg": 356, "scl": 442, "fra": 211, "yyz": 164, "lhr": 147, "bog": 435, "jnb": 373, "phx": 238, "gdl": 562, "syd": 416, "yul": 119, "ord": 138, "den": 246, "sea": 171, "gru": 228, "cdg": 260, "otp": 310, "lax": 158, "ams": 181, "sjc": 187, "dfw": 178, "mia": 164, "nrt": 435, "atl": 120, "gig": 351 }, { "timestamp": "2025-08-21T08:30:00.000Z", "eze": 379, "scl": 531, "hkg": 477, "ewr": 170, "bom": 291, "iad": 135, "bos": 104, "arn": 258, "mad": 334, "sin": 412, "ams": 191, "dfw": 176, "sjc": 142, "lax": 156, "otp": 280, "cdg": 194, "gig": 348, "atl": 177, "nrt": 428, "mia": 179, "jnb": 495, "gdl": 587, "phx": 217, "bog": 467, "yyz": 165, "lhr": 147, "fra": 168, "gru": 211, "yul": 120, "sea": 171, "ord": 136, "den": 240, "syd": 288 }, { "timestamp": "2025-08-21T09:00:00.000Z", "gig": 369, "atl": 142, "mia": 150, "nrt": 439, "ams": 196, "sjc": 158, "dfw": 200, "otp": 274, "lax": 177, "cdg": 159, "gru": 223, "yul": 105, "den": 338, "ord": 180, "sea": 159, "syd": 392, "jnb": 464, "phx": 184, "gdl": 554, "bog": 398, "yyz": 151, "lhr": 179, "fra": 193, "scl": 494, "hkg": 326, "bom": 400, "ewr": 142, "eze": 454, "arn": 219, "mad": 377, "sin": 425, "iad": 152, "bos": 124 }, { "timestamp": "2025-08-21T09:30:00.000Z", "eze": 448, "hkg": 458, "scl": 504, "ewr": 146, "bom": 328, "iad": 144, "bos": 123, "arn": 188, "sin": 517, "mad": 354, "otp": 275, "lax": 211, "ams": 183, "dfw": 193, "sjc": 176, "cdg": 146, "atl": 162, "gig": 358, "nrt": 454, "mia": 173, "bog": 499, "jnb": 371, "gdl": 606, "phx": 188, "fra": 180, "yyz": 150, "lhr": 312, "yul": 116, "sea": 175, "ord": 151, "den": 261, "gru": 194, "syd": 298 }, { "timestamp": "2025-08-21T10:00:00.000Z", "yyz": 138, "lhr": 141, "fra": 169, "jnb": 494, "gdl": 574, "phx": 189, "bog": 497, "syd": 412, "gru": 337, "yul": 122, "sea": 178, "den": 232, "ord": 203, "cdg": 146, "ams": 195, "dfw": 180, "sjc": 132, "otp": 269, "lax": 150, "nrt": 454, "mia": 173, "gig": 353, "atl": 140, "bos": 112, "iad": 145, "mad": 436, "sin": 436, "arn": 202, "eze": 451, "bom": 289, "ewr": 149, "scl": 499, "hkg": 470 }, { "timestamp": "2025-08-21T10:30:00.000Z", "arn": 195, "sin": 506, "mad": 325, "iad": 211, "bos": 120, "hkg": 349, "scl": 505, "bom": 289, "ewr": 158, "eze": 385, "sea": 189, "den": 235, "ord": 169, "yul": 120, "gru": 203, "syd": 413, "bog": 486, "gdl": 606, "phx": 196, "jnb": 454, "fra": 158, "lhr": 170, "yyz": 129, "atl": 196, "gig": 382, "nrt": 423, "mia": 187, "lax": 170, "otp": 284, "dfw": 189, "sjc": 136, "ams": 173, "cdg": 142 }, { "timestamp": "2025-08-21T11:00:00.000Z", "gru": 194, "sea": 175, "den": 234, "ord": 174, "yul": 118, "syd": 429, "gdl": 599, "phx": 208, "jnb": 461, "bog": 459, "lhr": 146, "yyz": 148, "fra": 155, "gig": 355, "atl": 165, "nrt": 428, "mia": 172, "dfw": 217, "sjc": 135, "ams": 178, "otp": 283, "lax": 197, "cdg": 211, "arn": 186, "mad": 349, "sin": 460, "iad": 144, "bos": 122, "scl": 511, "hkg": 326, "ewr": 163, "bom": 288, "eze": 455 }, { "timestamp": "2025-08-21T11:30:00.000Z", "eze": 459, "scl": 515, "hkg": 333, "ewr": 144, "bom": 351, "iad": 161, "bos": 133, "arn": 192, "mad": 331, "sin": 470, "sjc": 145, "dfw": 200, "ams": 174, "otp": 326, "lax": 178, "cdg": 155, "gig": 395, "atl": 144, "mia": 173, "nrt": 419, "phx": 207, "gdl": 641, "jnb": 456, "bog": 409, "lhr": 198, "yyz": 150, "fra": 157, "gru": 286, "ord": 190, "den": 229, "sea": 220, "yul": 116, "syd": 334 }, { "timestamp": "2025-08-21T12:00:00.000Z", "sin": 471, "mad": 363, "arn": 237, "bos": 107, "iad": 234, "ewr": 128, "bom": 297, "hkg": 348, "scl": 539, "eze": 457, "syd": 355, "sea": 201, "ord": 187, "den": 219, "yul": 114, "gru": 303, "fra": 158, "lhr": 158, "yyz": 170, "bog": 461, "gdl": 582, "phx": 201, "jnb": 507, "nrt": 436, "mia": 185, "atl": 135, "gig": 361, "cdg": 172, "otp": 305, "lax": 180, "dfw": 285, "sjc": 136, "ams": 238 }, { "timestamp": "2025-08-21T12:30:00.000Z", "jnb": 430, "gdl": 529, "phx": 195, "bog": 483, "yyz": 138, "lhr": 211, "fra": 169, "gru": 208, "yul": 128, "sea": 185, "ord": 181, "den": 318, "syd": 377, "ams": 187, "dfw": 186, "sjc": 133, "lax": 160, "otp": 278, "cdg": 220, "gig": 368, "atl": 143, "nrt": 432, "mia": 177, "iad": 172, "bos": 121, "arn": 234, "mad": 343, "sin": 456, "eze": 465, "scl": 383, "hkg": 364, "ewr": 161, "bom": 287 }, { "timestamp": "2025-08-21T13:00:00.000Z", "iad": 179, "bos": 167, "arn": 202, "sin": 405, "mad": 344, "eze": 459, "hkg": 654, "scl": 507, "bom": 288, "ewr": 136, "bog": 402, "jnb": 524, "gdl": 550, "phx": 205, "fra": 197, "yyz": 159, "lhr": 174, "yul": 129, "sea": 273, "ord": 177, "den": 237, "gru": 268, "syd": 358, "otp": 286, "lax": 163, "ams": 189, "dfw": 176, "sjc": 136, "cdg": 316, "atl": 139, "gig": 373, "nrt": 380, "mia": 179 }, { "timestamp": "2025-08-21T13:30:00.000Z", "gru": 292, "yul": 125, "den": 272, "ord": 190, "sea": 191, "syd": 365, "jnb": 522, "phx": 197, "gdl": 605, "bog": 412, "yyz": 147, "lhr": 214, "fra": 173, "gig": 350, "atl": 147, "mia": 173, "nrt": 469, "ams": 184, "sjc": 161, "dfw": 176, "otp": 295, "lax": 212, "cdg": 169, "arn": 190, "mad": 336, "sin": 546, "iad": 220, "bos": 126, "scl": 506, "hkg": 443, "ewr": 147, "bom": 291, "eze": 470 }, { "timestamp": "2025-08-21T14:00:00.000Z", "eze": 385, "bom": 303, "ewr": 169, "scl": 504, "hkg": 390, "bos": 156, "iad": 261, "mad": 341, "sin": 430, "arn": 194, "cdg": 152, "ams": 186, "dfw": 207, "sjc": 137, "otp": 311, "lax": 160, "nrt": 356, "mia": 167, "gig": 371, "atl": 145, "yyz": 177, "lhr": 208, "fra": 179, "jnb": 360, "gdl": 605, "phx": 187, "bog": 456, "syd": 414, "gru": 225, "yul": 134, "sea": 199, "den": 240, "ord": 175 }, { "timestamp": "2025-08-21T14:30:00.000Z", "iad": 171, "bos": 214, "arn": 213, "sin": 526, "mad": 329, "eze": 382, "hkg": 498, "scl": 504, "bom": 300, "ewr": 139, "bog": 476, "jnb": 551, "phx": 287, "gdl": 567, "fra": 208, "yyz": 136, "lhr": 144, "yul": 111, "den": 242, "ord": 139, "sea": 170, "gru": 194, "syd": 404, "lax": 164, "otp": 269, "ams": 176, "sjc": 138, "dfw": 198, "cdg": 159, "atl": 144, "gig": 354, "mia": 182, "nrt": 446 }, { "timestamp": "2025-08-21T15:00:00.000Z", "scl": 550, "hkg": 446, "ewr": 162, "bom": 307, "eze": 397, "arn": 190, "mad": 352, "sin": 523, "iad": 162, "bos": 127, "gig": 356, "atl": 138, "nrt": 266, "mia": 190, "dfw": 195, "sjc": 174, "ams": 177, "otp": 280, "lax": 161, "cdg": 161, "gru": 345, "sea": 166, "den": 254, "ord": 179, "yul": 143, "syd": 415, "gdl": 548, "phx": 238, "jnb": 464, "bog": 407, "lhr": 144, "yyz": 173, "fra": 171 }, { "timestamp": "2025-08-21T15:30:00.000Z", "syd": 296, "sea": 173, "ord": 159, "den": 234, "yul": 124, "gru": 201, "fra": 207, "lhr": 196, "yyz": 159, "bog": 424, "gdl": 618, "phx": 249, "jnb": 437, "nrt": 400, "mia": 166, "atl": 174, "gig": 350, "cdg": 206, "lax": 160, "otp": 277, "dfw": 183, "sjc": 130, "ams": 195, "sin": 502, "mad": 383, "arn": 228, "bos": 125, "iad": 133, "bom": 368, "ewr": 190, "hkg": 349, "scl": 494, "eze": 426 }, { "timestamp": "2025-08-21T16:00:00.000Z", "eze": 370, "scl": 511, "hkg": 399, "ewr": 147, "bom": 309, "iad": 247, "bos": 113, "arn": 188, "mad": 369, "sin": 440, "sjc": 132, "dfw": 174, "ams": 169, "otp": 340, "lax": 213, "cdg": 207, "gig": 351, "atl": 140, "mia": 170, "nrt": 295, "phx": 297, "gdl": 606, "jnb": 443, "bog": 476, "lhr": 155, "yyz": 186, "fra": 160, "gru": 196, "ord": 171, "den": 292, "sea": 178, "yul": 155, "syd": 442 }, { "timestamp": "2025-08-21T16:30:00.000Z", "bos": 137, "iad": 160, "sin": 434, "mad": 336, "arn": 263, "eze": 305, "ewr": 244, "bom": 302, "hkg": 358, "scl": 511, "fra": 173, "lhr": 172, "yyz": 137, "bog": 392, "gdl": 555, "phx": 214, "jnb": 409, "syd": 337, "sea": 182, "ord": 148, "den": 250, "yul": 123, "gru": 199, "cdg": 150, "lax": 165, "otp": 296, "dfw": 223, "sjc": 128, "ams": 212, "nrt": 429, "mia": 168, "atl": 151, "gig": 358 }, { "timestamp": "2025-08-21T17:00:00.000Z", "bom": 296, "ewr": 213, "scl": 504, "hkg": 395, "eze": 462, "mad": 320, "sin": 458, "arn": 202, "bos": 113, "iad": 141, "mia": 151, "nrt": 246, "gig": 359, "atl": 139, "cdg": 175, "ams": 184, "sjc": 157, "dfw": 207, "otp": 276, "lax": 155, "syd": 487, "gru": 194, "yul": 113, "ord": 135, "den": 251, "sea": 157, "yyz": 135, "lhr": 156, "fra": 154, "jnb": 464, "phx": 192, "gdl": 512, "bog": 399 }, { "timestamp": "2025-08-21T17:30:00.000Z", "cdg": 147, "otp": 276, "lax": 167, "ams": 196, "dfw": 167, "sjc": 134, "nrt": 349, "mia": 197, "atl": 128, "gig": 363, "fra": 162, "yyz": 136, "lhr": 207, "bog": 430, "jnb": 451, "gdl": 612, "phx": 198, "syd": 317, "yul": 115, "sea": 178, "den": 229, "ord": 135, "gru": 185, "eze": 286, "ewr": 145, "bom": 293, "hkg": 358, "scl": 344, "bos": 136, "iad": 141, "sin": 541, "mad": 323, "arn": 198 }, { "timestamp": "2025-08-21T18:00:00.000Z", "iad": 147, "bos": 111, "arn": 228, "mad": 387, "sin": 545, "eze": 385, "scl": 490, "hkg": 386, "bom": 295, "ewr": 181, "jnb": 514, "gdl": 662, "phx": 217, "bog": 385, "yyz": 129, "lhr": 158, "fra": 173, "gru": 189, "yul": 108, "sea": 294, "ord": 174, "den": 234, "syd": 284, "ams": 218, "dfw": 193, "sjc": 151, "otp": 286, "lax": 155, "cdg": 147, "gig": 352, "atl": 141, "nrt": 423, "mia": 177 }, { "timestamp": "2025-08-21T18:30:00.000Z", "bom": 291, "ewr": 157, "scl": 497, "hkg": 341, "eze": 450, "mad": 343, "sin": 543, "arn": 183, "bos": 124, "iad": 190, "nrt": 431, "mia": 191, "gig": 353, "atl": 123, "cdg": 151, "ams": 190, "dfw": 242, "sjc": 129, "lax": 164, "otp": 307, "syd": 353, "gru": 186, "yul": 127, "sea": 186, "den": 239, "ord": 137, "yyz": 139, "lhr": 138, "fra": 182, "jnb": 473, "gdl": 623, "phx": 192, "bog": 501 }, { "timestamp": "2025-08-21T19:00:00.000Z", "nrt": 379, "mia": 159, "atl": 137, "gig": 349, "cdg": 153, "otp": 278, "lax": 155, "ams": 190, "dfw": 200, "sjc": 164, "syd": 338, "yul": 118, "sea": 170, "den": 246, "ord": 159, "gru": 197, "fra": 160, "yyz": 162, "lhr": 236, "bog": 408, "jnb": 473, "gdl": 659, "phx": 229, "ewr": 147, "bom": 290, "hkg": 330, "scl": 435, "eze": 297, "sin": 467, "mad": 333, "arn": 192, "bos": 128, "iad": 240 }, { "timestamp": "2025-08-21T19:30:00.000Z", "sjc": 139, "dfw": 205, "ams": 201, "lax": 208, "otp": 274, "cdg": 152, "gig": 377, "atl": 144, "mia": 159, "nrt": 423, "phx": 200, "gdl": 644, "jnb": 493, "bog": 442, "lhr": 157, "yyz": 163, "fra": 203, "gru": 225, "ord": 138, "den": 226, "sea": 161, "yul": 117, "syd": 405, "eze": 454, "scl": 491, "hkg": 334, "bom": 360, "ewr": 137, "iad": 132, "bos": 117, "arn": 250, "mad": 346, "sin": 425 }, { "timestamp": "2025-08-21T20:00:00.000Z", "syd": 423, "sea": 195, "ord": 181, "den": 240, "yul": 126, "gru": 214, "fra": 159, "lhr": 163, "yyz": 133, "bog": 396, "gdl": 641, "phx": 231, "jnb": 452, "nrt": 370, "mia": 167, "atl": 143, "gig": 378, "cdg": 165, "lax": 245, "otp": 287, "dfw": 184, "sjc": 137, "ams": 174, "sin": 456, "mad": 359, "arn": 214, "bos": 102, "iad": 146, "bom": 282, "ewr": 128, "hkg": 384, "scl": 497, "eze": 386 }, { "timestamp": "2025-08-21T20:30:00.000Z", "bog": 461, "jnb": 386, "gdl": 722, "phx": 197, "fra": 168, "yyz": 138, "lhr": 139, "yul": 135, "sea": 188, "den": 253, "ord": 161, "gru": 237, "syd": 304, "otp": 363, "lax": 204, "ams": 188, "dfw": 247, "sjc": 163, "cdg": 149, "atl": 128, "gig": 361, "nrt": 346, "mia": 156, "iad": 146, "bos": 116, "arn": 201, "sin": 437, "mad": 412, "eze": 446, "hkg": 334, "scl": 492, "ewr": 130, "bom": 321 }, { "timestamp": "2025-08-21T21:00:00.000Z", "iad": 138, "bos": 119, "arn": 231, "mad": 368, "sin": 438, "eze": 459, "scl": 426, "hkg": 470, "bom": 283, "ewr": 160, "jnb": 447, "gdl": 629, "phx": 207, "bog": 481, "yyz": 137, "lhr": 141, "fra": 153, "gru": 205, "yul": 111, "sea": 245, "den": 240, "ord": 147, "syd": 417, "ams": 175, "dfw": 210, "sjc": 133, "lax": 154, "otp": 284, "cdg": 143, "gig": 353, "atl": 136, "nrt": 352, "mia": 174 }, { "timestamp": "2025-08-21T21:30:00.000Z", "yul": 118, "ord": 171, "den": 227, "sea": 177, "gru": 193, "syd": 353, "bog": 500, "jnb": 451, "phx": 234, "gdl": 666, "fra": 158, "yyz": 149, "lhr": 157, "atl": 141, "gig": 388, "mia": 176, "nrt": 421, "lax": 157, "otp": 298, "ams": 202, "sjc": 142, "dfw": 174, "cdg": 156, "arn": 197, "sin": 460, "mad": 361, "iad": 172, "bos": 117, "hkg": 387, "scl": 508, "ewr": 181, "bom": 308, "eze": 382 }, { "timestamp": "2025-08-21T22:00:00.000Z", "lhr": 144, "yyz": 131, "fra": 155, "gdl": 660, "phx": 191, "jnb": 540, "bog": 388, "syd": 282, "gru": 250, "sea": 171, "den": 228, "ord": 145, "yul": 117, "cdg": 161, "dfw": 179, "sjc": 138, "ams": 196, "lax": 164, "otp": 276, "nrt": 405, "mia": 169, "gig": 349, "atl": 144, "bos": 102, "iad": 187, "mad": 337, "sin": 410, "arn": 192, "eze": 473, "bom": 288, "ewr": 148, "scl": 499, "hkg": 331 }, { "timestamp": "2025-08-21T22:30:00.000Z", "otp": 290, "lax": 218, "sjc": 131, "dfw": 179, "ams": 200, "cdg": 246, "atl": 135, "gig": 350, "mia": 156, "nrt": 360, "bog": 417, "phx": 208, "gdl": 645, "jnb": 518, "fra": 184, "lhr": 146, "yyz": 131, "den": 232, "ord": 180, "sea": 195, "yul": 152, "gru": 332, "syd": 420, "eze": 405, "hkg": 375, "scl": 526, "bom": 306, "ewr": 165, "iad": 198, "bos": 114, "arn": 201, "sin": 444, "mad": 339 }, { "timestamp": "2025-08-21T23:00:00.000Z", "hkg": 374, "scl": 488, "ewr": 151, "bom": 299, "eze": 449, "arn": 197, "sin": 436, "mad": 336, "iad": 200, "bos": 112, "atl": 175, "gig": 349, "nrt": 422, "mia": 183, "lax": 162, "otp": 270, "dfw": 177, "sjc": 145, "ams": 190, "cdg": 139, "sea": 166, "ord": 135, "den": 234, "yul": 124, "gru": 281, "syd": 323, "bog": 403, "gdl": 602, "phx": 208, "jnb": 370, "fra": 179, "lhr": 146, "yyz": 139 }, { "timestamp": "2025-08-21T23:30:00.000Z", "syd": 325, "gru": 229, "sea": 179, "den": 234, "ord": 200, "yul": 117, "lhr": 141, "yyz": 150, "fra": 165, "gdl": 508, "phx": 196, "jnb": 393, "bog": 409, "nrt": 438, "mia": 173, "gig": 345, "atl": 192, "cdg": 206, "dfw": 207, "sjc": 127, "ams": 179, "otp": 279, "lax": 155, "mad": 342, "sin": 421, "arn": 186, "bos": 109, "iad": 202, "bom": 284, "ewr": 233, "scl": 501, "hkg": 394, "eze": 388 }, { "timestamp": "2025-08-22T00:00:00.000Z", "yyz": 138, "lhr": 146, "fra": 162, "jnb": 477, "gdl": 631, "phx": 205, "bog": 440, "syd": 417, "gru": 200, "yul": 101, "sea": 174, "ord": 157, "den": 252, "cdg": 139, "ams": 172, "dfw": 183, "sjc": 132, "otp": 299, "lax": 159, "nrt": 422, "mia": 153, "gig": 356, "atl": 203, "bos": 124, "iad": 234, "mad": 348, "sin": 437, "arn": 204, "eze": 451, "ewr": 138, "bom": 359, "scl": 517, "hkg": 394 }, { "timestamp": "2025-08-22T00:30:00.000Z", "mad": 375, "sin": 514, "arn": 205, "bos": 169, "iad": 191, "ewr": 141, "bom": 327, "scl": 495, "hkg": 333, "eze": 459, "syd": 415, "gru": 230, "yul": 170, "sea": 168, "ord": 155, "den": 227, "yyz": 156, "lhr": 138, "fra": 153, "jnb": 484, "gdl": 567, "phx": 198, "bog": 473, "nrt": 425, "mia": 191, "gig": 354, "atl": 142, "cdg": 150, "ams": 177, "dfw": 185, "sjc": 196, "lax": 162, "otp": 301 }, { "timestamp": "2025-08-22T01:00:00.000Z", "phx": 199, "gdl": 601, "jnb": 515, "bog": 448, "lhr": 180, "yyz": 135, "fra": 163, "gru": 187, "den": 307, "ord": 181, "sea": 219, "yul": 189, "syd": 290, "sjc": 135, "dfw": 195, "ams": 191, "otp": 284, "lax": 149, "cdg": 144, "gig": 350, "atl": 150, "mia": 168, "nrt": 425, "iad": 398, "bos": 111, "arn": 191, "mad": 350, "sin": 501, "eze": 384, "scl": 349, "hkg": 359, "bom": 287, "ewr": 179 }, { "timestamp": "2025-08-22T01:30:00.000Z", "arn": 193, "mad": 339, "sin": 544, "iad": 164, "bos": 117, "scl": 507, "hkg": 337, "bom": 339, "ewr": 154, "eze": 464, "gru": 228, "den": 234, "ord": 157, "sea": 171, "yul": 129, "syd": 414, "phx": 194, "gdl": 606, "jnb": 373, "bog": 500, "lhr": 159, "yyz": 133, "fra": 191, "gig": 360, "atl": 164, "mia": 161, "nrt": 436, "sjc": 160, "dfw": 190, "ams": 176, "lax": 151, "otp": 296, "cdg": 159 }, { "timestamp": "2025-08-22T02:00:00.000Z", "nrt": 259, "mia": 161, "gig": 360, "atl": 139, "cdg": 161, "dfw": 191, "sjc": 131, "ams": 182, "otp": 279, "lax": 184, "syd": 372, "gru": 266, "sea": 165, "den": 233, "ord": 134, "yul": 101, "lhr": 147, "yyz": 180, "fra": 148, "gdl": 552, "phx": 201, "jnb": 519, "bog": 440, "bom": 298, "ewr": 159, "scl": 492, "hkg": 354, "eze": 492, "mad": 330, "sin": 436, "arn": 213, "bos": 121, "iad": 131 }, { "timestamp": "2025-08-22T02:30:00.000Z", "syd": 297, "gru": 206, "yul": 118, "den": 239, "ord": 284, "sea": 172, "yyz": 142, "lhr": 136, "fra": 152, "jnb": 414, "phx": 191, "gdl": 597, "bog": 493, "mia": 158, "nrt": 428, "gig": 468, "atl": 169, "cdg": 166, "ams": 180, "sjc": 134, "dfw": 199, "lax": 150, "otp": 304, "mad": 365, "sin": 470, "arn": 217, "bos": 113, "iad": 184, "bom": 291, "ewr": 124, "scl": 431, "hkg": 406, "eze": 462 }, { "timestamp": "2025-08-22T03:00:00.000Z", "ams": 166, "dfw": 204, "sjc": 137, "otp": 294, "lax": 188, "cdg": 143, "gig": 441, "atl": 152, "nrt": 432, "mia": 186, "jnb": 433, "gdl": 621, "phx": 193, "bog": 473, "yyz": 146, "lhr": 134, "fra": 169, "gru": 334, "yul": 118, "sea": 185, "den": 238, "ord": 149, "syd": 457, "eze": 451, "scl": 439, "hkg": 381, "ewr": 123, "bom": 300, "iad": 206, "bos": 109, "arn": 220, "mad": 345, "sin": 473 }, { "timestamp": "2025-08-22T03:30:00.000Z", "eze": 401, "hkg": 375, "scl": 496, "ewr": 121, "bom": 308, "iad": 136, "bos": 122, "arn": 187, "sin": 459, "mad": 328, "lax": 166, "otp": 288, "ams": 180, "sjc": 144, "dfw": 313, "cdg": 162, "atl": 168, "gig": 360, "mia": 154, "nrt": 424, "bog": 504, "jnb": 367, "phx": 186, "gdl": 542, "fra": 213, "yyz": 148, "lhr": 158, "yul": 111, "ord": 141, "den": 218, "sea": 187, "gru": 300, "syd": 320 }, { "timestamp": "2025-08-22T04:00:00.000Z", "eze": 446, "hkg": 368, "scl": 494, "bom": 290, "ewr": 153, "iad": 139, "bos": 105, "arn": 210, "sin": 465, "mad": 347, "lax": 154, "otp": 272, "ams": 175, "dfw": 195, "sjc": 132, "cdg": 188, "atl": 276, "gig": 359, "nrt": 421, "mia": 211, "bog": 439, "jnb": 514, "gdl": 626, "phx": 293, "fra": 157, "yyz": 205, "lhr": 161, "yul": 120, "sea": 177, "den": 245, "ord": 188, "gru": 209, "syd": 417 }, { "timestamp": "2025-08-22T04:30:00.000Z", "iad": 166, "bos": 148, "arn": 209, "sin": 438, "mad": 415, "eze": 454, "hkg": 323, "scl": 433, "bom": 433, "ewr": 174, "bog": 522, "phx": 200, "gdl": 563, "jnb": 482, "fra": 157, "lhr": 154, "yyz": 130, "den": 240, "ord": 180, "sea": 169, "yul": 121, "gru": 195, "syd": 326, "otp": 274, "lax": 182, "sjc": 157, "dfw": 177, "ams": 191, "cdg": 142, "atl": 322, "gig": 212, "mia": 201, "nrt": 421 }, { "timestamp": "2025-08-22T05:00:00.000Z", "ewr": 130, "bom": 307, "hkg": 533, "scl": 496, "eze": 390, "sin": 429, "mad": 348, "arn": 221, "bos": 146, "iad": 137, "nrt": 359, "mia": 214, "atl": 245, "gig": 351, "cdg": 209, "lax": 249, "otp": 275, "dfw": 305, "sjc": 137, "ams": 182, "syd": 418, "sea": 173, "den": 261, "ord": 156, "yul": 113, "gru": 219, "fra": 152, "lhr": 142, "yyz": 131, "bog": 442, "gdl": 573, "phx": 196, "jnb": 495 }, { "timestamp": "2025-08-22T05:30:00.000Z", "arn": 209, "sin": 454, "mad": 339, "iad": 126, "bos": 103, "hkg": 372, "scl": 493, "ewr": 169, "bom": 303, "eze": 455, "yul": 104, "sea": 179, "den": 248, "ord": 185, "gru": 215, "syd": 428, "bog": 442, "jnb": 445, "gdl": 589, "phx": 199, "fra": 149, "yyz": 134, "lhr": 147, "atl": 193, "gig": 354, "nrt": 265, "mia": 181, "lax": 149, "otp": 282, "ams": 174, "dfw": 183, "sjc": 146, "cdg": 142 }, { "timestamp": "2025-08-22T06:00:00.000Z", "mia": 172, "nrt": 358, "atl": 181, "gig": 353, "cdg": 168, "otp": 278, "lax": 160, "ams": 176, "sjc": 146, "dfw": 171, "syd": 303, "yul": 123, "den": 244, "ord": 148, "sea": 176, "gru": 276, "fra": 264, "yyz": 160, "lhr": 145, "bog": 517, "jnb": 468, "phx": 232, "gdl": 569, "ewr": 207, "bom": 439, "hkg": 346, "scl": 498, "eze": 454, "sin": 480, "mad": 360, "arn": 212, "bos": 162, "iad": 165 }, { "timestamp": "2025-08-22T06:30:00.000Z", "eze": 457, "ewr": 131, "bom": 297, "hkg": 373, "scl": 505, "bos": 115, "iad": 137, "sin": 430, "mad": 342, "arn": 238, "cdg": 139, "lax": 162, "otp": 273, "ams": 181, "sjc": 140, "dfw": 197, "mia": 218, "nrt": 445, "atl": 178, "gig": 352, "fra": 155, "yyz": 151, "lhr": 151, "bog": 418, "jnb": 540, "phx": 210, "gdl": 636, "syd": 292, "yul": 112, "den": 235, "ord": 133, "sea": 252, "gru": 237 }, { "timestamp": "2025-08-22T07:00:00.000Z", "otp": 281, "lax": 234, "sjc": 142, "dfw": 173, "ams": 198, "cdg": 222, "atl": 123, "gig": 349, "mia": 173, "nrt": 394, "bog": 435, "phx": 198, "gdl": 569, "jnb": 526, "fra": 171, "lhr": 150, "yyz": 142, "den": 218, "ord": 222, "sea": 182, "yul": 105, "gru": 185, "syd": 371, "eze": 478, "hkg": 394, "scl": 503, "bom": 315, "ewr": 133, "iad": 129, "bos": 107, "arn": 197, "sin": 468, "mad": 455 }, { "timestamp": "2025-08-22T07:30:00.000Z", "hkg": 429, "scl": 500, "bom": 329, "ewr": 149, "eze": 380, "arn": 200, "sin": 458, "mad": 343, "iad": 216, "bos": 113, "atl": 156, "gig": 353, "mia": 239, "nrt": 426, "lax": 147, "otp": 284, "sjc": 129, "dfw": 233, "ams": 178, "cdg": 179, "den": 224, "ord": 176, "sea": 287, "yul": 113, "gru": 210, "syd": 415, "bog": 417, "phx": 203, "gdl": 569, "jnb": 444, "fra": 162, "lhr": 152, "yyz": 170 }, { "timestamp": "2025-08-22T08:00:00.000Z", "iad": 173, "bos": 110, "arn": 204, "mad": 359, "sin": 516, "eze": 464, "scl": 492, "hkg": 355, "bom": 294, "ewr": 159, "jnb": 511, "phx": 198, "gdl": 600, "bog": 444, "yyz": 150, "lhr": 155, "fra": 165, "gru": 210, "yul": 217, "ord": 171, "den": 233, "sea": 192, "syd": 311, "ams": 206, "sjc": 142, "dfw": 235, "otp": 273, "lax": 151, "cdg": 148, "gig": 354, "atl": 191, "mia": 170, "nrt": 354 }, { "timestamp": "2025-08-22T08:30:00.000Z", "gru": 273, "yul": 103, "ord": 174, "den": 242, "sea": 173, "syd": 328, "jnb": 480, "phx": 212, "gdl": 620, "bog": 549, "yyz": 135, "lhr": 149, "fra": 169, "gig": 380, "atl": 140, "mia": 161, "nrt": 358, "ams": 189, "sjc": 157, "dfw": 193, "lax": 190, "otp": 284, "cdg": 189, "arn": 205, "mad": 393, "sin": 461, "iad": 145, "bos": 138, "scl": 498, "hkg": 334, "bom": 296, "ewr": 143, "eze": 458 }, { "timestamp": "2025-08-22T09:00:00.000Z", "mad": 362, "sin": 480, "arn": 196, "bos": 112, "iad": 149, "ewr": 143, "bom": 356, "scl": 494, "hkg": 469, "eze": 466, "syd": 299, "gru": 222, "ord": 178, "den": 232, "sea": 172, "yul": 120, "lhr": 144, "yyz": 136, "fra": 161, "phx": 192, "gdl": 499, "jnb": 397, "bog": 454, "mia": 181, "nrt": 420, "gig": 367, "atl": 170, "cdg": 160, "sjc": 131, "dfw": 206, "ams": 207, "otp": 279, "lax": 167 }, { "timestamp": "2025-08-22T09:30:00.000Z", "lhr": 168, "yyz": 135, "fra": 178, "phx": 200, "gdl": 626, "jnb": 501, "bog": 417, "syd": 424, "gru": 203, "ord": 144, "den": 239, "sea": 158, "yul": 113, "cdg": 172, "sjc": 134, "dfw": 199, "ams": 189, "lax": 163, "otp": 288, "mia": 154, "nrt": 359, "gig": 328, "atl": 124, "bos": 110, "iad": 150, "mad": 348, "sin": 422, "arn": 197, "eze": 476, "ewr": 147, "bom": 296, "scl": 444, "hkg": 396 }, { "timestamp": "2025-08-22T10:00:00.000Z", "eze": 456, "scl": 495, "hkg": 338, "ewr": 132, "bom": 310, "iad": 212, "bos": 115, "arn": 213, "mad": 344, "sin": 486, "dfw": 167, "sjc": 128, "ams": 184, "otp": 317, "lax": 164, "cdg": 230, "gig": 362, "atl": 169, "nrt": 420, "mia": 152, "gdl": 504, "phx": 201, "jnb": 499, "bog": 396, "lhr": 156, "yyz": 133, "fra": 162, "gru": 278, "sea": 188, "ord": 167, "den": 230, "yul": 113, "syd": 285 }, { "timestamp": "2025-08-22T10:30:00.000Z", "arn": 246, "mad": 358, "sin": 420, "iad": 164, "bos": 122, "scl": 498, "hkg": 411, "ewr": 158, "bom": 301, "eze": 450, "gru": 340, "yul": 105, "sea": 161, "den": 257, "ord": 221, "syd": 366, "jnb": 495, "gdl": 575, "phx": 197, "bog": 438, "yyz": 139, "lhr": 273, "fra": 166, "gig": 296, "atl": 140, "nrt": 449, "mia": 171, "ams": 200, "dfw": 206, "sjc": 170, "lax": 206, "otp": 274, "cdg": 155 }, { "timestamp": "2025-08-22T11:00:00.000Z", "eze": 384, "bom": 295, "ewr": 139, "scl": 508, "hkg": 493, "bos": 108, "iad": 228, "mad": 330, "sin": 514, "arn": 207, "cdg": 163, "ams": 208, "sjc": 140, "dfw": 222, "otp": 276, "lax": 150, "mia": 165, "nrt": 442, "gig": 363, "atl": 214, "yyz": 150, "lhr": 152, "fra": 174, "jnb": 521, "phx": 200, "gdl": 578, "bog": 463, "syd": 388, "gru": 274, "yul": 153, "den": 284, "ord": 174, "sea": 223 }, { "timestamp": "2025-08-22T11:30:00.000Z", "bos": 126, "iad": 265, "mad": 347, "sin": 467, "arn": 203, "eze": 483, "bom": 321, "ewr": 135, "scl": 505, "hkg": 462, "lhr": 149, "yyz": 134, "fra": 232, "gdl": 587, "phx": 274, "jnb": 479, "bog": 540, "syd": 297, "gru": 207, "sea": 183, "den": 250, "ord": 176, "yul": 115, "cdg": 285, "dfw": 195, "sjc": 135, "ams": 253, "lax": 375, "otp": 326, "nrt": 445, "mia": 179, "gig": 374, "atl": 160 }, { "timestamp": "2025-08-22T12:00:00.000Z", "bos": 124, "iad": 358, "mad": 368, "sin": 490, "arn": 198, "eze": 454, "ewr": 175, "bom": 291, "scl": 394, "hkg": 405, "lhr": 149, "yyz": 129, "fra": 168, "phx": 191, "gdl": 564, "jnb": 374, "bog": 447, "syd": 423, "gru": 312, "ord": 171, "den": 245, "sea": 174, "yul": 112, "cdg": 150, "sjc": 149, "dfw": 179, "ams": 192, "lax": 168, "otp": 277, "mia": 225, "nrt": 279, "gig": 370, "atl": 128 }, { "timestamp": "2025-08-22T12:30:00.000Z", "fra": 159, "lhr": 144, "yyz": 145, "bog": 442, "gdl": 620, "phx": 208, "jnb": 509, "syd": 418, "sea": 165, "den": 325, "ord": 152, "yul": 136, "gru": 202, "cdg": 166, "otp": 278, "lax": 157, "dfw": 183, "sjc": 152, "ams": 243, "nrt": 445, "mia": 152, "atl": 156, "gig": 473, "bos": 115, "iad": 180, "sin": 505, "mad": 333, "arn": 190, "eze": 425, "ewr": 142, "bom": 297, "hkg": 335, "scl": 495 }, { "timestamp": "2025-08-22T13:00:00.000Z", "atl": 126, "gig": 367, "mia": 163, "nrt": 308, "lax": 216, "otp": 277, "sjc": 168, "dfw": 209, "ams": 194, "cdg": 179, "den": 250, "ord": 186, "sea": 179, "yul": 115, "gru": 230, "syd": 420, "bog": 526, "phx": 204, "gdl": 623, "jnb": 489, "fra": 163, "lhr": 171, "yyz": 152, "hkg": 543, "scl": 496, "bom": 298, "ewr": 164, "eze": 462, "arn": 220, "sin": 444, "mad": 387, "iad": 138, "bos": 124 }, { "timestamp": "2025-08-22T13:30:00.000Z", "yul": 114, "sea": 181, "den": 229, "ord": 160, "gru": 315, "syd": 464, "bog": 461, "jnb": 449, "gdl": 562, "phx": 210, "fra": 175, "yyz": 147, "lhr": 144, "atl": 151, "gig": 468, "nrt": 319, "mia": 166, "otp": 289, "lax": 213, "ams": 213, "dfw": 221, "sjc": 163, "cdg": 187, "arn": 201, "sin": 551, "mad": 391, "iad": 231, "bos": 120, "hkg": 357, "scl": 451, "bom": 304, "ewr": 173, "eze": 470 }, { "timestamp": "2025-08-22T14:00:00.000Z", "bom": 308, "ewr": 139, "hkg": 377, "scl": 496, "eze": 402, "sin": 468, "mad": 519, "arn": 326, "bos": 132, "iad": 273, "mia": 177, "nrt": 450, "atl": 135, "gig": 356, "cdg": 234, "lax": 188, "otp": 288, "ams": 188, "sjc": 188, "dfw": 317, "syd": 352, "yul": 131, "den": 272, "ord": 138, "sea": 177, "gru": 284, "fra": 185, "yyz": 237, "lhr": 144, "bog": 437, "jnb": 458, "phx": 260, "gdl": 643 }, { "timestamp": "2025-08-22T14:30:00.000Z", "cdg": 145, "otp": 275, "lax": 161, "ams": 193, "sjc": 138, "dfw": 174, "mia": 165, "nrt": 434, "atl": 150, "gig": 384, "fra": 166, "yyz": 181, "lhr": 173, "bog": 516, "jnb": 364, "phx": 210, "gdl": 580, "syd": 440, "yul": 112, "den": 231, "ord": 162, "sea": 176, "gru": 215, "eze": 466, "bom": 297, "ewr": 197, "hkg": 496, "scl": 510, "bos": 130, "iad": 443, "sin": 523, "mad": 368, "arn": 219 }, { "timestamp": "2025-08-22T15:00:00.000Z", "sjc": 141, "dfw": 203, "ams": 214, "lax": 167, "otp": 268, "cdg": 151, "gig": 291, "atl": 144, "mia": 158, "nrt": 425, "phx": 197, "gdl": 573, "jnb": 373, "bog": 440, "lhr": 189, "yyz": 159, "fra": 160, "gru": 284, "ord": 166, "den": 238, "sea": 180, "yul": 166, "syd": 337, "eze": 458, "scl": 505, "hkg": 411, "bom": 285, "ewr": 168, "iad": 185, "bos": 127, "arn": 188, "mad": 362, "sin": 412 }, { "timestamp": "2025-08-22T15:30:00.000Z", "scl": 497, "hkg": 367, "bom": 332, "ewr": 176, "eze": 449, "arn": 192, "mad": 378, "sin": 489, "iad": 177, "bos": 121, "gig": 356, "atl": 154, "mia": 189, "nrt": 432, "sjc": 144, "dfw": 208, "ams": 195, "otp": 284, "lax": 162, "cdg": 171, "gru": 193, "ord": 166, "den": 238, "sea": 189, "yul": 151, "syd": 318, "phx": 192, "gdl": 626, "jnb": 495, "bog": 465, "lhr": 150, "yyz": 151, "fra": 167 }, { "timestamp": "2025-08-22T16:00:00.000Z", "eze": 463, "scl": 496, "hkg": 364, "ewr": 137, "bom": 296, "iad": 161, "bos": 127, "arn": 204, "mad": 378, "sin": 527, "ams": 184, "sjc": 132, "dfw": 210, "otp": 323, "lax": 172, "cdg": 171, "gig": 359, "atl": 149, "mia": 165, "nrt": 430, "jnb": 458, "phx": 212, "gdl": 618, "bog": 406, "yyz": 154, "lhr": 155, "fra": 159, "gru": 195, "yul": 125, "den": 245, "ord": 157, "sea": 178, "syd": 331 }, { "timestamp": "2025-08-22T16:30:00.000Z", "fra": 162, "yyz": 129, "lhr": 170, "bog": 438, "jnb": 389, "phx": 204, "gdl": 623, "syd": 376, "yul": 111, "ord": 151, "den": 230, "sea": 224, "gru": 187, "cdg": 179, "lax": 157, "otp": 273, "ams": 202, "sjc": 140, "dfw": 197, "mia": 160, "nrt": 354, "atl": 138, "gig": 371, "bos": 116, "iad": 145, "sin": 480, "mad": 365, "arn": 201, "eze": 450, "bom": 290, "ewr": 189, "hkg": 490, "scl": 524 }, { "timestamp": "2025-08-22T17:00:00.000Z", "sin": 455, "mad": 376, "arn": 222, "bos": 126, "iad": 415, "ewr": 131, "bom": 301, "hkg": 508, "scl": 499, "eze": 454, "syd": 381, "yul": 108, "sea": 180, "den": 248, "ord": 143, "gru": 309, "fra": 162, "yyz": 146, "lhr": 147, "bog": 419, "jnb": 453, "gdl": 620, "phx": 197, "nrt": 468, "mia": 161, "atl": 138, "gig": 440, "cdg": 187, "otp": 268, "lax": 158, "ams": 185, "dfw": 217, "sjc": 149 }, { "timestamp": "2025-08-22T17:30:00.000Z", "scl": 499, "hkg": 441, "ewr": 143, "bom": 293, "eze": 407, "arn": 203, "mad": 375, "sin": 407, "iad": 316, "bos": 111, "gig": 356, "atl": 143, "mia": 285, "nrt": 433, "ams": 197, "sjc": 150, "dfw": 183, "lax": 166, "otp": 271, "cdg": 184, "gru": 206, "yul": 131, "den": 242, "ord": 163, "sea": 165, "syd": 419, "jnb": 444, "phx": 196, "gdl": 637, "bog": 521, "yyz": 144, "lhr": 160, "fra": 179 }, { "timestamp": "2025-08-22T18:00:00.000Z", "eze": 461, "bom": 294, "ewr": 163, "hkg": 374, "scl": 498, "bos": 119, "iad": 171, "sin": 433, "mad": 346, "arn": 196, "cdg": 160, "lax": 191, "otp": 279, "dfw": 173, "sjc": 161, "ams": 203, "nrt": 358, "mia": 272, "atl": 217, "gig": 373, "fra": 208, "lhr": 164, "yyz": 177, "bog": 396, "gdl": 588, "phx": 203, "jnb": 381, "syd": 299, "sea": 199, "ord": 145, "den": 247, "yul": 119, "gru": 229 }, { "timestamp": "2025-08-22T18:30:00.000Z", "mia": 225, "nrt": 362, "gig": 346, "atl": 132, "cdg": 175, "sjc": 153, "dfw": 206, "ams": 202, "lax": 170, "otp": 291, "syd": 455, "gru": 229, "den": 261, "ord": 186, "sea": 181, "yul": 122, "lhr": 142, "yyz": 142, "fra": 149, "phx": 209, "gdl": 865, "jnb": 385, "bog": 476, "ewr": 128, "bom": 292, "scl": 488, "hkg": 327, "eze": 386, "mad": 364, "sin": 502, "arn": 197, "bos": 128, "iad": 438 }, { "timestamp": "2025-08-22T19:00:00.000Z", "bom": 287, "ewr": 177, "hkg": 339, "scl": 442, "eze": 457, "sin": 522, "mad": 367, "arn": 208, "bos": 127, "iad": 276, "mia": 179, "nrt": 352, "atl": 140, "gig": 393, "cdg": 149, "otp": 277, "lax": 152, "sjc": 151, "dfw": 176, "ams": 180, "syd": 393, "den": 230, "ord": 171, "sea": 164, "yul": 128, "gru": 298, "fra": 169, "lhr": 141, "yyz": 149, "bog": 464, "phx": 244, "gdl": 635, "jnb": 436 }, { "timestamp": "2025-08-22T19:30:00.000Z", "gig": 367, "atl": 149, "nrt": 483, "mia": 196, "ams": 202, "dfw": 202, "sjc": 135, "lax": 150, "otp": 269, "cdg": 180, "gru": 218, "yul": 123, "sea": 167, "ord": 147, "den": 242, "syd": 327, "jnb": 473, "gdl": 616, "phx": 197, "bog": 430, "yyz": 141, "lhr": 170, "fra": 171, "scl": 443, "hkg": 398, "bom": 285, "ewr": 159, "eze": 382, "arn": 239, "mad": 330, "sin": 481, "iad": 142, "bos": 123 }, { "timestamp": "2025-08-22T20:00:00.000Z", "fra": 236, "yyz": 151, "lhr": 155, "bog": 425, "jnb": 510, "phx": 187, "gdl": 647, "syd": 394, "yul": 105, "ord": 196, "den": 239, "sea": 168, "gru": 215, "cdg": 306, "lax": 195, "otp": 322, "ams": 184, "sjc": 137, "dfw": 211, "mia": 174, "nrt": 422, "atl": 165, "gig": 381, "bos": 109, "iad": 133, "sin": 463, "mad": 331, "arn": 198, "eze": 453, "bom": 303, "ewr": 228, "hkg": 458, "scl": 503 }, { "timestamp": "2025-08-22T20:30:00.000Z", "gru": 285, "sea": 171, "den": 246, "ord": 182, "yul": 120, "syd": 402, "gdl": 556, "phx": 201, "jnb": 508, "bog": 415, "lhr": 154, "yyz": 171, "fra": 154, "gig": 356, "atl": 140, "nrt": 425, "mia": 181, "dfw": 171, "sjc": 141, "ams": 202, "otp": 293, "lax": 159, "cdg": 149, "arn": 203, "mad": 362, "sin": 600, "iad": 133, "bos": 104, "scl": 512, "hkg": 368, "ewr": 186, "bom": 291, "eze": 452 }, { "timestamp": "2025-08-22T21:00:00.000Z", "arn": 193, "sin": 425, "mad": 390, "iad": 181, "bos": 117, "hkg": 399, "scl": 500, "bom": 295, "ewr": 127, "eze": 464, "sea": 184, "den": 341, "ord": 161, "yul": 115, "gru": 209, "syd": 416, "bog": 439, "gdl": 602, "phx": 370, "jnb": 488, "fra": 153, "lhr": 160, "yyz": 130, "atl": 147, "gig": 379, "nrt": 431, "mia": 157, "lax": 160, "otp": 291, "dfw": 195, "sjc": 258, "ams": 207, "cdg": 144 }, { "timestamp": "2025-08-22T21:30:00.000Z", "eze": 447, "bom": 290, "ewr": 134, "hkg": 505, "scl": 494, "bos": 108, "iad": 425, "sin": 471, "mad": 338, "arn": 198, "cdg": 156, "otp": 305, "lax": 167, "dfw": 192, "sjc": 166, "ams": 175, "nrt": 461, "mia": 173, "atl": 161, "gig": 354, "fra": 175, "lhr": 158, "yyz": 149, "bog": 465, "gdl": 611, "phx": 205, "jnb": 484, "syd": 294, "sea": 168, "ord": 147, "den": 235, "yul": 100, "gru": 273 }, { "timestamp": "2025-08-22T22:00:00.000Z", "gdl": 975, "phx": 198, "jnb": 460, "bog": 423, "lhr": 168, "yyz": 196, "fra": 150, "gru": 281, "sea": 189, "den": 234, "ord": 179, "yul": 103, "syd": 378, "dfw": 184, "sjc": 150, "ams": 194, "otp": 282, "lax": 175, "cdg": 160, "gig": 357, "atl": 138, "nrt": 381, "mia": 154, "iad": 149, "bos": 131, "arn": 248, "mad": 337, "sin": 488, "eze": 398, "scl": 477, "hkg": 350, "ewr": 140, "bom": 286 }, { "timestamp": "2025-08-22T22:30:00.000Z", "arn": 192, "sin": 424, "mad": 348, "iad": 164, "bos": 196, "hkg": 473, "scl": 502, "bom": 294, "ewr": 161, "eze": 456, "ord": 172, "den": 234, "sea": 181, "yul": 135, "gru": 199, "syd": 430, "bog": 413, "phx": 192, "gdl": 624, "jnb": 510, "fra": 161, "lhr": 210, "yyz": 153, "atl": 179, "gig": 302, "mia": 159, "nrt": 434, "otp": 297, "lax": 151, "sjc": 140, "dfw": 191, "ams": 199, "cdg": 156 }, { "timestamp": "2025-08-22T23:00:00.000Z", "eze": 388, "scl": 496, "hkg": 371, "ewr": 169, "bom": 284, "iad": 251, "bos": 132, "arn": 195, "mad": 352, "sin": 416, "ams": 184, "dfw": 202, "sjc": 138, "lax": 164, "otp": 288, "cdg": 151, "gig": 366, "atl": 334, "nrt": 340, "mia": 166, "jnb": 470, "gdl": 631, "phx": 221, "bog": 386, "yyz": 135, "lhr": 138, "fra": 166, "gru": 214, "yul": 126, "sea": 199, "ord": 154, "den": 222, "syd": 382 }, { "timestamp": "2025-08-22T23:30:00.000Z", "bos": 112, "iad": 241, "sin": 469, "mad": 353, "arn": 187, "eze": 451, "ewr": 169, "bom": 332, "hkg": 430, "scl": 501, "fra": 173, "yyz": 134, "lhr": 136, "bog": 410, "jnb": 391, "phx": 192, "gdl": 635, "syd": 369, "yul": 121, "ord": 150, "den": 238, "sea": 171, "gru": 286, "cdg": 149, "otp": 299, "lax": 168, "ams": 184, "sjc": 150, "dfw": 194, "mia": 160, "nrt": 423, "atl": 153, "gig": 350 }, { "timestamp": "2025-08-23T00:00:00.000Z", "bos": 112, "iad": 150, "sin": 475, "mad": 337, "arn": 202, "eze": 472, "ewr": 130, "bom": 288, "hkg": 352, "scl": 431, "fra": 160, "lhr": 138, "yyz": 140, "bog": 414, "phx": 193, "gdl": 617, "jnb": 400, "syd": 382, "den": 249, "ord": 172, "sea": 171, "yul": 111, "gru": 213, "cdg": 151, "otp": 284, "lax": 164, "sjc": 137, "dfw": 187, "ams": 178, "mia": 183, "nrt": 419, "atl": 213, "gig": 348 }, { "timestamp": "2025-08-23T00:30:00.000Z", "sjc": 297, "dfw": 208, "ams": 196, "lax": 155, "otp": 303, "cdg": 146, "gig": 288, "atl": 162, "mia": 172, "nrt": 429, "phx": 203, "gdl": 605, "jnb": 395, "bog": 396, "lhr": 158, "yyz": 210, "fra": 157, "gru": 257, "ord": 177, "den": 232, "sea": 166, "yul": 127, "syd": 449, "eze": 454, "scl": 514, "hkg": 320, "bom": 294, "ewr": 157, "iad": 161, "bos": 111, "arn": 193, "mad": 390, "sin": 469 }, { "timestamp": "2025-08-23T01:00:00.000Z", "yul": 106, "sea": 175, "ord": 152, "den": 228, "gru": 306, "syd": 398, "bog": 394, "jnb": 509, "gdl": 622, "phx": 264, "fra": 151, "yyz": 128, "lhr": 204, "atl": 184, "gig": 369, "nrt": 329, "mia": 183, "otp": 270, "lax": 165, "ams": 173, "dfw": 210, "sjc": 130, "cdg": 146, "arn": 271, "sin": 411, "mad": 384, "iad": 139, "bos": 121, "hkg": 312, "scl": 440, "ewr": 132, "bom": 328, "eze": 305 }, { "timestamp": "2025-08-23T01:30:00.000Z", "mia": 160, "nrt": 364, "gig": 293, "atl": 172, "cdg": 144, "ams": 180, "sjc": 134, "dfw": 198, "lax": 171, "otp": 305, "syd": 422, "gru": 289, "yul": 130, "ord": 139, "den": 237, "sea": 173, "yyz": 138, "lhr": 148, "fra": 158, "jnb": 508, "phx": 193, "gdl": 632, "bog": 497, "ewr": 135, "bom": 340, "scl": 492, "hkg": 406, "eze": 450, "mad": 391, "sin": 454, "arn": 197, "bos": 112, "iad": 190 }, { "timestamp": "2025-08-23T02:00:00.000Z", "iad": 227, "bos": 107, "arn": 204, "mad": 358, "sin": 419, "eze": 456, "scl": 505, "hkg": 436, "bom": 289, "ewr": 133, "jnb": 500, "gdl": 623, "phx": 197, "bog": 410, "yyz": 133, "lhr": 142, "fra": 156, "gru": 342, "yul": 99, "sea": 263, "ord": 172, "den": 239, "syd": 316, "ams": 259, "dfw": 209, "sjc": 153, "lax": 176, "otp": 284, "cdg": 158, "gig": 376, "atl": 194, "nrt": 433, "mia": 187 }, { "timestamp": "2025-08-23T02:30:00.000Z", "yul": 114, "den": 236, "ord": 171, "sea": 196, "gru": 282, "syd": 417, "bog": 417, "jnb": 386, "phx": 194, "gdl": 563, "fra": 165, "yyz": 151, "lhr": 157, "atl": 200, "gig": 369, "mia": 175, "nrt": 361, "lax": 210, "otp": 272, "ams": 174, "sjc": 144, "dfw": 192, "cdg": 177, "arn": 197, "sin": 444, "mad": 328, "iad": 289, "bos": 143, "hkg": 472, "scl": 519, "ewr": 179, "bom": 323, "eze": 475 }, { "timestamp": "2025-08-23T03:00:00.000Z", "arn": 301, "mad": 349, "sin": 518, "iad": 189, "bos": 148, "scl": 500, "hkg": 478, "bom": 363, "ewr": 181, "eze": 448, "gru": 202, "yul": 104, "den": 237, "ord": 191, "sea": 185, "syd": 376, "jnb": 387, "phx": 217, "gdl": 639, "bog": 417, "yyz": 133, "lhr": 142, "fra": 167, "gig": 354, "atl": 144, "mia": 173, "nrt": 421, "ams": 234, "sjc": 140, "dfw": 179, "otp": 272, "lax": 157, "cdg": 143 }, { "timestamp": "2025-08-23T03:30:00.000Z", "fra": 166, "lhr": 144, "yyz": 193, "bog": 431, "phx": 198, "gdl": 603, "jnb": 462, "syd": 455, "den": 244, "ord": 134, "sea": 160, "yul": 110, "gru": 343, "cdg": 156, "lax": 147, "otp": 281, "sjc": 157, "dfw": 195, "ams": 179, "mia": 175, "nrt": 419, "atl": 223, "gig": 334, "bos": 113, "iad": 136, "sin": 448, "mad": 325, "arn": 193, "eze": 450, "bom": 297, "ewr": 193, "hkg": 472, "scl": 492 }, { "timestamp": "2025-08-23T04:00:00.000Z", "gig": 343, "atl": 237, "nrt": 260, "mia": 174, "dfw": 191, "sjc": 131, "ams": 179, "lax": 162, "otp": 306, "cdg": 218, "gru": 191, "sea": 179, "den": 272, "ord": 135, "yul": 106, "syd": 445, "gdl": 736, "phx": 212, "jnb": 434, "bog": 483, "lhr": 166, "yyz": 131, "fra": 167, "scl": 500, "hkg": 448, "bom": 332, "ewr": 179, "eze": 454, "arn": 187, "mad": 374, "sin": 416, "iad": 135, "bos": 194 }, { "timestamp": "2025-08-23T04:30:00.000Z", "nrt": 366, "mia": 222, "atl": 278, "gig": 357, "cdg": 155, "otp": 405, "lax": 156, "ams": 188, "dfw": 258, "sjc": 159, "syd": 293, "yul": 121, "sea": 162, "den": 219, "ord": 169, "gru": 337, "fra": 157, "yyz": 140, "lhr": 139, "bog": 404, "jnb": 406, "gdl": 607, "phx": 202, "ewr": 126, "bom": 301, "hkg": 466, "scl": 502, "eze": 446, "sin": 425, "mad": 347, "arn": 241, "bos": 104, "iad": 134 }, { "timestamp": "2025-08-23T05:00:00.000Z", "bom": 341, "ewr": 159, "scl": 500, "hkg": 403, "eze": 383, "mad": 361, "sin": 445, "arn": 200, "bos": 96, "iad": 140, "nrt": 417, "mia": 170, "gig": 365, "atl": 276, "cdg": 148, "ams": 181, "dfw": 186, "sjc": 129, "lax": 155, "otp": 277, "syd": 357, "gru": 211, "yul": 106, "sea": 172, "den": 355, "ord": 147, "yyz": 145, "lhr": 226, "fra": 166, "jnb": 470, "gdl": 631, "phx": 196, "bog": 387 }, { "timestamp": "2025-08-23T05:30:00.000Z", "otp": 282, "lax": 149, "dfw": 179, "sjc": 148, "ams": 195, "cdg": 198, "atl": 245, "gig": 344, "nrt": 430, "mia": 163, "bog": 444, "gdl": 663, "phx": 203, "jnb": 510, "fra": 190, "lhr": 166, "yyz": 136, "sea": 195, "den": 245, "ord": 161, "yul": 102, "gru": 247, "syd": 408, "eze": 457, "hkg": 454, "scl": 496, "bom": 304, "ewr": 145, "iad": 146, "bos": 111, "arn": 193, "sin": 520, "mad": 365 }, { "timestamp": "2025-08-23T06:00:00.000Z", "bos": 103, "iad": 158, "mad": 344, "sin": 461, "arn": 219, "eze": 381, "ewr": 128, "bom": 296, "scl": 431, "hkg": 356, "lhr": 168, "yyz": 135, "fra": 225, "gdl": 615, "phx": 209, "jnb": 376, "bog": 440, "syd": 386, "gru": 194, "sea": 160, "ord": 222, "den": 245, "yul": 115, "cdg": 177, "dfw": 177, "sjc": 132, "ams": 182, "otp": 270, "lax": 152, "nrt": 419, "mia": 159, "gig": 347, "atl": 139 }, { "timestamp": "2025-08-23T06:30:00.000Z", "syd": 384, "den": 240, "ord": 149, "sea": 182, "yul": 109, "gru": 265, "fra": 153, "lhr": 225, "yyz": 153, "bog": 418, "phx": 193, "gdl": 570, "jnb": 418, "mia": 162, "nrt": 427, "atl": 134, "gig": 357, "cdg": 143, "otp": 442, "lax": 164, "sjc": 136, "dfw": 256, "ams": 196, "sin": 531, "mad": 345, "arn": 188, "bos": 102, "iad": 136, "bom": 321, "ewr": 153, "hkg": 486, "scl": 493, "eze": 472 }, { "timestamp": "2025-08-23T07:00:00.000Z", "bos": 99, "iad": 146, "sin": 444, "mad": 340, "arn": 366, "eze": 459, "ewr": 155, "bom": 304, "hkg": 472, "scl": 492, "fra": 166, "lhr": 148, "yyz": 137, "bog": 445, "gdl": 616, "phx": 316, "jnb": 466, "syd": 420, "sea": 218, "ord": 150, "den": 268, "yul": 112, "gru": 241, "cdg": 157, "lax": 209, "otp": 282, "dfw": 171, "sjc": 144, "ams": 288, "nrt": 437, "mia": 185, "atl": 167, "gig": 449 }, { "timestamp": "2025-08-23T07:30:00.000Z", "eze": 457, "scl": 497, "hkg": 384, "ewr": 180, "bom": 299, "iad": 182, "bos": 120, "arn": 209, "mad": 341, "sin": 429, "sjc": 157, "dfw": 194, "ams": 185, "otp": 311, "lax": 158, "cdg": 288, "gig": 285, "atl": 142, "mia": 180, "nrt": 345, "phx": 198, "gdl": 615, "jnb": 513, "bog": 427, "lhr": 141, "yyz": 131, "fra": 175, "gru": 210, "ord": 160, "den": 238, "sea": 166, "yul": 112, "syd": 378 }, { "timestamp": "2025-08-23T08:00:00.000Z", "scl": 489, "hkg": 483, "bom": 299, "ewr": 184, "eze": 470, "arn": 197, "mad": 324, "sin": 412, "iad": 135, "bos": 114, "gig": 366, "atl": 149, "mia": 171, "nrt": 425, "ams": 213, "sjc": 136, "dfw": 177, "otp": 377, "lax": 164, "cdg": 160, "gru": 191, "yul": 158, "den": 222, "ord": 180, "sea": 209, "syd": 301, "jnb": 379, "phx": 197, "gdl": 600, "bog": 391, "yyz": 153, "lhr": 138, "fra": 153 }, { "timestamp": "2025-08-23T08:30:00.000Z", "ams": 201, "sjc": 128, "dfw": 171, "lax": 150, "otp": 274, "cdg": 200, "gig": 351, "atl": 132, "mia": 170, "nrt": 425, "jnb": 464, "phx": 233, "gdl": 495, "bog": 421, "yyz": 134, "lhr": 140, "fra": 165, "gru": 203, "yul": 152, "den": 221, "ord": 154, "sea": 206, "syd": 317, "eze": 468, "scl": 444, "hkg": 362, "bom": 325, "ewr": 126, "iad": 133, "bos": 108, "arn": 187, "mad": 321, "sin": 483 }, { "timestamp": "2025-08-23T09:00:00.000Z", "eze": 452, "ewr": 143, "bom": 295, "hkg": 520, "scl": 496, "bos": 130, "iad": 195, "sin": 506, "mad": 346, "arn": 199, "cdg": 145, "otp": 273, "lax": 159, "dfw": 177, "sjc": 125, "ams": 190, "nrt": 430, "mia": 174, "atl": 151, "gig": 250, "fra": 167, "lhr": 266, "yyz": 142, "bog": 447, "gdl": 577, "phx": 189, "jnb": 483, "syd": 417, "sea": 168, "den": 232, "ord": 165, "yul": 115, "gru": 190 }, { "timestamp": "2025-08-23T09:30:00.000Z", "nrt": 418, "mia": 178, "atl": 150, "gig": 350, "cdg": 149, "lax": 152, "otp": 286, "dfw": 174, "sjc": 134, "ams": 190, "syd": 404, "sea": 184, "den": 226, "ord": 173, "yul": 107, "gru": 196, "fra": 168, "lhr": 139, "yyz": 151, "bog": 438, "gdl": 495, "phx": 262, "jnb": 503, "ewr": 185, "bom": 302, "hkg": 429, "scl": 501, "eze": 454, "sin": 439, "mad": 351, "arn": 187, "bos": 115, "iad": 177 }, { "timestamp": "2025-08-23T10:00:00.000Z", "sin": 459, "mad": 353, "arn": 205, "bos": 109, "iad": 138, "bom": 284, "ewr": 169, "hkg": 349, "scl": 498, "eze": 459, "syd": 373, "yul": 116, "sea": 177, "ord": 157, "den": 226, "gru": 271, "fra": 183, "yyz": 136, "lhr": 152, "bog": 491, "jnb": 459, "gdl": 591, "phx": 200, "nrt": 420, "mia": 164, "atl": 137, "gig": 365, "cdg": 156, "lax": 154, "otp": 305, "ams": 208, "dfw": 194, "sjc": 157 }, { "timestamp": "2025-08-23T10:30:00.000Z", "fra": 164, "yyz": 138, "lhr": 146, "bog": 392, "jnb": 406, "gdl": 622, "phx": 199, "syd": 418, "yul": 128, "sea": 166, "ord": 136, "den": 240, "gru": 276, "cdg": 157, "otp": 276, "lax": 157, "ams": 178, "dfw": 194, "sjc": 142, "nrt": 433, "mia": 168, "atl": 146, "gig": 363, "bos": 115, "iad": 387, "sin": 451, "mad": 337, "arn": 206, "eze": 449, "bom": 286, "ewr": 170, "hkg": 476, "scl": 432 }, { "timestamp": "2025-08-23T11:00:00.000Z", "bos": 112, "iad": 188, "sin": 429, "mad": 407, "arn": 197, "eze": 290, "bom": 317, "ewr": 125, "hkg": 456, "scl": 499, "fra": 164, "yyz": 183, "lhr": 242, "bog": 435, "jnb": 457, "phx": 241, "gdl": 570, "syd": 358, "yul": 102, "den": 223, "ord": 191, "sea": 169, "gru": 237, "cdg": 148, "otp": 281, "lax": 200, "ams": 183, "sjc": 161, "dfw": 192, "mia": 156, "nrt": 415, "atl": 129, "gig": 357 }, { "timestamp": "2025-08-23T11:30:00.000Z", "yyz": 130, "lhr": 153, "fra": 228, "jnb": 510, "gdl": 595, "phx": 201, "bog": 459, "syd": 422, "gru": 198, "yul": 116, "sea": 177, "ord": 137, "den": 255, "cdg": 158, "ams": 175, "dfw": 237, "sjc": 148, "lax": 163, "otp": 272, "nrt": 428, "mia": 172, "gig": 434, "atl": 180, "bos": 115, "iad": 183, "mad": 346, "sin": 445, "arn": 206, "eze": 477, "bom": 348, "ewr": 183, "scl": 499, "hkg": 345 }, { "timestamp": "2025-08-23T12:00:00.000Z", "bos": 126, "iad": 308, "mad": 341, "sin": 462, "arn": 211, "eze": 389, "bom": 308, "ewr": 182, "scl": 490, "hkg": 443, "yyz": 137, "lhr": 179, "fra": 163, "jnb": 512, "gdl": 600, "phx": 192, "bog": 470, "syd": 378, "gru": 188, "yul": 112, "sea": 187, "ord": 168, "den": 231, "cdg": 224, "ams": 172, "dfw": 202, "sjc": 143, "lax": 187, "otp": 297, "nrt": 423, "mia": 170, "gig": 348, "atl": 140 }, { "timestamp": "2025-08-23T12:30:00.000Z", "eze": 476, "bom": 301, "ewr": 158, "scl": 489, "hkg": 397, "bos": 103, "iad": 225, "mad": 342, "sin": 462, "arn": 190, "cdg": 156, "sjc": 139, "dfw": 263, "ams": 193, "otp": 352, "lax": 150, "mia": 162, "nrt": 248, "gig": 351, "atl": 162, "lhr": 158, "yyz": 152, "fra": 160, "phx": 209, "gdl": 509, "jnb": 507, "bog": 436, "syd": 330, "gru": 213, "ord": 193, "den": 245, "sea": 191, "yul": 109 }, { "timestamp": "2025-08-23T13:00:00.000Z", "gru": 190, "den": 294, "ord": 190, "sea": 173, "yul": 172, "syd": 314, "phx": 205, "gdl": 622, "jnb": 392, "bog": 461, "lhr": 157, "yyz": 157, "fra": 262, "gig": 353, "atl": 129, "mia": 184, "nrt": 451, "sjc": 162, "dfw": 192, "ams": 178, "lax": 163, "otp": 310, "cdg": 147, "arn": 195, "mad": 342, "sin": 467, "iad": 359, "bos": 145, "scl": 354, "hkg": 345, "bom": 293, "ewr": 159, "eze": 450 }, { "timestamp": "2025-08-23T13:30:00.000Z", "ams": 179, "sjc": 142, "dfw": 261, "otp": 418, "lax": 162, "cdg": 211, "gig": 363, "atl": 186, "mia": 185, "nrt": 372, "jnb": 527, "phx": 198, "gdl": 605, "bog": 436, "yyz": 162, "lhr": 145, "fra": 164, "gru": 220, "yul": 125, "ord": 195, "den": 246, "sea": 181, "syd": 422, "eze": 451, "scl": 494, "hkg": 459, "bom": 300, "ewr": 133, "iad": 148, "bos": 134, "arn": 194, "mad": 342, "sin": 468 }, { "timestamp": "2025-08-23T14:00:00.000Z", "gig": 347, "atl": 170, "nrt": 381, "mia": 180, "ams": 188, "dfw": 186, "sjc": 169, "otp": 364, "lax": 225, "cdg": 143, "gru": 211, "yul": 122, "sea": 182, "den": 256, "ord": 169, "syd": 310, "jnb": 506, "gdl": 495, "phx": 228, "bog": 486, "yyz": 137, "lhr": 202, "fra": 161, "scl": 492, "hkg": 384, "ewr": 130, "bom": 293, "eze": 455, "arn": 217, "mad": 383, "sin": 417, "iad": 449, "bos": 113 }, { "timestamp": "2025-08-23T14:30:00.000Z", "gru": 296, "den": 221, "ord": 178, "sea": 176, "yul": 111, "syd": 385, "phx": 192, "gdl": 601, "jnb": 516, "bog": 444, "lhr": 191, "yyz": 127, "fra": 175, "gig": 353, "atl": 128, "mia": 167, "nrt": 443, "sjc": 161, "dfw": 185, "ams": 178, "lax": 155, "otp": 297, "cdg": 153, "arn": 190, "mad": 378, "sin": 461, "iad": 257, "bos": 98, "scl": 491, "hkg": 466, "ewr": 139, "bom": 282, "eze": 383 }, { "timestamp": "2025-08-23T15:00:00.000Z", "arn": 187, "mad": 343, "sin": 475, "iad": 236, "bos": 142, "scl": 500, "hkg": 464, "ewr": 173, "bom": 297, "eze": 412, "gru": 223, "sea": 182, "ord": 216, "den": 235, "yul": 123, "syd": 407, "gdl": 876, "phx": 191, "jnb": 380, "bog": 458, "lhr": 141, "yyz": 133, "fra": 160, "gig": 350, "atl": 134, "nrt": 441, "mia": 179, "dfw": 191, "sjc": 134, "ams": 251, "lax": 242, "otp": 315, "cdg": 262 }, { "timestamp": "2025-08-23T15:30:00.000Z", "gdl": 598, "phx": 194, "jnb": 380, "bog": 505, "lhr": 149, "yyz": 145, "fra": 163, "gru": 278, "sea": 180, "ord": 181, "den": 232, "yul": 124, "syd": 371, "dfw": 238, "sjc": 135, "ams": 175, "otp": 294, "lax": 174, "cdg": 154, "gig": 349, "atl": 143, "nrt": 369, "mia": 189, "iad": 165, "bos": 105, "arn": 214, "mad": 343, "sin": 445, "eze": 396, "scl": 447, "hkg": 340, "ewr": 136, "bom": 296 }, { "timestamp": "2025-08-23T16:00:00.000Z", "hkg": 348, "scl": 502, "bom": 345, "ewr": 193, "eze": 464, "arn": 195, "sin": 422, "mad": 337, "iad": 197, "bos": 125, "atl": 137, "gig": 348, "mia": 245, "nrt": 430, "lax": 177, "otp": 309, "ams": 213, "sjc": 132, "dfw": 167, "cdg": 153, "yul": 126, "ord": 184, "den": 231, "sea": 184, "gru": 194, "syd": 371, "bog": 466, "jnb": 520, "phx": 212, "gdl": 526, "fra": 206, "yyz": 183, "lhr": 153 }, { "timestamp": "2025-08-23T16:30:00.000Z", "otp": 288, "lax": 150, "ams": 182, "sjc": 184, "dfw": 202, "cdg": 171, "atl": 142, "gig": 375, "mia": 173, "nrt": 458, "bog": 419, "jnb": 523, "phx": 199, "gdl": 615, "fra": 251, "yyz": 133, "lhr": 146, "yul": 109, "ord": 147, "den": 231, "sea": 171, "gru": 199, "syd": 407, "eze": 390, "hkg": 703, "scl": 507, "bom": 292, "ewr": 157, "iad": 232, "bos": 122, "arn": 225, "sin": 507, "mad": 337 }, { "timestamp": "2025-08-23T17:00:00.000Z", "cdg": 178, "lax": 207, "otp": 289, "dfw": 189, "sjc": 151, "ams": 179, "nrt": 420, "mia": 159, "atl": 147, "gig": 245, "fra": 176, "lhr": 164, "yyz": 137, "bog": 421, "gdl": 596, "phx": 207, "jnb": 404, "syd": 343, "sea": 169, "den": 247, "ord": 144, "yul": 111, "gru": 278, "eze": 460, "bom": 321, "ewr": 129, "hkg": 529, "scl": 497, "bos": 103, "iad": 155, "sin": 451, "mad": 333, "arn": 191 }, { "timestamp": "2025-08-23T17:30:00.000Z", "bom": 298, "ewr": 154, "hkg": 464, "scl": 519, "eze": 448, "sin": 424, "mad": 321, "arn": 192, "bos": 220, "iad": 144, "nrt": 426, "mia": 181, "atl": 163, "gig": 353, "cdg": 162, "otp": 293, "lax": 162, "dfw": 203, "sjc": 146, "ams": 209, "syd": 418, "sea": 172, "den": 229, "ord": 137, "yul": 127, "gru": 282, "fra": 153, "lhr": 142, "yyz": 141, "bog": 385, "gdl": 603, "phx": 212, "jnb": 381 }, { "timestamp": "2025-08-23T18:00:00.000Z", "lax": 184, "otp": 278, "sjc": 141, "dfw": 197, "ams": 174, "cdg": 146, "atl": 139, "gig": 292, "mia": 176, "nrt": 269, "bog": 414, "phx": 274, "gdl": 772, "jnb": 462, "fra": 158, "lhr": 207, "yyz": 153, "ord": 175, "den": 301, "sea": 180, "yul": 107, "gru": 223, "syd": 395, "eze": 460, "hkg": 346, "scl": 495, "ewr": 165, "bom": 292, "iad": 137, "bos": 127, "arn": 189, "sin": 450, "mad": 347 }, { "timestamp": "2025-08-23T18:30:00.000Z", "hkg": 474, "scl": 462, "ewr": 150, "bom": 280, "eze": 447, "arn": 208, "sin": 447, "mad": 370, "iad": 144, "bos": 123, "atl": 143, "gig": 348, "mia": 276, "nrt": 417, "otp": 284, "lax": 158, "sjc": 137, "dfw": 227, "ams": 209, "cdg": 164, "ord": 168, "den": 273, "sea": 181, "yul": 117, "gru": 220, "syd": 417, "bog": 438, "phx": 206, "gdl": 573, "jnb": 488, "fra": 162, "lhr": 141, "yyz": 151 }, { "timestamp": "2025-08-23T19:00:00.000Z", "atl": 139, "gig": 353, "nrt": 357, "mia": 186, "otp": 278, "lax": 161, "dfw": 197, "sjc": 149, "ams": 171, "cdg": 149, "sea": 167, "den": 249, "ord": 185, "yul": 115, "gru": 201, "syd": 398, "bog": 502, "gdl": 619, "phx": 199, "jnb": 407, "fra": 168, "lhr": 148, "yyz": 187, "hkg": 491, "scl": 429, "ewr": 139, "bom": 293, "eze": 454, "arn": 195, "sin": 440, "mad": 348, "iad": 149, "bos": 109 }, { "timestamp": "2025-08-23T19:30:00.000Z", "yul": 118, "den": 246, "ord": 281, "sea": 166, "gru": 252, "syd": 396, "bog": 380, "jnb": 460, "phx": 204, "gdl": 644, "fra": 161, "yyz": 142, "lhr": 167, "atl": 140, "gig": 352, "mia": 179, "nrt": 422, "lax": 166, "otp": 299, "ams": 178, "sjc": 135, "dfw": 176, "cdg": 156, "arn": 200, "sin": 430, "mad": 322, "iad": 451, "bos": 148, "hkg": 359, "scl": 494, "ewr": 165, "bom": 303, "eze": 487 }, { "timestamp": "2025-08-23T20:00:00.000Z", "arn": 226, "sin": 544, "mad": 369, "iad": 434, "bos": 114, "hkg": 641, "scl": 508, "ewr": 185, "bom": 283, "eze": 457, "yul": 112, "den": 226, "ord": 148, "sea": 162, "gru": 191, "syd": 420, "bog": 394, "jnb": 485, "phx": 222, "gdl": 613, "fra": 165, "yyz": 144, "lhr": 148, "atl": 155, "gig": 359, "mia": 168, "nrt": 428, "lax": 157, "otp": 283, "ams": 182, "sjc": 149, "dfw": 194, "cdg": 164 }, { "timestamp": "2025-08-23T20:30:00.000Z", "gru": 188, "yul": 116, "sea": 178, "ord": 190, "den": 230, "syd": 439, "jnb": 461, "gdl": 594, "phx": 212, "bog": 405, "yyz": 134, "lhr": 136, "fra": 162, "gig": 355, "atl": 138, "nrt": 430, "mia": 163, "ams": 203, "dfw": 209, "sjc": 188, "otp": 282, "lax": 154, "cdg": 150, "arn": 191, "mad": 337, "sin": 471, "iad": 132, "bos": 120, "scl": 425, "hkg": 482, "ewr": 132, "bom": 296, "eze": 383 }, { "timestamp": "2025-08-23T21:00:00.000Z", "eze": 468, "ewr": 183, "bom": 294, "scl": 496, "hkg": 641, "bos": 107, "iad": 166, "mad": 359, "sin": 417, "arn": 193, "cdg": 173, "ams": 191, "dfw": 201, "sjc": 155, "lax": 151, "otp": 276, "nrt": 434, "mia": 182, "gig": 312, "atl": 149, "yyz": 174, "lhr": 188, "fra": 160, "jnb": 507, "gdl": 627, "phx": 192, "bog": 440, "syd": 418, "gru": 248, "yul": 119, "sea": 164, "den": 253, "ord": 176 }, { "timestamp": "2025-08-23T21:30:00.000Z", "bos": 141, "iad": 133, "mad": 338, "sin": 424, "arn": 211, "eze": 448, "ewr": 184, "bom": 279, "scl": 508, "hkg": 360, "lhr": 175, "yyz": 152, "fra": 151, "phx": 202, "gdl": 618, "jnb": 455, "bog": 461, "syd": 311, "gru": 303, "den": 239, "ord": 172, "sea": 164, "yul": 109, "cdg": 158, "sjc": 135, "dfw": 172, "ams": 171, "otp": 279, "lax": 176, "mia": 184, "nrt": 306, "gig": 353, "atl": 132 }, { "timestamp": "2025-08-23T22:00:00.000Z", "mad": 337, "sin": 376, "arn": 197, "bos": 119, "iad": 174, "bom": 319, "ewr": 131, "scl": 504, "hkg": 350, "eze": 487, "syd": 406, "gru": 209, "sea": 160, "ord": 194, "den": 236, "yul": 103, "lhr": 150, "yyz": 183, "fra": 154, "gdl": 656, "phx": 205, "jnb": 446, "bog": 433, "nrt": 372, "mia": 180, "gig": 352, "atl": 172, "cdg": 182, "dfw": 202, "sjc": 132, "ams": 173, "otp": 283, "lax": 163 }, { "timestamp": "2025-08-23T22:30:00.000Z", "eze": 463, "bom": 301, "ewr": 131, "scl": 436, "hkg": 464, "bos": 107, "iad": 137, "mad": 340, "sin": 489, "arn": 193, "cdg": 143, "ams": 179, "dfw": 196, "sjc": 138, "lax": 179, "otp": 392, "nrt": 425, "mia": 173, "gig": 385, "atl": 142, "yyz": 132, "lhr": 133, "fra": 166, "jnb": 373, "gdl": 618, "phx": 262, "bog": 489, "syd": 388, "gru": 191, "yul": 105, "sea": 170, "den": 235, "ord": 176 }, { "timestamp": "2025-08-23T23:00:00.000Z", "cdg": 153, "ams": 182, "sjc": 223, "dfw": 191, "lax": 151, "otp": 328, "mia": 179, "nrt": 424, "gig": 365, "atl": 138, "yyz": 145, "lhr": 142, "fra": 215, "jnb": 364, "phx": 227, "gdl": 614, "bog": 497, "syd": 346, "gru": 200, "yul": 112, "ord": 179, "den": 230, "sea": 183, "eze": 456, "bom": 296, "ewr": 180, "scl": 497, "hkg": 532, "bos": 104, "iad": 501, "mad": 344, "sin": 559, "arn": 198 }, { "timestamp": "2025-08-23T23:30:00.000Z", "bom": 289, "ewr": 135, "scl": 495, "hkg": 390, "eze": 436, "mad": 325, "sin": 412, "arn": 199, "bos": 102, "iad": 189, "mia": 175, "nrt": 427, "gig": 344, "atl": 156, "cdg": 153, "ams": 177, "sjc": 136, "dfw": 209, "otp": 328, "lax": 151, "syd": 344, "gru": 275, "yul": 107, "ord": 140, "den": 215, "sea": 165, "yyz": 139, "lhr": 138, "fra": 156, "jnb": 484, "phx": 190, "gdl": 535, "bog": 399 }, { "timestamp": "2025-08-24T00:00:00.000Z", "eze": 460, "ewr": 188, "bom": 286, "scl": 442, "hkg": 324, "bos": 114, "iad": 133, "mad": 338, "sin": 417, "arn": 191, "cdg": 165, "sjc": 133, "dfw": 173, "ams": 188, "otp": 319, "lax": 200, "mia": 168, "nrt": 426, "gig": 353, "atl": 140, "lhr": 148, "yyz": 134, "fra": 206, "phx": 210, "gdl": 574, "jnb": 463, "bog": 421, "syd": 308, "gru": 320, "den": 246, "ord": 176, "sea": 169, "yul": 131 }, { "timestamp": "2025-08-24T00:30:00.000Z", "iad": 323, "bos": 103, "arn": 180, "sin": 423, "mad": 328, "eze": 396, "hkg": 321, "scl": 485, "ewr": 158, "bom": 288, "bog": 366, "gdl": 579, "phx": 200, "jnb": 438, "fra": 150, "lhr": 160, "yyz": 137, "sea": 158, "den": 243, "ord": 193, "yul": 116, "gru": 206, "syd": 301, "lax": 156, "otp": 279, "dfw": 169, "sjc": 138, "ams": 223, "cdg": 140, "atl": 139, "gig": 396, "nrt": 307, "mia": 210 }, { "timestamp": "2025-08-24T01:00:00.000Z", "ord": 132, "den": 245, "sea": 164, "yul": 162, "gru": 189, "syd": 433, "bog": 383, "phx": 312, "gdl": 574, "jnb": 517, "fra": 162, "lhr": 158, "yyz": 151, "atl": 155, "gig": 341, "mia": 154, "nrt": 349, "otp": 312, "lax": 150, "sjc": 153, "dfw": 183, "ams": 191, "cdg": 173, "arn": 188, "sin": 427, "mad": 325, "iad": 167, "bos": 112, "hkg": 473, "scl": 481, "bom": 292, "ewr": 125, "eze": 467 }, { "timestamp": "2025-08-24T01:30:00.000Z", "ewr": 129, "bom": 288, "scl": 503, "hkg": 377, "eze": 437, "mad": 330, "sin": 580, "arn": 197, "bos": 109, "iad": 212, "mia": 159, "nrt": 374, "gig": 345, "atl": 141, "cdg": 173, "sjc": 185, "dfw": 191, "ams": 212, "lax": 240, "otp": 289, "syd": 328, "gru": 273, "den": 214, "ord": 243, "sea": 162, "yul": 123, "lhr": 146, "yyz": 131, "fra": 150, "phx": 188, "gdl": 593, "jnb": 400, "bog": 441 }, { "timestamp": "2025-08-24T02:00:00.000Z", "eze": 405, "hkg": 319, "scl": 478, "bom": 289, "ewr": 120, "iad": 204, "bos": 105, "arn": 192, "sin": 451, "mad": 374, "lax": 156, "otp": 289, "ams": 174, "dfw": 166, "sjc": 147, "cdg": 161, "atl": 164, "gig": 359, "nrt": 341, "mia": 158, "bog": 375, "jnb": 484, "gdl": 586, "phx": 198, "fra": 151, "yyz": 144, "lhr": 147, "yul": 111, "sea": 160, "ord": 131, "den": 221, "gru": 285, "syd": 393 }, { "timestamp": "2025-08-24T02:30:00.000Z", "bos": 119, "iad": 133, "mad": 370, "sin": 429, "arn": 186, "eze": 440, "bom": 274, "ewr": 147, "scl": 486, "hkg": 322, "yyz": 150, "lhr": 143, "fra": 158, "jnb": 511, "phx": 193, "gdl": 583, "bog": 477, "syd": 346, "gru": 210, "yul": 105, "ord": 141, "den": 234, "sea": 176, "cdg": 165, "ams": 234, "sjc": 154, "dfw": 191, "otp": 289, "lax": 154, "mia": 167, "nrt": 348, "gig": 350, "atl": 249 }, { "timestamp": "2025-08-24T03:00:00.000Z", "nrt": 422, "mia": 191, "gig": 352, "atl": 174, "cdg": 178, "dfw": 167, "sjc": 131, "ams": 174, "lax": 153, "otp": 329, "syd": 372, "gru": 200, "sea": 208, "den": 235, "ord": 144, "yul": 124, "lhr": 139, "yyz": 146, "fra": 168, "gdl": 726, "phx": 204, "jnb": 387, "bog": 385, "bom": 313, "ewr": 125, "scl": 484, "hkg": 352, "eze": 381, "mad": 327, "sin": 514, "arn": 202, "bos": 105, "iad": 131 }, { "timestamp": "2025-08-24T03:30:00.000Z", "eze": 441, "ewr": 134, "bom": 286, "hkg": 317, "scl": 490, "bos": 111, "iad": 140, "sin": 452, "mad": 418, "arn": 203, "cdg": 174, "lax": 151, "otp": 281, "sjc": 249, "dfw": 200, "ams": 175, "mia": 165, "nrt": 423, "atl": 292, "gig": 305, "fra": 154, "lhr": 139, "yyz": 141, "bog": 373, "phx": 273, "gdl": 600, "jnb": 453, "syd": 314, "ord": 146, "den": 270, "sea": 170, "yul": 99, "gru": 185 }, { "timestamp": "2025-08-24T04:00:00.000Z", "arn": 188, "mad": 381, "sin": 453, "iad": 136, "bos": 105, "scl": 504, "hkg": 467, "ewr": 138, "bom": 297, "eze": 462, "gru": 230, "sea": 165, "ord": 141, "den": 237, "yul": 105, "syd": 371, "gdl": 570, "phx": 195, "jnb": 456, "bog": 376, "lhr": 139, "yyz": 147, "fra": 153, "gig": 362, "atl": 179, "nrt": 429, "mia": 157, "dfw": 172, "sjc": 154, "ams": 191, "lax": 165, "otp": 286, "cdg": 155 }, { "timestamp": "2025-08-24T04:30:00.000Z", "fra": 149, "yyz": 149, "lhr": 151, "bog": 407, "jnb": 383, "gdl": 550, "phx": 193, "syd": 423, "yul": 108, "sea": 169, "ord": 159, "den": 246, "gru": 199, "cdg": 154, "otp": 317, "lax": 149, "ams": 185, "dfw": 181, "sjc": 132, "nrt": 385, "mia": 159, "atl": 206, "gig": 368, "bos": 107, "iad": 136, "sin": 455, "mad": 344, "arn": 205, "eze": 442, "ewr": 141, "bom": 299, "hkg": 347, "scl": 531 }, { "timestamp": "2025-08-24T05:00:00.000Z", "bos": 116, "iad": 132, "mad": 365, "sin": 417, "arn": 200, "eze": 454, "bom": 329, "ewr": 127, "scl": 484, "hkg": 359, "yyz": 147, "lhr": 146, "fra": 156, "jnb": 389, "gdl": 622, "phx": 196, "bog": 410, "syd": 366, "gru": 259, "yul": 104, "sea": 171, "ord": 160, "den": 219, "cdg": 158, "ams": 185, "dfw": 211, "sjc": 154, "lax": 183, "otp": 285, "nrt": 422, "mia": 182, "gig": 341, "atl": 149 }, { "timestamp": "2025-08-24T05:30:00.000Z", "gig": 343, "atl": 148, "mia": 153, "nrt": 343, "ams": 199, "sjc": 224, "dfw": 178, "otp": 278, "lax": 213, "cdg": 216, "gru": 276, "yul": 102, "ord": 141, "den": 269, "sea": 172, "syd": 442, "jnb": 461, "phx": 196, "gdl": 584, "bog": 380, "yyz": 194, "lhr": 137, "fra": 164, "scl": 480, "hkg": 362, "ewr": 122, "bom": 296, "eze": 436, "arn": 199, "mad": 329, "sin": 420, "iad": 136, "bos": 104 }, { "timestamp": "2025-08-24T06:00:00.000Z", "sin": 450, "mad": 337, "arn": 231, "bos": 103, "iad": 123, "bom": 282, "ewr": 128, "hkg": 332, "scl": 482, "eze": 437, "syd": 366, "yul": 131, "den": 235, "ord": 152, "sea": 177, "gru": 180, "fra": 152, "yyz": 141, "lhr": 207, "bog": 466, "jnb": 507, "phx": 235, "gdl": 647, "mia": 148, "nrt": 349, "atl": 125, "gig": 351, "cdg": 150, "otp": 300, "lax": 155, "ams": 185, "sjc": 129, "dfw": 173 }, { "timestamp": "2025-08-24T06:30:00.000Z", "phx": 242, "gdl": 597, "jnb": 512, "bog": 480, "lhr": 138, "yyz": 154, "fra": 184, "gru": 7780, "den": 229, "ord": 174, "sea": 191, "yul": 107, "syd": 337, "sjc": 164, "dfw": 207, "ams": 267, "lax": 154, "otp": 266, "cdg": 187, "gig": 344, "atl": 141, "mia": 172, "nrt": 430, "iad": 138, "bos": 109, "arn": 201, "mad": 343, "sin": 451, "eze": 444, "scl": 485, "hkg": 367, "bom": 485, "ewr": 173 }, { "timestamp": "2025-08-24T07:00:00.000Z", "iad": 176, "bos": 112, "arn": 201, "sin": 485, "mad": 333, "eze": 435, "hkg": 354, "scl": 486, "ewr": 128, "bom": 338, "bog": 629, "phx": 194, "gdl": 629, "jnb": 436, "fra": 156, "lhr": 154, "yyz": 155, "den": 224, "ord": 132, "sea": 163, "yul": 120, "gru": 333, "syd": 418, "otp": 271, "lax": 189, "sjc": 185, "dfw": 184, "ams": 175, "cdg": 148, "atl": 124, "gig": 353, "mia": 187, "nrt": 348 }, { "timestamp": "2025-08-24T07:30:00.000Z", "gru": 308, "sea": 169, "ord": 155, "den": 239, "yul": 151, "syd": 438, "gdl": 623, "phx": 192, "jnb": 366, "bog": 728, "lhr": 248, "yyz": 128, "fra": 266, "gig": 353, "atl": 152, "nrt": 383, "mia": 160, "dfw": 213, "sjc": 136, "ams": 175, "otp": 304, "lax": 232, "cdg": 148, "arn": 305, "mad": 340, "sin": 454, "iad": 145, "bos": 120, "scl": 477, "hkg": 344, "bom": 298, "ewr": 124, "eze": 459 }, { "timestamp": "2025-08-24T08:00:00.000Z", "eze": 451, "ewr": 208, "bom": 364, "hkg": 442, "scl": 477, "bos": 105, "iad": 165, "sin": 483, "mad": 349, "arn": 198, "cdg": 155, "lax": 169, "otp": 279, "sjc": 160, "dfw": 223, "ams": 190, "mia": 178, "nrt": 377, "atl": 119, "gig": 350, "fra": 154, "lhr": 174, "yyz": 129, "bog": 391, "phx": 227, "gdl": 575, "jnb": 491, "syd": 418, "ord": 167, "den": 226, "sea": 178, "yul": 102, "gru": 322 }, { "timestamp": "2025-08-24T08:30:00.000Z", "iad": 153, "bos": 95, "arn": 186, "mad": 350, "sin": 500, "eze": 446, "scl": 507, "hkg": 437, "ewr": 135, "bom": 293, "gdl": 562, "phx": 195, "jnb": 576, "bog": 412, "lhr": 177, "yyz": 151, "fra": 174, "gru": 262, "sea": 221, "ord": 177, "den": 245, "yul": 103, "syd": 336, "dfw": 262, "sjc": 132, "ams": 192, "otp": 287, "lax": 174, "cdg": 152, "gig": 310, "atl": 148, "nrt": 427, "mia": 190 }, { "timestamp": "2025-08-24T09:00:00.000Z", "hkg": 383, "scl": 490, "bom": 296, "ewr": 136, "eze": 440, "arn": 196, "sin": 431, "mad": 341, "iad": 143, "bos": 108, "atl": 144, "gig": 352, "mia": 157, "nrt": 307, "lax": 162, "otp": 289, "ams": 189, "sjc": 273, "dfw": 180, "cdg": 158, "yul": 113, "ord": 179, "den": 264, "sea": 179, "gru": 201, "syd": 430, "bog": 478, "jnb": 520, "phx": 189, "gdl": 579, "fra": 154, "yyz": 132, "lhr": 155 }, { "timestamp": "2025-08-24T09:30:00.000Z", "syd": 422, "gru": 228, "yul": 112, "den": 259, "ord": 146, "sea": 175, "yyz": 150, "lhr": 145, "fra": 158, "jnb": 536, "phx": 218, "gdl": 582, "bog": 464, "mia": 164, "nrt": 340, "gig": 365, "atl": 131, "cdg": 141, "ams": 242, "sjc": 139, "dfw": 173, "otp": 279, "lax": 152, "mad": 342, "sin": 485, "arn": 194, "bos": 110, "iad": 164, "ewr": 185, "bom": 333, "scl": 498, "hkg": 337, "eze": 439 }, { "timestamp": "2025-08-24T10:00:00.000Z", "eze": 436, "scl": 501, "hkg": 353, "bom": 292, "ewr": 151, "iad": 161, "bos": 117, "arn": 204, "mad": 339, "sin": 446, "ams": 196, "dfw": 208, "sjc": 139, "otp": 277, "lax": 152, "cdg": 148, "gig": 349, "atl": 124, "nrt": 438, "mia": 193, "jnb": 517, "gdl": 738, "phx": 194, "bog": 372, "yyz": 153, "lhr": 170, "fra": 172, "gru": 193, "yul": 107, "sea": 178, "den": 236, "ord": 151, "syd": 352 }, { "timestamp": "2025-08-24T10:30:00.000Z", "bos": 117, "iad": 217, "sin": 433, "mad": 372, "arn": 199, "eze": 458, "bom": 311, "ewr": 128, "hkg": 380, "scl": 483, "fra": 154, "yyz": 140, "lhr": 143, "bog": 415, "jnb": 549, "phx": 193, "gdl": 614, "syd": 356, "yul": 117, "den": 232, "ord": 157, "sea": 174, "gru": 220, "cdg": 161, "lax": 155, "otp": 270, "ams": 180, "sjc": 133, "dfw": 204, "mia": 189, "nrt": 431, "atl": 133, "gig": 353 }, { "timestamp": "2025-08-24T11:00:00.000Z", "ewr": 131, "bom": 295, "scl": 478, "hkg": 343, "eze": 484, "mad": 344, "sin": 433, "arn": 199, "bos": 119, "iad": 153, "nrt": 427, "mia": 169, "gig": 361, "atl": 134, "cdg": 152, "dfw": 183, "sjc": 133, "ams": 185, "otp": 288, "lax": 191, "syd": 352, "gru": 218, "sea": 166, "den": 231, "ord": 174, "yul": 102, "lhr": 148, "yyz": 178, "fra": 174, "gdl": 505, "phx": 209, "jnb": 470, "bog": 428 }, { "timestamp": "2025-08-24T11:30:00.000Z", "cdg": 194, "otp": 334, "lax": 159, "sjc": 233, "dfw": 308, "ams": 174, "mia": 176, "nrt": 428, "atl": 121, "gig": 357, "fra": 175, "lhr": 148, "yyz": 162, "bog": 514, "phx": 202, "gdl": 582, "jnb": 515, "syd": 415, "ord": 155, "den": 233, "sea": 201, "yul": 116, "gru": 199, "eze": 439, "bom": 281, "ewr": 164, "hkg": 346, "scl": 482, "bos": 130, "iad": 156, "sin": 446, "mad": 338, "arn": 203 }, { "timestamp": "2025-08-24T12:00:00.000Z", "gru": 267, "sea": 173, "ord": 183, "den": 232, "yul": 106, "syd": 417, "gdl": 617, "phx": 204, "jnb": 400, "bog": 455, "lhr": 162, "yyz": 148, "fra": 158, "gig": 358, "atl": 128, "nrt": 426, "mia": 183, "dfw": 290, "sjc": 147, "ams": 179, "otp": 286, "lax": 159, "cdg": 214, "arn": 222, "mad": 338, "sin": 431, "iad": 149, "bos": 105, "scl": 488, "hkg": 484, "bom": 296, "ewr": 182, "eze": 435 }, { "timestamp": "2025-08-24T12:30:00.000Z", "dfw": 187, "sjc": 128, "ams": 171, "otp": 318, "lax": 172, "cdg": 147, "gig": 343, "atl": 131, "nrt": 347, "mia": 159, "gdl": 608, "phx": 197, "jnb": 508, "bog": 387, "lhr": 144, "yyz": 189, "fra": 158, "gru": 207, "sea": 201, "ord": 174, "den": 222, "yul": 106, "syd": 396, "eze": 458, "scl": 495, "hkg": 353, "ewr": 168, "bom": 301, "iad": 196, "bos": 109, "arn": 197, "mad": 339, "sin": 495 }, { "timestamp": "2025-08-24T13:00:00.000Z", "eze": 429, "hkg": 355, "scl": 534, "bom": 303, "ewr": 127, "iad": 198, "bos": 108, "arn": 191, "sin": 440, "mad": 337, "lax": 153, "otp": 276, "dfw": 192, "sjc": 159, "ams": 178, "cdg": 147, "atl": 169, "gig": 349, "nrt": 370, "mia": 167, "bog": 376, "gdl": 546, "phx": 204, "jnb": 462, "fra": 153, "lhr": 166, "yyz": 137, "sea": 170, "ord": 132, "den": 233, "yul": 105, "gru": 320, "syd": 316 }, { "timestamp": "2025-08-24T13:30:00.000Z", "ewr": 182, "bom": 303, "scl": 477, "hkg": 374, "eze": 412, "mad": 352, "sin": 472, "arn": 265, "bos": 109, "iad": 187, "mia": 169, "nrt": 433, "gig": 343, "atl": 152, "cdg": 148, "ams": 176, "sjc": 139, "dfw": 192, "otp": 284, "lax": 178, "syd": 364, "gru": 196, "yul": 161, "den": 224, "ord": 186, "sea": 176, "yyz": 156, "lhr": 162, "fra": 207, "jnb": 510, "phx": 262, "gdl": 599, "bog": 443 }, { "timestamp": "2025-08-24T14:00:00.000Z", "yul": 122, "ord": 171, "den": 237, "sea": 172, "gru": 191, "syd": 364, "bog": 412, "jnb": 384, "phx": 190, "gdl": 647, "fra": 159, "yyz": 133, "lhr": 141, "atl": 142, "gig": 344, "mia": 160, "nrt": 444, "otp": 270, "lax": 157, "ams": 178, "sjc": 127, "dfw": 173, "cdg": 150, "arn": 231, "sin": 439, "mad": 341, "iad": 139, "bos": 133, "hkg": 395, "scl": 484, "bom": 284, "ewr": 161, "eze": 449 }, { "timestamp": "2025-08-24T14:30:00.000Z", "cdg": 153, "lax": 156, "otp": 280, "ams": 192, "sjc": 142, "dfw": 192, "mia": 173, "nrt": 438, "atl": 152, "gig": 345, "fra": 156, "yyz": 135, "lhr": 162, "bog": 445, "jnb": 445, "phx": 217, "gdl": 599, "syd": 377, "yul": 125, "den": 238, "ord": 147, "sea": 167, "gru": 216, "eze": 508, "bom": 368, "ewr": 163, "hkg": 440, "scl": 497, "bos": 112, "iad": 179, "sin": 571, "mad": 335, "arn": 188 }, { "timestamp": "2025-08-24T15:00:00.000Z", "eze": 439, "ewr": 126, "bom": 304, "scl": 461, "hkg": 422, "bos": 120, "iad": 153, "mad": 375, "sin": 408, "arn": 198, "cdg": 144, "ams": 185, "sjc": 142, "dfw": 189, "otp": 285, "lax": 155, "mia": 173, "nrt": 266, "gig": 347, "atl": 127, "yyz": 144, "lhr": 136, "fra": 162, "jnb": 474, "phx": 199, "gdl": 602, "bog": 432, "syd": 321, "gru": 208, "yul": 102, "den": 259, "ord": 167, "sea": 182 }, { "timestamp": "2025-08-24T15:30:00.000Z", "nrt": 361, "mia": 154, "atl": 154, "gig": 364, "cdg": 160, "otp": 345, "lax": 155, "ams": 181, "dfw": 166, "sjc": 130, "syd": 416, "yul": 104, "sea": 182, "ord": 139, "den": 236, "gru": 206, "fra": 159, "yyz": 154, "lhr": 141, "bog": 420, "jnb": 372, "gdl": 602, "phx": 204, "bom": 348, "ewr": 128, "hkg": 338, "scl": 514, "eze": 445, "sin": 488, "mad": 337, "arn": 221, "bos": 102, "iad": 476 }, { "timestamp": "2025-08-24T16:00:00.000Z", "cdg": 168, "otp": 283, "lax": 201, "dfw": 197, "sjc": 132, "ams": 191, "nrt": 432, "mia": 156, "atl": 155, "gig": 325, "fra": 180, "lhr": 136, "yyz": 137, "bog": 433, "gdl": 563, "phx": 212, "jnb": 394, "syd": 305, "sea": 174, "den": 265, "ord": 143, "yul": 119, "gru": 211, "eze": 440, "ewr": 133, "bom": 301, "hkg": 342, "scl": 418, "bos": 198, "iad": 304, "sin": 451, "mad": 341, "arn": 198 }, { "timestamp": "2025-08-24T16:30:00.000Z", "ewr": 165, "bom": 300, "hkg": 497, "scl": 485, "eze": 381, "sin": 509, "mad": 329, "arn": 213, "bos": 120, "iad": 452, "nrt": 287, "mia": 181, "atl": 142, "gig": 365, "cdg": 163, "lax": 168, "otp": 294, "dfw": 192, "sjc": 137, "ams": 186, "syd": 388, "sea": 170, "den": 220, "ord": 190, "yul": 123, "gru": 321, "fra": 153, "lhr": 143, "yyz": 166, "bog": 428, "gdl": 612, "phx": 207, "jnb": 433 }, { "timestamp": "2025-08-24T17:00:00.000Z", "otp": 281, "lax": 241, "ams": 183, "sjc": 155, "dfw": 175, "cdg": 155, "atl": 133, "gig": 346, "mia": 174, "nrt": 390, "bog": 375, "jnb": 416, "phx": 191, "gdl": 626, "fra": 162, "yyz": 136, "lhr": 167, "yul": 108, "ord": 138, "den": 238, "sea": 204, "gru": 216, "syd": 415, "eze": 459, "hkg": 361, "scl": 493, "bom": 296, "ewr": 139, "iad": 154, "bos": 108, "arn": 203, "sin": 442, "mad": 327 }, { "timestamp": "2025-08-24T17:30:00.000Z", "hkg": 338, "scl": 483, "bom": 280, "ewr": 127, "eze": 447, "arn": 263, "sin": 486, "mad": 323, "iad": 194, "bos": 127, "atl": 138, "gig": 344, "mia": 171, "nrt": 430, "lax": 161, "otp": 300, "ams": 183, "sjc": 125, "dfw": 176, "cdg": 258, "yul": 108, "ord": 167, "den": 274, "sea": 184, "gru": 204, "syd": 418, "bog": 404, "jnb": 462, "phx": 189, "gdl": 600, "fra": 163, "yyz": 141, "lhr": 139 }, { "timestamp": "2025-08-24T18:00:00.000Z", "arn": 205, "sin": 462, "mad": 342, "iad": 175, "bos": 125, "hkg": 364, "scl": 483, "bom": 289, "ewr": 157, "eze": 310, "sea": 196, "ord": 144, "den": 216, "yul": 105, "gru": 191, "syd": 298, "bog": 502, "gdl": 623, "phx": 191, "jnb": 525, "fra": 191, "lhr": 234, "yyz": 144, "atl": 130, "gig": 341, "nrt": 360, "mia": 164, "lax": 157, "otp": 280, "dfw": 207, "sjc": 133, "ams": 182, "cdg": 148 }, { "timestamp": "2025-08-24T18:30:00.000Z", "bog": 407, "gdl": 607, "phx": 439, "jnb": 560, "fra": 166, "lhr": 182, "yyz": 162, "sea": 189, "ord": 154, "den": 243, "yul": 109, "gru": 286, "syd": 298, "otp": 275, "lax": 327, "dfw": 167, "sjc": 175, "ams": 181, "cdg": 172, "atl": 136, "gig": 368, "nrt": 428, "mia": 181, "iad": 157, "bos": 118, "arn": 197, "sin": 458, "mad": 347, "eze": 441, "hkg": 344, "scl": 514, "bom": 317, "ewr": 182 }, { "timestamp": "2025-08-24T19:00:00.000Z", "cdg": 168, "dfw": 210, "sjc": 136, "ams": 233, "lax": 152, "otp": 280, "nrt": 435, "mia": 158, "gig": 349, "atl": 132, "lhr": 139, "yyz": 144, "fra": 205, "gdl": 612, "phx": 194, "jnb": 494, "bog": 413, "syd": 415, "gru": 307, "sea": 177, "den": 239, "ord": 138, "yul": 151, "eze": 440, "ewr": 135, "bom": 340, "scl": 481, "hkg": 474, "bos": 120, "iad": 137, "mad": 343, "sin": 423, "arn": 282 }, { "timestamp": "2025-08-24T19:30:00.000Z", "ewr": 180, "bom": 294, "scl": 485, "hkg": 425, "eze": 430, "mad": 350, "sin": 424, "arn": 199, "bos": 111, "iad": 143, "nrt": 441, "mia": 245, "gig": 348, "atl": 137, "cdg": 149, "dfw": 177, "sjc": 141, "ams": 176, "otp": 281, "lax": 152, "syd": 294, "gru": 253, "sea": 183, "den": 219, "ord": 141, "yul": 114, "lhr": 166, "yyz": 192, "fra": 221, "gdl": 612, "phx": 220, "jnb": 463, "bog": 426 }, { "timestamp": "2025-08-24T20:00:00.000Z", "iad": 485, "bos": 120, "arn": 222, "mad": 358, "sin": 508, "eze": 441, "scl": 494, "hkg": 494, "bom": 306, "ewr": 172, "gdl": 617, "phx": 217, "jnb": 531, "bog": 385, "lhr": 182, "yyz": 139, "fra": 162, "gru": 208, "sea": 168, "ord": 174, "den": 232, "yul": 117, "syd": 376, "dfw": 181, "sjc": 149, "ams": 276, "lax": 156, "otp": 339, "cdg": 148, "gig": 344, "atl": 160, "nrt": 455, "mia": 152 }, { "timestamp": "2025-08-24T20:30:00.000Z", "eze": 437, "scl": 477, "hkg": 390, "bom": 284, "ewr": 129, "iad": 152, "bos": 109, "arn": 193, "mad": 331, "sin": 438, "ams": 174, "sjc": 150, "dfw": 217, "otp": 286, "lax": 191, "cdg": 175, "gig": 367, "atl": 146, "mia": 186, "nrt": 356, "jnb": 424, "phx": 209, "gdl": 647, "bog": 384, "yyz": 148, "lhr": 155, "fra": 153, "gru": 217, "yul": 113, "ord": 139, "den": 249, "sea": 167, "syd": 290 }, { "timestamp": "2025-08-24T21:00:00.000Z", "mad": 376, "sin": 451, "arn": 213, "bos": 120, "iad": 149, "ewr": 201, "bom": 323, "scl": 485, "hkg": 398, "eze": 448, "syd": 421, "gru": 201, "yul": 126, "sea": 192, "ord": 144, "den": 247, "yyz": 184, "lhr": 148, "fra": 157, "jnb": 370, "gdl": 605, "phx": 196, "bog": 456, "nrt": 472, "mia": 223, "gig": 346, "atl": 148, "cdg": 138, "ams": 187, "dfw": 176, "sjc": 134, "lax": 165, "otp": 277 }, { "timestamp": "2025-08-24T21:30:00.000Z", "mia": 171, "nrt": 422, "atl": 179, "gig": 380, "cdg": 204, "otp": 292, "lax": 194, "sjc": 149, "dfw": 179, "ams": 223, "syd": 308, "den": 230, "ord": 162, "sea": 170, "yul": 108, "gru": 268, "fra": 159, "lhr": 148, "yyz": 151, "bog": 411, "phx": 198, "gdl": 584, "jnb": 480, "bom": 288, "ewr": 201, "hkg": 334, "scl": 483, "eze": 436, "sin": 443, "mad": 337, "arn": 198, "bos": 111, "iad": 161 }, { "timestamp": "2025-08-24T22:00:00.000Z", "eze": 380, "bom": 296, "ewr": 166, "hkg": 492, "scl": 476, "bos": 128, "iad": 127, "sin": 527, "mad": 354, "arn": 196, "cdg": 146, "otp": 281, "lax": 234, "sjc": 130, "dfw": 190, "ams": 177, "mia": 171, "nrt": 430, "atl": 203, "gig": 352, "fra": 162, "lhr": 156, "yyz": 162, "bog": 415, "phx": 202, "gdl": 663, "jnb": 388, "syd": 361, "den": 222, "ord": 165, "sea": 193, "yul": 112, "gru": 330 }, { "timestamp": "2025-08-24T22:30:00.000Z", "bos": 194, "iad": 295, "sin": 458, "mad": 333, "arn": 189, "eze": 441, "bom": 281, "ewr": 136, "hkg": 355, "scl": 438, "fra": 207, "yyz": 220, "lhr": 144, "bog": 423, "jnb": 470, "gdl": 638, "phx": 194, "syd": 361, "yul": 103, "sea": 165, "den": 230, "ord": 144, "gru": 204, "cdg": 147, "lax": 174, "otp": 273, "ams": 196, "dfw": 213, "sjc": 139, "nrt": 363, "mia": 164, "atl": 182, "gig": 355 }, { "timestamp": "2025-08-24T23:00:00.000Z", "hkg": 339, "scl": 496, "ewr": 164, "bom": 281, "eze": 446, "arn": 199, "sin": 470, "mad": 339, "iad": 248, "bos": 127, "atl": 226, "gig": 350, "mia": 168, "nrt": 429, "otp": 273, "lax": 143, "ams": 180, "sjc": 153, "dfw": 188, "cdg": 150, "yul": 115, "den": 283, "ord": 176, "sea": 187, "gru": 244, "syd": 328, "bog": 408, "jnb": 462, "phx": 194, "gdl": 532, "fra": 183, "yyz": 146, "lhr": 148 }, { "timestamp": "2025-08-24T23:30:00.000Z", "gig": 345, "atl": 201, "nrt": 421, "mia": 194, "ams": 176, "dfw": 188, "sjc": 160, "lax": 148, "otp": 281, "cdg": 153, "gru": 286, "yul": 110, "sea": 212, "ord": 197, "den": 225, "syd": 417, "jnb": 512, "gdl": 642, "phx": 196, "bog": 440, "yyz": 134, "lhr": 137, "fra": 192, "scl": 496, "hkg": 353, "ewr": 142, "bom": 283, "eze": 385, "arn": 217, "mad": 331, "sin": 430, "iad": 304, "bos": 113 }, { "timestamp": "2025-08-25T00:00:00.000Z", "cdg": 159, "dfw": 169, "sjc": 140, "ams": 186, "lax": 155, "otp": 289, "nrt": 418, "mia": 168, "gig": 359, "atl": 282, "lhr": 146, "yyz": 147, "fra": 184, "gdl": 551, "phx": 187, "jnb": 527, "bog": 425, "syd": 392, "gru": 209, "sea": 163, "ord": 195, "den": 245, "yul": 108, "eze": 420, "ewr": 134, "bom": 324, "scl": 487, "hkg": 365, "bos": 116, "iad": 160, "mad": 349, "sin": 444, "arn": 196 }, { "timestamp": "2025-08-25T00:30:00.000Z", "ewr": 129, "bom": 297, "scl": 485, "hkg": 359, "eze": 445, "mad": 332, "sin": 516, "arn": 193, "bos": 177, "iad": 202, "nrt": 407, "mia": 178, "gig": 352, "atl": 215, "cdg": 150, "dfw": 192, "sjc": 150, "ams": 171, "otp": 289, "lax": 152, "syd": 425, "gru": 229, "sea": 180, "ord": 146, "den": 233, "yul": 142, "lhr": 167, "yyz": 136, "fra": 161, "gdl": 990, "phx": 196, "jnb": 496, "bog": 485 }, { "timestamp": "2025-08-25T01:00:00.000Z", "gig": 366, "atl": 240, "nrt": 425, "mia": 166, "ams": 186, "dfw": 188, "sjc": 140, "lax": 177, "otp": 281, "cdg": 146, "gru": 194, "yul": 108, "sea": 178, "ord": 147, "den": 241, "syd": 301, "jnb": 367, "gdl": 607, "phx": 198, "bog": 378, "yyz": 149, "lhr": 152, "fra": 154, "scl": 492, "hkg": 372, "bom": 294, "ewr": 183, "eze": 444, "arn": 192, "mad": 334, "sin": 536, "iad": 413, "bos": 131 }, { "timestamp": "2025-08-25T01:30:00.000Z", "eze": 442, "scl": 559, "hkg": 360, "bom": 288, "ewr": 215, "iad": 297, "bos": 116, "arn": 198, "mad": 348, "sin": 528, "ams": 177, "dfw": 201, "sjc": 154, "otp": 278, "lax": 166, "cdg": 158, "gig": 348, "atl": 232, "nrt": 342, "mia": 195, "jnb": 360, "gdl": 586, "phx": 282, "bog": 437, "yyz": 141, "lhr": 146, "fra": 164, "gru": 217, "yul": 126, "sea": 242, "ord": 147, "den": 238, "syd": 389 }, { "timestamp": "2025-08-25T02:00:00.000Z", "arn": 197, "mad": 347, "sin": 570, "iad": 156, "bos": 115, "scl": 375, "hkg": 318, "bom": 292, "ewr": 159, "eze": 438, "gru": 209, "sea": 207, "den": 229, "ord": 139, "yul": 107, "syd": 339, "gdl": 613, "phx": 191, "jnb": 475, "bog": 412, "lhr": 150, "yyz": 148, "fra": 161, "gig": 340, "atl": 308, "nrt": 430, "mia": 188, "dfw": 199, "sjc": 155, "ams": 199, "otp": 287, "lax": 176, "cdg": 143 }, { "timestamp": "2025-08-25T02:30:00.000Z", "gdl": 625, "phx": 222, "jnb": 363, "bog": 429, "lhr": 149, "yyz": 147, "fra": 160, "gru": 207, "sea": 186, "den": 238, "ord": 162, "yul": 112, "syd": 298, "dfw": 178, "sjc": 230, "ams": 192, "lax": 170, "otp": 273, "cdg": 181, "gig": 352, "atl": 383, "nrt": 453, "mia": 183, "iad": 167, "bos": 138, "arn": 201, "mad": 338, "sin": 462, "eze": 364, "scl": 502, "hkg": 318, "bom": 292, "ewr": 138 }, { "timestamp": "2025-08-25T03:00:00.000Z", "bos": 126, "iad": 195, "mad": 354, "sin": 454, "arn": 216, "eze": 435, "ewr": 152, "bom": 354, "scl": 492, "hkg": 321, "yyz": 137, "lhr": 176, "fra": 162, "jnb": 448, "gdl": 598, "phx": 202, "bog": 442, "syd": 420, "gru": 209, "yul": 127, "sea": 179, "den": 248, "ord": 180, "cdg": 148, "ams": 187, "dfw": 186, "sjc": 165, "otp": 288, "lax": 164, "nrt": 422, "mia": 177, "gig": 347, "atl": 340 }, { "timestamp": "2025-08-25T03:30:00.000Z", "syd": 374, "gru": 212, "yul": 162, "sea": 165, "den": 224, "ord": 143, "yyz": 145, "lhr": 139, "fra": 167, "jnb": 513, "gdl": 607, "phx": 197, "bog": 473, "nrt": 348, "mia": 160, "gig": 351, "atl": 397, "cdg": 168, "ams": 183, "dfw": 167, "sjc": 179, "lax": 145, "otp": 284, "mad": 392, "sin": 449, "arn": 199, "bos": 115, "iad": 159, "ewr": 130, "bom": 288, "scl": 491, "hkg": 322, "eze": 428 }, { "timestamp": "2025-08-25T04:00:00.000Z", "ams": 189, "dfw": 168, "sjc": 164, "otp": 275, "lax": 232, "cdg": 155, "gig": 357, "atl": 348, "nrt": 429, "mia": 159, "jnb": 494, "gdl": 629, "phx": 210, "bog": 372, "yyz": 136, "lhr": 149, "fra": 201, "gru": 237, "yul": 107, "sea": 173, "ord": 178, "den": 223, "syd": 379, "eze": 435, "scl": 501, "hkg": 314, "bom": 286, "ewr": 123, "iad": 152, "bos": 106, "arn": 186, "mad": 343, "sin": 454 }, { "timestamp": "2025-08-25T04:30:00.000Z", "eze": 436, "hkg": 485, "scl": 503, "bom": 448, "ewr": 136, "iad": 139, "bos": 116, "arn": 208, "sin": 412, "mad": 355, "lax": 159, "otp": 274, "ams": 173, "sjc": 141, "dfw": 208, "cdg": 157, "atl": 230, "gig": 344, "mia": 175, "nrt": 443, "bog": 423, "jnb": 463, "phx": 196, "gdl": 625, "fra": 164, "yyz": 145, "lhr": 195, "yul": 116, "den": 226, "ord": 155, "sea": 169, "gru": 202, "syd": 312 }, { "timestamp": "2025-08-25T05:00:00.000Z", "sin": 433, "mad": 337, "arn": 212, "bos": 168, "iad": 135, "ewr": 126, "bom": 358, "hkg": 325, "scl": 505, "eze": 441, "syd": 302, "yul": 106, "sea": 168, "den": 227, "ord": 165, "gru": 236, "fra": 156, "yyz": 129, "lhr": 142, "bog": 389, "jnb": 511, "gdl": 566, "phx": 194, "nrt": 354, "mia": 159, "atl": 176, "gig": 338, "cdg": 223, "otp": 291, "lax": 159, "ams": 176, "dfw": 202, "sjc": 129 }, { "timestamp": "2025-08-25T05:30:00.000Z", "ewr": 152, "bom": 292, "hkg": 380, "scl": 493, "eze": 443, "sin": 487, "mad": 331, "arn": 236, "bos": 109, "iad": 140, "mia": 184, "nrt": 345, "atl": 120, "gig": 347, "cdg": 295, "lax": 165, "otp": 285, "sjc": 134, "dfw": 169, "ams": 169, "syd": 331, "den": 227, "ord": 144, "sea": 209, "yul": 101, "gru": 274, "fra": 165, "lhr": 142, "yyz": 127, "bog": 385, "phx": 199, "gdl": 605, "jnb": 388 }, { "timestamp": "2025-08-25T06:00:00.000Z", "cdg": 189, "lax": 153, "otp": 275, "sjc": 156, "dfw": 205, "ams": 186, "mia": 161, "nrt": 347, "atl": 147, "gig": 411, "fra": 164, "lhr": 190, "yyz": 142, "bog": 458, "phx": 187, "gdl": 594, "jnb": 402, "syd": 328, "den": 242, "ord": 180, "sea": 203, "yul": 98, "gru": 256, "eze": 437, "ewr": 129, "bom": 298, "hkg": 438, "scl": 497, "bos": 114, "iad": 150, "sin": 449, "mad": 580, "arn": 208 }, { "timestamp": "2025-08-25T06:30:00.000Z", "otp": 302, "lax": 167, "dfw": 165, "sjc": 128, "ams": 221, "cdg": 156, "atl": 137, "gig": 381, "nrt": 366, "mia": 161, "bog": 414, "gdl": 594, "phx": 190, "jnb": 475, "fra": 169, "lhr": 143, "yyz": 134, "sea": 175, "ord": 179, "den": 221, "yul": 107, "gru": 237, "syd": 323, "eze": 440, "hkg": 335, "scl": 488, "bom": 292, "ewr": 129, "iad": 138, "bos": 117, "arn": 200, "sin": 434, "mad": 334 }, { "timestamp": "2025-08-25T07:00:00.000Z", "syd": 377, "ord": 160, "den": 223, "sea": 167, "yul": 108, "gru": 262, "fra": 167, "lhr": 140, "yyz": 183, "bog": 434, "phx": 212, "gdl": 595, "jnb": 379, "mia": 173, "nrt": 360, "atl": 148, "gig": 365, "cdg": 275, "lax": 163, "otp": 283, "sjc": 143, "dfw": 182, "ams": 196, "sin": 459, "mad": 374, "arn": 278, "bos": 154, "iad": 168, "ewr": 129, "bom": 297, "hkg": 336, "scl": 497, "eze": 442 }, { "timestamp": "2025-08-25T07:30:00.000Z", "nrt": 373, "mia": 155, "atl": 144, "gig": 379, "cdg": 171, "otp": 279, "lax": 161, "ams": 176, "dfw": 195, "sjc": 137, "syd": 351, "yul": 107, "sea": 166, "ord": 207, "den": 229, "gru": 246, "fra": 178, "yyz": 153, "lhr": 160, "bog": 383, "jnb": 456, "gdl": 531, "phx": 200, "ewr": 130, "bom": 308, "hkg": 398, "scl": 432, "eze": 443, "sin": 454, "mad": 357, "arn": 325, "bos": 105, "iad": 130 }, { "timestamp": "2025-08-25T08:00:00.000Z", "sin": 484, "mad": 337, "arn": 196, "bos": 136, "iad": 154, "bom": 297, "ewr": 162, "hkg": 365, "scl": 502, "eze": 438, "syd": 374, "yul": 112, "den": 241, "ord": 154, "sea": 174, "gru": 213, "fra": 155, "yyz": 145, "lhr": 169, "bog": 444, "jnb": 517, "phx": 203, "gdl": 566, "mia": 151, "nrt": 407, "atl": 142, "gig": 347, "cdg": 157, "otp": 278, "lax": 157, "ams": 195, "sjc": 207, "dfw": 214 }, { "timestamp": "2025-08-25T08:30:00.000Z", "yyz": 147, "lhr": 143, "fra": 159, "jnb": 458, "gdl": 600, "phx": 196, "bog": 382, "syd": 370, "gru": 186, "yul": 109, "sea": 184, "ord": 132, "den": 232, "cdg": 262, "ams": 189, "dfw": 177, "sjc": 139, "otp": 299, "lax": 144, "nrt": 348, "mia": 154, "gig": 345, "atl": 126, "bos": 117, "iad": 131, "mad": 348, "sin": 471, "arn": 193, "eze": 454, "ewr": 161, "bom": 304, "scl": 494, "hkg": 325 }, { "timestamp": "2025-08-25T09:00:00.000Z", "mad": 346, "sin": 476, "arn": 215, "bos": 109, "iad": 157, "bom": 310, "ewr": 165, "scl": 480, "hkg": 322, "eze": 434, "syd": 415, "gru": 217, "yul": 109, "den": 248, "ord": 215, "sea": 170, "yyz": 149, "lhr": 177, "fra": 162, "jnb": 611, "phx": 199, "gdl": 633, "bog": 415, "mia": 188, "nrt": 425, "gig": 353, "atl": 185, "cdg": 168, "ams": 209, "sjc": 141, "dfw": 230, "lax": 147, "otp": 281 }, { "timestamp": "2025-08-25T09:30:00.000Z", "hkg": 431, "scl": 482, "bom": 288, "ewr": 133, "eze": 439, "arn": 266, "sin": 437, "mad": 362, "iad": 156, "bos": 115, "atl": 148, "gig": 342, "nrt": 386, "mia": 160, "otp": 337, "lax": 170, "ams": 195, "dfw": 171, "sjc": 127, "cdg": 156, "yul": 105, "sea": 173, "den": 232, "ord": 135, "gru": 220, "syd": 420, "bog": 389, "jnb": 491, "gdl": 586, "phx": 202, "fra": 165, "yyz": 181, "lhr": 161 }, { "timestamp": "2025-08-25T10:00:00.000Z", "eze": 467, "ewr": 154, "bom": 290, "scl": 479, "hkg": 354, "bos": 123, "iad": 149, "mad": 345, "sin": 458, "arn": 220, "cdg": 165, "sjc": 170, "dfw": 173, "ams": 170, "otp": 332, "lax": 161, "mia": 155, "nrt": 353, "gig": 362, "atl": 133, "lhr": 182, "yyz": 143, "fra": 215, "phx": 188, "gdl": 618, "jnb": 499, "bog": 381, "syd": 293, "gru": 285, "ord": 216, "den": 244, "sea": 179, "yul": 112 }, { "timestamp": "2025-08-25T10:30:00.000Z", "bog": 412, "phx": 194, "gdl": 626, "jnb": 538, "fra": 183, "lhr": 139, "yyz": 142, "den": 258, "ord": 150, "sea": 173, "yul": 185, "gru": 277, "syd": 373, "lax": 156, "otp": 278, "sjc": 127, "dfw": 168, "ams": 222, "cdg": 146, "atl": 139, "gig": 357, "mia": 174, "nrt": 423, "iad": 195, "bos": 122, "arn": 199, "sin": 433, "mad": 339, "eze": 444, "hkg": 356, "scl": 480, "bom": 295, "ewr": 143 }, { "timestamp": "2025-08-25T11:00:00.000Z", "ams": 193, "sjc": 126, "dfw": 188, "otp": 281, "lax": 185, "cdg": 262, "gig": 501, "atl": 225, "mia": 163, "nrt": 427, "jnb": 395, "phx": 199, "gdl": 614, "bog": 384, "yyz": 140, "lhr": 144, "fra": 170, "gru": 245, "yul": 105, "ord": 158, "den": 253, "sea": 175, "syd": 384, "eze": 443, "scl": 486, "hkg": 333, "ewr": 141, "bom": 299, "iad": 138, "bos": 140, "arn": 212, "mad": 344, "sin": 437 }, { "timestamp": "2025-08-25T11:30:00.000Z", "fra": 162, "yyz": 158, "lhr": 170, "bog": 384, "jnb": 443, "gdl": 510, "phx": 223, "syd": 420, "yul": 120, "sea": 202, "ord": 187, "den": 231, "gru": 197, "cdg": 164, "lax": 162, "otp": 293, "ams": 185, "dfw": 228, "sjc": 216, "nrt": 470, "mia": 159, "atl": 131, "gig": 428, "bos": 181, "iad": 150, "sin": 419, "mad": 419, "arn": 242, "eze": 477, "ewr": 143, "bom": 294, "hkg": 465, "scl": 598 }, { "timestamp": "2025-08-25T12:00:00.000Z", "iad": 147, "bos": 114, "arn": 211, "mad": 369, "sin": 478, "eze": 443, "scl": 484, "hkg": 373, "ewr": 161, "bom": 314, "gdl": 628, "phx": 193, "jnb": 493, "bog": 428, "lhr": 147, "yyz": 140, "fra": 211, "gru": 255, "sea": 172, "ord": 159, "den": 254, "yul": 118, "syd": 303, "dfw": 192, "sjc": 139, "ams": 196, "lax": 233, "otp": 299, "cdg": 169, "gig": 347, "atl": 140, "nrt": 367, "mia": 150 }, { "timestamp": "2025-08-25T12:30:00.000Z", "den": 236, "ord": 170, "sea": 175, "yul": 108, "gru": 227, "syd": 397, "bog": 418, "phx": 197, "gdl": 619, "jnb": 489, "fra": 172, "lhr": 152, "yyz": 144, "atl": 137, "gig": 355, "mia": 190, "nrt": 374, "lax": 150, "otp": 330, "sjc": 158, "dfw": 191, "ams": 249, "cdg": 181, "arn": 275, "sin": 433, "mad": 380, "iad": 187, "bos": 153, "hkg": 332, "scl": 488, "bom": 322, "ewr": 167, "eze": 445 }, { "timestamp": "2025-08-25T13:00:00.000Z", "arn": 223, "mad": 334, "sin": 433, "iad": 224, "bos": 127, "scl": 489, "hkg": 344, "ewr": 131, "bom": 289, "eze": 451, "gru": 202, "den": 231, "ord": 200, "sea": 188, "yul": 194, "syd": 323, "phx": 198, "gdl": 637, "jnb": 518, "bog": 398, "lhr": 148, "yyz": 144, "fra": 166, "gig": 352, "atl": 137, "mia": 154, "nrt": 360, "sjc": 145, "dfw": 190, "ams": 178, "otp": 303, "lax": 164, "cdg": 159 }, { "timestamp": "2025-08-25T13:30:00.000Z", "eze": 423, "ewr": 185, "bom": 277, "scl": 453, "hkg": 444, "bos": 118, "iad": 135, "mad": 334, "sin": 429, "arn": 236, "cdg": 246, "sjc": 140, "dfw": 190, "ams": 259, "lax": 175, "otp": 312, "mia": 237, "nrt": 356, "gig": 352, "atl": 137, "lhr": 156, "yyz": 191, "fra": 177, "phx": 197, "gdl": 595, "jnb": 426, "bog": 409, "syd": 294, "gru": 196, "ord": 140, "den": 252, "sea": 158, "yul": 105 }, { "timestamp": "2025-08-25T14:00:00.000Z", "bog": 464, "phx": 224, "gdl": 771, "jnb": 476, "fra": 168, "lhr": 149, "yyz": 142, "den": 242, "ord": 144, "sea": 177, "yul": 115, "gru": 191, "syd": 414, "lax": 249, "otp": 315, "sjc": 144, "dfw": 188, "ams": 182, "cdg": 152, "atl": 135, "gig": 345, "mia": 153, "nrt": 351, "iad": 174, "bos": 132, "arn": 195, "sin": 517, "mad": 342, "eze": 440, "hkg": 340, "scl": 520, "bom": 315, "ewr": 186 }, { "timestamp": "2025-08-25T14:30:00.000Z", "syd": 322, "gru": 205, "yul": 120, "sea": 199, "ord": 164, "den": 246, "yyz": 261, "lhr": 171, "fra": 168, "jnb": 466, "gdl": 652, "phx": 203, "bog": 406, "nrt": 446, "mia": 201, "gig": 348, "atl": 128, "cdg": 180, "ams": 180, "dfw": 233, "sjc": 132, "otp": 298, "lax": 166, "mad": 400, "sin": 424, "arn": 254, "bos": 121, "iad": 211, "ewr": 139, "bom": 289, "scl": 485, "hkg": 331, "eze": 435 }, { "timestamp": "2025-08-25T15:00:00.000Z", "sin": 432, "mad": 338, "arn": 226, "bos": 206, "iad": 257, "bom": 302, "ewr": 124, "hkg": 416, "scl": 490, "eze": 397, "syd": 368, "yul": 112, "sea": 170, "ord": 144, "den": 228, "gru": 235, "fra": 171, "yyz": 136, "lhr": 143, "bog": 410, "jnb": 548, "gdl": 515, "phx": 198, "nrt": 298, "mia": 176, "atl": 164, "gig": 393, "cdg": 138, "lax": 167, "otp": 398, "ams": 204, "dfw": 199, "sjc": 135 } ] }, "metricsByRegion": [ { "region": "mad", "count": 10080, "ok": 10080, "p50Latency": 306, "p75Latency": 319, "p90Latency": 339, "p95Latency": 358, "p99Latency": 451 }, { "region": "sin", "count": 10075, "ok": 10075, "p50Latency": 324, "p75Latency": 403, "p90Latency": 426, "p95Latency": 469, "p99Latency": 571 }, { "region": "bos", "count": 10080, "ok": 10080, "p50Latency": 91, "p75Latency": 100, "p90Latency": 112, "p95Latency": 123, "p99Latency": 203 }, { "region": "lhr", "count": 10116, "ok": 10116, "p50Latency": 125, "p75Latency": 133, "p90Latency": 144, "p95Latency": 159, "p99Latency": 279 }, { "region": "yul", "count": 10080, "ok": 10080, "p50Latency": 92, "p75Latency": 101, "p90Latency": 112, "p95Latency": 123, "p99Latency": 186 }, { "region": "dfw", "count": 10080, "ok": 10080, "p50Latency": 157, "p75Latency": 164, "p90Latency": 178, "p95Latency": 202, "p99Latency": 289 }, { "region": "phx", "count": 10079, "ok": 10079, "p50Latency": 176, "p75Latency": 184, "p90Latency": 195, "p95Latency": 208, "p99Latency": 311 }, { "region": "gig", "count": 10080, "ok": 10080, "p50Latency": 194, "p75Latency": 210, "p90Latency": 349, "p95Latency": 359, "p99Latency": 428 }, { "region": "bog", "count": 10080, "ok": 10080, "p50Latency": 342, "p75Latency": 378, "p90Latency": 404, "p95Latency": 461, "p99Latency": 576 }, { "region": "sea", "count": 10079, "ok": 10079, "p50Latency": 144, "p75Latency": 153, "p90Latency": 168, "p95Latency": 179, "p99Latency": 278 }, { "region": "syd", "count": 10076, "ok": 10076, "p50Latency": 273, "p75Latency": 285, "p90Latency": 325, "p95Latency": 416, "p99Latency": 463 }, { "region": "gru", "count": 10080, "ok": 10080, "p50Latency": 169, "p75Latency": 183, "p90Latency": 201, "p95Latency": 249, "p99Latency": 348 }, { "region": "scl", "count": 10080, "ok": 10080, "p50Latency": 314, "p75Latency": 345, "p90Latency": 488, "p95Latency": 501, "p99Latency": 549 }, { "region": "ewr", "count": 10079, "ok": 10079, "p50Latency": 113, "p75Latency": 122, "p90Latency": 133, "p95Latency": 162, "p99Latency": 221 }, { "region": "mia", "count": 10074, "ok": 10074, "p50Latency": 136, "p75Latency": 153, "p90Latency": 172, "p95Latency": 185, "p99Latency": 251 }, { "region": "iad", "count": 10080, "ok": 10080, "p50Latency": 99, "p75Latency": 127, "p90Latency": 152, "p95Latency": 201, "p99Latency": 399 }, { "region": "ams", "count": 10092, "ok": 10092, "p50Latency": 161, "p75Latency": 168, "p90Latency": 179, "p95Latency": 192, "p99Latency": 276 }, { "region": "arn", "count": 10080, "ok": 10080, "p50Latency": 174, "p75Latency": 182, "p90Latency": 196, "p95Latency": 207, "p99Latency": 318 }, { "region": "atl", "count": 10080, "ok": 10080, "p50Latency": 112, "p75Latency": 129, "p90Latency": 154, "p95Latency": 198, "p99Latency": 355 }, { "region": "yyz", "count": 10080, "ok": 10080, "p50Latency": 121, "p75Latency": 128, "p90Latency": 140, "p95Latency": 152, "p99Latency": 243 }, { "region": "sjc", "count": 10074, "ok": 10074, "p50Latency": 117, "p75Latency": 124, "p90Latency": 134, "p95Latency": 146, "p99Latency": 237 }, { "region": "den", "count": 10079, "ok": 10079, "p50Latency": 207, "p75Latency": 218, "p90Latency": 240, "p95Latency": 264, "p99Latency": 331 }, { "region": "nrt", "count": 10078, "ok": 10078, "p50Latency": 210, "p75Latency": 243, "p90Latency": 342, "p95Latency": 431, "p99Latency": 484 }, { "region": "cdg", "count": 10079, "ok": 10079, "p50Latency": 131, "p75Latency": 137, "p90Latency": 149, "p95Latency": 164, "p99Latency": 281 }, { "region": "lax", "count": 10078, "ok": 10078, "p50Latency": 138, "p75Latency": 145, "p90Latency": 155, "p95Latency": 169, "p99Latency": 270 }, { "region": "ord", "count": 10069, "ok": 10069, "p50Latency": 122, "p75Latency": 132, "p90Latency": 146, "p95Latency": 176, "p99Latency": 247 }, { "region": "gdl", "count": 10079, "ok": 10079, "p50Latency": 464, "p75Latency": 492, "p90Latency": 575, "p95Latency": 618, "p99Latency": 683 }, { "region": "bom", "count": 10079, "ok": 10079, "p50Latency": 268, "p75Latency": 278, "p90Latency": 292, "p95Latency": 306, "p99Latency": 399 }, { "region": "eze", "count": 10079, "ok": 10079, "p50Latency": 265, "p75Latency": 277, "p90Latency": 441, "p95Latency": 455, "p99Latency": 496 }, { "region": "hkg", "count": 10080, "ok": 10080, "p50Latency": 259, "p75Latency": 315, "p90Latency": 339, "p95Latency": 398, "p99Latency": 586 }, { "region": "fra", "count": 10079, "ok": 10079, "p50Latency": 142, "p75Latency": 148, "p90Latency": 159, "p95Latency": 172, "p99Latency": 264 }, { "region": "otp", "count": 10079, "ok": 10079, "p50Latency": 242, "p75Latency": 264, "p90Latency": 278, "p95Latency": 294, "p99Latency": 374 }, { "region": "jnb", "count": 10079, "ok": 10079, "p50Latency": 340, "p75Latency": 358, "p90Latency": 390, "p95Latency": 505, "p99Latency": 543 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-latency/cloudflare.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Feb 4, 00:00", "ams": 64, "iad": 82, "syd": 78, "jnb": 724, "gru": 44, "hkg": 37 }, { "timestamp": "Feb 4, 01:00", "ams": 49, "iad": 81, "syd": 83, "jnb": 648, "gru": 73, "hkg": 50 }, { "timestamp": "Feb 4, 02:00", "hkg": 50, "ams": 61, "iad": 68, "jnb": 728, "syd": 48, "gru": 71 }, { "timestamp": "Feb 4, 03:00", "hkg": 59, "jnb": 646, "syd": 83, "ams": 50, "iad": 56, "gru": 39 }, { "timestamp": "Feb 4, 04:00", "hkg": 50, "gru": 42, "syd": 84, "jnb": 643, "ams": 54, "iad": 70 }, { "timestamp": "Feb 4, 05:00", "gru": 126, "syd": 87, "jnb": 646, "ams": 40, "iad": 76, "hkg": 49 }, { "timestamp": "Feb 4, 06:00", "jnb": 643, "syd": 87, "ams": 40, "iad": 52, "gru": 91, "hkg": 63 }, { "timestamp": "Feb 4, 07:00", "hkg": 58, "jnb": 642, "syd": 89, "ams": 46, "iad": 65, "gru": 95 }, { "timestamp": "Feb 4, 08:00", "hkg": 68, "gru": 63, "syd": 81, "jnb": 640, "ams": 48, "iad": 68 }, { "timestamp": "Feb 4, 09:00", "hkg": 70, "iad": 69, "ams": 58, "jnb": 648, "syd": 51, "gru": 38 }, { "timestamp": "Feb 4, 10:00", "hkg": 54, "gru": 116, "jnb": 664, "syd": 86, "iad": 68, "ams": 53 }, { "timestamp": "Feb 4, 11:00", "gru": 69, "syd": 101, "jnb": 647, "ams": 47, "iad": 54, "hkg": 76 }, { "timestamp": "Feb 4, 12:00", "gru": 70, "iad": 63, "ams": 56, "syd": 87, "jnb": 649, "hkg": 62 }, { "timestamp": "Feb 4, 13:00", "syd": 84, "jnb": 642, "ams": 55, "iad": 36, "gru": 99, "hkg": 51 }, { "timestamp": "Feb 4, 14:00", "hkg": 83, "gru": 98, "ams": 54, "iad": 58, "jnb": 648, "syd": 82 }, { "timestamp": "Feb 4, 15:00", "hkg": 83, "iad": 58, "ams": 63, "syd": 81, "jnb": 749, "gru": 72 }, { "timestamp": "Feb 4, 16:00", "ams": 64, "iad": 60, "syd": 39, "jnb": 731, "gru": 99, "hkg": 78 }, { "timestamp": "Feb 4, 17:00", "jnb": 650, "syd": 73, "ams": 92, "iad": 93, "gru": 93, "hkg": 52 }, { "timestamp": "Feb 4, 18:00", "gru": 72, "jnb": 660, "syd": 70, "ams": 73, "iad": 81, "hkg": 72 }, { "timestamp": "Feb 4, 19:00", "syd": 247, "jnb": 674, "iad": 74, "ams": 102, "gru": 95, "hkg": 105 }, { "timestamp": "Feb 4, 20:00", "ams": 68, "iad": 68, "syd": 133, "jnb": 659, "gru": 101, "hkg": 82 }, { "timestamp": "Feb 4, 21:00", "hkg": 49, "syd": 84, "jnb": 714, "iad": 72, "ams": 64, "gru": 54 }, { "timestamp": "Feb 4, 22:00", "hkg": 68, "gru": 77, "iad": 63, "ams": 51, "syd": 83, "jnb": 697 }, { "timestamp": "Feb 4, 23:00", "jnb": 649, "syd": 78, "iad": 66, "ams": 50, "gru": 78, "hkg": 61 }, { "timestamp": "Feb 5, 00:00", "gru": 152, "jnb": 715, "syd": 84, "ams": 46, "iad": 46, "hkg": 44 }, { "timestamp": "Feb 5, 01:00", "gru": 136, "iad": 78, "ams": 58, "syd": 83, "jnb": 640, "hkg": 38 }, { "timestamp": "Feb 5, 02:00", "syd": 52, "jnb": 635, "iad": 99, "ams": 67, "gru": 79, "hkg": 54 }, { "timestamp": "Feb 5, 03:00", "gru": 40, "ams": 44, "iad": 75, "jnb": 649, "syd": 83, "hkg": 50 }, { "timestamp": "Feb 5, 04:00", "gru": 122, "jnb": 723, "syd": 87, "iad": 58, "ams": 48, "hkg": 54 }, { "timestamp": "Feb 5, 05:00", "ams": 60, "iad": 59, "jnb": 649, "syd": 89, "gru": 68, "hkg": 49 }, { "timestamp": "Feb 5, 06:00", "hkg": 58, "jnb": 654, "syd": 85, "ams": 38, "iad": 56, "gru": 65 }, { "timestamp": "Feb 5, 07:00", "hkg": 67, "gru": 68, "syd": 85, "jnb": 633, "iad": 42, "ams": 41 }, { "timestamp": "Feb 5, 08:00", "jnb": 671, "syd": 88, "ams": 49, "iad": 69, "gru": 36, "hkg": 71 }, { "timestamp": "Feb 5, 09:00", "ams": 58, "iad": 66, "syd": 82, "jnb": 618, "gru": 121, "hkg": 52 }, { "timestamp": "Feb 5, 10:00", "hkg": 57, "syd": 86, "jnb": 630, "ams": 48, "iad": 68, "gru": 39 }, { "timestamp": "Feb 5, 11:00", "hkg": 68, "gru": 94, "jnb": 684, "syd": 51, "iad": 70, "ams": 124 }, { "timestamp": "Feb 5, 12:00", "hkg": 70, "gru": 80, "ams": 58, "iad": 57, "jnb": 661, "syd": 93 }, { "timestamp": "Feb 5, 13:00", "hkg": 78, "iad": 68, "ams": 87, "jnb": 659, "syd": 80, "gru": 68 }, { "timestamp": "Feb 5, 14:00", "hkg": 62, "gru": 53, "jnb": 651, "syd": 82, "iad": 67, "ams": 54 }, { "timestamp": "Feb 5, 15:00", "gru": 49, "syd": 86, "jnb": 657, "ams": 56, "iad": 83, "hkg": 73 }, { "timestamp": "Feb 5, 16:00", "syd": 140, "jnb": 658, "iad": 110, "ams": 67, "gru": 77, "hkg": 66 }, { "timestamp": "Feb 5, 17:00", "hkg": 57, "syd": 76, "jnb": 688, "iad": 80, "ams": 58, "gru": 76 }, { "timestamp": "Feb 5, 18:00", "hkg": 74, "gru": 114, "jnb": 668, "syd": 79, "iad": 88, "ams": 77 }, { "timestamp": "Feb 5, 19:00", "gru": 79, "ams": 68, "iad": 63, "jnb": 749, "syd": 105, "hkg": 61 }, { "timestamp": "Feb 5, 20:00", "ams": 64, "iad": 59, "syd": 132, "jnb": 658, "gru": 86, "hkg": 70 }, { "timestamp": "Feb 5, 21:00", "ams": 60, "iad": 82, "jnb": 742, "syd": 82, "gru": 59, "hkg": 64 }, { "timestamp": "Feb 5, 22:00", "gru": 112, "syd": 85, "jnb": 665, "ams": 56, "iad": 72, "hkg": 58 }, { "timestamp": "Feb 5, 23:00", "iad": 48, "ams": 49, "syd": 90, "jnb": 722, "gru": 74, "hkg": 58 }, { "timestamp": "Feb 6, 00:00", "hkg": 45, "iad": 69, "ams": 47, "jnb": 655, "syd": 84, "gru": 89 }, { "timestamp": "Feb 6, 01:00", "hkg": 38, "jnb": 643, "syd": 90, "ams": 39, "iad": 75, "gru": 111 }, { "timestamp": "Feb 6, 02:00", "gru": 50, "jnb": 660, "syd": 96, "ams": 42, "iad": 61, "hkg": 58 }, { "timestamp": "Feb 6, 03:00", "iad": 61, "ams": 46, "jnb": 728, "syd": 92, "gru": 132, "hkg": 56 }, { "timestamp": "Feb 6, 04:00", "hkg": 59, "iad": 52, "ams": 42, "syd": 60, "jnb": 653, "gru": 46 }, { "timestamp": "Feb 6, 05:00", "hkg": 51, "iad": 60, "ams": 39, "jnb": 724, "syd": 85, "gru": 39 }, { "timestamp": "Feb 6, 06:00", "hkg": 67, "gru": 97, "iad": 72, "ams": 46, "syd": 90, "jnb": 686 }, { "timestamp": "Feb 6, 07:00", "hkg": 50, "ams": 52, "iad": 54, "syd": 88, "jnb": 661, "gru": 95 }, { "timestamp": "Feb 6, 08:00", "hkg": 74, "gru": 64, "ams": 54, "iad": 51, "jnb": 639, "syd": 86 }, { "timestamp": "Feb 6, 09:00", "hkg": 44, "gru": 94, "jnb": 729, "syd": 86, "ams": 82, "iad": 54 }, { "timestamp": "Feb 6, 10:00", "hkg": 78, "syd": 68, "jnb": 656, "ams": 67, "iad": 59, "gru": 37 }, { "timestamp": "Feb 6, 11:00", "hkg": 58, "syd": 48, "jnb": 725, "ams": 62, "iad": 59, "gru": 95 }, { "timestamp": "Feb 6, 12:00", "hkg": 45, "gru": 35, "jnb": 656, "syd": 62, "ams": 69, "iad": 45 }, { "timestamp": "Feb 6, 13:00", "hkg": 63, "gru": 69, "syd": 64, "jnb": 703, "ams": 57, "iad": 78 }, { "timestamp": "Feb 6, 14:00", "gru": 45, "jnb": 645, "syd": 63, "ams": 61, "iad": 62, "hkg": 68 }, { "timestamp": "Feb 6, 15:00", "iad": 51, "ams": 57, "jnb": 648, "syd": 58, "gru": 73, "hkg": 78 }, { "timestamp": "Feb 6, 16:00", "gru": 83, "syd": 66, "jnb": 646, "iad": 67, "ams": 73, "hkg": 66 }, { "timestamp": "Feb 6, 17:00", "gru": 82, "ams": 77, "iad": 84, "syd": 82, "jnb": 641, "hkg": 62 }, { "timestamp": "Feb 6, 18:00", "hkg": 46, "gru": 110, "ams": 72, "iad": 76, "jnb": 665, "syd": 81 }, { "timestamp": "Feb 6, 19:00", "hkg": 112, "jnb": 655, "syd": 82, "iad": 75, "ams": 79, "gru": 73 }, { "timestamp": "Feb 6, 20:00", "gru": 80, "jnb": 654, "syd": 84, "iad": 55, "ams": 128, "hkg": 49 }, { "timestamp": "Feb 6, 21:00", "gru": 91, "syd": 79, "jnb": 650, "iad": 85, "ams": 83, "hkg": 94 }, { "timestamp": "Feb 6, 22:00", "jnb": 652, "syd": 86, "iad": 84, "ams": 62, "gru": 81, "hkg": 61 }, { "timestamp": "Feb 6, 23:00", "hkg": 60, "ams": 46, "iad": 86, "jnb": 700, "syd": 92, "gru": 111 }, { "timestamp": "Feb 7, 00:00", "hkg": 55, "gru": 78, "ams": 60, "iad": 84, "syd": 97, "jnb": 643 }, { "timestamp": "Feb 7, 01:00", "hkg": 36, "jnb": 628, "syd": 183, "iad": 100, "ams": 36, "gru": 162 }, { "timestamp": "Feb 7, 02:00", "hkg": 62, "gru": 59, "jnb": 643, "syd": 88, "ams": 41, "iad": 76 }, { "timestamp": "Feb 7, 03:00", "gru": 74, "syd": 85, "jnb": 616, "iad": 59, "ams": 42, "hkg": 67 }, { "timestamp": "Feb 7, 04:00", "iad": 82, "ams": 64, "syd": 101, "jnb": 649, "gru": 44, "hkg": 81 }, { "timestamp": "Feb 7, 05:00", "hkg": 45, "jnb": 638, "syd": 87, "iad": 74, "ams": 40, "gru": 68 }, { "timestamp": "Feb 7, 06:00", "hkg": 56, "ams": 51, "iad": 60, "jnb": 633, "syd": 92, "gru": 68 }, { "timestamp": "Feb 7, 07:00", "hkg": 45, "gru": 119, "iad": 38, "ams": 48, "syd": 84, "jnb": 642 }, { "timestamp": "Feb 7, 08:00", "gru": 65, "syd": 87, "jnb": 653, "iad": 80, "ams": 42, "hkg": 73 }, { "timestamp": "Feb 7, 09:00", "gru": 143, "iad": 69, "ams": 113, "jnb": 722, "syd": 95, "hkg": 78 }, { "timestamp": "Feb 7, 10:00", "jnb": 646, "syd": 70, "ams": 68, "iad": 80, "gru": 114, "hkg": 64 }, { "timestamp": "Feb 7, 11:00", "gru": 67, "syd": 72, "jnb": 662, "iad": 68, "ams": 63, "hkg": 66 }, { "timestamp": "Feb 7, 12:00", "hkg": 57, "gru": 105, "iad": 77, "ams": 64, "syd": 135, "jnb": 661 }, { "timestamp": "Feb 7, 13:00", "hkg": 67, "syd": 136, "jnb": 661, "ams": 62, "iad": 66, "gru": 47 }, { "timestamp": "Feb 7, 14:00", "hkg": 59, "iad": 65, "ams": 54, "syd": 79, "jnb": 656, "gru": 48 }, { "timestamp": "Feb 7, 15:00", "gru": 70, "ams": 64, "iad": 109, "syd": 76, "jnb": 702, "hkg": 71 }, { "timestamp": "Feb 7, 16:00", "syd": 78, "jnb": 655, "ams": 56, "iad": 94, "gru": 75, "hkg": 65 }, { "timestamp": "Feb 7, 17:00", "iad": 82, "ams": 68, "jnb": 654, "syd": 78, "gru": 111, "hkg": 51 }, { "timestamp": "Feb 7, 18:00", "gru": 84, "ams": 86, "iad": 73, "jnb": 656, "syd": 76, "hkg": 91 }, { "timestamp": "Feb 7, 19:00", "syd": 83, "jnb": 656, "iad": 83, "ams": 65, "gru": 141, "hkg": 55 }, { "timestamp": "Feb 7, 20:00", "gru": 86, "iad": 61, "ams": 80, "syd": 166, "jnb": 661, "hkg": 222 }, { "timestamp": "Feb 7, 21:00", "hkg": 63, "gru": 87, "syd": 84, "jnb": 662, "ams": 62, "iad": 113 }, { "timestamp": "Feb 7, 22:00", "hkg": 58, "gru": 109, "iad": 99, "ams": 52, "syd": 84, "jnb": 707 }, { "timestamp": "Feb 7, 23:00", "hkg": 55, "ams": 45, "iad": 70, "jnb": 653, "syd": 88, "gru": 55 }, { "timestamp": "Feb 8, 00:00", "hkg": 55, "syd": 85, "jnb": 640, "ams": 42, "iad": 63, "gru": 82 }, { "timestamp": "Feb 8, 01:00", "hkg": 50, "ams": 45, "iad": 62, "jnb": 631, "syd": 87, "gru": 82 }, { "timestamp": "Feb 8, 02:00", "iad": 64, "ams": 42, "jnb": 647, "syd": 89, "gru": 136, "hkg": 45 }, { "timestamp": "Feb 8, 03:00", "gru": 80, "ams": 55, "iad": 60, "syd": 95, "jnb": 639, "hkg": 50 }, { "timestamp": "Feb 8, 04:00", "ams": 40, "iad": 66, "syd": 83, "jnb": 720, "gru": 130, "hkg": 71 }, { "timestamp": "Feb 8, 05:00", "gru": 71, "syd": 48, "jnb": 642, "iad": 64, "ams": 41, "hkg": 54 }, { "timestamp": "Feb 8, 06:00", "gru": 100, "ams": 42, "iad": 54, "syd": 89, "jnb": 643, "hkg": 73 }, { "timestamp": "Feb 8, 07:00", "hkg": 48, "gru": 66, "iad": 60, "ams": 47, "jnb": 654, "syd": 87 }, { "timestamp": "Feb 8, 08:00", "hkg": 59, "jnb": 657, "syd": 88, "iad": 72, "ams": 44, "gru": 94 }, { "timestamp": "Feb 8, 09:00", "gru": 36, "jnb": 650, "syd": 92, "ams": 49, "iad": 54, "hkg": 58 }, { "timestamp": "Feb 8, 10:00", "syd": 85, "jnb": 653, "ams": 54, "iad": 38, "gru": 131, "hkg": 45 }, { "timestamp": "Feb 8, 11:00", "hkg": 56, "syd": 85, "jnb": 649, "ams": 52, "iad": 41, "gru": 73 }, { "timestamp": "Feb 8, 12:00", "hkg": 71, "gru": 38, "jnb": 730, "syd": 87, "ams": 56, "iad": 44 }, { "timestamp": "Feb 8, 13:00", "hkg": 59, "iad": 109, "ams": 49, "syd": 84, "jnb": 658, "gru": 185 }, { "timestamp": "Feb 8, 14:00", "hkg": 53, "gru": 68, "iad": 64, "ams": 96, "jnb": 658, "syd": 48 }, { "timestamp": "Feb 8, 15:00", "hkg": 66, "gru": 85, "jnb": 662, "syd": 80, "iad": 83, "ams": 59 }, { "timestamp": "Feb 8, 16:00", "gru": 78, "syd": 80, "jnb": 745, "iad": 114, "ams": 76, "hkg": 53 }, { "timestamp": "Feb 8, 17:00", "gru": 102, "syd": 84, "jnb": 661, "iad": 88, "ams": 59, "hkg": 71 }, { "timestamp": "Feb 8, 18:00", "hkg": 79, "gru": 105, "syd": 80, "jnb": 655, "ams": 60, "iad": 89 }, { "timestamp": "Feb 8, 19:00", "hkg": 78, "syd": 80, "jnb": 663, "ams": 75, "iad": 60, "gru": 111 }, { "timestamp": "Feb 8, 20:00", "jnb": 649, "syd": 111, "ams": 61, "iad": 88, "gru": 89, "hkg": 64 }, { "timestamp": "Feb 8, 21:00", "gru": 132, "syd": 87, "jnb": 647, "iad": 81, "ams": 64, "hkg": 291 }, { "timestamp": "Feb 8, 22:00", "iad": 54, "ams": 48, "jnb": 660, "syd": 86, "gru": 103, "hkg": 37 }, { "timestamp": "Feb 8, 23:00", "jnb": 645, "syd": 50, "iad": 57, "ams": 48, "gru": 112, "hkg": 48 }, { "timestamp": "Feb 9, 00:00", "gru": 102, "syd": 94, "jnb": 640, "iad": 67, "ams": 47, "hkg": 64 }, { "timestamp": "Feb 9, 01:00", "gru": 58, "syd": 86, "jnb": 646, "ams": 44, "iad": 72, "hkg": 65 }, { "timestamp": "Feb 9, 02:00", "jnb": 722, "syd": 96, "ams": 49, "iad": 68, "gru": 78, "hkg": 49 }, { "timestamp": "Feb 9, 03:00", "hkg": 64, "gru": 55, "syd": 94, "jnb": 636, "ams": 42, "iad": 71 }, { "timestamp": "Feb 9, 04:00", "hkg": 45, "jnb": 642, "syd": 97, "ams": 43, "iad": 62, "gru": 73 }, { "timestamp": "Feb 9, 05:00", "syd": 84, "jnb": 643, "iad": 61, "ams": 42, "gru": 66, "hkg": 55 }, { "timestamp": "Feb 9, 06:00", "gru": 77, "jnb": 621, "syd": 87, "iad": 62, "ams": 40, "hkg": 63 }, { "timestamp": "Feb 9, 07:00", "gru": 42, "iad": 69, "ams": 42, "jnb": 652, "syd": 87, "hkg": 56 }, { "timestamp": "Feb 9, 08:00", "syd": 42, "jnb": 733, "iad": 56, "ams": 50, "gru": 63, "hkg": 60 }, { "timestamp": "Feb 9, 09:00", "syd": 78, "jnb": 640, "iad": 71, "ams": 49, "gru": 63, "hkg": 57 }, { "timestamp": "Feb 9, 10:00", "hkg": 59, "jnb": 663, "syd": 78, "iad": 64, "ams": 58, "gru": 71 }, { "timestamp": "Feb 9, 11:00", "hkg": 60, "gru": 65, "jnb": 655, "syd": 86, "iad": 51, "ams": 69 }, { "timestamp": "Feb 9, 12:00", "hkg": 73, "gru": 69, "ams": 53, "iad": 62, "syd": 82, "jnb": 736 }, { "timestamp": "Feb 9, 13:00", "hkg": 58, "iad": 56, "ams": 56, "jnb": 650, "syd": 85, "gru": 47 }, { "timestamp": "Feb 9, 14:00", "iad": 53, "ams": 75, "syd": 82, "jnb": 649, "gru": 72, "hkg": 58 }, { "timestamp": "Feb 9, 15:00", "syd": 84, "jnb": 786, "iad": 68, "ams": 52, "gru": 66, "hkg": 57 }, { "timestamp": "Feb 9, 16:00", "gru": 170, "jnb": 942, "syd": 43, "iad": 113, "ams": 54, "hkg": 52 }, { "timestamp": "Feb 9, 17:00", "ams": 53, "iad": 80, "syd": 43, "jnb": 916, "gru": 116, "hkg": 52 }, { "timestamp": "Feb 9, 18:00", "gru": 86, "syd": 77, "jnb": 925, "ams": 50, "iad": 67, "hkg": 45 }, { "timestamp": "Feb 9, 19:00", "hkg": 44, "gru": 52, "jnb": 921, "syd": 82, "iad": 68, "ams": 56 }, { "timestamp": "Feb 9, 20:00", "hkg": 53, "jnb": 919, "syd": 79, "ams": 72, "iad": 74, "gru": 126 }, { "timestamp": "Feb 9, 21:00", "gru": 73, "iad": 79, "ams": 47, "syd": 125, "jnb": 964, "hkg": 212 }, { "timestamp": "Feb 9, 22:00", "syd": 77, "jnb": 935, "iad": 81, "ams": 42, "gru": 45, "hkg": 87 }, { "timestamp": "Feb 9, 23:00", "hkg": 57, "jnb": 923, "syd": 92, "ams": 45, "iad": 58, "gru": 130 }, { "timestamp": "Feb 10, 00:00", "hkg": 47, "iad": 88, "ams": 45, "jnb": 918, "syd": 81, "gru": 87 }, { "timestamp": "Feb 10, 01:00", "hkg": 45, "syd": 86, "jnb": 1016, "iad": 74, "ams": 40, "gru": 75 }, { "timestamp": "Feb 10, 02:00", "hkg": 79, "gru": 112, "syd": 82, "jnb": 906, "iad": 54, "ams": 45 }, { "timestamp": "Feb 10, 03:00", "hkg": 45, "jnb": 920, "syd": 80, "ams": 50, "iad": 64, "gru": 99 }, { "timestamp": "Feb 10, 04:00", "jnb": 934, "syd": 50, "iad": 71, "ams": 44, "gru": 72, "hkg": 63 }, { "timestamp": "Feb 10, 05:00", "gru": 46, "ams": 43, "iad": 65, "jnb": 992, "syd": 86, "hkg": 54 }, { "timestamp": "Feb 10, 06:00", "hkg": 49, "gru": 62, "syd": 95, "jnb": 901, "iad": 78, "ams": 41 }, { "timestamp": "Feb 10, 07:00", "hkg": 57, "jnb": 918, "syd": 82, "ams": 42, "iad": 52, "gru": 66 }, { "timestamp": "Feb 10, 08:00", "hkg": 44, "iad": 56, "ams": 44, "jnb": 916, "syd": 85, "gru": 65 }, { "timestamp": "Feb 10, 09:00", "hkg": 56, "syd": 87, "jnb": 918, "ams": 44, "iad": 53, "gru": 63 }, { "timestamp": "Feb 10, 10:00", "hkg": 59, "gru": 88, "ams": 49, "iad": 68, "syd": 84, "jnb": 929 }, { "timestamp": "Feb 10, 11:00", "hkg": 61, "jnb": 925, "syd": 91, "iad": 67, "ams": 56, "gru": 68 }, { "timestamp": "Feb 10, 12:00", "hkg": 50, "gru": 67, "jnb": 904, "syd": 77, "ams": 52, "iad": 63 }, { "timestamp": "Feb 10, 13:00", "gru": 98, "iad": 57, "ams": 51, "jnb": 934, "syd": 38, "hkg": 54 }, { "timestamp": "Feb 10, 14:00", "jnb": 928, "syd": 81, "iad": 69, "ams": 134, "gru": 104, "hkg": 56 }, { "timestamp": "Feb 10, 15:00", "hkg": 57, "gru": 63, "jnb": 1006, "syd": 84, "ams": 49, "iad": 59 }, { "timestamp": "Feb 10, 16:00", "hkg": 62, "gru": 108, "iad": 86, "ams": 61, "jnb": 921, "syd": 83 }, { "timestamp": "Feb 10, 17:00", "hkg": 78, "gru": 77, "syd": 77, "jnb": 910, "iad": 71, "ams": 60 }, { "timestamp": "Feb 10, 18:00", "gru": 77, "iad": 58, "ams": 78, "syd": 81, "jnb": 920, "hkg": 66 }, { "timestamp": "Feb 10, 19:00", "ams": 57, "iad": 71, "jnb": 902, "syd": 84, "gru": 128, "hkg": 66 }, { "timestamp": "Feb 10, 20:00", "gru": 46, "jnb": 929, "syd": 114, "iad": 58, "ams": 133, "hkg": 52 }, { "timestamp": "Feb 10, 21:00", "ams": 74, "iad": 72, "jnb": 915, "syd": 134, "gru": 122, "hkg": 49 }, { "timestamp": "Feb 10, 22:00", "hkg": 92, "jnb": 925, "syd": 83, "ams": 48, "iad": 65, "gru": 81 }, { "timestamp": "Feb 10, 23:00", "hkg": 50, "gru": 73, "syd": 85, "jnb": 996, "iad": 59, "ams": 45 }, { "timestamp": "Feb 11, 00:00", "hkg": 49, "gru": 53, "ams": 59, "iad": 58, "syd": 81, "jnb": 894 }, { "timestamp": "Feb 11, 01:00", "hkg": 47, "jnb": 914, "syd": 88, "ams": 45, "iad": 71, "gru": 53 }, { "timestamp": "Feb 11, 02:00", "hkg": 44, "gru": 81, "syd": 89, "jnb": 885, "ams": 80, "iad": 75 }, { "timestamp": "Feb 11, 03:00", "gru": 44, "iad": 75, "ams": 54, "syd": 86, "jnb": 883, "hkg": 72 }, { "timestamp": "Feb 11, 04:00", "iad": 73, "ams": 51, "jnb": 892, "syd": 90, "gru": 48, "hkg": 74 }, { "timestamp": "Feb 11, 05:00", "gru": 92, "iad": 60, "ams": 44, "syd": 83, "jnb": 906, "hkg": 60 }, { "timestamp": "Feb 11, 06:00", "iad": 60, "ams": 54, "jnb": 916, "syd": 86, "gru": 63, "hkg": 58 }, { "timestamp": "Feb 11, 07:00", "hkg": 44, "jnb": 982, "syd": 89, "ams": 62, "iad": 58, "gru": 66 }, { "timestamp": "Feb 11, 08:00", "hkg": 50, "gru": 65, "syd": 89, "jnb": 924, "ams": 48, "iad": 62 }, { "timestamp": "Feb 11, 09:00", "hkg": 54, "syd": 120, "jnb": 942, "iad": 56, "ams": 44, "gru": 122 }, { "timestamp": "Feb 11, 10:00", "gru": 37, "syd": 94, "jnb": 921, "iad": 56, "ams": 52, "hkg": 56 }, { "timestamp": "Feb 11, 11:00", "ams": 59, "iad": 57, "syd": 90, "jnb": 912, "gru": 92, "hkg": 53 }, { "timestamp": "Feb 11, 12:00", "hkg": 54, "ams": 144, "iad": 56, "jnb": 921, "syd": 50, "gru": 62 }, { "timestamp": "Feb 11, 13:00", "hkg": 46, "gru": 50, "syd": 84, "jnb": 916, "ams": 58, "iad": 66 }, { "timestamp": "Feb 11, 14:00", "hkg": 45, "ams": 55, "iad": 74, "jnb": 920, "syd": 80, "gru": 92 }, { "timestamp": "Feb 11, 15:00", "hkg": 58, "gru": 75, "jnb": 894, "syd": 81, "iad": 52, "ams": 63 }, { "timestamp": "Feb 11, 16:00", "gru": 64, "syd": 84, "jnb": 927, "iad": 64, "ams": 109, "hkg": 53 }, { "timestamp": "Feb 11, 17:00", "hkg": 68, "gru": 43, "syd": 78, "jnb": 920, "iad": 67, "ams": 68 }, { "timestamp": "Feb 11, 18:00", "hkg": 46, "jnb": 1003, "syd": 76, "iad": 82, "ams": 95, "gru": 68 }, { "timestamp": "Feb 11, 19:00", "iad": 70, "ams": 68, "syd": 80, "jnb": 934, "gru": 106, "hkg": 45 }, { "timestamp": "Feb 11, 20:00", "gru": 73, "iad": 42, "ams": 63, "jnb": 935, "syd": 83, "hkg": 67 }, { "timestamp": "Feb 11, 21:00", "hkg": 56, "gru": 96, "syd": 88, "jnb": 929, "iad": 70, "ams": 82 }, { "timestamp": "Feb 11, 22:00", "hkg": 38, "jnb": 993, "syd": 127, "iad": 80, "ams": 75, "gru": 40 }, { "timestamp": "Feb 11, 23:00", "hkg": 98, "gru": 69, "jnb": 925, "syd": 54, "ams": 76, "iad": 62 }, { "timestamp": "Feb 12, 00:00", "hkg": 39, "syd": 85, "jnb": 913, "ams": 68, "iad": 59, "gru": 99 }, { "timestamp": "Feb 12, 01:00", "hkg": 54, "gru": 47, "syd": 92, "jnb": 926, "iad": 70, "ams": 41 }, { "timestamp": "Feb 12, 02:00", "gru": 83, "jnb": 997, "syd": 87, "iad": 68, "ams": 40, "hkg": 48 }, { "timestamp": "Feb 12, 03:00", "ams": 51, "iad": 59, "jnb": 919, "syd": 87, "gru": 38, "hkg": 50 }, { "timestamp": "Feb 12, 04:00", "hkg": 68, "gru": 45, "ams": 98, "iad": 58, "jnb": 918, "syd": 125 }, { "timestamp": "Feb 12, 05:00", "hkg": 51, "syd": 86, "jnb": 914, "ams": 86, "iad": 78, "gru": 124 }, { "timestamp": "Feb 12, 06:00", "jnb": 913, "syd": 464, "ams": 443, "iad": 56, "gru": 88, "hkg": 44 }, { "timestamp": "Feb 12, 07:00", "gru": 63, "iad": 54, "ams": 100, "jnb": 912, "syd": 92, "hkg": 48 }, { "timestamp": "Feb 12, 08:00", "syd": 46, "jnb": 910, "iad": 54, "ams": 185, "gru": 34, "hkg": 62 }, { "timestamp": "Feb 12, 09:00", "gru": 88, "jnb": 914, "syd": 210, "ams": 351, "iad": 56, "hkg": 58 }, { "timestamp": "Feb 12, 10:00", "gru": 33, "iad": 56, "ams": 59, "jnb": 928, "syd": 90, "hkg": 64 }, { "timestamp": "Feb 12, 11:00", "hkg": 62, "gru": 93, "ams": 62, "iad": 64, "syd": 90, "jnb": 928 }, { "timestamp": "Feb 12, 12:00", "hkg": 62, "syd": 118, "jnb": 1041, "ams": 57, "iad": 48, "gru": 42 }, { "timestamp": "Feb 12, 13:00", "hkg": 72, "syd": 81, "jnb": 926, "ams": 58, "iad": 58, "gru": 80 }, { "timestamp": "Feb 12, 14:00", "hkg": 57, "gru": 77, "syd": 80, "jnb": 1012, "iad": 66, "ams": 151 }, { "timestamp": "Feb 12, 15:00", "gru": 104, "jnb": 937, "syd": 80, "ams": 252, "iad": 63, "hkg": 116 }, { "timestamp": "Feb 12, 16:00", "ams": 131, "iad": 99, "jnb": 729, "syd": 74, "gru": 68, "hkg": 80 }, { "timestamp": "Feb 12, 17:00", "ams": 68, "iad": 70, "syd": 43, "jnb": 665, "gru": 102, "hkg": 79 }, { "timestamp": "Feb 12, 18:00", "syd": 81, "jnb": 657, "iad": 78, "ams": 69, "gru": 105, "hkg": 55 }, { "timestamp": "Feb 12, 19:00", "gru": 80, "jnb": 672, "syd": 82, "ams": 116, "iad": 57, "hkg": 50 }, { "timestamp": "Feb 12, 20:00", "hkg": 59, "gru": 47, "ams": 66, "iad": 67, "jnb": 734, "syd": 115 }, { "timestamp": "Feb 12, 21:00", "hkg": 48, "jnb": 640, "syd": 81, "iad": 75, "ams": 103, "gru": 109 }, { "timestamp": "Feb 12, 22:00", "gru": 228, "syd": 131, "jnb": 798, "ams": 54, "iad": 82, "hkg": 62 }, { "timestamp": "Feb 12, 23:00", "jnb": 621, "syd": 74, "iad": 79, "ams": 53, "gru": 106, "hkg": 103 }, { "timestamp": "Feb 13, 00:00", "hkg": 51, "iad": 76, "ams": 50, "jnb": 642, "syd": 52, "gru": 77 }, { "timestamp": "Feb 13, 01:00", "gru": 131, "syd": 92, "jnb": 640, "ams": 38, "iad": 71, "hkg": 72 }, { "timestamp": "Feb 13, 02:00", "gru": 83, "iad": 85, "ams": 68, "syd": 85, "jnb": 749, "hkg": 51 }, { "timestamp": "Feb 13, 03:00", "jnb": 714, "syd": 84, "ams": 37, "iad": 63, "gru": 42, "hkg": 54 }, { "timestamp": "Feb 13, 04:00", "gru": 40, "ams": 61, "iad": 85, "jnb": 641, "syd": 92, "hkg": 62 }, { "timestamp": "Feb 13, 05:00", "hkg": 73, "gru": 34, "jnb": 641, "syd": 87, "iad": 72, "ams": 44 }, { "timestamp": "Feb 13, 06:00", "hkg": 46, "jnb": 711, "syd": 88, "ams": 47, "iad": 61, "gru": 39 }, { "timestamp": "Feb 13, 07:00", "jnb": 718, "syd": 106, "ams": 48, "iad": 76, "gru": 34, "hkg": 63 }, { "timestamp": "Feb 13, 08:00", "gru": 40, "ams": 47, "iad": 49, "jnb": 629, "syd": 48, "hkg": 66 }, { "timestamp": "Feb 13, 09:00", "hkg": 54, "syd": 86, "jnb": 658, "ams": 83, "iad": 72, "gru": 68 }, { "timestamp": "Feb 13, 10:00", "hkg": 56, "iad": 76, "ams": 52, "syd": 83, "jnb": 653, "gru": 118 }, { "timestamp": "Feb 13, 11:00", "hkg": 59, "gru": 219, "ams": 100, "iad": 56, "jnb": 660, "syd": 88 }, { "timestamp": "Feb 13, 12:00", "hkg": 43, "ams": 115, "iad": 54, "jnb": 675, "syd": 90, "gru": 62 }, { "timestamp": "Feb 13, 13:00", "hkg": 60, "gru": 98, "jnb": 668, "syd": 84, "iad": 69, "ams": 48 }, { "timestamp": "Feb 13, 14:00", "gru": 126, "jnb": 660, "syd": 81, "ams": 55, "iad": 74, "hkg": 73 }, { "timestamp": "Feb 13, 15:00", "syd": 85, "jnb": 787, "iad": 59, "ams": 78, "gru": 38, "hkg": 69 }, { "timestamp": "Feb 13, 16:00", "hkg": 65, "gru": 86, "iad": 84, "ams": 78, "jnb": 659, "syd": 103 }, { "timestamp": "Feb 13, 17:00", "hkg": 83, "ams": 69, "iad": 74, "syd": 78, "jnb": 885, "gru": 100 }, { "timestamp": "Feb 13, 18:00", "ams": 156, "iad": 56, "jnb": 712, "syd": 76, "gru": 50, "hkg": 61 }, { "timestamp": "Feb 13, 19:00", "jnb": 650, "syd": 38, "ams": 71, "iad": 78, "gru": 81, "hkg": 62 }, { "timestamp": "Feb 13, 20:00", "gru": 46, "syd": 134, "jnb": 787, "ams": 87, "iad": 106, "hkg": 64 }, { "timestamp": "Feb 13, 21:00", "iad": 88, "ams": 70, "jnb": 668, "syd": 85, "gru": 101, "hkg": 46 }, { "timestamp": "Feb 13, 22:00", "gru": 130, "iad": 94, "ams": 105, "syd": 127, "jnb": 650, "hkg": 48 }, { "timestamp": "Feb 13, 23:00", "hkg": 103, "gru": 104, "iad": 94, "ams": 47, "syd": 89, "jnb": 659 }, { "timestamp": "Feb 14, 00:00", "hkg": 59, "iad": 90, "ams": 48, "jnb": 648, "syd": 90, "gru": 148 }, { "timestamp": "Feb 14, 01:00", "hkg": 70, "gru": 54, "iad": 64, "ams": 47, "syd": 86, "jnb": 744 }, { "timestamp": "Feb 14, 02:00", "hkg": 86, "iad": 62, "ams": 50, "jnb": 637, "syd": 88, "gru": 78 }, { "timestamp": "Feb 14, 03:00", "hkg": 44, "jnb": 798, "syd": 98, "iad": 74, "ams": 39, "gru": 80 }, { "timestamp": "Feb 14, 04:00", "syd": 87, "jnb": 633, "iad": 60, "ams": 52, "gru": 43, "hkg": 64 }, { "timestamp": "Feb 14, 05:00", "gru": 38, "jnb": 738, "syd": 96, "ams": 45, "iad": 75, "hkg": 68 }, { "timestamp": "Feb 14, 06:00", "ams": 44, "iad": 66, "syd": 52, "jnb": 724, "gru": 75, "hkg": 71 }, { "timestamp": "Feb 14, 07:00", "gru": 63, "ams": 57, "iad": 67, "syd": 100, "jnb": 776, "hkg": 51 }, { "timestamp": "Feb 14, 08:00", "hkg": 75, "gru": 40, "ams": 41, "iad": 71, "jnb": 651, "syd": 83 }, { "timestamp": "Feb 14, 09:00", "iad": 70, "ams": 80, "jnb": 750, "syd": 87, "gru": 67, "hkg": 63 }, { "timestamp": "Feb 14, 10:00", "hkg": 64, "gru": 66, "iad": 53, "ams": 51, "jnb": 655, "syd": 84 }, { "timestamp": "Feb 14, 11:00", "hkg": 42, "gru": 68, "jnb": 812, "syd": 84, "iad": 68, "ams": 56 }, { "timestamp": "Feb 14, 12:00", "hkg": 58, "syd": 83, "jnb": 652, "iad": 67, "ams": 57, "gru": 98 }, { "timestamp": "Feb 14, 13:00", "jnb": 680, "syd": 86, "ams": 50, "iad": 64, "gru": 122, "hkg": 78 }, { "timestamp": "Feb 14, 14:00", "gru": 165, "syd": 80, "jnb": 716, "ams": 49, "iad": 83, "hkg": 96 }, { "timestamp": "Feb 14, 15:00", "ams": 81, "iad": 84, "syd": 82, "jnb": 724, "gru": 75, "hkg": 85 }, { "timestamp": "Feb 14, 16:00", "gru": 52, "ams": 47, "iad": 76, "jnb": 685, "syd": 80, "hkg": 63 }, { "timestamp": "Feb 14, 17:00", "hkg": 78, "ams": 53, "iad": 79, "jnb": 686, "syd": 73, "gru": 53 }, { "timestamp": "Feb 14, 18:00", "hkg": 74, "gru": 84, "ams": 54, "iad": 71, "syd": 39, "jnb": 659 }, { "timestamp": "Feb 14, 19:00", "hkg": 59, "gru": 93, "syd": 79, "jnb": 643, "ams": 49, "iad": 98 }, { "timestamp": "Feb 14, 20:00", "gru": 70, "syd": 73, "jnb": 653, "iad": 49, "ams": 55, "hkg": 36 }, { "timestamp": "Feb 14, 21:00", "jnb": 619, "syd": 82, "ams": 58, "iad": 73, "gru": 136, "hkg": 70 }, { "timestamp": "Feb 14, 22:00", "hkg": 40, "syd": 132, "jnb": 685, "ams": 72, "iad": 58, "gru": 109 }, { "timestamp": "Feb 14, 23:00", "hkg": 51, "gru": 98, "syd": 90, "jnb": 682, "ams": 42, "iad": 73 }, { "timestamp": "Feb 15, 00:00", "hkg": 72, "ams": 49, "iad": 81, "jnb": 643, "syd": 102, "gru": 123 }, { "timestamp": "Feb 15, 01:00", "hkg": 65, "gru": 85, "iad": 76, "ams": 38, "syd": 90, "jnb": 666 }, { "timestamp": "Feb 15, 02:00", "gru": 90, "syd": 91, "jnb": 640, "iad": 59, "ams": 38, "hkg": 58 }, { "timestamp": "Feb 15, 03:00", "jnb": 636, "syd": 90, "ams": 44, "iad": 76, "gru": 77, "hkg": 61 }, { "timestamp": "Feb 15, 04:00", "iad": 68, "ams": 50, "jnb": 642, "syd": 92, "gru": 45, "hkg": 56 }, { "timestamp": "Feb 15, 05:00", "hkg": 64, "gru": 128, "syd": 90, "jnb": 640, "ams": 49, "iad": 69 }, { "timestamp": "Feb 15, 06:00", "hkg": 69, "ams": 44, "iad": 55, "syd": 58, "jnb": 644, "gru": 70 }, { "timestamp": "Feb 15, 07:00", "iad": 66, "ams": 44, "jnb": 642, "syd": 90, "gru": 67, "hkg": 78 }, { "timestamp": "Feb 15, 08:00", "gru": 38, "ams": 45, "iad": 55, "jnb": 644, "syd": 89, "hkg": 65 }, { "timestamp": "Feb 15, 09:00", "gru": 68, "ams": 52, "iad": 58, "syd": 88, "jnb": 653, "hkg": 65 }, { "timestamp": "Feb 15, 10:00", "syd": 89, "jnb": 652, "ams": 62, "iad": 70, "gru": 116, "hkg": 70 }, { "timestamp": "Feb 15, 11:00", "hkg": 87, "jnb": 694, "syd": 88, "iad": 63, "ams": 58, "gru": 41 }, { "timestamp": "Feb 15, 12:00", "hkg": 66, "ams": 50, "iad": 51, "jnb": 650, "syd": 86, "gru": 99 }, { "timestamp": "Feb 15, 13:00", "hkg": 73, "gru": 100, "jnb": 767, "syd": 85, "iad": 58, "ams": 49 }, { "timestamp": "Feb 15, 14:00", "hkg": 95, "gru": 47, "jnb": 712, "syd": 79, "ams": 50, "iad": 78 }, { "timestamp": "Feb 15, 15:00", "hkg": 103, "syd": 80, "jnb": 689, "iad": 82, "ams": 66, "gru": 92 }, { "timestamp": "Feb 15, 16:00", "syd": 148, "jnb": 625, "ams": 68, "iad": 57, "gru": 168, "hkg": 74 }, { "timestamp": "Feb 17, 10:00", "hkg": 123, "iad": 123, "ams": 57, "syd": 550, "jnb": 695, "gru": 39 }, { "timestamp": "Feb 17, 11:00", "gru": 68, "jnb": 640, "syd": 49, "ams": 47, "iad": 83, "hkg": 57 }, { "timestamp": "Feb 17, 12:00", "syd": 93, "jnb": 639, "ams": 47, "iad": 127, "gru": 74, "hkg": 46 }, { "timestamp": "Feb 17, 13:00", "jnb": 648, "syd": 87, "ams": 48, "iad": 118, "gru": 68, "hkg": 53 }, { "timestamp": "Feb 17, 14:00", "hkg": 79, "syd": 86, "jnb": 655, "ams": 50, "iad": 117, "gru": 74 }, { "timestamp": "Feb 17, 15:00", "hkg": 71, "gru": 80, "iad": 93, "ams": 56, "syd": 86, "jnb": 663 }, { "timestamp": "Feb 17, 16:00", "hkg": 72, "jnb": 750, "syd": 80, "iad": 84, "ams": 48, "gru": 72 }, { "timestamp": "Feb 17, 17:00", "hkg": 52, "gru": 78, "syd": 83, "jnb": 657, "ams": 54, "iad": 66 }, { "timestamp": "Feb 17, 18:00", "gru": 50, "syd": 96, "jnb": 702, "iad": 90, "ams": 126, "hkg": 54 }, { "timestamp": "Feb 17, 19:00", "jnb": 683, "syd": 73, "ams": 58, "iad": 104, "gru": 42, "hkg": 58 }, { "timestamp": "Feb 17, 20:00", "hkg": 85, "iad": 78, "ams": 60, "syd": 81, "jnb": 744, "gru": 55 }, { "timestamp": "Feb 17, 21:00", "hkg": 78, "gru": 76, "syd": 87, "jnb": 674, "ams": 67, "iad": 64 }, { "timestamp": "Feb 17, 22:00", "hkg": 52, "gru": 110, "iad": 73, "ams": 55, "syd": 81, "jnb": 708 }, { "timestamp": "Feb 17, 23:00", "gru": 46, "ams": 46, "iad": 76, "jnb": 614, "syd": 83, "hkg": 46 }, { "timestamp": "Feb 18, 00:00", "jnb": 610, "syd": 81, "ams": 64, "iad": 72, "gru": 46, "hkg": 52 }, { "timestamp": "Feb 18, 01:00", "syd": 92, "jnb": 617, "ams": 60, "iad": 73, "gru": 110, "hkg": 62 }, { "timestamp": "Feb 18, 02:00", "gru": 108, "syd": 90, "jnb": 610, "iad": 70, "ams": 62, "hkg": 61 }, { "timestamp": "Feb 18, 03:00", "hkg": 68, "gru": 73, "jnb": 606, "syd": 88, "ams": 39, "iad": 78 }, { "timestamp": "Feb 18, 04:00", "hkg": 59, "ams": 46, "iad": 72, "jnb": 622, "syd": 86, "gru": 71 }, { "timestamp": "Feb 18, 05:00", "gru": 70, "syd": 87, "jnb": 644, "iad": 72, "ams": 43, "hkg": 71 }, { "timestamp": "Feb 18, 06:00", "gru": 66, "ams": 64, "iad": 56, "syd": 94, "jnb": 643, "hkg": 68 }, { "timestamp": "Feb 18, 07:00", "iad": 44, "ams": 48, "jnb": 643, "syd": 92, "gru": 38, "hkg": 68 }, { "timestamp": "Feb 18, 08:00", "hkg": 75, "jnb": 658, "syd": 120, "iad": 65, "ams": 47, "gru": 66 }, { "timestamp": "Feb 18, 09:00", "gru": 66, "ams": 59, "iad": 71, "syd": 89, "jnb": 649, "hkg": 66 }, { "timestamp": "Feb 18, 10:00", "syd": 97, "jnb": 647, "iad": 61, "ams": 54, "gru": 66, "hkg": 70 }, { "timestamp": "Feb 18, 11:00", "gru": 73, "jnb": 664, "syd": 129, "ams": 70, "iad": 66, "hkg": 77 }, { "timestamp": "Feb 18, 12:00", "hkg": 43, "gru": 41, "ams": 61, "iad": 67, "jnb": 639, "syd": 119 }, { "timestamp": "Feb 18, 13:00", "hkg": 58, "jnb": 635, "syd": 91, "iad": 75, "ams": 59, "gru": 73 }, { "timestamp": "Feb 18, 14:00", "hkg": 98, "ams": 66, "iad": 54, "jnb": 664, "syd": 82, "gru": 74 }, { "timestamp": "Feb 18, 15:00", "hkg": 65, "syd": 79, "jnb": 687, "ams": 79, "iad": 77, "gru": 130 }, { "timestamp": "Feb 18, 16:00", "hkg": 59, "gru": 146, "ams": 95, "iad": 69, "syd": 85, "jnb": 727 }, { "timestamp": "Feb 18, 17:00", "jnb": 659, "syd": 82, "ams": 69, "iad": 74, "gru": 116, "hkg": 74 }, { "timestamp": "Feb 18, 18:00", "gru": 77, "jnb": 646, "syd": 93, "iad": 89, "ams": 76, "hkg": 46 } ] }, "metricsByRegion": [ { "region": "syd", "avgLatency": 89, "p75Latency": 60, "p90Latency": 268, "p95Latency": 287, "p99Latency": 310 }, { "region": "gru", "avgLatency": 77, "p75Latency": 60, "p90Latency": 211, "p95Latency": 219, "p99Latency": 296 }, { "region": "iad", "avgLatency": 74, "p75Latency": 120, "p90Latency": 132, "p95Latency": 139, "p99Latency": 177 }, { "region": "ams", "avgLatency": 57, "p75Latency": 60, "p90Latency": 73, "p95Latency": 88, "p99Latency": 169 }, { "region": "hkg", "avgLatency": 67, "p75Latency": 92, "p90Latency": 111, "p95Latency": 123, "p99Latency": 158 }, { "region": "jnb", "avgLatency": 660, "p75Latency": 705, "p90Latency": 731, "p95Latency": 748, "p99Latency": 1110 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-latency/fly.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Feb 4, 00:00", "ams": 1489, "iad": 1498, "syd": 1465, "jnb": 1434, "gru": 1405, "hkg": 1512 }, { "timestamp": "Feb 4, 01:00", "ams": 1482, "iad": 1477, "syd": 1565, "jnb": 1448, "gru": 1559, "hkg": 1658 }, { "timestamp": "Feb 4, 02:00", "hkg": 1511, "ams": 1488, "iad": 1488, "jnb": 1444, "syd": 1749, "gru": 1424 }, { "timestamp": "Feb 4, 03:00", "hkg": 1636, "jnb": 1427, "syd": 1377, "ams": 1484, "iad": 1458, "gru": 1524 }, { "timestamp": "Feb 4, 04:00", "hkg": 1500, "gru": 1411, "syd": 1294, "jnb": 1580, "ams": 1474, "iad": 1482 }, { "timestamp": "Feb 4, 05:00", "gru": 1435, "syd": 1281, "jnb": 1463, "ams": 1490, "iad": 1481, "hkg": 1537 }, { "timestamp": "Feb 4, 06:00", "jnb": 1434, "syd": 1435, "ams": 1476, "iad": 1466, "gru": 1363, "hkg": 1475 }, { "timestamp": "Feb 4, 07:00", "hkg": 1664, "jnb": 1458, "syd": 1430, "ams": 1487, "iad": 1496, "gru": 1401 }, { "timestamp": "Feb 4, 08:00", "hkg": 1493, "gru": 1389, "syd": 1260, "jnb": 1433, "ams": 1476, "iad": 1456 }, { "timestamp": "Feb 4, 09:00", "hkg": 1510, "iad": 1508, "ams": 1489, "jnb": 1474, "syd": 1274, "gru": 1302 }, { "timestamp": "Feb 4, 10:00", "hkg": 1360, "gru": 1564, "jnb": 1586, "syd": 1452, "iad": 1478, "ams": 1478 }, { "timestamp": "Feb 4, 11:00", "gru": 1391, "syd": 1545, "jnb": 1577, "ams": 1487, "iad": 1464, "hkg": 1486 }, { "timestamp": "Feb 4, 12:00", "gru": 1400, "iad": 1506, "ams": 1494, "syd": 1432, "jnb": 1468, "hkg": 1534 }, { "timestamp": "Feb 4, 13:00", "syd": 1630, "jnb": 1474, "ams": 1485, "iad": 1478, "gru": 1433, "hkg": 1720 }, { "timestamp": "Feb 4, 14:00", "hkg": 1928, "gru": 1822, "ams": 1499, "iad": 1477, "jnb": 1456, "syd": 2190 }, { "timestamp": "Feb 4, 15:00", "hkg": 1558, "iad": 1524, "ams": 1462, "syd": 1816, "jnb": 1489, "gru": 1430 }, { "timestamp": "Feb 4, 16:00", "ams": 1497, "iad": 1488, "syd": 1376, "jnb": 1450, "gru": 1424, "hkg": 1521 }, { "timestamp": "Feb 4, 17:00", "jnb": 1433, "syd": 1617, "ams": 1506, "iad": 1477, "gru": 1394, "hkg": 1523 }, { "timestamp": "Feb 4, 18:00", "gru": 1448, "jnb": 1601, "syd": 1604, "ams": 1507, "iad": 1514, "hkg": 1540 }, { "timestamp": "Feb 4, 19:00", "syd": 1278, "jnb": 1446, "iad": 1441, "ams": 1494, "gru": 1407, "hkg": 1536 }, { "timestamp": "Feb 4, 20:00", "ams": 1484, "iad": 1496, "syd": 1614, "jnb": 1454, "gru": 1433, "hkg": 1524 }, { "timestamp": "Feb 4, 21:00", "hkg": 1686, "syd": 1889, "jnb": 1454, "iad": 1496, "ams": 1486, "gru": 1902 }, { "timestamp": "Feb 4, 22:00", "hkg": 1552, "gru": 1611, "iad": 1515, "ams": 1485, "syd": 1476, "jnb": 1451 }, { "timestamp": "Feb 4, 23:00", "jnb": 1442, "syd": 1600, "iad": 1486, "ams": 1497, "gru": 1410, "hkg": 1506 }, { "timestamp": "Feb 5, 00:00", "gru": 1528, "jnb": 1444, "syd": 1614, "ams": 1484, "iad": 1486, "hkg": 1504 }, { "timestamp": "Feb 5, 01:00", "gru": 1532, "iad": 1486, "ams": 1493, "syd": 1588, "jnb": 1451, "hkg": 1521 }, { "timestamp": "Feb 5, 02:00", "syd": 1588, "jnb": 1282, "iad": 1456, "ams": 1478, "gru": 1406, "hkg": 1498 }, { "timestamp": "Feb 5, 03:00", "gru": 1419, "ams": 1481, "iad": 1477, "jnb": 1431, "syd": 1586, "hkg": 1512 }, { "timestamp": "Feb 5, 04:00", "gru": 1411, "jnb": 1447, "syd": 1606, "iad": 1477, "ams": 1484, "hkg": 1522 }, { "timestamp": "Feb 5, 05:00", "ams": 1474, "iad": 1464, "jnb": 1437, "syd": 1647, "gru": 1370, "hkg": 1492 }, { "timestamp": "Feb 5, 06:00", "hkg": 1354, "jnb": 1447, "syd": 1122, "ams": 1495, "iad": 1484, "gru": 1227 }, { "timestamp": "Feb 5, 07:00", "hkg": 1498, "gru": 1415, "syd": 1406, "jnb": 1451, "iad": 1477, "ams": 1486 }, { "timestamp": "Feb 5, 08:00", "jnb": 1596, "syd": 1760, "ams": 1490, "iad": 1470, "gru": 1576, "hkg": 1560 }, { "timestamp": "Feb 5, 09:00", "ams": 1490, "iad": 1470, "syd": 1239, "jnb": 1404, "gru": 1226, "hkg": 2302 }, { "timestamp": "Feb 5, 10:00", "hkg": 1451, "syd": 1264, "jnb": 1410, "ams": 1485, "iad": 1460, "gru": 1400 }, { "timestamp": "Feb 5, 11:00", "hkg": 1443, "gru": 1248, "jnb": 1443, "syd": 1437, "iad": 1476, "ams": 1483 }, { "timestamp": "Feb 5, 12:00", "hkg": 1512, "gru": 1412, "ams": 1510, "iad": 1520, "jnb": 1458, "syd": 1457 }, { "timestamp": "Feb 5, 13:00", "hkg": 1481, "iad": 1483, "ams": 1515, "jnb": 1442, "syd": 1443, "gru": 1274 }, { "timestamp": "Feb 5, 14:00", "hkg": 1450, "gru": 1415, "jnb": 1413, "syd": 1421, "iad": 1465, "ams": 1492 }, { "timestamp": "Feb 5, 15:00", "gru": 1414, "syd": 1610, "jnb": 1437, "ams": 1494, "iad": 1468, "hkg": 1255 }, { "timestamp": "Feb 5, 16:00", "syd": 1440, "jnb": 1465, "iad": 1492, "ams": 1507, "gru": 1434, "hkg": 1468 }, { "timestamp": "Feb 5, 17:00", "hkg": 1440, "syd": 1616, "jnb": 1444, "iad": 1502, "ams": 1512, "gru": 1436 }, { "timestamp": "Feb 5, 18:00", "hkg": 1460, "gru": 1420, "jnb": 1448, "syd": 1611, "iad": 1495, "ams": 1502 }, { "timestamp": "Feb 5, 19:00", "gru": 1660, "ams": 1566, "iad": 1550, "jnb": 1513, "syd": 1631, "hkg": 1525 }, { "timestamp": "Feb 5, 20:00", "ams": 1499, "iad": 1473, "syd": 1432, "jnb": 1247, "gru": 1375, "hkg": 1452 }, { "timestamp": "Feb 5, 21:00", "ams": 1495, "iad": 1470, "jnb": 1619, "syd": 1582, "gru": 1553, "hkg": 1464 }, { "timestamp": "Feb 5, 22:00", "gru": 1563, "syd": 1436, "jnb": 1569, "ams": 1494, "iad": 1435, "hkg": 1442 }, { "timestamp": "Feb 5, 23:00", "iad": 1471, "ams": 1499, "syd": 1424, "jnb": 1446, "gru": 1421, "hkg": 1466 }, { "timestamp": "Feb 6, 00:00", "hkg": 1463, "iad": 1482, "ams": 1485, "jnb": 1438, "syd": 1242, "gru": 1417 }, { "timestamp": "Feb 6, 01:00", "hkg": 1437, "jnb": 1426, "syd": 1460, "ams": 1485, "iad": 1474, "gru": 1408 }, { "timestamp": "Feb 6, 02:00", "gru": 1392, "jnb": 1428, "syd": 1408, "ams": 1483, "iad": 1463, "hkg": 1449 }, { "timestamp": "Feb 6, 03:00", "iad": 1473, "ams": 1489, "jnb": 1612, "syd": 1529, "gru": 1539, "hkg": 1435 }, { "timestamp": "Feb 6, 04:00", "hkg": 1512, "iad": 1540, "ams": 1495, "syd": 1604, "jnb": 1662, "gru": 1477 }, { "timestamp": "Feb 6, 05:00", "hkg": 1452, "iad": 1498, "ams": 1437, "jnb": 1458, "syd": 1439, "gru": 1430 }, { "timestamp": "Feb 6, 06:00", "hkg": 1442, "gru": 1400, "iad": 1443, "ams": 1486, "syd": 1398, "jnb": 1414 }, { "timestamp": "Feb 6, 07:00", "hkg": 1454, "ams": 1486, "iad": 1473, "syd": 1309, "jnb": 1444, "gru": 1386 }, { "timestamp": "Feb 6, 08:00", "hkg": 1468, "gru": 1451, "ams": 1512, "iad": 1466, "jnb": 1464, "syd": 1391 }, { "timestamp": "Feb 6, 09:00", "hkg": 1440, "gru": 1410, "jnb": 1400, "syd": 1300, "ams": 1479, "iad": 1437 }, { "timestamp": "Feb 6, 10:00", "hkg": 1466, "syd": 1619, "jnb": 1450, "ams": 1495, "iad": 1486, "gru": 1390 }, { "timestamp": "Feb 6, 11:00", "hkg": 1495, "syd": 1451, "jnb": 1491, "ams": 1485, "iad": 1518, "gru": 1461 }, { "timestamp": "Feb 6, 12:00", "hkg": 1441, "gru": 1402, "jnb": 1454, "syd": 1419, "ams": 1514, "iad": 1482 }, { "timestamp": "Feb 6, 13:00", "hkg": 1456, "gru": 1425, "syd": 1428, "jnb": 1471, "ams": 1502, "iad": 1493 }, { "timestamp": "Feb 6, 14:00", "gru": 1448, "jnb": 1471, "syd": 1304, "ams": 1494, "iad": 1515, "hkg": 1471 }, { "timestamp": "Feb 6, 15:00", "iad": 1466, "ams": 1486, "jnb": 1312, "syd": 1436, "gru": 1528, "hkg": 1451 }, { "timestamp": "Feb 6, 16:00", "gru": 1408, "syd": 1273, "jnb": 1243, "iad": 1486, "ams": 1496, "hkg": 1470 }, { "timestamp": "Feb 6, 17:00", "gru": 1430, "ams": 1497, "iad": 1523, "syd": 1427, "jnb": 1444, "hkg": 1471 }, { "timestamp": "Feb 6, 18:00", "hkg": 1550, "gru": 1424, "ams": 1291, "iad": 1294, "jnb": 1275, "syd": 1447 }, { "timestamp": "Feb 6, 19:00", "hkg": 1474, "jnb": 1464, "syd": 1288, "iad": 1504, "ams": 1510, "gru": 1431 }, { "timestamp": "Feb 6, 20:00", "gru": 1442, "jnb": 1475, "syd": 1447, "iad": 1509, "ams": 1520, "hkg": 1457 }, { "timestamp": "Feb 6, 21:00", "gru": 1424, "syd": 1414, "jnb": 1303, "iad": 1470, "ams": 1499, "hkg": 1462 }, { "timestamp": "Feb 6, 22:00", "jnb": 1438, "syd": 1610, "iad": 1506, "ams": 1525, "gru": 1597, "hkg": 1496 }, { "timestamp": "Feb 6, 23:00", "hkg": 1279, "ams": 1505, "iad": 1458, "jnb": 1438, "syd": 1440, "gru": 1428 }, { "timestamp": "Feb 7, 00:00", "hkg": 1472, "gru": 1431, "ams": 1512, "iad": 1509, "syd": 1452, "jnb": 1476 }, { "timestamp": "Feb 7, 01:00", "hkg": 1455, "jnb": 1416, "syd": 1409, "iad": 1477, "ams": 1490, "gru": 1408 }, { "timestamp": "Feb 7, 02:00", "hkg": 1281, "gru": 1239, "jnb": 1250, "syd": 1456, "ams": 1259, "iad": 1272 }, { "timestamp": "Feb 7, 03:00", "gru": 1395, "syd": 1411, "jnb": 1453, "iad": 1498, "ams": 1501, "hkg": 1452 }, { "timestamp": "Feb 7, 04:00", "iad": 1496, "ams": 1495, "syd": 1523, "jnb": 1448, "gru": 1386, "hkg": 1461 }, { "timestamp": "Feb 7, 05:00", "hkg": 1479, "jnb": 1461, "syd": 1289, "iad": 1508, "ams": 1507, "gru": 1431 }, { "timestamp": "Feb 7, 06:00", "hkg": 1439, "ams": 1497, "iad": 1498, "jnb": 1471, "syd": 1275, "gru": 1415 }, { "timestamp": "Feb 7, 07:00", "hkg": 1462, "gru": 1425, "iad": 1488, "ams": 1512, "syd": 1375, "jnb": 1464 }, { "timestamp": "Feb 7, 08:00", "gru": 1405, "syd": 1845, "jnb": 1455, "iad": 1486, "ams": 1494, "hkg": 1465 }, { "timestamp": "Feb 7, 09:00", "gru": 1473, "iad": 1541, "ams": 1518, "jnb": 1492, "syd": 1482, "hkg": 1504 }, { "timestamp": "Feb 7, 10:00", "jnb": 1312, "syd": 1491, "ams": 1263, "iad": 1299, "gru": 1283, "hkg": 1308 }, { "timestamp": "Feb 7, 11:00", "gru": 1447, "syd": 1435, "jnb": 1472, "iad": 1510, "ams": 1515, "hkg": 1465 }, { "timestamp": "Feb 7, 12:00", "hkg": 1451, "gru": 1607, "iad": 1511, "ams": 1514, "syd": 1630, "jnb": 1454 }, { "timestamp": "Feb 7, 13:00", "hkg": 1462, "syd": 1398, "jnb": 1427, "ams": 1508, "iad": 1487, "gru": 1421 }, { "timestamp": "Feb 7, 14:00", "hkg": 1448, "iad": 1504, "ams": 1512, "syd": 1437, "jnb": 1468, "gru": 1272 }, { "timestamp": "Feb 7, 15:00", "gru": 1423, "ams": 1506, "iad": 1498, "syd": 1594, "jnb": 1619, "hkg": 1446 }, { "timestamp": "Feb 7, 16:00", "syd": 1804, "jnb": 1285, "ams": 1505, "iad": 1491, "gru": 1427, "hkg": 1486 }, { "timestamp": "Feb 7, 17:00", "iad": 1509, "ams": 1510, "jnb": 1311, "syd": 1446, "gru": 1463, "hkg": 1502 }, { "timestamp": "Feb 7, 18:00", "gru": 1438, "ams": 1519, "iad": 1497, "jnb": 1470, "syd": 1604, "hkg": 1482 }, { "timestamp": "Feb 7, 19:00", "syd": 1412, "jnb": 1491, "iad": 1520, "ams": 1530, "gru": 1457, "hkg": 1495 }, { "timestamp": "Feb 7, 20:00", "gru": 1430, "iad": 1497, "ams": 1505, "syd": 1435, "jnb": 1454, "hkg": 1452 }, { "timestamp": "Feb 7, 21:00", "hkg": 1464, "gru": 1436, "syd": 1412, "jnb": 1474, "ams": 1523, "iad": 1503 }, { "timestamp": "Feb 7, 22:00", "hkg": 1486, "gru": 1458, "iad": 1513, "ams": 1530, "syd": 1839, "jnb": 1634 }, { "timestamp": "Feb 7, 23:00", "hkg": 1492, "ams": 1522, "iad": 1534, "jnb": 1477, "syd": 1446, "gru": 1457 }, { "timestamp": "Feb 8, 00:00", "hkg": 1520, "syd": 1682, "jnb": 1478, "ams": 1510, "iad": 1540, "gru": 1616 }, { "timestamp": "Feb 8, 01:00", "hkg": 1423, "ams": 1514, "iad": 1496, "jnb": 1642, "syd": 1613, "gru": 1422 }, { "timestamp": "Feb 8, 02:00", "iad": 1520, "ams": 1521, "jnb": 1659, "syd": 1475, "gru": 1428, "hkg": 1484 }, { "timestamp": "Feb 8, 03:00", "gru": 1609, "ams": 1520, "iad": 1451, "syd": 1639, "jnb": 1607, "hkg": 1478 }, { "timestamp": "Feb 8, 04:00", "ams": 1508, "iad": 1387, "syd": 1437, "jnb": 1464, "gru": 1412, "hkg": 1422 }, { "timestamp": "Feb 8, 05:00", "gru": 1556, "syd": 1611, "jnb": 1642, "iad": 1523, "ams": 1514, "hkg": 1490 }, { "timestamp": "Feb 8, 06:00", "gru": 1334, "ams": 1506, "iad": 1498, "syd": 1706, "jnb": 1621, "hkg": 1472 }, { "timestamp": "Feb 8, 07:00", "hkg": 1463, "gru": 1425, "iad": 1494, "ams": 1502, "jnb": 1441, "syd": 1281 }, { "timestamp": "Feb 8, 08:00", "hkg": 1424, "jnb": 1511, "syd": 1673, "iad": 1524, "ams": 1481, "gru": 1472 }, { "timestamp": "Feb 8, 09:00", "gru": 1284, "jnb": 1289, "syd": 1490, "ams": 1278, "iad": 1297, "hkg": 1277 }, { "timestamp": "Feb 8, 10:00", "syd": 1332, "jnb": 1291, "ams": 1275, "iad": 1299, "gru": 1268, "hkg": 1304 }, { "timestamp": "Feb 8, 11:00", "hkg": 1473, "syd": 1268, "jnb": 1469, "ams": 1512, "iad": 1501, "gru": 1438 }, { "timestamp": "Feb 8, 12:00", "hkg": 1468, "gru": 1565, "jnb": 1426, "syd": 1765, "ams": 1507, "iad": 1497 }, { "timestamp": "Feb 8, 13:00", "hkg": 1474, "iad": 1505, "ams": 1514, "syd": 1554, "jnb": 1441, "gru": 1424 }, { "timestamp": "Feb 8, 14:00", "hkg": 1451, "gru": 1447, "iad": 1486, "ams": 1532, "jnb": 1478, "syd": 1644 }, { "timestamp": "Feb 8, 15:00", "hkg": 1471, "gru": 1440, "jnb": 1426, "syd": 1436, "iad": 1494, "ams": 1512 }, { "timestamp": "Feb 8, 16:00", "gru": 1272, "syd": 1574, "jnb": 1451, "iad": 1286, "ams": 1267, "hkg": 1307 }, { "timestamp": "Feb 8, 17:00", "gru": 1300, "syd": 1419, "jnb": 1481, "iad": 1521, "ams": 1512, "hkg": 1503 }, { "timestamp": "Feb 8, 18:00", "hkg": 1458, "gru": 1437, "syd": 1585, "jnb": 1270, "ams": 1518, "iad": 1488 }, { "timestamp": "Feb 8, 19:00", "hkg": 1531, "syd": 1866, "jnb": 1515, "ams": 1552, "iad": 1564, "gru": 1490 }, { "timestamp": "Feb 8, 20:00", "jnb": 1250, "syd": 1315, "ams": 1316, "iad": 1331, "gru": 1276, "hkg": 1326 }, { "timestamp": "Feb 8, 21:00", "gru": 1303, "syd": 1861, "jnb": 1486, "iad": 1534, "ams": 1546, "hkg": 1512 }, { "timestamp": "Feb 8, 22:00", "iad": 1505, "ams": 1513, "jnb": 1468, "syd": 1789, "gru": 1594, "hkg": 1471 }, { "timestamp": "Feb 8, 23:00", "jnb": 1481, "syd": 1444, "iad": 1491, "ams": 1530, "gru": 1407, "hkg": 1476 }, { "timestamp": "Feb 9, 00:00", "gru": 1458, "syd": 2006, "jnb": 1479, "iad": 1517, "ams": 1524, "hkg": 1471 }, { "timestamp": "Feb 9, 01:00", "gru": 1449, "syd": 1638, "jnb": 1488, "ams": 1522, "iad": 1515, "hkg": 1487 }, { "timestamp": "Feb 9, 02:00", "jnb": 1520, "syd": 1330, "ams": 1546, "iad": 1552, "gru": 1481, "hkg": 1530 }, { "timestamp": "Feb 9, 03:00", "hkg": 1494, "gru": 1465, "syd": 1660, "jnb": 1490, "ams": 1540, "iad": 1536 }, { "timestamp": "Feb 9, 04:00", "hkg": 1481, "jnb": 1482, "syd": 1451, "ams": 1521, "iad": 1514, "gru": 1444 }, { "timestamp": "Feb 9, 05:00", "syd": 1423, "jnb": 1471, "iad": 1504, "ams": 1515, "gru": 1433, "hkg": 1457 }, { "timestamp": "Feb 9, 06:00", "gru": 1432, "jnb": 1450, "syd": 1398, "iad": 1533, "ams": 1514, "hkg": 1418 }, { "timestamp": "Feb 9, 07:00", "gru": 1412, "iad": 1441, "ams": 1518, "jnb": 1477, "syd": 1886, "hkg": 1479 }, { "timestamp": "Feb 9, 08:00", "syd": 1698, "jnb": 1678, "iad": 1522, "ams": 1523, "gru": 1828, "hkg": 1482 }, { "timestamp": "Feb 9, 09:00", "syd": 1509, "jnb": 1531, "iad": 1561, "ams": 1539, "gru": 1474, "hkg": 1554 }, { "timestamp": "Feb 9, 10:00", "hkg": 1494, "jnb": 1504, "syd": 1500, "iad": 1510, "ams": 1528, "gru": 1287 }, { "timestamp": "Feb 9, 11:00", "hkg": 1485, "gru": 1300, "jnb": 1424, "syd": 1443, "iad": 1534, "ams": 1544 }, { "timestamp": "Feb 9, 12:00", "hkg": 1520, "gru": 1482, "ams": 1572, "iad": 1563, "syd": 1678, "jnb": 1515 }, { "timestamp": "Feb 9, 13:00", "hkg": 1521, "iad": 1524, "ams": 1554, "jnb": 1511, "syd": 1477, "gru": 1275 }, { "timestamp": "Feb 9, 14:00", "iad": 1542, "ams": 1551, "syd": 1652, "jnb": 1498, "gru": 1429, "hkg": 1508 }, { "timestamp": "Feb 9, 15:00", "syd": 1468, "jnb": 1466, "iad": 1504, "ams": 1518, "gru": 1427, "hkg": 1476 }, { "timestamp": "Feb 9, 16:00", "gru": 1278, "jnb": 1265, "syd": 1619, "iad": 1512, "ams": 1531, "hkg": 1316 }, { "timestamp": "Feb 9, 17:00", "ams": 1533, "iad": 1521, "syd": 1450, "jnb": 1479, "gru": 1462, "hkg": 1488 }, { "timestamp": "Feb 9, 18:00", "gru": 1626, "syd": 1628, "jnb": 1622, "ams": 1536, "iad": 1513, "hkg": 1499 }, { "timestamp": "Feb 9, 19:00", "hkg": 1524, "gru": 1481, "jnb": 1457, "syd": 1644, "iad": 1548, "ams": 1550 }, { "timestamp": "Feb 9, 20:00", "hkg": 1311, "jnb": 1484, "syd": 1458, "ams": 1525, "iad": 1525, "gru": 1385 }, { "timestamp": "Feb 9, 21:00", "gru": 1469, "iad": 1520, "ams": 1543, "syd": 1472, "jnb": 1508, "hkg": 1519 }, { "timestamp": "Feb 9, 22:00", "syd": 1318, "jnb": 1347, "iad": 1523, "ams": 1520, "gru": 1464, "hkg": 1494 }, { "timestamp": "Feb 9, 23:00", "hkg": 1460, "jnb": 1463, "syd": 1442, "ams": 1519, "iad": 1500, "gru": 1446 }, { "timestamp": "Feb 10, 00:00", "hkg": 1489, "iad": 1514, "ams": 1521, "jnb": 1650, "syd": 1460, "gru": 1444 }, { "timestamp": "Feb 10, 01:00", "hkg": 1486, "syd": 1439, "jnb": 1320, "iad": 1505, "ams": 1515, "gru": 1270 }, { "timestamp": "Feb 10, 02:00", "hkg": 1526, "gru": 1289, "syd": 1280, "jnb": 1521, "iad": 1547, "ams": 1526 }, { "timestamp": "Feb 10, 03:00", "hkg": 1418, "jnb": 1285, "syd": 1450, "ams": 1257, "iad": 1282, "gru": 1300 }, { "timestamp": "Feb 10, 04:00", "jnb": 1480, "syd": 1654, "iad": 1528, "ams": 1533, "gru": 1462, "hkg": 1507 }, { "timestamp": "Feb 10, 05:00", "gru": 1277, "ams": 1516, "iad": 1507, "jnb": 1471, "syd": 1488, "hkg": 1488 }, { "timestamp": "Feb 10, 06:00", "hkg": 1488, "gru": 1428, "syd": 1604, "jnb": 1612, "iad": 1514, "ams": 1518 }, { "timestamp": "Feb 10, 07:00", "hkg": 1485, "jnb": 1483, "syd": 1427, "ams": 1516, "iad": 1497, "gru": 1439 }, { "timestamp": "Feb 10, 08:00", "hkg": 1526, "iad": 1544, "ams": 1540, "jnb": 1514, "syd": 1675, "gru": 1487 }, { "timestamp": "Feb 10, 09:00", "hkg": 1454, "syd": 1466, "jnb": 1293, "ams": 1266, "iad": 1236, "gru": 1270 }, { "timestamp": "Feb 10, 10:00", "hkg": 1455, "gru": 1412, "ams": 1268, "iad": 1247, "syd": 1298, "jnb": 1290 }, { "timestamp": "Feb 10, 11:00", "hkg": 1506, "jnb": 1318, "syd": 1464, "iad": 1530, "ams": 1511, "gru": 1420 }, { "timestamp": "Feb 10, 12:00", "hkg": 1346, "gru": 1308, "jnb": 1300, "syd": 1503, "ams": 1344, "iad": 1318 }, { "timestamp": "Feb 10, 13:00", "gru": 1457, "iad": 1520, "ams": 1536, "jnb": 1483, "syd": 1450, "hkg": 1500 }, { "timestamp": "Feb 10, 14:00", "jnb": 1478, "syd": 1456, "iad": 1532, "ams": 1527, "gru": 1452, "hkg": 1463 }, { "timestamp": "Feb 10, 15:00", "hkg": 1496, "gru": 1447, "jnb": 1470, "syd": 1636, "ams": 1519, "iad": 1511 }, { "timestamp": "Feb 10, 16:00", "hkg": 1328, "gru": 1289, "iad": 1293, "ams": 1288, "jnb": 1297, "syd": 1466 }, { "timestamp": "Feb 10, 17:00", "hkg": 1515, "gru": 1462, "syd": 1636, "jnb": 1394, "iad": 1518, "ams": 1529 }, { "timestamp": "Feb 10, 18:00", "gru": 1462, "iad": 1292, "ams": 1303, "syd": 1499, "jnb": 1320, "hkg": 1346 }, { "timestamp": "Feb 10, 19:00", "ams": 1570, "iad": 1595, "jnb": 1524, "syd": 1492, "gru": 1506, "hkg": 1562 }, { "timestamp": "Feb 10, 20:00", "gru": 1262, "jnb": 1455, "syd": 1298, "iad": 1518, "ams": 1514, "hkg": 1494 }, { "timestamp": "Feb 10, 21:00", "ams": 1529, "iad": 1521, "jnb": 1454, "syd": 1456, "gru": 1299, "hkg": 1500 }, { "timestamp": "Feb 10, 22:00", "hkg": 1669, "jnb": 1483, "syd": 1614, "ams": 1522, "iad": 1517, "gru": 1445 }, { "timestamp": "Feb 10, 23:00", "hkg": 1498, "gru": 1574, "syd": 1628, "jnb": 1493, "iad": 1533, "ams": 1528 }, { "timestamp": "Feb 11, 00:00", "hkg": 1499, "gru": 1349, "ams": 1519, "iad": 1500, "syd": 1433, "jnb": 1478 }, { "timestamp": "Feb 11, 01:00", "hkg": 1516, "jnb": 1497, "syd": 1456, "ams": 1523, "iad": 1518, "gru": 1419 }, { "timestamp": "Feb 11, 02:00", "hkg": 1315, "gru": 1488, "syd": 1919, "jnb": 1317, "ams": 1284, "iad": 1333 }, { "timestamp": "Feb 11, 03:00", "gru": 1480, "iad": 1500, "ams": 1555, "syd": 1928, "jnb": 1768, "hkg": 1733 }, { "timestamp": "Feb 11, 04:00", "iad": 1525, "ams": 1534, "jnb": 1496, "syd": 1514, "gru": 1617, "hkg": 1518 }, { "timestamp": "Feb 11, 05:00", "gru": 1452, "iad": 1505, "ams": 1526, "syd": 1448, "jnb": 1483, "hkg": 1500 }, { "timestamp": "Feb 11, 06:00", "iad": 1499, "ams": 1536, "jnb": 1458, "syd": 1479, "gru": 1297, "hkg": 1410 }, { "timestamp": "Feb 11, 07:00", "hkg": 1498, "jnb": 1624, "syd": 1447, "ams": 1524, "iad": 1514, "gru": 1581 }, { "timestamp": "Feb 11, 08:00", "hkg": 1497, "gru": 1451, "syd": 1306, "jnb": 1372, "ams": 1525, "iad": 1520 }, { "timestamp": "Feb 11, 09:00", "hkg": 1482, "syd": 1654, "jnb": 1474, "iad": 1515, "ams": 1520, "gru": 1443 }, { "timestamp": "Feb 11, 10:00", "gru": 1264, "syd": 1564, "jnb": 1478, "iad": 1511, "ams": 1532, "hkg": 1503 }, { "timestamp": "Feb 11, 11:00", "ams": 1574, "iad": 1550, "syd": 1654, "jnb": 1501, "gru": 1485, "hkg": 1676 }, { "timestamp": "Feb 11, 12:00", "hkg": 1422, "ams": 1533, "iad": 1553, "jnb": 1374, "syd": 1600, "gru": 1379 }, { "timestamp": "Feb 11, 13:00", "hkg": 1530, "gru": 1608, "syd": 1497, "jnb": 1656, "ams": 1564, "iad": 1544 }, { "timestamp": "Feb 11, 14:00", "hkg": 1510, "ams": 1549, "iad": 1524, "jnb": 1484, "syd": 1458, "gru": 1454 }, { "timestamp": "Feb 11, 15:00", "hkg": 1506, "gru": 1460, "jnb": 1459, "syd": 1461, "iad": 1529, "ams": 1532 }, { "timestamp": "Feb 11, 16:00", "gru": 1611, "syd": 2035, "jnb": 1471, "iad": 1535, "ams": 1540, "hkg": 1510 }, { "timestamp": "Feb 11, 17:00", "hkg": 1341, "gru": 1282, "syd": 1482, "jnb": 1258, "iad": 1309, "ams": 1276 }, { "timestamp": "Feb 11, 18:00", "hkg": 1514, "jnb": 1500, "syd": 1465, "iad": 1526, "ams": 1531, "gru": 1470 }, { "timestamp": "Feb 11, 19:00", "iad": 1542, "ams": 1534, "syd": 1457, "jnb": 1488, "gru": 1440, "hkg": 1466 }, { "timestamp": "Feb 11, 20:00", "gru": 1457, "iad": 1317, "ams": 1294, "jnb": 1274, "syd": 1487, "hkg": 1305 }, { "timestamp": "Feb 11, 21:00", "hkg": 1315, "gru": 1575, "syd": 2010, "jnb": 1437, "iad": 1263, "ams": 1268 }, { "timestamp": "Feb 11, 22:00", "hkg": 1539, "jnb": 1518, "syd": 1332, "iad": 1537, "ams": 1543, "gru": 1476 }, { "timestamp": "Feb 11, 23:00", "hkg": 1554, "gru": 1651, "jnb": 1518, "syd": 1634, "ams": 1564, "iad": 1565 }, { "timestamp": "Feb 12, 00:00", "hkg": 1491, "syd": 1477, "jnb": 1517, "ams": 1535, "iad": 1543, "gru": 1418 }, { "timestamp": "Feb 12, 01:00", "hkg": 1498, "gru": 1459, "syd": 1470, "jnb": 1494, "iad": 1518, "ams": 1550 }, { "timestamp": "Feb 12, 02:00", "gru": 1606, "jnb": 1484, "syd": 1580, "iad": 1512, "ams": 1532, "hkg": 1499 }, { "timestamp": "Feb 12, 03:00", "ams": 1564, "iad": 1535, "jnb": 1494, "syd": 1450, "gru": 1469, "hkg": 1523 }, { "timestamp": "Feb 12, 04:00", "hkg": 1539, "gru": 1380, "ams": 1561, "iad": 1564, "jnb": 1530, "syd": 1497 }, { "timestamp": "Feb 12, 05:00", "hkg": 1531, "syd": 1663, "jnb": 1515, "ams": 1544, "iad": 1546, "gru": 1471 }, { "timestamp": "Feb 12, 06:00", "jnb": 1482, "syd": 1398, "ams": 1534, "iad": 1517, "gru": 1446, "hkg": 1454 }, { "timestamp": "Feb 12, 07:00", "gru": 1462, "iad": 1529, "ams": 1532, "jnb": 1489, "syd": 1872, "hkg": 1516 }, { "timestamp": "Feb 12, 08:00", "syd": 1470, "jnb": 1502, "iad": 1519, "ams": 1538, "gru": 1468, "hkg": 1522 }, { "timestamp": "Feb 12, 09:00", "gru": 1446, "jnb": 1507, "syd": 1301, "ams": 1466, "iad": 1544, "hkg": 1532 }, { "timestamp": "Feb 12, 10:00", "gru": 1425, "iad": 1519, "ams": 1523, "jnb": 1495, "syd": 1463, "hkg": 1516 }, { "timestamp": "Feb 12, 11:00", "hkg": 1521, "gru": 1444, "ams": 1542, "iad": 1533, "syd": 1470, "jnb": 1502 }, { "timestamp": "Feb 12, 12:00", "hkg": 1530, "syd": 1493, "jnb": 1313, "ams": 1545, "iad": 1535, "gru": 1484 }, { "timestamp": "Feb 12, 13:00", "hkg": 1537, "syd": 1670, "jnb": 1517, "ams": 1561, "iad": 1565, "gru": 1470 }, { "timestamp": "Feb 12, 14:00", "hkg": 1561, "gru": 1544, "syd": 1501, "jnb": 1558, "iad": 1602, "ams": 1578 }, { "timestamp": "Feb 12, 15:00", "gru": 1543, "jnb": 1574, "syd": 1525, "ams": 1617, "iad": 1603, "hkg": 1372 }, { "timestamp": "Feb 12, 16:00", "ams": 1381, "iad": 1355, "jnb": 1464, "syd": 1470, "gru": 1454, "hkg": 1373 }, { "timestamp": "Feb 12, 17:00", "ams": 1562, "iad": 1550, "syd": 1491, "jnb": 1507, "gru": 1490, "hkg": 1538 }, { "timestamp": "Feb 12, 18:00", "syd": 1465, "jnb": 1498, "iad": 1530, "ams": 1534, "gru": 1427, "hkg": 1516 }, { "timestamp": "Feb 12, 19:00", "gru": 1473, "jnb": 1333, "syd": 1487, "ams": 1558, "iad": 1553, "hkg": 1541 }, { "timestamp": "Feb 12, 20:00", "hkg": 1521, "gru": 1473, "ams": 1557, "iad": 1546, "jnb": 1510, "syd": 1480 }, { "timestamp": "Feb 12, 21:00", "hkg": 1531, "jnb": 1484, "syd": 1477, "iad": 1546, "ams": 1544, "gru": 1462 }, { "timestamp": "Feb 12, 22:00", "gru": 1453, "syd": 1454, "jnb": 1414, "ams": 1534, "iad": 1498, "hkg": 1509 }, { "timestamp": "Feb 12, 23:00", "jnb": 1219, "syd": 1248, "iad": 1222, "ams": 1194, "gru": 1188, "hkg": 1264 }, { "timestamp": "Feb 13, 00:00", "hkg": 1514, "iad": 1528, "ams": 1526, "jnb": 1492, "syd": 1415, "gru": 1456 }, { "timestamp": "Feb 13, 01:00", "gru": 1288, "syd": 1478, "jnb": 1474, "ams": 1294, "iad": 1287, "hkg": 1340 }, { "timestamp": "Feb 13, 02:00", "gru": 1491, "iad": 1566, "ams": 1547, "syd": 1734, "jnb": 1526, "hkg": 1545 }, { "timestamp": "Feb 13, 03:00", "jnb": 1655, "syd": 1636, "ams": 1534, "iad": 1538, "gru": 1636, "hkg": 1527 }, { "timestamp": "Feb 13, 04:00", "gru": 1502, "ams": 1558, "iad": 1539, "jnb": 1515, "syd": 1502, "hkg": 1541 }, { "timestamp": "Feb 13, 05:00", "hkg": 1514, "gru": 1459, "jnb": 1496, "syd": 1454, "iad": 1527, "ams": 1538 }, { "timestamp": "Feb 13, 06:00", "hkg": 1094, "jnb": 1105, "syd": 1352, "ams": 1025, "iad": 1069, "gru": 1290 }, { "timestamp": "Feb 13, 07:00", "jnb": 1493, "syd": 1459, "ams": 1525, "iad": 1510, "gru": 1412, "hkg": 1491 }, { "timestamp": "Feb 13, 08:00", "gru": 1291, "ams": 1323, "iad": 1332, "jnb": 1295, "syd": 1307, "hkg": 1353 }, { "timestamp": "Feb 13, 09:00", "hkg": 1549, "syd": 1463, "jnb": 1540, "ams": 1540, "iad": 1553, "gru": 1481 }, { "timestamp": "Feb 13, 10:00", "hkg": 1110, "iad": 1085, "ams": 1036, "syd": 1573, "jnb": 1279, "gru": 1114 }, { "timestamp": "Feb 13, 11:00", "hkg": 1550, "gru": 1498, "ams": 1524, "iad": 1518, "jnb": 1535, "syd": 1499 }, { "timestamp": "Feb 13, 12:00", "hkg": 943, "ams": 1050, "iad": 1060, "jnb": 1092, "syd": 1272, "gru": 1085 }, { "timestamp": "Feb 13, 13:00", "hkg": 1536, "gru": 1460, "jnb": 1467, "syd": 1484, "iad": 1554, "ams": 1526 }, { "timestamp": "Feb 13, 14:00", "gru": 1454, "jnb": 1612, "syd": 1608, "ams": 1529, "iad": 1516, "hkg": 1489 }, { "timestamp": "Feb 13, 15:00", "syd": 1801, "jnb": 1514, "iad": 1534, "ams": 1562, "gru": 1493, "hkg": 1569 }, { "timestamp": "Feb 13, 16:00", "hkg": 1326, "gru": 1295, "iad": 1347, "ams": 1316, "jnb": 1310, "syd": 1313 }, { "timestamp": "Feb 13, 17:00", "hkg": 1200, "ams": 1037, "iad": 1110, "syd": 1557, "jnb": 1165, "gru": 1133 }, { "timestamp": "Feb 13, 18:00", "ams": 1290, "iad": 1300, "jnb": 1114, "syd": 1487, "gru": 1280, "hkg": 1302 }, { "timestamp": "Feb 13, 19:00", "jnb": 1312, "syd": 1465, "ams": 1540, "iad": 1530, "gru": 1474, "hkg": 1520 }, { "timestamp": "Feb 13, 20:00", "gru": 1458, "syd": 1459, "jnb": 1482, "ams": 1532, "iad": 1523, "hkg": 1512 }, { "timestamp": "Feb 13, 21:00", "iad": 1534, "ams": 1535, "jnb": 1436, "syd": 1440, "gru": 1464, "hkg": 1506 }, { "timestamp": "Feb 13, 22:00", "gru": 1503, "iad": 1557, "ams": 1534, "syd": 1494, "jnb": 1504, "hkg": 1540 }, { "timestamp": "Feb 13, 23:00", "hkg": 1513, "gru": 1630, "iad": 1526, "ams": 1545, "syd": 1614, "jnb": 1620 }, { "timestamp": "Feb 14, 00:00", "hkg": 1510, "iad": 1528, "ams": 1538, "jnb": 1493, "syd": 1418, "gru": 1601 }, { "timestamp": "Feb 14, 01:00", "hkg": 1350, "gru": 1774, "iad": 1321, "ams": 1302, "syd": 1701, "jnb": 1325 }, { "timestamp": "Feb 14, 02:00", "hkg": 1522, "iad": 1532, "ams": 1540, "jnb": 1498, "syd": 1462, "gru": 1482 }, { "timestamp": "Feb 14, 03:00", "hkg": 1509, "jnb": 1493, "syd": 1325, "iad": 1523, "ams": 1534, "gru": 1591 }, { "timestamp": "Feb 14, 04:00", "syd": 1310, "jnb": 1326, "iad": 1333, "ams": 1320, "gru": 1497, "hkg": 1336 }, { "timestamp": "Feb 14, 05:00", "gru": 1544, "jnb": 1345, "syd": 1558, "ams": 1239, "iad": 1267, "hkg": 1307 }, { "timestamp": "Feb 14, 06:00", "ams": 1532, "iad": 1511, "syd": 1459, "jnb": 1493, "gru": 1464, "hkg": 1480 }, { "timestamp": "Feb 14, 07:00", "gru": 1484, "ams": 1556, "iad": 1543, "syd": 1480, "jnb": 1509, "hkg": 1526 }, { "timestamp": "Feb 14, 08:00", "hkg": 1569, "gru": 1479, "ams": 1590, "iad": 1566, "jnb": 1551, "syd": 1515 }, { "timestamp": "Feb 14, 09:00", "iad": 1530, "ams": 1550, "jnb": 1496, "syd": 1462, "gru": 1406, "hkg": 1518 }, { "timestamp": "Feb 14, 10:00", "hkg": 1532, "gru": 1491, "iad": 1527, "ams": 1585, "jnb": 1492, "syd": 1497 }, { "timestamp": "Feb 14, 11:00", "hkg": 1546, "gru": 1506, "jnb": 1540, "syd": 1493, "iad": 1570, "ams": 1516 }, { "timestamp": "Feb 14, 12:00", "hkg": 1520, "syd": 1654, "jnb": 1522, "iad": 1548, "ams": 1552, "gru": 1475 }, { "timestamp": "Feb 14, 13:00", "jnb": 1477, "syd": 1485, "ams": 1296, "iad": 1304, "gru": 1457, "hkg": 1345 }, { "timestamp": "Feb 14, 14:00", "gru": 1531, "syd": 1528, "jnb": 1558, "ams": 1547, "iad": 1535, "hkg": 1543 }, { "timestamp": "Feb 14, 15:00", "ams": 1538, "iad": 1532, "syd": 1470, "jnb": 1506, "gru": 1474, "hkg": 1487 }, { "timestamp": "Feb 14, 16:00", "gru": 1441, "ams": 1562, "iad": 1501, "jnb": 1509, "syd": 1236, "hkg": 1538 }, { "timestamp": "Feb 14, 17:00", "hkg": 1519, "ams": 1541, "iad": 1521, "jnb": 1463, "syd": 1636, "gru": 1587 }, { "timestamp": "Feb 14, 18:00", "hkg": 1534, "gru": 1485, "ams": 1571, "iad": 1552, "syd": 1841, "jnb": 1508 }, { "timestamp": "Feb 14, 19:00", "hkg": 1382, "gru": 1356, "syd": 1564, "jnb": 1350, "ams": 1333, "iad": 1337 }, { "timestamp": "Feb 14, 20:00", "gru": 1331, "syd": 1342, "jnb": 1324, "iad": 1341, "ams": 1320, "hkg": 1369 }, { "timestamp": "Feb 14, 21:00", "jnb": 1510, "syd": 1670, "ams": 1575, "iad": 1564, "gru": 1477, "hkg": 1548 }, { "timestamp": "Feb 14, 22:00", "hkg": 1520, "syd": 1496, "jnb": 1526, "ams": 1573, "iad": 1574, "gru": 1488 }, { "timestamp": "Feb 14, 23:00", "hkg": 1504, "gru": 1423, "syd": 1446, "jnb": 1492, "ams": 1535, "iad": 1498 }, { "timestamp": "Feb 15, 00:00", "hkg": 1507, "ams": 1528, "iad": 1508, "jnb": 1482, "syd": 1282, "gru": 1444 }, { "timestamp": "Feb 15, 01:00", "hkg": 1490, "gru": 1475, "iad": 1537, "ams": 1546, "syd": 1493, "jnb": 1506 }, { "timestamp": "Feb 15, 02:00", "gru": 1472, "syd": 1636, "jnb": 1492, "iad": 1502, "ams": 1540, "hkg": 1661 }, { "timestamp": "Feb 15, 03:00", "jnb": 1503, "syd": 1640, "ams": 1545, "iad": 1537, "gru": 1437, "hkg": 1523 }, { "timestamp": "Feb 15, 04:00", "iad": 1576, "ams": 1553, "jnb": 1516, "syd": 1487, "gru": 1490, "hkg": 1561 }, { "timestamp": "Feb 15, 05:00", "hkg": 1516, "gru": 1472, "syd": 1656, "jnb": 1510, "ams": 1548, "iad": 1544 }, { "timestamp": "Feb 15, 06:00", "hkg": 1512, "ams": 1563, "iad": 1540, "syd": 1504, "jnb": 1500, "gru": 1476 }, { "timestamp": "Feb 15, 07:00", "iad": 1533, "ams": 1543, "jnb": 1475, "syd": 1310, "gru": 1460, "hkg": 1522 }, { "timestamp": "Feb 15, 08:00", "gru": 1297, "ams": 1541, "iad": 1495, "jnb": 1496, "syd": 1456, "hkg": 1518 }, { "timestamp": "Feb 15, 09:00", "gru": 1485, "ams": 1546, "iad": 1552, "syd": 1487, "jnb": 1496, "hkg": 1538 }, { "timestamp": "Feb 15, 10:00", "syd": 1698, "jnb": 1532, "ams": 1582, "iad": 1570, "gru": 1498, "hkg": 1528 }, { "timestamp": "Feb 15, 11:00", "hkg": 1497, "jnb": 1500, "syd": 1481, "iad": 1542, "ams": 1549, "gru": 1466 }, { "timestamp": "Feb 15, 12:00", "hkg": 1514, "ams": 1561, "iad": 1545, "jnb": 1523, "syd": 1662, "gru": 1453 }, { "timestamp": "Feb 15, 13:00", "hkg": 1328, "gru": 1291, "jnb": 1287, "syd": 1149, "iad": 1296, "ams": 1293 }, { "timestamp": "Feb 15, 14:00", "hkg": 1530, "gru": 1470, "jnb": 1405, "syd": 1483, "ams": 1570, "iad": 1551 }, { "timestamp": "Feb 15, 15:00", "hkg": 1527, "syd": 1455, "jnb": 1496, "iad": 1532, "ams": 1571, "gru": 1472 }, { "timestamp": "Feb 15, 16:00", "syd": 1457, "jnb": 1512, "ams": 1578, "iad": 1554, "gru": 1469, "hkg": 1522 }, { "timestamp": "Feb 17, 10:00", "hkg": 1600, "iad": 1627, "ams": 1621, "syd": 1553, "jnb": 1579, "gru": 1559 }, { "timestamp": "Feb 17, 11:00", "gru": 1620, "jnb": 1612, "syd": 1785, "ams": 1545, "iad": 1536, "hkg": 1508 }, { "timestamp": "Feb 17, 12:00", "syd": 1884, "jnb": 1502, "ams": 1566, "iad": 1552, "gru": 1491, "hkg": 1551 }, { "timestamp": "Feb 17, 13:00", "jnb": 1498, "syd": 1680, "ams": 1544, "iad": 1560, "gru": 1637, "hkg": 1527 }, { "timestamp": "Feb 17, 14:00", "hkg": 1539, "syd": 1456, "jnb": 1511, "ams": 1552, "iad": 1516, "gru": 1464 }, { "timestamp": "Feb 17, 15:00", "hkg": 1511, "gru": 1456, "iad": 1551, "ams": 1558, "syd": 1480, "jnb": 1430 }, { "timestamp": "Feb 17, 16:00", "hkg": 1570, "jnb": 1496, "syd": 1712, "iad": 1545, "ams": 1547, "gru": 1471 }, { "timestamp": "Feb 17, 17:00", "hkg": 1568, "gru": 1507, "syd": 1493, "jnb": 1298, "ams": 1529, "iad": 1595 }, { "timestamp": "Feb 17, 18:00", "gru": 1506, "syd": 1490, "jnb": 1519, "iad": 1563, "ams": 1544, "hkg": 1577 }, { "timestamp": "Feb 17, 19:00", "jnb": 1540, "syd": 1503, "ams": 1548, "iad": 1596, "gru": 1527, "hkg": 1526 }, { "timestamp": "Feb 17, 20:00", "hkg": 1557, "iad": 1542, "ams": 1542, "syd": 1842, "jnb": 1498, "gru": 1475 }, { "timestamp": "Feb 17, 21:00", "hkg": 1544, "gru": 1503, "syd": 1852, "jnb": 1506, "ams": 1557, "iad": 1538 }, { "timestamp": "Feb 17, 22:00", "hkg": 1554, "gru": 1484, "iad": 1582, "ams": 1542, "syd": 1619, "jnb": 1512 }, { "timestamp": "Feb 17, 23:00", "gru": 1503, "ams": 1559, "iad": 1537, "jnb": 1488, "syd": 1690, "hkg": 1546 }, { "timestamp": "Feb 18, 00:00", "jnb": 1536, "syd": 1678, "ams": 1571, "iad": 1592, "gru": 1366, "hkg": 1701 }, { "timestamp": "Feb 18, 01:00", "syd": 1646, "jnb": 1502, "ams": 1550, "iad": 1554, "gru": 1457, "hkg": 1526 }, { "timestamp": "Feb 18, 02:00", "gru": 1485, "syd": 1346, "jnb": 1636, "iad": 1526, "ams": 1539, "hkg": 1532 }, { "timestamp": "Feb 18, 03:00", "hkg": 1471, "gru": 1480, "jnb": 1523, "syd": 1659, "ams": 1538, "iad": 1550 }, { "timestamp": "Feb 18, 04:00", "hkg": 1520, "ams": 1552, "iad": 1548, "jnb": 1515, "syd": 1326, "gru": 1495 }, { "timestamp": "Feb 18, 05:00", "gru": 1448, "syd": 1484, "jnb": 1504, "iad": 1542, "ams": 1549, "hkg": 1538 }, { "timestamp": "Feb 18, 06:00", "gru": 1501, "ams": 1575, "iad": 1563, "syd": 1398, "jnb": 1537, "hkg": 1542 }, { "timestamp": "Feb 18, 07:00", "iad": 1542, "ams": 1556, "jnb": 1508, "syd": 1483, "gru": 1496, "hkg": 1537 }, { "timestamp": "Feb 18, 08:00", "hkg": 1528, "jnb": 1482, "syd": 1328, "iad": 1545, "ams": 1551, "gru": 1490 }, { "timestamp": "Feb 18, 09:00", "gru": 1495, "ams": 1549, "iad": 1560, "syd": 1548, "jnb": 1489, "hkg": 1538 }, { "timestamp": "Feb 18, 10:00", "syd": 1291, "jnb": 1454, "iad": 1557, "ams": 1554, "gru": 1499, "hkg": 1523 }, { "timestamp": "Feb 18, 11:00", "gru": 1518, "jnb": 1524, "syd": 1838, "ams": 1585, "iad": 1551, "hkg": 1707 }, { "timestamp": "Feb 18, 12:00", "hkg": 1565, "gru": 1546, "ams": 1584, "iad": 1566, "jnb": 1570, "syd": 1494 }, { "timestamp": "Feb 18, 13:00", "hkg": 1578, "jnb": 1559, "syd": 1549, "iad": 1613, "ams": 1589, "gru": 1548 }, { "timestamp": "Feb 18, 14:00", "hkg": 1573, "ams": 1553, "iad": 1577, "jnb": 1543, "syd": 1513, "gru": 1495 }, { "timestamp": "Feb 18, 15:00", "hkg": 1546, "syd": 1479, "jnb": 1492, "ams": 1547, "iad": 1560, "gru": 1319 }, { "timestamp": "Feb 18, 16:00", "hkg": 1561, "gru": 1513, "ams": 1552, "iad": 1568, "syd": 1498, "jnb": 1518 }, { "timestamp": "Feb 18, 17:00", "jnb": 1502, "syd": 1466, "ams": 1544, "iad": 1546, "gru": 1474, "hkg": 1538 }, { "timestamp": "Feb 18, 18:00", "gru": 1522, "jnb": 1540, "syd": 1308, "iad": 1595, "ams": 1553, "hkg": 1551 } ] }, "metricsByRegion": [ { "region": "syd", "avgLatency": 1542, "p75Latency": 1514, "p90Latency": 1666, "p95Latency": 2560, "p99Latency": 2641 }, { "region": "gru", "avgLatency": 1478, "p75Latency": 1510, "p90Latency": 1542, "p95Latency": 1567, "p99Latency": 1878 }, { "region": "iad", "avgLatency": 1547, "p75Latency": 1572, "p90Latency": 1599, "p95Latency": 1627, "p99Latency": 1716 }, { "region": "ams", "avgLatency": 1548, "p75Latency": 1562, "p90Latency": 1584, "p95Latency": 1606, "p99Latency": 1654 }, { "region": "hkg", "avgLatency": 1541, "p75Latency": 1556, "p90Latency": 1600, "p95Latency": 1624, "p99Latency": 1975 }, { "region": "jnb", "avgLatency": 1501, "p75Latency": 1530, "p90Latency": 1555, "p95Latency": 1591, "p99Latency": 1689 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-latency/koyeb.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Feb 4, 00:00", "ams": 142, "iad": 173, "syd": 786, "jnb": 900, "gru": 640, "hkg": 369 }, { "timestamp": "Feb 4, 01:00", "ams": 136, "iad": 426, "syd": 770, "jnb": 841, "gru": 732, "hkg": 375 }, { "timestamp": "Feb 4, 02:00", "hkg": 351, "ams": 131, "iad": 170, "jnb": 880, "syd": 698, "gru": 678 }, { "timestamp": "Feb 4, 03:00", "hkg": 371, "jnb": 897, "syd": 793, "ams": 145, "iad": 170, "gru": 668 }, { "timestamp": "Feb 4, 04:00", "hkg": 369, "gru": 693, "syd": 668, "jnb": 834, "ams": 138, "iad": 172 }, { "timestamp": "Feb 4, 05:00", "gru": 630, "syd": 665, "jnb": 951, "ams": 145, "iad": 174, "hkg": 375 }, { "timestamp": "Feb 4, 06:00", "jnb": 842, "syd": 687, "ams": 161, "iad": 170, "gru": 644, "hkg": 366 }, { "timestamp": "Feb 4, 07:00", "hkg": 381, "jnb": 846, "syd": 590, "ams": 145, "iad": 169, "gru": 604 }, { "timestamp": "Feb 4, 08:00", "hkg": 358, "gru": 696, "syd": 693, "jnb": 923, "ams": 148, "iad": 166 }, { "timestamp": "Feb 4, 09:00", "hkg": 380, "iad": 168, "ams": 164, "jnb": 911, "syd": 697, "gru": 665 }, { "timestamp": "Feb 4, 10:00", "hkg": 376, "gru": 662, "jnb": 903, "syd": 812, "iad": 166, "ams": 146 }, { "timestamp": "Feb 4, 11:00", "gru": 681, "syd": 749, "jnb": 884, "ams": 138, "iad": 170, "hkg": 364 }, { "timestamp": "Feb 4, 12:00", "gru": 644, "iad": 168, "ams": 144, "syd": 675, "jnb": 1045, "hkg": 371 }, { "timestamp": "Feb 4, 13:00", "syd": 676, "jnb": 866, "ams": 165, "iad": 167, "gru": 667, "hkg": 381 }, { "timestamp": "Feb 4, 14:00", "hkg": 381, "gru": 677, "ams": 154, "iad": 185, "jnb": 876, "syd": 791 }, { "timestamp": "Feb 4, 15:00", "hkg": 377, "iad": 164, "ams": 166, "syd": 659, "jnb": 895, "gru": 662 }, { "timestamp": "Feb 4, 16:00", "ams": 153, "iad": 166, "syd": 744, "jnb": 864, "gru": 662, "hkg": 410 }, { "timestamp": "Feb 4, 17:00", "jnb": 886, "syd": 614, "ams": 159, "iad": 175, "gru": 705, "hkg": 376 }, { "timestamp": "Feb 4, 18:00", "gru": 662, "jnb": 938, "syd": 731, "ams": 155, "iad": 174, "hkg": 352 }, { "timestamp": "Feb 4, 19:00", "syd": 674, "jnb": 991, "iad": 182, "ams": 186, "gru": 677, "hkg": 350 }, { "timestamp": "Feb 4, 20:00", "ams": 175, "iad": 166, "syd": 690, "jnb": 816, "gru": 694, "hkg": 374 }, { "timestamp": "Feb 4, 21:00", "hkg": 722, "syd": 620, "jnb": 817, "iad": 174, "ams": 161, "gru": 670 }, { "timestamp": "Feb 4, 22:00", "hkg": 381, "gru": 730, "iad": 172, "ams": 170, "syd": 682, "jnb": 892 }, { "timestamp": "Feb 4, 23:00", "jnb": 940, "syd": 743, "iad": 174, "ams": 162, "gru": 681, "hkg": 374 }, { "timestamp": "Feb 5, 00:00", "gru": 670, "jnb": 890, "syd": 665, "ams": 162, "iad": 483, "hkg": 396 }, { "timestamp": "Feb 5, 01:00", "gru": 647, "iad": 168, "ams": 162, "syd": 644, "jnb": 833, "hkg": 377 }, { "timestamp": "Feb 5, 02:00", "syd": 666, "jnb": 905, "iad": 178, "ams": 146, "gru": 735, "hkg": 384 }, { "timestamp": "Feb 5, 03:00", "gru": 724, "ams": 131, "iad": 167, "jnb": 846, "syd": 647, "hkg": 372 }, { "timestamp": "Feb 5, 04:00", "gru": 653, "jnb": 835, "syd": 714, "iad": 169, "ams": 138, "hkg": 370 }, { "timestamp": "Feb 5, 05:00", "ams": 146, "iad": 167, "jnb": 936, "syd": 762, "gru": 638, "hkg": 360 }, { "timestamp": "Feb 5, 06:00", "hkg": 370, "jnb": 942, "syd": 722, "ams": 145, "iad": 164, "gru": 677 }, { "timestamp": "Feb 5, 07:00", "hkg": 381, "gru": 692, "syd": 566, "jnb": 894, "iad": 166, "ams": 152 }, { "timestamp": "Feb 5, 08:00", "jnb": 6466, "syd": 612, "ams": 148, "iad": 160, "gru": 632, "hkg": 436 }, { "timestamp": "Feb 5, 09:00", "ams": 152, "iad": 164, "syd": 715, "jnb": 988, "gru": 677, "hkg": 476 }, { "timestamp": "Feb 5, 10:00", "hkg": 388, "syd": 621, "jnb": 929, "ams": 137, "iad": 177, "gru": 668 }, { "timestamp": "Feb 5, 11:00", "hkg": 410, "gru": 712, "jnb": 902, "syd": 756, "iad": 626, "ams": 170 }, { "timestamp": "Feb 5, 12:00", "hkg": 386, "gru": 716, "ams": 179, "iad": 162, "jnb": 1020, "syd": 748 }, { "timestamp": "Feb 5, 13:00", "hkg": 369, "iad": 178, "ams": 172, "jnb": 930, "syd": 747, "gru": 648 }, { "timestamp": "Feb 5, 14:00", "hkg": 380, "gru": 754, "jnb": 949, "syd": 650, "iad": 178, "ams": 159 }, { "timestamp": "Feb 5, 15:00", "gru": 698, "syd": 628, "jnb": 875, "ams": 162, "iad": 180, "hkg": 390 }, { "timestamp": "Feb 5, 16:00", "syd": 660, "jnb": 882, "iad": 181, "ams": 155, "gru": 631, "hkg": 388 }, { "timestamp": "Feb 5, 17:00", "hkg": 366, "syd": 707, "jnb": 851, "iad": 180, "ams": 181, "gru": 688 }, { "timestamp": "Feb 5, 18:00", "hkg": 362, "gru": 686, "jnb": 871, "syd": 717, "iad": 181, "ams": 177 }, { "timestamp": "Feb 5, 19:00", "gru": 638, "ams": 180, "iad": 181, "jnb": 912, "syd": 614, "hkg": 353 }, { "timestamp": "Feb 5, 20:00", "ams": 148, "iad": 180, "syd": 664, "jnb": 808, "gru": 714, "hkg": 360 }, { "timestamp": "Feb 5, 21:00", "ams": 189, "iad": 179, "jnb": 866, "syd": 627, "gru": 731, "hkg": 376 }, { "timestamp": "Feb 5, 22:00", "gru": 725, "syd": 723, "jnb": 859, "ams": 159, "iad": 236, "hkg": 352 }, { "timestamp": "Feb 5, 23:00", "iad": 177, "ams": 141, "syd": 674, "jnb": 841, "gru": 643, "hkg": 367 }, { "timestamp": "Feb 6, 00:00", "hkg": 372, "iad": 172, "ams": 137, "jnb": 937, "syd": 806, "gru": 704 }, { "timestamp": "Feb 6, 01:00", "hkg": 363, "jnb": 885, "syd": 612, "ams": 154, "iad": 171, "gru": 675 }, { "timestamp": "Feb 6, 02:00", "gru": 700, "jnb": 873, "syd": 751, "ams": 171, "iad": 178, "hkg": 347 }, { "timestamp": "Feb 6, 03:00", "iad": 184, "ams": 141, "jnb": 945, "syd": 748, "gru": 731, "hkg": 352 }, { "timestamp": "Feb 6, 04:00", "hkg": 385, "iad": 167, "ams": 149, "syd": 612, "jnb": 910, "gru": 774 }, { "timestamp": "Feb 6, 05:00", "hkg": 532, "iad": 163, "ams": 163, "jnb": 927, "syd": 638, "gru": 694 }, { "timestamp": "Feb 6, 06:00", "hkg": 378, "gru": 676, "iad": 166, "ams": 159, "syd": 670, "jnb": 1006 }, { "timestamp": "Feb 6, 07:00", "hkg": 341, "ams": 155, "iad": 157, "syd": 779, "jnb": 1624, "gru": 670 }, { "timestamp": "Feb 6, 08:00", "hkg": 371, "gru": 659, "ams": 145, "iad": 218, "jnb": 1467, "syd": 615 }, { "timestamp": "Feb 6, 09:00", "hkg": 365, "gru": 681, "jnb": 1459, "syd": 690, "ams": 162, "iad": 167 }, { "timestamp": "Feb 6, 10:00", "hkg": 368, "syd": 658, "jnb": 1406, "ams": 141, "iad": 167, "gru": 674 }, { "timestamp": "Feb 6, 11:00", "hkg": 382, "syd": 792, "jnb": 1345, "ams": 156, "iad": 162, "gru": 650 }, { "timestamp": "Feb 6, 12:00", "hkg": 376, "gru": 692, "jnb": 1471, "syd": 708, "ams": 202, "iad": 166 }, { "timestamp": "Feb 6, 13:00", "hkg": 378, "gru": 700, "syd": 709, "jnb": 1482, "ams": 154, "iad": 170 }, { "timestamp": "Feb 6, 14:00", "gru": 662, "jnb": 1530, "syd": 724, "ams": 146, "iad": 167, "hkg": 411 }, { "timestamp": "Feb 6, 15:00", "iad": 181, "ams": 156, "jnb": 1548, "syd": 728, "gru": 774, "hkg": 382 }, { "timestamp": "Feb 6, 16:00", "gru": 663, "syd": 734, "jnb": 1468, "iad": 168, "ams": 194, "hkg": 367 }, { "timestamp": "Feb 6, 17:00", "gru": 713, "ams": 161, "iad": 167, "syd": 704, "jnb": 1370, "hkg": 360 }, { "timestamp": "Feb 6, 18:00", "hkg": 356, "gru": 725, "ams": 173, "iad": 170, "jnb": 1609, "syd": 667 }, { "timestamp": "Feb 6, 19:00", "hkg": 352, "jnb": 1512, "syd": 659, "iad": 180, "ams": 176, "gru": 669 }, { "timestamp": "Feb 6, 20:00", "gru": 635, "jnb": 1492, "syd": 823, "iad": 178, "ams": 195, "hkg": 377 }, { "timestamp": "Feb 6, 21:00", "gru": 718, "syd": 704, "jnb": 1405, "iad": 174, "ams": 196, "hkg": 370 }, { "timestamp": "Feb 6, 22:00", "jnb": 1460, "syd": 710, "iad": 177, "ams": 162, "gru": 663, "hkg": 372 }, { "timestamp": "Feb 6, 23:00", "hkg": 372, "ams": 139, "iad": 180, "jnb": 1466, "syd": 696, "gru": 653 }, { "timestamp": "Feb 7, 00:00", "hkg": 367, "gru": 728, "ams": 163, "iad": 174, "syd": 852, "jnb": 1600 }, { "timestamp": "Feb 7, 01:00", "hkg": 384, "jnb": 1302, "syd": 544, "iad": 168, "ams": 178, "gru": 782 }, { "timestamp": "Feb 7, 02:00", "hkg": 370, "gru": 642, "jnb": 1403, "syd": 694, "ams": 159, "iad": 178 }, { "timestamp": "Feb 7, 03:00", "gru": 708, "syd": 652, "jnb": 1346, "iad": 172, "ams": 138, "hkg": 377 }, { "timestamp": "Feb 7, 04:00", "iad": 181, "ams": 159, "syd": 786, "jnb": 1361, "gru": 688, "hkg": 386 }, { "timestamp": "Feb 7, 05:00", "hkg": 370, "jnb": 1382, "syd": 614, "iad": 165, "ams": 138, "gru": 618 }, { "timestamp": "Feb 7, 06:00", "hkg": 376, "ams": 166, "iad": 170, "jnb": 1540, "syd": 704, "gru": 676 }, { "timestamp": "Feb 7, 07:00", "hkg": 365, "gru": 653, "iad": 168, "ams": 137, "syd": 648, "jnb": 1192 }, { "timestamp": "Feb 7, 08:00", "gru": 684, "syd": 744, "jnb": 1561, "iad": 208, "ams": 177, "hkg": 359 }, { "timestamp": "Feb 7, 09:00", "gru": 691, "iad": 177, "ams": 188, "jnb": 3767, "syd": 663, "hkg": 387 }, { "timestamp": "Feb 7, 10:00", "jnb": 927, "syd": 701, "ams": 165, "iad": 166, "gru": 705, "hkg": 376 }, { "timestamp": "Feb 7, 11:00", "gru": 649, "syd": 639, "jnb": 927, "iad": 168, "ams": 203, "hkg": 357 }, { "timestamp": "Feb 7, 12:00", "hkg": 370, "gru": 691, "iad": 171, "ams": 174, "syd": 761, "jnb": 1248 }, { "timestamp": "Feb 7, 13:00", "hkg": 375, "syd": 769, "jnb": 954, "ams": 156, "iad": 176, "gru": 732 }, { "timestamp": "Feb 7, 14:00", "hkg": 380, "iad": 167, "ams": 151, "syd": 748, "jnb": 918, "gru": 700 }, { "timestamp": "Feb 7, 15:00", "gru": 742, "ams": 138, "iad": 196, "syd": 684, "jnb": 886, "hkg": 392 }, { "timestamp": "Feb 7, 16:00", "syd": 635, "jnb": 948, "ams": 166, "iad": 182, "gru": 698, "hkg": 377 }, { "timestamp": "Feb 7, 17:00", "iad": 197, "ams": 172, "jnb": 899, "syd": 692, "gru": 658, "hkg": 370 }, { "timestamp": "Feb 7, 18:00", "gru": 710, "ams": 163, "iad": 196, "jnb": 880, "syd": 595, "hkg": 364 }, { "timestamp": "Feb 7, 19:00", "syd": 968, "jnb": 870, "iad": 184, "ams": 160, "gru": 697, "hkg": 368 }, { "timestamp": "Feb 7, 20:00", "gru": 692, "iad": 180, "ams": 180, "syd": 686, "jnb": 915, "hkg": 343 }, { "timestamp": "Feb 7, 21:00", "hkg": 348, "gru": 740, "syd": 660, "jnb": 930, "ams": 155, "iad": 182 }, { "timestamp": "Feb 7, 22:00", "hkg": 362, "gru": 727, "iad": 201, "ams": 251, "syd": 752, "jnb": 854 }, { "timestamp": "Feb 7, 23:00", "hkg": 456, "ams": 171, "iad": 173, "jnb": 931, "syd": 749, "gru": 752 }, { "timestamp": "Feb 8, 00:00", "hkg": 346, "syd": 787, "jnb": 907, "ams": 148, "iad": 187, "gru": 691 }, { "timestamp": "Feb 8, 01:00", "hkg": 357, "ams": 132, "iad": 178, "jnb": 880, "syd": 755, "gru": 686 }, { "timestamp": "Feb 8, 02:00", "iad": 169, "ams": 199, "jnb": 898, "syd": 839, "gru": 676, "hkg": 352 }, { "timestamp": "Feb 8, 03:00", "gru": 663, "ams": 184, "iad": 172, "syd": 744, "jnb": 809, "hkg": 360 }, { "timestamp": "Feb 8, 04:00", "ams": 176, "iad": 170, "syd": 648, "jnb": 894, "gru": 724, "hkg": 344 }, { "timestamp": "Feb 8, 05:00", "gru": 735, "syd": 649, "jnb": 885, "iad": 168, "ams": 132, "hkg": 365 }, { "timestamp": "Feb 8, 06:00", "gru": 682, "ams": 137, "iad": 170, "syd": 671, "jnb": 744, "hkg": 348 }, { "timestamp": "Feb 8, 07:00", "hkg": 351, "gru": 733, "iad": 170, "ams": 158, "jnb": 857, "syd": 740 }, { "timestamp": "Feb 8, 08:00", "hkg": 364, "jnb": 920, "syd": 716, "iad": 168, "ams": 167, "gru": 686 }, { "timestamp": "Feb 8, 09:00", "gru": 649, "jnb": 831, "syd": 684, "ams": 151, "iad": 174, "hkg": 355 }, { "timestamp": "Feb 8, 10:00", "syd": 692, "jnb": 849, "ams": 204, "iad": 171, "gru": 766, "hkg": 370 }, { "timestamp": "Feb 8, 11:00", "hkg": 354, "syd": 681, "jnb": 934, "ams": 198, "iad": 176, "gru": 700 }, { "timestamp": "Feb 8, 12:00", "hkg": 365, "gru": 687, "jnb": 1001, "syd": 715, "ams": 172, "iad": 177 }, { "timestamp": "Feb 8, 13:00", "hkg": 350, "iad": 176, "ams": 186, "syd": 519, "jnb": 909, "gru": 704 }, { "timestamp": "Feb 8, 14:00", "hkg": 372, "gru": 659, "iad": 182, "ams": 169, "jnb": 858, "syd": 849 }, { "timestamp": "Feb 8, 15:00", "hkg": 364, "gru": 681, "jnb": 922, "syd": 687, "iad": 184, "ams": 186 }, { "timestamp": "Feb 8, 16:00", "gru": 716, "syd": 622, "jnb": 902, "iad": 177, "ams": 163, "hkg": 356 }, { "timestamp": "Feb 8, 17:00", "gru": 709, "syd": 671, "jnb": 877, "iad": 184, "ams": 171, "hkg": 394 }, { "timestamp": "Feb 8, 18:00", "hkg": 364, "gru": 686, "syd": 641, "jnb": 891, "ams": 170, "iad": 188 }, { "timestamp": "Feb 8, 19:00", "hkg": 351, "syd": 740, "jnb": 906, "ams": 155, "iad": 188, "gru": 684 }, { "timestamp": "Feb 8, 20:00", "jnb": 838, "syd": 668, "ams": 154, "iad": 167, "gru": 660, "hkg": 369 }, { "timestamp": "Feb 8, 21:00", "gru": 684, "syd": 599, "jnb": 908, "iad": 192, "ams": 160, "hkg": 357 }, { "timestamp": "Feb 8, 22:00", "iad": 186, "ams": 160, "jnb": 880, "syd": 661, "gru": 683, "hkg": 354 }, { "timestamp": "Feb 8, 23:00", "jnb": 870, "syd": 777, "iad": 187, "ams": 177, "gru": 806, "hkg": 360 }, { "timestamp": "Feb 9, 00:00", "gru": 683, "syd": 674, "jnb": 861, "iad": 171, "ams": 161, "hkg": 354 }, { "timestamp": "Feb 9, 01:00", "gru": 720, "syd": 634, "jnb": 840, "ams": 138, "iad": 176, "hkg": 338 }, { "timestamp": "Feb 9, 02:00", "jnb": 812, "syd": 723, "ams": 433, "iad": 186, "gru": 669, "hkg": 347 }, { "timestamp": "Feb 9, 03:00", "hkg": 361, "gru": 676, "syd": 718, "jnb": 774, "ams": 140, "iad": 171 }, { "timestamp": "Feb 9, 04:00", "hkg": 360, "jnb": 908, "syd": 716, "ams": 146, "iad": 167, "gru": 620 }, { "timestamp": "Feb 9, 05:00", "syd": 626, "jnb": 932, "iad": 168, "ams": 176, "gru": 644, "hkg": 373 }, { "timestamp": "Feb 9, 06:00", "gru": 727, "jnb": 827, "syd": 744, "iad": 170, "ams": 148, "hkg": 354 }, { "timestamp": "Feb 9, 07:00", "gru": 676, "iad": 168, "ams": 170, "jnb": 900, "syd": 640, "hkg": 368 }, { "timestamp": "Feb 9, 08:00", "syd": 755, "jnb": 866, "iad": 161, "ams": 149, "gru": 683, "hkg": 366 }, { "timestamp": "Feb 9, 09:00", "syd": 692, "jnb": 883, "iad": 173, "ams": 150, "gru": 670, "hkg": 368 }, { "timestamp": "Feb 9, 10:00", "hkg": 359, "jnb": 882, "syd": 4936, "iad": 170, "ams": 165, "gru": 701 }, { "timestamp": "Feb 9, 11:00", "hkg": 350, "gru": 728, "jnb": 869, "syd": 628, "iad": 168, "ams": 171 }, { "timestamp": "Feb 9, 12:00", "hkg": 366, "gru": 686, "ams": 171, "iad": 171, "syd": 725, "jnb": 1057 }, { "timestamp": "Feb 9, 13:00", "hkg": 361, "iad": 172, "ams": 190, "jnb": 904, "syd": 785, "gru": 696 }, { "timestamp": "Feb 9, 14:00", "iad": 165, "ams": 207, "syd": 692, "jnb": 868, "gru": 668, "hkg": 354 }, { "timestamp": "Feb 9, 15:00", "syd": 742, "jnb": 838, "iad": 180, "ams": 172, "gru": 684, "hkg": 370 }, { "timestamp": "Feb 9, 16:00", "gru": 685, "jnb": 5243, "syd": 712, "iad": 185, "ams": 158, "hkg": 384 }, { "timestamp": "Feb 9, 17:00", "ams": 174, "iad": 192, "syd": 9398, "jnb": 886, "gru": 687, "hkg": 348 }, { "timestamp": "Feb 9, 18:00", "gru": 706, "syd": 677, "jnb": 878, "ams": 156, "iad": 177, "hkg": 346 }, { "timestamp": "Feb 9, 19:00", "hkg": 361, "gru": 696, "jnb": 920, "syd": 564, "iad": 174, "ams": 218 }, { "timestamp": "Feb 9, 20:00", "hkg": 365, "jnb": 860, "syd": 673, "ams": 148, "iad": 174, "gru": 763 }, { "timestamp": "Feb 9, 21:00", "gru": 746, "iad": 181, "ams": 166, "syd": 666, "jnb": 842, "hkg": 377 }, { "timestamp": "Feb 9, 22:00", "syd": 696, "jnb": 911, "iad": 184, "ams": 155, "gru": 730, "hkg": 345 }, { "timestamp": "Feb 9, 23:00", "hkg": 341, "jnb": 777, "syd": 700, "ams": 163, "iad": 176, "gru": 717 }, { "timestamp": "Feb 10, 00:00", "hkg": 360, "iad": 172, "ams": 133, "jnb": 877, "syd": 691, "gru": 673 }, { "timestamp": "Feb 10, 01:00", "hkg": 359, "syd": 707, "jnb": 866, "iad": 177, "ams": 181, "gru": 717 }, { "timestamp": "Feb 10, 02:00", "hkg": 367, "gru": 734, "syd": 641, "jnb": 887, "iad": 176, "ams": 170 }, { "timestamp": "Feb 10, 03:00", "hkg": 346, "jnb": 915, "syd": 740, "ams": 142, "iad": 180, "gru": 732 }, { "timestamp": "Feb 10, 04:00", "jnb": 822, "syd": 595, "iad": 160, "ams": 142, "gru": 674, "hkg": 342 }, { "timestamp": "Feb 10, 05:00", "gru": 689, "ams": 142, "iad": 222, "jnb": 830, "syd": 725, "hkg": 343 }, { "timestamp": "Feb 10, 06:00", "hkg": 356, "gru": 678, "syd": 4115, "jnb": 774, "iad": 160, "ams": 142 }, { "timestamp": "Feb 10, 07:00", "hkg": 344, "jnb": 850, "syd": 569, "ams": 135, "iad": 163, "gru": 702 }, { "timestamp": "Feb 10, 08:00", "hkg": 392, "iad": 171, "ams": 166, "jnb": 772, "syd": 667, "gru": 683 }, { "timestamp": "Feb 10, 09:00", "hkg": 397, "syd": 767, "jnb": 854, "ams": 146, "iad": 165, "gru": 694 }, { "timestamp": "Feb 10, 10:00", "hkg": 351, "gru": 676, "ams": 169, "iad": 159, "syd": 733, "jnb": 847 }, { "timestamp": "Feb 10, 11:00", "hkg": 350, "jnb": 830, "syd": 602, "iad": 163, "ams": 160, "gru": 663 }, { "timestamp": "Feb 10, 12:00", "hkg": 360, "gru": 658, "jnb": 959, "syd": 731, "ams": 144, "iad": 165 }, { "timestamp": "Feb 10, 13:00", "gru": 692, "iad": 160, "ams": 167, "jnb": 954, "syd": 741, "hkg": 352 }, { "timestamp": "Feb 10, 14:00", "jnb": 920, "syd": 648, "iad": 162, "ams": 174, "gru": 661, "hkg": 350 }, { "timestamp": "Feb 10, 15:00", "hkg": 362, "gru": 698, "jnb": 849, "syd": 662, "ams": 169, "iad": 171 }, { "timestamp": "Feb 10, 16:00", "hkg": 340, "gru": 677, "iad": 172, "ams": 197, "jnb": 836, "syd": 625 }, { "timestamp": "Feb 10, 17:00", "hkg": 358, "gru": 806, "syd": 794, "jnb": 839, "iad": 172, "ams": 197 }, { "timestamp": "Feb 10, 18:00", "gru": 682, "iad": 168, "ams": 197, "syd": 582, "jnb": 879, "hkg": 344 }, { "timestamp": "Feb 10, 19:00", "ams": 147, "iad": 166, "jnb": 838, "syd": 720, "gru": 698, "hkg": 345 }, { "timestamp": "Feb 10, 20:00", "gru": 677, "jnb": 882, "syd": 647, "iad": 168, "ams": 146, "hkg": 343 }, { "timestamp": "Feb 10, 21:00", "ams": 157, "iad": 169, "jnb": 883, "syd": 713, "gru": 830, "hkg": 345 }, { "timestamp": "Feb 10, 22:00", "hkg": 349, "jnb": 774, "syd": 674, "ams": 142, "iad": 160, "gru": 718 }, { "timestamp": "Feb 10, 23:00", "hkg": 351, "gru": 656, "syd": 752, "jnb": 925, "iad": 172, "ams": 156 }, { "timestamp": "Feb 11, 00:00", "hkg": 324, "gru": 710, "ams": 135, "iad": 180, "syd": 584, "jnb": 828 }, { "timestamp": "Feb 11, 01:00", "hkg": 349, "jnb": 789, "syd": 683, "ams": 142, "iad": 161, "gru": 631 }, { "timestamp": "Feb 11, 02:00", "hkg": 356, "gru": 662, "syd": 644, "jnb": 740, "ams": 178, "iad": 165 }, { "timestamp": "Feb 11, 03:00", "gru": 634, "iad": 169, "ams": 201, "syd": 721, "jnb": 784, "hkg": 355 }, { "timestamp": "Feb 11, 04:00", "iad": 164, "ams": 133, "jnb": 840, "syd": 665, "gru": 712, "hkg": 341 }, { "timestamp": "Feb 11, 05:00", "gru": 696, "iad": 170, "ams": 140, "syd": 792, "jnb": 873, "hkg": 366 }, { "timestamp": "Feb 11, 06:00", "iad": 167, "ams": 142, "jnb": 905, "syd": 684, "gru": 749, "hkg": 339 }, { "timestamp": "Feb 11, 07:00", "hkg": 355, "jnb": 872, "syd": 640, "ams": 187, "iad": 169, "gru": 711 }, { "timestamp": "Feb 11, 08:00", "hkg": 344, "gru": 652, "syd": 709, "jnb": 844, "ams": 179, "iad": 160 }, { "timestamp": "Feb 11, 09:00", "hkg": 374, "syd": 657, "jnb": 865, "iad": 166, "ams": 143, "gru": 710 }, { "timestamp": "Feb 11, 10:00", "gru": 760, "syd": 783, "jnb": 811, "iad": 165, "ams": 198, "hkg": 342 }, { "timestamp": "Feb 11, 11:00", "ams": 194, "iad": 168, "syd": 714, "jnb": 933, "gru": 689, "hkg": 346 }, { "timestamp": "Feb 11, 12:00", "hkg": 352, "ams": 280, "iad": 166, "jnb": 922, "syd": 649, "gru": 666 }, { "timestamp": "Feb 11, 13:00", "hkg": 357, "gru": 684, "syd": 665, "jnb": 890, "ams": 166, "iad": 166 }, { "timestamp": "Feb 11, 14:00", "hkg": 360, "ams": 176, "iad": 167, "jnb": 812, "syd": 3391, "gru": 656 }, { "timestamp": "Feb 11, 15:00", "hkg": 352, "gru": 730, "jnb": 786, "syd": 671, "iad": 169, "ams": 180 }, { "timestamp": "Feb 11, 16:00", "gru": 730, "syd": 728, "jnb": 861, "iad": 174, "ams": 218, "hkg": 366 }, { "timestamp": "Feb 11, 17:00", "hkg": 342, "gru": 701, "syd": 8049, "jnb": 868, "iad": 177, "ams": 168 }, { "timestamp": "Feb 11, 18:00", "hkg": 376, "jnb": 1115, "syd": 784, "iad": 173, "ams": 236, "gru": 683 }, { "timestamp": "Feb 11, 19:00", "iad": 176, "ams": 208, "syd": 680, "jnb": 1540, "gru": 716, "hkg": 343 }, { "timestamp": "Feb 11, 20:00", "gru": 684, "iad": 170, "ams": 162, "jnb": 1553, "syd": 774, "hkg": 345 }, { "timestamp": "Feb 11, 21:00", "hkg": 344, "gru": 714, "syd": 590, "jnb": 1320, "iad": 176, "ams": 154 }, { "timestamp": "Feb 11, 22:00", "hkg": 345, "jnb": 1380, "syd": 702, "iad": 178, "ams": 229, "gru": 685 }, { "timestamp": "Feb 11, 23:00", "hkg": 345, "gru": 691, "jnb": 1521, "syd": 684, "ams": 206, "iad": 166 }, { "timestamp": "Feb 12, 00:00", "hkg": 360, "syd": 615, "jnb": 1498, "ams": 174, "iad": 173, "gru": 691 }, { "timestamp": "Feb 12, 01:00", "hkg": 330, "gru": 722, "syd": 586, "jnb": 1478, "iad": 168, "ams": 148 }, { "timestamp": "Feb 12, 02:00", "gru": 680, "jnb": 1466, "syd": 832, "iad": 167, "ams": 137, "hkg": 360 }, { "timestamp": "Feb 12, 03:00", "ams": 182, "iad": 169, "jnb": 1548, "syd": 636, "gru": 728, "hkg": 376 }, { "timestamp": "Feb 12, 04:00", "hkg": 350, "gru": 681, "ams": 216, "iad": 161, "jnb": 1223, "syd": 664 }, { "timestamp": "Feb 12, 05:00", "hkg": 348, "syd": 693, "jnb": 7838, "ams": 184, "iad": 161, "gru": 714 }, { "timestamp": "Feb 12, 06:00", "jnb": 2068, "syd": 800, "ams": 3691, "iad": 166, "gru": 655, "hkg": 339 }, { "timestamp": "Feb 12, 07:00", "gru": 656, "iad": 158, "ams": 191, "jnb": 824, "syd": 727, "hkg": 359 }, { "timestamp": "Feb 12, 08:00", "syd": 770, "jnb": 814, "iad": 164, "ams": 166, "gru": 687, "hkg": 336 }, { "timestamp": "Feb 12, 09:00", "gru": 675, "jnb": 852, "syd": 673, "ams": 177, "iad": 165, "hkg": 354 }, { "timestamp": "Feb 12, 10:00", "gru": 728, "iad": 163, "ams": 175, "jnb": 826, "syd": 678, "hkg": 359 }, { "timestamp": "Feb 12, 11:00", "hkg": 384, "gru": 699, "ams": 176, "iad": 180, "syd": 622, "jnb": 877 }, { "timestamp": "Feb 12, 12:00", "hkg": 345, "syd": 724, "jnb": 1046, "ams": 200, "iad": 170, "gru": 670 }, { "timestamp": "Feb 12, 13:00", "hkg": 356, "syd": 630, "jnb": 962, "ams": 212, "iad": 170, "gru": 675 }, { "timestamp": "Feb 12, 14:00", "hkg": 360, "gru": 678, "syd": 672, "jnb": 901, "iad": 167, "ams": 284 }, { "timestamp": "Feb 12, 15:00", "gru": 710, "jnb": 865, "syd": 630, "ams": 241, "iad": 177, "hkg": 409 }, { "timestamp": "Feb 12, 16:00", "ams": 199, "iad": 164, "jnb": 880, "syd": 609, "gru": 683, "hkg": 400 }, { "timestamp": "Feb 12, 17:00", "ams": 187, "iad": 178, "syd": 712, "jnb": 1004, "gru": 730, "hkg": 378 }, { "timestamp": "Feb 12, 18:00", "syd": 589, "jnb": 863, "iad": 170, "ams": 194, "gru": 686, "hkg": 348 }, { "timestamp": "Feb 12, 19:00", "gru": 696, "jnb": 973, "syd": 676, "ams": 185, "iad": 184, "hkg": 367 }, { "timestamp": "Feb 12, 20:00", "hkg": 369, "gru": 686, "ams": 192, "iad": 178, "jnb": 957, "syd": 725 }, { "timestamp": "Feb 12, 21:00", "hkg": 386, "jnb": 955, "syd": 698, "iad": 173, "ams": 185, "gru": 710 }, { "timestamp": "Feb 12, 22:00", "gru": 666, "syd": 672, "jnb": 794, "ams": 178, "iad": 172, "hkg": 340 }, { "timestamp": "Feb 12, 23:00", "jnb": 840, "syd": 754, "iad": 168, "ams": 153, "gru": 643, "hkg": 343 }, { "timestamp": "Feb 13, 00:00", "hkg": 352, "iad": 166, "ams": 179, "jnb": 879, "syd": 730, "gru": 653 }, { "timestamp": "Feb 13, 01:00", "gru": 740, "syd": 691, "jnb": 828, "ams": 165, "iad": 169, "hkg": 345 }, { "timestamp": "Feb 13, 02:00", "gru": 720, "iad": 164, "ams": 130, "syd": 623, "jnb": 867, "hkg": 344 }, { "timestamp": "Feb 13, 03:00", "jnb": 894, "syd": 811, "ams": 148, "iad": 172, "gru": 652, "hkg": 351 }, { "timestamp": "Feb 13, 04:00", "gru": 778, "ams": 182, "iad": 172, "jnb": 904, "syd": 734, "hkg": 362 }, { "timestamp": "Feb 13, 05:00", "hkg": 398, "gru": 751, "jnb": 868, "syd": 636, "iad": 163, "ams": 166 }, { "timestamp": "Feb 13, 06:00", "hkg": 365, "jnb": 886, "syd": 632, "ams": 174, "iad": 174, "gru": 713 }, { "timestamp": "Feb 13, 07:00", "jnb": 854, "syd": 752, "ams": 166, "iad": 164, "gru": 676, "hkg": 376 }, { "timestamp": "Feb 13, 08:00", "gru": 608, "ams": 138, "iad": 150, "jnb": 778, "syd": 667, "hkg": 350 }, { "timestamp": "Feb 13, 09:00", "hkg": 368, "syd": 743, "jnb": 892, "ams": 207, "iad": 168, "gru": 643 }, { "timestamp": "Feb 13, 10:00", "hkg": 372, "iad": 164, "ams": 184, "syd": 646, "jnb": 828, "gru": 680 }, { "timestamp": "Feb 13, 11:00", "hkg": 354, "gru": 731, "ams": 183, "iad": 441, "jnb": 858, "syd": 638 }, { "timestamp": "Feb 13, 12:00", "hkg": 362, "ams": 168, "iad": 164, "jnb": 973, "syd": 687, "gru": 654 }, { "timestamp": "Feb 13, 13:00", "hkg": 357, "gru": 706, "jnb": 976, "syd": 731, "iad": 162, "ams": 146 }, { "timestamp": "Feb 13, 14:00", "gru": 681, "jnb": 887, "syd": 752, "ams": 179, "iad": 162, "hkg": 356 }, { "timestamp": "Feb 13, 15:00", "syd": 694, "jnb": 808, "iad": 177, "ams": 530, "gru": 666, "hkg": 369 }, { "timestamp": "Feb 13, 16:00", "hkg": 375, "gru": 702, "iad": 191, "ams": 156, "jnb": 798, "syd": 665 }, { "timestamp": "Feb 13, 17:00", "hkg": 354, "ams": 201, "iad": 182, "syd": 640, "jnb": 817, "gru": 700 }, { "timestamp": "Feb 13, 18:00", "ams": 155, "iad": 190, "jnb": 815, "syd": 651, "gru": 691, "hkg": 350 }, { "timestamp": "Feb 13, 19:00", "jnb": 872, "syd": 651, "ams": 169, "iad": 177, "gru": 708, "hkg": 360 }, { "timestamp": "Feb 13, 20:00", "gru": 696, "syd": 604, "jnb": 948, "ams": 168, "iad": 186, "hkg": 360 }, { "timestamp": "Feb 13, 21:00", "iad": 180, "ams": 148, "jnb": 924, "syd": 793, "gru": 681, "hkg": 366 }, { "timestamp": "Feb 13, 22:00", "gru": 695, "iad": 168, "ams": 166, "syd": 726, "jnb": 1160, "hkg": 344 }, { "timestamp": "Feb 13, 23:00", "hkg": 325, "gru": 660, "iad": 171, "ams": 135, "syd": 717, "jnb": 823 }, { "timestamp": "Feb 14, 00:00", "hkg": 343, "iad": 167, "ams": 125, "jnb": 838, "syd": 671, "gru": 696 }, { "timestamp": "Feb 14, 01:00", "hkg": 351, "gru": 770, "iad": 173, "ams": 137, "syd": 768, "jnb": 898 }, { "timestamp": "Feb 14, 02:00", "hkg": 350, "iad": 171, "ams": 119, "jnb": 784, "syd": 720, "gru": 637 }, { "timestamp": "Feb 14, 03:00", "hkg": 348, "jnb": 679, "syd": 716, "iad": 164, "ams": 133, "gru": 683 }, { "timestamp": "Feb 14, 04:00", "syd": 823, "jnb": 803, "iad": 169, "ams": 126, "gru": 688, "hkg": 362 }, { "timestamp": "Feb 14, 05:00", "gru": 660, "jnb": 790, "syd": 654, "ams": 126, "iad": 168, "hkg": 334 }, { "timestamp": "Feb 14, 06:00", "ams": 127, "iad": 163, "syd": 653, "jnb": 860, "gru": 682, "hkg": 354 }, { "timestamp": "Feb 14, 07:00", "gru": 644, "ams": 120, "iad": 163, "syd": 730, "jnb": 833, "hkg": 360 }, { "timestamp": "Feb 14, 08:00", "hkg": 348, "gru": 700, "ams": 128, "iad": 165, "jnb": 872, "syd": 687 }, { "timestamp": "Feb 14, 09:00", "iad": 165, "ams": 141, "jnb": 841, "syd": 645, "gru": 675, "hkg": 348 }, { "timestamp": "Feb 14, 10:00", "hkg": 354, "gru": 703, "iad": 162, "ams": 151, "jnb": 1348, "syd": 730 }, { "timestamp": "Feb 14, 11:00", "hkg": 350, "gru": 708, "jnb": 1546, "syd": 680, "iad": 166, "ams": 140 }, { "timestamp": "Feb 14, 12:00", "hkg": 361, "syd": 630, "jnb": 1355, "iad": 170, "ams": 156, "gru": 682 }, { "timestamp": "Feb 14, 13:00", "jnb": 1374, "syd": 673, "ams": 135, "iad": 168, "gru": 684, "hkg": 352 }, { "timestamp": "Feb 14, 14:00", "gru": 700, "syd": 641, "jnb": 1362, "ams": 150, "iad": 170, "hkg": 369 }, { "timestamp": "Feb 14, 15:00", "ams": 128, "iad": 172, "syd": 6737, "jnb": 1423, "gru": 690, "hkg": 369 }, { "timestamp": "Feb 14, 16:00", "gru": 744, "ams": 148, "iad": 170, "jnb": 1438, "syd": 5441, "hkg": 361 }, { "timestamp": "Feb 14, 17:00", "hkg": 340, "ams": 156, "iad": 171, "jnb": 1440, "syd": 821, "gru": 660 }, { "timestamp": "Feb 14, 18:00", "hkg": 365, "gru": 697, "ams": 158, "iad": 172, "syd": 639, "jnb": 1550 }, { "timestamp": "Feb 14, 19:00", "hkg": 350, "gru": 693, "syd": 595, "jnb": 1537, "ams": 165, "iad": 170 }, { "timestamp": "Feb 14, 20:00", "gru": 643, "syd": 749, "jnb": 1497, "iad": 176, "ams": 165, "hkg": 364 }, { "timestamp": "Feb 14, 21:00", "jnb": 1487, "syd": 632, "ams": 200, "iad": 185, "gru": 683, "hkg": 352 }, { "timestamp": "Feb 14, 22:00", "hkg": 336, "syd": 779, "jnb": 1302, "ams": 223, "iad": 178, "gru": 716 }, { "timestamp": "Feb 14, 23:00", "hkg": 347, "gru": 746, "syd": 673, "jnb": 1548, "ams": 166, "iad": 166 }, { "timestamp": "Feb 15, 00:00", "hkg": 335, "ams": 159, "iad": 172, "jnb": 953, "syd": 704, "gru": 675 }, { "timestamp": "Feb 15, 01:00", "hkg": 335, "gru": 670, "iad": 168, "ams": 214, "syd": 590, "jnb": 978 }, { "timestamp": "Feb 15, 02:00", "gru": 731, "syd": 652, "jnb": 824, "iad": 163, "ams": 124, "hkg": 364 }, { "timestamp": "Feb 15, 03:00", "jnb": 882, "syd": 749, "ams": 155, "iad": 171, "gru": 691, "hkg": 356 }, { "timestamp": "Feb 15, 04:00", "iad": 163, "ams": 170, "jnb": 878, "syd": 676, "gru": 680, "hkg": 360 }, { "timestamp": "Feb 15, 05:00", "hkg": 346, "gru": 708, "syd": 679, "jnb": 883, "ams": 160, "iad": 167 }, { "timestamp": "Feb 15, 06:00", "hkg": 377, "ams": 131, "iad": 172, "syd": 676, "jnb": 910, "gru": 726 }, { "timestamp": "Feb 15, 07:00", "iad": 169, "ams": 135, "jnb": 925, "syd": 700, "gru": 702, "hkg": 387 }, { "timestamp": "Feb 15, 08:00", "gru": 718, "ams": 154, "iad": 162, "jnb": 928, "syd": 791, "hkg": 367 }, { "timestamp": "Feb 15, 09:00", "gru": 706, "ams": 161, "iad": 160, "syd": 663, "jnb": 942, "hkg": 397 }, { "timestamp": "Feb 15, 10:00", "syd": 802, "jnb": 832, "ams": 151, "iad": 174, "gru": 686, "hkg": 372 }, { "timestamp": "Feb 15, 11:00", "hkg": 365, "jnb": 864, "syd": 656, "iad": 158, "ams": 154, "gru": 691 }, { "timestamp": "Feb 15, 12:00", "hkg": 382, "ams": 150, "iad": 162, "jnb": 910, "syd": 616, "gru": 685 }, { "timestamp": "Feb 15, 13:00", "hkg": 374, "gru": 687, "jnb": 897, "syd": 601, "iad": 164, "ams": 142 }, { "timestamp": "Feb 15, 14:00", "hkg": 368, "gru": 680, "jnb": 903, "syd": 731, "ams": 130, "iad": 174 }, { "timestamp": "Feb 15, 15:00", "hkg": 361, "syd": 622, "jnb": 859, "iad": 171, "ams": 160, "gru": 665 }, { "timestamp": "Feb 15, 16:00", "syd": 584, "jnb": 788, "ams": 155, "iad": 170, "gru": 692, "hkg": 355 }, { "timestamp": "Feb 17, 10:00", "hkg": 370, "iad": 170, "ams": 229, "syd": 834, "jnb": 1435, "gru": 904 }, { "timestamp": "Feb 17, 11:00", "gru": 650, "jnb": 1493, "syd": 762, "ams": 135, "iad": 184, "hkg": 356 }, { "timestamp": "Feb 17, 12:00", "syd": 748, "jnb": 1436, "ams": 158, "iad": 182, "gru": 736, "hkg": 343 }, { "timestamp": "Feb 17, 13:00", "jnb": 1375, "syd": 664, "ams": 154, "iad": 181, "gru": 658, "hkg": 365 }, { "timestamp": "Feb 17, 14:00", "hkg": 364, "syd": 562, "jnb": 1404, "ams": 160, "iad": 178, "gru": 683 }, { "timestamp": "Feb 17, 15:00", "hkg": 357, "gru": 635, "iad": 175, "ams": 152, "syd": 728, "jnb": 1522 }, { "timestamp": "Feb 17, 16:00", "hkg": 351, "jnb": 1239, "syd": 601, "iad": 168, "ams": 137, "gru": 714 }, { "timestamp": "Feb 17, 17:00", "hkg": 358, "gru": 679, "syd": 699, "jnb": 1457, "ams": 139, "iad": 174 }, { "timestamp": "Feb 17, 18:00", "gru": 645, "syd": 719, "jnb": 1445, "iad": 173, "ams": 153, "hkg": 350 }, { "timestamp": "Feb 17, 19:00", "jnb": 1639, "syd": 675, "ams": 157, "iad": 173, "gru": 685, "hkg": 334 }, { "timestamp": "Feb 17, 20:00", "hkg": 320, "iad": 172, "ams": 152, "syd": 657, "jnb": 1386, "gru": 686 }, { "timestamp": "Feb 17, 21:00", "hkg": 337, "gru": 685, "syd": 604, "jnb": 1509, "ams": 151, "iad": 171 }, { "timestamp": "Feb 17, 22:00", "hkg": 363, "gru": 658, "iad": 173, "ams": 136, "syd": 758, "jnb": 1281 }, { "timestamp": "Feb 17, 23:00", "gru": 672, "ams": 118, "iad": 166, "jnb": 1456, "syd": 668, "hkg": 329 }, { "timestamp": "Feb 18, 00:00", "jnb": 1198, "syd": 664, "ams": 124, "iad": 169, "gru": 650, "hkg": 337 }, { "timestamp": "Feb 18, 01:00", "syd": 631, "jnb": 1305, "ams": 128, "iad": 163, "gru": 641, "hkg": 352 }, { "timestamp": "Feb 18, 02:00", "gru": 704, "syd": 682, "jnb": 1412, "iad": 164, "ams": 119, "hkg": 340 }, { "timestamp": "Feb 18, 03:00", "hkg": 357, "gru": 690, "jnb": 1329, "syd": 714, "ams": 134, "iad": 160 }, { "timestamp": "Feb 18, 04:00", "hkg": 334, "ams": 128, "iad": 162, "jnb": 1252, "syd": 720, "gru": 632 }, { "timestamp": "Feb 18, 05:00", "gru": 664, "syd": 659, "jnb": 1445, "iad": 172, "ams": 125, "hkg": 343 }, { "timestamp": "Feb 18, 06:00", "gru": 639, "ams": 136, "iad": 158, "syd": 615, "jnb": 1291, "hkg": 355 }, { "timestamp": "Feb 18, 07:00", "iad": 161, "ams": 128, "jnb": 1240, "syd": 724, "gru": 654, "hkg": 368 }, { "timestamp": "Feb 18, 08:00", "hkg": 366, "jnb": 1442, "syd": 706, "iad": 159, "ams": 145, "gru": 668 }, { "timestamp": "Feb 18, 09:00", "gru": 657, "ams": 136, "iad": 158, "syd": 699, "jnb": 1166, "hkg": 354 }, { "timestamp": "Feb 18, 10:00", "syd": 855, "jnb": 1591, "iad": 158, "ams": 132, "gru": 622, "hkg": 336 }, { "timestamp": "Feb 18, 11:00", "gru": 663, "jnb": 1461, "syd": 798, "ams": 132, "iad": 154, "hkg": 366 }, { "timestamp": "Feb 18, 12:00", "hkg": 331, "gru": 697, "ams": 146, "iad": 157, "jnb": 1483, "syd": 563 }, { "timestamp": "Feb 18, 13:00", "hkg": 363, "jnb": 1547, "syd": 855, "iad": 160, "ams": 165, "gru": 712 }, { "timestamp": "Feb 18, 14:00", "hkg": 355, "ams": 162, "iad": 166, "jnb": 1407, "syd": 631, "gru": 746 }, { "timestamp": "Feb 18, 15:00", "hkg": 346, "syd": 764, "jnb": 1550, "ams": 187, "iad": 167, "gru": 779 }, { "timestamp": "Feb 18, 16:00", "hkg": 344, "gru": 697, "ams": 166, "iad": 175, "syd": 711, "jnb": 1298 }, { "timestamp": "Feb 18, 17:00", "jnb": 1252, "syd": 740, "ams": 208, "iad": 176, "gru": 730, "hkg": 370 }, { "timestamp": "Feb 18, 18:00", "gru": 677, "jnb": 1442, "syd": 689, "iad": 167, "ams": 154, "hkg": 370 } ] }, "metricsByRegion": [ { "region": "syd", "avgLatency": 691, "p75Latency": 825, "p90Latency": 862, "p95Latency": 869, "p99Latency": 915 }, { "region": "gru", "avgLatency": 685, "p75Latency": 705, "p90Latency": 781, "p95Latency": 815, "p99Latency": 989 }, { "region": "iad", "avgLatency": 168, "p75Latency": 173, "p90Latency": 186, "p95Latency": 195, "p99Latency": 213 }, { "region": "ams", "avgLatency": 148, "p75Latency": 158, "p90Latency": 206, "p95Latency": 225, "p99Latency": 250 }, { "region": "hkg", "avgLatency": 356, "p75Latency": 372, "p90Latency": 386, "p95Latency": 395, "p99Latency": 416 }, { "region": "jnb", "avgLatency": 1229, "p75Latency": 1532, "p90Latency": 1685, "p95Latency": 1744, "p99Latency": 1812 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-latency/railway.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Feb 4, 00:00", "ams": 176, "iad": 84, "syd": 487, "jnb": 659, "gru": 456, "hkg": 292 }, { "timestamp": "Feb 4, 01:00", "ams": 176, "iad": 84, "syd": 497, "jnb": 691, "gru": 437, "hkg": 299 }, { "timestamp": "Feb 4, 02:00", "hkg": 293, "ams": 183, "iad": 88, "jnb": 665, "syd": 504, "gru": 418 }, { "timestamp": "Feb 4, 03:00", "hkg": 304, "jnb": 680, "syd": 463, "ams": 175, "iad": 80, "gru": 416 }, { "timestamp": "Feb 4, 04:00", "hkg": 301, "gru": 428, "syd": 517, "jnb": 672, "ams": 173, "iad": 80 }, { "timestamp": "Feb 4, 05:00", "gru": 427, "syd": 500, "jnb": 657, "ams": 174, "iad": 84, "hkg": 291 }, { "timestamp": "Feb 4, 06:00", "jnb": 849, "syd": 526, "ams": 179, "iad": 90, "gru": 417, "hkg": 288 }, { "timestamp": "Feb 4, 07:00", "hkg": 304, "jnb": 656, "syd": 471, "ams": 210, "iad": 82, "gru": 418 }, { "timestamp": "Feb 4, 08:00", "hkg": 303, "gru": 438, "syd": 463, "jnb": 688, "ams": 198, "iad": 90 }, { "timestamp": "Feb 4, 09:00", "hkg": 372, "iad": 84, "ams": 182, "jnb": 657, "syd": 512, "gru": 422 }, { "timestamp": "Feb 4, 10:00", "hkg": 302, "gru": 419, "jnb": 687, "syd": 488, "iad": 86, "ams": 204 }, { "timestamp": "Feb 4, 11:00", "gru": 417, "syd": 483, "jnb": 659, "ams": 177, "iad": 83, "hkg": 317 }, { "timestamp": "Feb 4, 12:00", "gru": 462, "iad": 82, "ams": 177, "syd": 502, "jnb": 694, "hkg": 305 }, { "timestamp": "Feb 4, 13:00", "syd": 486, "jnb": 657, "ams": 184, "iad": 88, "gru": 437, "hkg": 298 }, { "timestamp": "Feb 4, 14:00", "hkg": 292, "gru": 424, "ams": 196, "iad": 78, "jnb": 693, "syd": 465 }, { "timestamp": "Feb 4, 15:00", "hkg": 295, "iad": 79, "ams": 181, "syd": 460, "jnb": 694, "gru": 436 }, { "timestamp": "Feb 4, 16:00", "ams": 174, "iad": 79, "syd": 502, "jnb": 660, "gru": 425, "hkg": 287 }, { "timestamp": "Feb 4, 17:00", "jnb": 657, "syd": 513, "ams": 200, "iad": 80, "gru": 416, "hkg": 316 }, { "timestamp": "Feb 4, 18:00", "gru": 427, "jnb": 661, "syd": 470, "ams": 178, "iad": 82, "hkg": 299 }, { "timestamp": "Feb 4, 19:00", "syd": 466, "jnb": 708, "iad": 85, "ams": 179, "gru": 422, "hkg": 286 }, { "timestamp": "Feb 4, 20:00", "ams": 176, "iad": 81, "syd": 462, "jnb": 692, "gru": 440, "hkg": 285 }, { "timestamp": "Feb 4, 21:00", "hkg": 305, "syd": 514, "jnb": 658, "iad": 88, "ams": 178, "gru": 417 }, { "timestamp": "Feb 4, 22:00", "hkg": 302, "gru": 417, "iad": 90, "ams": 178, "syd": 462, "jnb": 680 }, { "timestamp": "Feb 4, 23:00", "jnb": 685, "syd": 528, "iad": 83, "ams": 181, "gru": 438, "hkg": 302 }, { "timestamp": "Feb 5, 00:00", "gru": 416, "jnb": 677, "syd": 520, "ams": 178, "iad": 81, "hkg": 286 }, { "timestamp": "Feb 5, 01:00", "gru": 416, "iad": 80, "ams": 196, "syd": 462, "jnb": 682, "hkg": 319 }, { "timestamp": "Feb 5, 02:00", "syd": 478, "jnb": 660, "iad": 81, "ams": 180, "gru": 415, "hkg": 291 }, { "timestamp": "Feb 5, 03:00", "gru": 416, "ams": 174, "iad": 78, "jnb": 657, "syd": 461, "hkg": 287 }, { "timestamp": "Feb 5, 04:00", "gru": 416, "jnb": 657, "syd": 488, "iad": 84, "ams": 178, "hkg": 304 }, { "timestamp": "Feb 5, 05:00", "ams": 198, "iad": 79, "jnb": 685, "syd": 486, "gru": 423, "hkg": 290 }, { "timestamp": "Feb 5, 06:00", "hkg": 288, "jnb": 682, "syd": 458, "ams": 180, "iad": 89, "gru": 415 }, { "timestamp": "Feb 5, 07:00", "hkg": 307, "gru": 447, "syd": 470, "jnb": 657, "iad": 85, "ams": 189 }, { "timestamp": "Feb 5, 08:00", "jnb": 660, "syd": 466, "ams": 184, "iad": 79, "gru": 416, "hkg": 292 }, { "timestamp": "Feb 5, 09:00", "ams": 178, "iad": 89, "syd": 472, "jnb": 738, "gru": 436, "hkg": 306 }, { "timestamp": "Feb 5, 10:00", "hkg": 310, "syd": 505, "jnb": 698, "ams": 178, "iad": 237, "gru": 416 }, { "timestamp": "Feb 5, 11:00", "hkg": 319, "gru": 416, "jnb": 664, "syd": 465, "iad": 84, "ams": 203 }, { "timestamp": "Feb 5, 12:00", "hkg": 302, "gru": 425, "ams": 201, "iad": 84, "jnb": 701, "syd": 468 }, { "timestamp": "Feb 5, 13:00", "hkg": 311, "iad": 84, "ams": 204, "jnb": 690, "syd": 482, "gru": 425 }, { "timestamp": "Feb 5, 14:00", "hkg": 289, "gru": 417, "jnb": 678, "syd": 466, "iad": 84, "ams": 179 }, { "timestamp": "Feb 5, 15:00", "gru": 489, "syd": 465, "jnb": 882, "ams": 202, "iad": 154, "hkg": 417 }, { "timestamp": "Feb 5, 16:00", "syd": 464, "jnb": 690, "iad": 83, "ams": 182, "gru": 418, "hkg": 295 }, { "timestamp": "Feb 5, 17:00", "hkg": 294, "syd": 483, "jnb": 660, "iad": 82, "ams": 178, "gru": 522 }, { "timestamp": "Feb 5, 18:00", "hkg": 289, "gru": 418, "jnb": 710, "syd": 464, "iad": 82, "ams": 198 }, { "timestamp": "Feb 5, 19:00", "gru": 419, "ams": 185, "iad": 84, "jnb": 659, "syd": 494, "hkg": 310 }, { "timestamp": "Feb 5, 20:00", "ams": 177, "iad": 82, "syd": 461, "jnb": 663, "gru": 460, "hkg": 284 }, { "timestamp": "Feb 5, 21:00", "ams": 192, "iad": 83, "jnb": 680, "syd": 494, "gru": 454, "hkg": 289 }, { "timestamp": "Feb 5, 22:00", "gru": 424, "syd": 464, "jnb": 658, "ams": 188, "iad": 83, "hkg": 290 }, { "timestamp": "Feb 5, 23:00", "iad": 82, "ams": 201, "syd": 466, "jnb": 691, "gru": 457, "hkg": 366 }, { "timestamp": "Feb 6, 00:00", "hkg": 289, "iad": 80, "ams": 180, "jnb": 656, "syd": 486, "gru": 438 }, { "timestamp": "Feb 6, 01:00", "hkg": 287, "jnb": 692, "syd": 464, "ams": 179, "iad": 83, "gru": 417 }, { "timestamp": "Feb 6, 02:00", "gru": 460, "jnb": 657, "syd": 479, "ams": 185, "iad": 88, "hkg": 313 }, { "timestamp": "Feb 6, 03:00", "iad": 77, "ams": 185, "jnb": 700, "syd": 467, "gru": 420, "hkg": 293 }, { "timestamp": "Feb 6, 04:00", "hkg": 296, "iad": 85, "ams": 168, "syd": 514, "jnb": 652, "gru": 435 }, { "timestamp": "Feb 6, 05:00", "hkg": 322, "iad": 82, "ams": 166, "jnb": 651, "syd": 471, "gru": 423 }, { "timestamp": "Feb 6, 06:00", "hkg": 295, "gru": 450, "iad": 78, "ams": 168, "syd": 504, "jnb": 650 }, { "timestamp": "Feb 6, 07:00", "hkg": 291, "ams": 167, "iad": 78, "syd": 466, "jnb": 651, "gru": 416 }, { "timestamp": "Feb 6, 08:00", "hkg": 294, "gru": 415, "ams": 178, "iad": 80, "jnb": 651, "syd": 465 }, { "timestamp": "Feb 6, 09:00", "hkg": 296, "gru": 464, "jnb": 651, "syd": 480, "ams": 170, "iad": 77 }, { "timestamp": "Feb 6, 10:00", "hkg": 288, "syd": 483, "jnb": 651, "ams": 186, "iad": 79, "gru": 434 }, { "timestamp": "Feb 6, 11:00", "hkg": 289, "syd": 461, "jnb": 653, "ams": 169, "iad": 82, "gru": 438 }, { "timestamp": "Feb 6, 12:00", "hkg": 302, "gru": 417, "jnb": 680, "syd": 500, "ams": 170, "iad": 87 }, { "timestamp": "Feb 6, 13:00", "hkg": 299, "gru": 438, "syd": 480, "jnb": 650, "ams": 167, "iad": 78 }, { "timestamp": "Feb 6, 14:00", "gru": 419, "jnb": 652, "syd": 461, "ams": 166, "iad": 100, "hkg": 300 }, { "timestamp": "Feb 6, 15:00", "iad": 78, "ams": 168, "jnb": 672, "syd": 465, "gru": 477, "hkg": 343 }, { "timestamp": "Feb 6, 16:00", "gru": 418, "syd": 487, "jnb": 655, "iad": 83, "ams": 179, "hkg": 294 }, { "timestamp": "Feb 6, 17:00", "gru": 426, "ams": 183, "iad": 87, "syd": 481, "jnb": 648, "hkg": 301 }, { "timestamp": "Feb 6, 18:00", "hkg": 308, "gru": 498, "ams": 184, "iad": 95, "jnb": 734, "syd": 483 }, { "timestamp": "Feb 6, 19:00", "hkg": 300, "jnb": 677, "syd": 460, "iad": 79, "ams": 171, "gru": 437 }, { "timestamp": "Feb 6, 20:00", "gru": 441, "jnb": 684, "syd": 480, "iad": 80, "ams": 179, "hkg": 314 }, { "timestamp": "Feb 6, 21:00", "gru": 434, "syd": 508, "jnb": 684, "iad": 102, "ams": 175, "hkg": 308 }, { "timestamp": "Feb 6, 22:00", "jnb": 706, "syd": 5418, "iad": 1044, "ams": 181, "gru": 2103, "hkg": 4239 }, { "timestamp": "Feb 6, 23:00", "hkg": 298, "ams": 169, "iad": 90, "jnb": 710, "syd": 502, "gru": 469 }, { "timestamp": "Feb 7, 00:00", "hkg": 316, "gru": 466, "ams": 168, "iad": 90, "syd": 502, "jnb": 712 }, { "timestamp": "Feb 7, 01:00", "hkg": 286, "jnb": 652, "syd": 462, "iad": 80, "ams": 166, "gru": 416 }, { "timestamp": "Feb 7, 02:00", "hkg": 308, "gru": 451, "jnb": 712, "syd": 513, "ams": 175, "iad": 90 }, { "timestamp": "Feb 7, 03:00", "gru": 460, "syd": 491, "jnb": 677, "iad": 83, "ams": 171, "hkg": 291 }, { "timestamp": "Feb 7, 04:00", "iad": 82, "ams": 166, "syd": 463, "jnb": 650, "gru": 413, "hkg": 287 }, { "timestamp": "Feb 7, 05:00", "hkg": 295, "jnb": 772, "syd": 496, "iad": 89, "ams": 173, "gru": 471 }, { "timestamp": "Feb 7, 06:00", "hkg": 295, "ams": 168, "iad": 78, "jnb": 649, "syd": 488, "gru": 416 }, { "timestamp": "Feb 7, 07:00", "hkg": 292, "gru": 416, "iad": 89, "ams": 174, "syd": 476, "jnb": 662 }, { "timestamp": "Feb 7, 08:00", "gru": 422, "syd": 462, "jnb": 649, "iad": 78, "ams": 175, "hkg": 429 }, { "timestamp": "Feb 7, 09:00", "gru": 471, "iad": 82, "ams": 186, "jnb": 648, "syd": 462, "hkg": 289 }, { "timestamp": "Feb 7, 10:00", "jnb": 652, "syd": 486, "ams": 167, "iad": 90, "gru": 435, "hkg": 294 }, { "timestamp": "Feb 7, 11:00", "gru": 420, "syd": 462, "jnb": 649, "iad": 88, "ams": 205, "hkg": 294 }, { "timestamp": "Feb 7, 12:00", "hkg": 292, "gru": 435, "iad": 88, "ams": 195, "syd": 465, "jnb": 679 }, { "timestamp": "Feb 7, 13:00", "hkg": 293, "syd": 465, "jnb": 705, "ams": 175, "iad": 88, "gru": 439 }, { "timestamp": "Feb 7, 14:00", "hkg": 298, "iad": 84, "ams": 176, "syd": 504, "jnb": 681, "gru": 448 }, { "timestamp": "Feb 7, 15:00", "gru": 445, "ams": 195, "iad": 83, "syd": 463, "jnb": 679, "hkg": 307 }, { "timestamp": "Feb 7, 16:00", "syd": 486, "jnb": 650, "ams": 176, "iad": 81, "gru": 455, "hkg": 288 }, { "timestamp": "Feb 7, 17:00", "iad": 88, "ams": 188, "jnb": 739, "syd": 462, "gru": 437, "hkg": 289 }, { "timestamp": "Feb 7, 18:00", "gru": 497, "ams": 170, "iad": 82, "jnb": 678, "syd": 506, "hkg": 308 }, { "timestamp": "Feb 7, 19:00", "syd": 462, "jnb": 660, "iad": 80, "ams": 166, "gru": 425, "hkg": 287 }, { "timestamp": "Feb 7, 20:00", "gru": 438, "iad": 90, "ams": 175, "syd": 460, "jnb": 679, "hkg": 297 }, { "timestamp": "Feb 7, 21:00", "hkg": 326, "gru": 490, "syd": 514, "jnb": 709, "ams": 171, "iad": 96 }, { "timestamp": "Feb 7, 22:00", "hkg": 290, "gru": 424, "iad": 82, "ams": 190, "syd": 492, "jnb": 650 }, { "timestamp": "Feb 7, 23:00", "hkg": 290, "ams": 172, "iad": 80, "jnb": 652, "syd": 484, "gru": 418 }, { "timestamp": "Feb 8, 00:00", "hkg": 291, "syd": 479, "jnb": 681, "ams": 172, "iad": 81, "gru": 441 }, { "timestamp": "Feb 8, 01:00", "hkg": 292, "ams": 174, "iad": 79, "jnb": 683, "syd": 460, "gru": 436 }, { "timestamp": "Feb 8, 02:00", "iad": 81, "ams": 188, "jnb": 652, "syd": 509, "gru": 436, "hkg": 285 }, { "timestamp": "Feb 8, 03:00", "gru": 417, "ams": 169, "iad": 94, "syd": 487, "jnb": 650, "hkg": 292 }, { "timestamp": "Feb 8, 04:00", "ams": 176, "iad": 82, "syd": 467, "jnb": 653, "gru": 416, "hkg": 288 }, { "timestamp": "Feb 8, 05:00", "gru": 425, "syd": 461, "jnb": 654, "iad": 97, "ams": 177, "hkg": 288 }, { "timestamp": "Feb 8, 06:00", "gru": 415, "ams": 166, "iad": 78, "syd": 484, "jnb": 651, "hkg": 379 }, { "timestamp": "Feb 8, 07:00", "hkg": 289, "gru": 415, "iad": 78, "ams": 167, "jnb": 650, "syd": 462 }, { "timestamp": "Feb 8, 08:00", "hkg": 288, "jnb": 662, "syd": 463, "iad": 78, "ams": 167, "gru": 417 }, { "timestamp": "Feb 8, 09:00", "gru": 415, "jnb": 679, "syd": 465, "ams": 167, "iad": 82, "hkg": 286 }, { "timestamp": "Feb 8, 10:00", "syd": 464, "jnb": 653, "ams": 188, "iad": 77, "gru": 426, "hkg": 300 }, { "timestamp": "Feb 8, 11:00", "hkg": 300, "syd": 479, "jnb": 652, "ams": 166, "iad": 84, "gru": 436 }, { "timestamp": "Feb 8, 12:00", "hkg": 285, "gru": 417, "jnb": 653, "syd": 464, "ams": 175, "iad": 79 }, { "timestamp": "Feb 8, 13:00", "hkg": 284, "iad": 80, "ams": 167, "syd": 466, "jnb": 651, "gru": 418 }, { "timestamp": "Feb 8, 14:00", "hkg": 285, "gru": 416, "iad": 80, "ams": 180, "jnb": 650, "syd": 466 }, { "timestamp": "Feb 8, 15:00", "hkg": 288, "gru": 418, "jnb": 650, "syd": 498, "iad": 78, "ams": 170 }, { "timestamp": "Feb 8, 16:00", "gru": 422, "syd": 464, "jnb": 649, "iad": 86, "ams": 177, "hkg": 303 }, { "timestamp": "Feb 8, 17:00", "gru": 418, "syd": 466, "jnb": 649, "iad": 80, "ams": 336, "hkg": 288 }, { "timestamp": "Feb 8, 18:00", "hkg": 305, "gru": 436, "syd": 507, "jnb": 678, "ams": 178, "iad": 224 }, { "timestamp": "Feb 8, 19:00", "hkg": 290, "syd": 466, "jnb": 653, "ams": 172, "iad": 82, "gru": 417 }, { "timestamp": "Feb 8, 20:00", "jnb": 857, "syd": 497, "ams": 176, "iad": 85, "gru": 443, "hkg": 291 }, { "timestamp": "Feb 8, 21:00", "gru": 417, "syd": 462, "jnb": 650, "iad": 78, "ams": 175, "hkg": 298 }, { "timestamp": "Feb 8, 22:00", "iad": 79, "ams": 166, "jnb": 648, "syd": 467, "gru": 418, "hkg": 288 }, { "timestamp": "Feb 8, 23:00", "jnb": 652, "syd": 484, "iad": 80, "ams": 172, "gru": 419, "hkg": 305 }, { "timestamp": "Feb 9, 00:00", "gru": 416, "syd": 464, "jnb": 651, "iad": 80, "ams": 170, "hkg": 285 }, { "timestamp": "Feb 9, 01:00", "gru": 416, "syd": 478, "jnb": 674, "ams": 168, "iad": 79, "hkg": 284 }, { "timestamp": "Feb 9, 02:00", "jnb": 652, "syd": 460, "ams": 175, "iad": 79, "gru": 416, "hkg": 289 }, { "timestamp": "Feb 9, 03:00", "hkg": 287, "gru": 436, "syd": 464, "jnb": 650, "ams": 172, "iad": 79 }, { "timestamp": "Feb 9, 04:00", "hkg": 313, "jnb": 658, "syd": 466, "ams": 165, "iad": 78, "gru": 421 }, { "timestamp": "Feb 9, 05:00", "syd": 463, "jnb": 650, "iad": 78, "ams": 173, "gru": 416, "hkg": 287 }, { "timestamp": "Feb 9, 06:00", "gru": 416, "jnb": 711, "syd": 515, "iad": 86, "ams": 169, "hkg": 291 }, { "timestamp": "Feb 9, 07:00", "gru": 414, "iad": 78, "ams": 167, "jnb": 651, "syd": 460, "hkg": 287 }, { "timestamp": "Feb 9, 08:00", "syd": 465, "jnb": 648, "iad": 78, "ams": 171, "gru": 417, "hkg": 288 }, { "timestamp": "Feb 9, 09:00", "syd": 464, "jnb": 654, "iad": 78, "ams": 168, "gru": 416, "hkg": 287 }, { "timestamp": "Feb 9, 10:00", "hkg": 287, "jnb": 652, "syd": 473, "iad": 79, "ams": 173, "gru": 416 }, { "timestamp": "Feb 9, 11:00", "hkg": 285, "gru": 416, "jnb": 652, "syd": 491, "iad": 80, "ams": 183 }, { "timestamp": "Feb 9, 12:00", "hkg": 287, "gru": 444, "ams": 169, "iad": 78, "syd": 466, "jnb": 652 }, { "timestamp": "Feb 9, 13:00", "hkg": 286, "iad": 78, "ams": 170, "jnb": 653, "syd": 482, "gru": 421 }, { "timestamp": "Feb 9, 14:00", "iad": 78, "ams": 189, "syd": 468, "jnb": 650, "gru": 416, "hkg": 297 }, { "timestamp": "Feb 9, 15:00", "syd": 483, "jnb": 649, "iad": 79, "ams": 174, "gru": 417, "hkg": 314 }, { "timestamp": "Feb 9, 16:00", "gru": 455, "jnb": 690, "syd": 489, "iad": 85, "ams": 169, "hkg": 294 }, { "timestamp": "Feb 9, 17:00", "ams": 169, "iad": 78, "syd": 475, "jnb": 652, "gru": 417, "hkg": 285 }, { "timestamp": "Feb 9, 18:00", "gru": 416, "syd": 460, "jnb": 654, "ams": 168, "iad": 78, "hkg": 305 }, { "timestamp": "Feb 9, 19:00", "hkg": 286, "gru": 418, "jnb": 656, "syd": 617, "iad": 79, "ams": 172 }, { "timestamp": "Feb 9, 20:00", "hkg": 286, "jnb": 650, "syd": 461, "ams": 170, "iad": 88, "gru": 416 }, { "timestamp": "Feb 9, 21:00", "gru": 415, "iad": 83, "ams": 184, "syd": 467, "jnb": 652, "hkg": 297 }, { "timestamp": "Feb 9, 22:00", "syd": 458, "jnb": 651, "iad": 78, "ams": 168, "gru": 418, "hkg": 286 }, { "timestamp": "Feb 9, 23:00", "hkg": 287, "jnb": 678, "syd": 474, "ams": 166, "iad": 78, "gru": 417 }, { "timestamp": "Feb 10, 00:00", "hkg": 285, "iad": 85, "ams": 166, "jnb": 649, "syd": 464, "gru": 415 }, { "timestamp": "Feb 10, 01:00", "hkg": 285, "syd": 484, "jnb": 650, "iad": 78, "ams": 166, "gru": 419 }, { "timestamp": "Feb 10, 02:00", "hkg": 309, "gru": 416, "syd": 465, "jnb": 667, "iad": 86, "ams": 167 }, { "timestamp": "Feb 10, 03:00", "hkg": 312, "jnb": 649, "syd": 462, "ams": 170, "iad": 78, "gru": 425 }, { "timestamp": "Feb 10, 04:00", "jnb": 652, "syd": 464, "iad": 87, "ams": 166, "gru": 416, "hkg": 285 }, { "timestamp": "Feb 10, 05:00", "gru": 415, "ams": 167, "iad": 81, "jnb": 650, "syd": 464, "hkg": 283 }, { "timestamp": "Feb 10, 06:00", "hkg": 288, "gru": 432, "syd": 489, "jnb": 650, "iad": 77, "ams": 167 }, { "timestamp": "Feb 10, 07:00", "hkg": 287, "jnb": 649, "syd": 464, "ams": 168, "iad": 77, "gru": 416 }, { "timestamp": "Feb 10, 08:00", "hkg": 285, "iad": 78, "ams": 168, "jnb": 647, "syd": 470, "gru": 417 }, { "timestamp": "Feb 10, 09:00", "hkg": 284, "syd": 478, "jnb": 648, "ams": 167, "iad": 79, "gru": 415 }, { "timestamp": "Feb 10, 10:00", "hkg": 317, "gru": 416, "ams": 169, "iad": 78, "syd": 479, "jnb": 650 }, { "timestamp": "Feb 10, 11:00", "hkg": 288, "jnb": 684, "syd": 465, "iad": 78, "ams": 167, "gru": 435 }, { "timestamp": "Feb 10, 12:00", "hkg": 288, "gru": 417, "jnb": 694, "syd": 508, "ams": 167, "iad": 94 }, { "timestamp": "Feb 10, 13:00", "gru": 418, "iad": 96, "ams": 171, "jnb": 687, "syd": 473, "hkg": 290 }, { "timestamp": "Feb 10, 14:00", "jnb": 686, "syd": 461, "iad": 84, "ams": 172, "gru": 440, "hkg": 293 }, { "timestamp": "Feb 10, 15:00", "hkg": 286, "gru": 417, "jnb": 649, "syd": 462, "ams": 176, "iad": 77 }, { "timestamp": "Feb 10, 16:00", "hkg": 287, "gru": 438, "iad": 80, "ams": 166, "jnb": 676, "syd": 484 }, { "timestamp": "Feb 10, 17:00", "hkg": 297, "gru": 415, "syd": 555, "jnb": 674, "iad": 84, "ams": 214 }, { "timestamp": "Feb 10, 18:00", "gru": 417, "iad": 77, "ams": 377, "syd": 547, "jnb": 828, "hkg": 420 }, { "timestamp": "Feb 10, 19:00", "ams": 404, "iad": 82, "jnb": 871, "syd": 568, "gru": 416, "hkg": 400 }, { "timestamp": "Feb 10, 20:00", "gru": 424, "jnb": 728, "syd": 541, "iad": 83, "ams": 196, "hkg": 426 }, { "timestamp": "Feb 10, 21:00", "ams": 362, "iad": 80, "jnb": 713, "syd": 487, "gru": 434, "hkg": 349 }, { "timestamp": "Feb 10, 22:00", "hkg": 434, "jnb": 647, "syd": 486, "ams": 187, "iad": 78, "gru": 423 }, { "timestamp": "Feb 10, 23:00", "hkg": 337, "gru": 416, "syd": 538, "jnb": 648, "iad": 76, "ams": 167 }, { "timestamp": "Feb 11, 00:00", "hkg": 310, "gru": 438, "ams": 195, "iad": 77, "syd": 543, "jnb": 721 }, { "timestamp": "Feb 11, 01:00", "hkg": 812, "jnb": 648, "syd": 506, "ams": 210, "iad": 89, "gru": 414 }, { "timestamp": "Feb 11, 02:00", "hkg": 366, "gru": 420, "syd": 556, "jnb": 675, "ams": 258, "iad": 81 }, { "timestamp": "Feb 11, 03:00", "gru": 415, "iad": 78, "ams": 266, "syd": 472, "jnb": 733, "hkg": 414 }, { "timestamp": "Feb 11, 04:00", "iad": 79, "ams": 189, "jnb": 737, "syd": 514, "gru": 434, "hkg": 315 }, { "timestamp": "Feb 11, 05:00", "gru": 434, "iad": 93, "ams": 192, "syd": 562, "jnb": 673, "hkg": 328 }, { "timestamp": "Feb 11, 06:00", "iad": 95, "ams": 196, "jnb": 658, "syd": 576, "gru": 416, "hkg": 338 }, { "timestamp": "Feb 11, 07:00", "hkg": 347, "jnb": 685, "syd": 578, "ams": 425, "iad": 88, "gru": 414 }, { "timestamp": "Feb 11, 08:00", "hkg": 398, "gru": 423, "syd": 565, "jnb": 669, "ams": 210, "iad": 86 }, { "timestamp": "Feb 11, 09:00", "hkg": 406, "syd": 519, "jnb": 683, "iad": 79, "ams": 208, "gru": 423 }, { "timestamp": "Feb 11, 10:00", "gru": 416, "syd": 534, "jnb": 676, "iad": 76, "ams": 223, "hkg": 329 }, { "timestamp": "Feb 11, 11:00", "ams": 195, "iad": 77, "syd": 541, "jnb": 866, "gru": 414, "hkg": 325 }, { "timestamp": "Feb 11, 12:00", "hkg": 384, "ams": 770, "iad": 80, "jnb": 714, "syd": 530, "gru": 423 }, { "timestamp": "Feb 11, 13:00", "hkg": 418, "gru": 414, "syd": 596, "jnb": 672, "ams": 736, "iad": 81 }, { "timestamp": "Feb 11, 14:00", "hkg": 408, "ams": 214, "iad": 80, "jnb": 668, "syd": 559, "gru": 415 }, { "timestamp": "Feb 11, 15:00", "hkg": 327, "gru": 418, "jnb": 1195, "syd": 543, "iad": 83, "ams": 212 }, { "timestamp": "Feb 11, 16:00", "gru": 415, "syd": 534, "jnb": 885, "iad": 85, "ams": 186, "hkg": 352 }, { "timestamp": "Feb 11, 17:00", "hkg": 357, "gru": 415, "syd": 590, "jnb": 670, "iad": 83, "ams": 209 }, { "timestamp": "Feb 11, 18:00", "hkg": 350, "jnb": 3586, "syd": 554, "iad": 80, "ams": 406, "gru": 414 }, { "timestamp": "Feb 11, 19:00", "iad": 78, "ams": 226, "syd": 588, "jnb": 3298, "gru": 415, "hkg": 358 }, { "timestamp": "Feb 11, 20:00", "gru": 415, "iad": 78, "ams": 723, "jnb": 843, "syd": 500, "hkg": 370 }, { "timestamp": "Feb 11, 21:00", "hkg": 368, "gru": 424, "syd": 568, "jnb": 881, "iad": 86, "ams": 217 }, { "timestamp": "Feb 11, 22:00", "hkg": 330, "jnb": 646, "syd": 522, "iad": 89, "ams": 331, "gru": 415 }, { "timestamp": "Feb 11, 23:00", "hkg": 331, "gru": 416, "jnb": 690, "syd": 525, "ams": 264, "iad": 76 }, { "timestamp": "Feb 12, 00:00", "hkg": 312, "syd": 490, "jnb": 706, "ams": 235, "iad": 84, "gru": 416 }, { "timestamp": "Feb 12, 01:00", "hkg": 314, "gru": 424, "syd": 514, "jnb": 671, "iad": 83, "ams": 212 }, { "timestamp": "Feb 12, 02:00", "gru": 415, "jnb": 673, "syd": 536, "iad": 86, "ams": 217, "hkg": 338 }, { "timestamp": "Feb 12, 03:00", "ams": 185, "iad": 79, "jnb": 648, "syd": 528, "gru": 414, "hkg": 348 }, { "timestamp": "Feb 12, 04:00", "hkg": 357, "gru": 434, "ams": 236, "iad": 78, "jnb": 692, "syd": 549 }, { "timestamp": "Feb 12, 05:00", "hkg": 384, "syd": 460, "jnb": 693, "ams": 224, "iad": 78, "gru": 432 }, { "timestamp": "Feb 12, 06:00", "jnb": 653, "syd": 486, "ams": 173, "iad": 86, "gru": 415, "hkg": 377 }, { "timestamp": "Feb 12, 07:00", "gru": 441, "iad": 98, "ams": 209, "jnb": 670, "syd": 509, "hkg": 381 }, { "timestamp": "Feb 12, 08:00", "syd": 503, "jnb": 706, "iad": 78, "ams": 203, "gru": 415, "hkg": 349 }, { "timestamp": "Feb 12, 09:00", "gru": 416, "jnb": 662, "syd": 545, "ams": 188, "iad": 76, "hkg": 329 }, { "timestamp": "Feb 12, 10:00", "gru": 414, "iad": 78, "ams": 196, "jnb": 711, "syd": 516, "hkg": 364 }, { "timestamp": "Feb 12, 11:00", "hkg": 362, "gru": 440, "ams": 188, "iad": 83, "syd": 598, "jnb": 647 }, { "timestamp": "Feb 12, 12:00", "hkg": 356, "syd": 554, "jnb": 718, "ams": 214, "iad": 76, "gru": 428 }, { "timestamp": "Feb 12, 13:00", "hkg": 384, "syd": 538, "jnb": 690, "ams": 386, "iad": 86, "gru": 414 }, { "timestamp": "Feb 12, 14:00", "hkg": 347, "gru": 417, "syd": 521, "jnb": 658, "iad": 79, "ams": 273 }, { "timestamp": "Feb 12, 15:00", "gru": 436, "jnb": 1427, "syd": 572, "ams": 272, "iad": 80, "hkg": 416 }, { "timestamp": "Feb 12, 16:00", "ams": 1859, "iad": 87, "jnb": 760, "syd": 534, "gru": 443, "hkg": 366 }, { "timestamp": "Feb 12, 17:00", "ams": 209, "iad": 80, "syd": 525, "jnb": 925, "gru": 438, "hkg": 338 }, { "timestamp": "Feb 12, 18:00", "syd": 561, "jnb": 1236, "iad": 84, "ams": 1210, "gru": 434, "hkg": 303 }, { "timestamp": "Feb 12, 19:00", "gru": 422, "jnb": 1261, "syd": 529, "ams": 360, "iad": 82, "hkg": 340 }, { "timestamp": "Feb 12, 20:00", "hkg": 285, "gru": 424, "ams": 376, "iad": 79, "jnb": 670, "syd": 489 }, { "timestamp": "Feb 12, 21:00", "hkg": 337, "jnb": 672, "syd": 522, "iad": 80, "ams": 192, "gru": 418 }, { "timestamp": "Feb 12, 22:00", "gru": 422, "syd": 492, "jnb": 683, "ams": 199, "iad": 79, "hkg": 311 }, { "timestamp": "Feb 12, 23:00", "jnb": 711, "syd": 582, "iad": 91, "ams": 216, "gru": 470, "hkg": 338 }, { "timestamp": "Feb 13, 00:00", "hkg": 377, "iad": 77, "ams": 182, "jnb": 671, "syd": 515, "gru": 415 }, { "timestamp": "Feb 13, 01:00", "gru": 422, "syd": 515, "jnb": 685, "ams": 216, "iad": 83, "hkg": 314 }, { "timestamp": "Feb 13, 02:00", "gru": 416, "iad": 80, "ams": 195, "syd": 516, "jnb": 657, "hkg": 327 }, { "timestamp": "Feb 13, 03:00", "jnb": 672, "syd": 529, "ams": 174, "iad": 89, "gru": 418, "hkg": 373 }, { "timestamp": "Feb 13, 04:00", "gru": 416, "ams": 224, "iad": 82, "jnb": 698, "syd": 533, "hkg": 329 }, { "timestamp": "Feb 13, 05:00", "hkg": 344, "gru": 416, "jnb": 648, "syd": 540, "iad": 85, "ams": 177 }, { "timestamp": "Feb 13, 06:00", "hkg": 311, "jnb": 670, "syd": 546, "ams": 241, "iad": 88, "gru": 444 }, { "timestamp": "Feb 13, 07:00", "jnb": 690, "syd": 499, "ams": 206, "iad": 97, "gru": 414, "hkg": 369 }, { "timestamp": "Feb 13, 08:00", "gru": 455, "ams": 221, "iad": 89, "jnb": 725, "syd": 574, "hkg": 347 }, { "timestamp": "Feb 13, 09:00", "hkg": 368, "syd": 520, "jnb": 672, "ams": 187, "iad": 84, "gru": 423 }, { "timestamp": "Feb 13, 10:00", "hkg": 339, "iad": 75, "ams": 197, "syd": 524, "jnb": 695, "gru": 414 }, { "timestamp": "Feb 13, 11:00", "hkg": 350, "gru": 437, "ams": 207, "iad": 77, "jnb": 702, "syd": 554 }, { "timestamp": "Feb 13, 12:00", "hkg": 382, "ams": 195, "iad": 83, "jnb": 669, "syd": 592, "gru": 445 }, { "timestamp": "Feb 13, 13:00", "hkg": 354, "gru": 460, "jnb": 676, "syd": 552, "iad": 97, "ams": 190 }, { "timestamp": "Feb 13, 14:00", "gru": 417, "jnb": 678, "syd": 532, "ams": 169, "iad": 78, "hkg": 346 }, { "timestamp": "Feb 13, 15:00", "syd": 484, "jnb": 704, "iad": 80, "ams": 229, "gru": 424, "hkg": 349 }, { "timestamp": "Feb 13, 16:00", "hkg": 361, "gru": 456, "iad": 92, "ams": 195, "jnb": 728, "syd": 509 }, { "timestamp": "Feb 13, 17:00", "hkg": 311, "ams": 176, "iad": 82, "syd": 486, "jnb": 673, "gru": 416 }, { "timestamp": "Feb 13, 18:00", "ams": 222, "iad": 78, "jnb": 683, "syd": 544, "gru": 415, "hkg": 356 }, { "timestamp": "Feb 13, 19:00", "jnb": 670, "syd": 565, "ams": 236, "iad": 88, "gru": 415, "hkg": 381 }, { "timestamp": "Feb 13, 20:00", "gru": 426, "syd": 601, "jnb": 703, "ams": 172, "iad": 85, "hkg": 365 }, { "timestamp": "Feb 13, 21:00", "iad": 76, "ams": 188, "jnb": 774, "syd": 524, "gru": 428, "hkg": 402 }, { "timestamp": "Feb 13, 22:00", "gru": 416, "iad": 199, "ams": 196, "syd": 527, "jnb": 700, "hkg": 362 }, { "timestamp": "Feb 13, 23:00", "hkg": 348, "gru": 427, "iad": 76, "ams": 176, "syd": 499, "jnb": 649 }, { "timestamp": "Feb 14, 00:00", "hkg": 342, "iad": 76, "ams": 210, "jnb": 720, "syd": 563, "gru": 416 }, { "timestamp": "Feb 14, 01:00", "hkg": 318, "gru": 457, "iad": 87, "ams": 170, "syd": 547, "jnb": 682 }, { "timestamp": "Feb 14, 02:00", "hkg": 379, "iad": 86, "ams": 202, "jnb": 662, "syd": 509, "gru": 416 }, { "timestamp": "Feb 14, 03:00", "hkg": 347, "jnb": 680, "syd": 572, "iad": 90, "ams": 167, "gru": 436 }, { "timestamp": "Feb 14, 04:00", "syd": 553, "jnb": 718, "iad": 86, "ams": 199, "gru": 448, "hkg": 372 }, { "timestamp": "Feb 14, 05:00", "gru": 413, "jnb": 674, "syd": 548, "ams": 211, "iad": 77, "hkg": 363 }, { "timestamp": "Feb 14, 06:00", "ams": 184, "iad": 76, "syd": 535, "jnb": 690, "gru": 420, "hkg": 364 }, { "timestamp": "Feb 14, 07:00", "gru": 414, "ams": 201, "iad": 76, "syd": 525, "jnb": 717, "hkg": 381 }, { "timestamp": "Feb 14, 08:00", "hkg": 374, "gru": 418, "ams": 163, "iad": 79, "jnb": 658, "syd": 557 }, { "timestamp": "Feb 14, 09:00", "iad": 80, "ams": 187, "jnb": 656, "syd": 520, "gru": 423, "hkg": 366 }, { "timestamp": "Feb 14, 10:00", "hkg": 290, "gru": 413, "iad": 77, "ams": 170, "jnb": 717, "syd": 563 }, { "timestamp": "Feb 14, 11:00", "hkg": 328, "gru": 437, "jnb": 664, "syd": 481, "iad": 76, "ams": 227 }, { "timestamp": "Feb 14, 12:00", "hkg": 353, "syd": 511, "jnb": 699, "iad": 75, "ams": 184, "gru": 424 }, { "timestamp": "Feb 14, 13:00", "jnb": 708, "syd": 518, "ams": 227, "iad": 86, "gru": 414, "hkg": 326 }, { "timestamp": "Feb 14, 14:00", "gru": 425, "syd": 531, "jnb": 673, "ams": 196, "iad": 83, "hkg": 357 }, { "timestamp": "Feb 14, 15:00", "ams": 242, "iad": 86, "syd": 516, "jnb": 665, "gru": 414, "hkg": 372 }, { "timestamp": "Feb 14, 16:00", "gru": 436, "ams": 189, "iad": 99, "jnb": 714, "syd": 536, "hkg": 350 }, { "timestamp": "Feb 14, 17:00", "hkg": 350, "ams": 212, "iad": 78, "jnb": 1005, "syd": 498, "gru": 415 }, { "timestamp": "Feb 14, 18:00", "hkg": 388, "gru": 468, "ams": 202, "iad": 85, "syd": 566, "jnb": 743 }, { "timestamp": "Feb 14, 19:00", "hkg": 390, "gru": 415, "syd": 512, "jnb": 703, "ams": 209, "iad": 76 }, { "timestamp": "Feb 14, 20:00", "gru": 437, "syd": 516, "jnb": 684, "iad": 88, "ams": 193, "hkg": 388 }, { "timestamp": "Feb 14, 21:00", "jnb": 697, "syd": 572, "ams": 203, "iad": 95, "gru": 417, "hkg": 420 }, { "timestamp": "Feb 14, 22:00", "hkg": 348, "syd": 536, "jnb": 675, "ams": 216, "iad": 87, "gru": 425 }, { "timestamp": "Feb 14, 23:00", "hkg": 319, "gru": 414, "syd": 583, "jnb": 690, "ams": 206, "iad": 77 }, { "timestamp": "Feb 15, 00:00", "hkg": 321, "ams": 201, "iad": 77, "jnb": 661, "syd": 506, "gru": 416 }, { "timestamp": "Feb 15, 01:00", "hkg": 308, "gru": 425, "iad": 78, "ams": 208, "syd": 486, "jnb": 679 }, { "timestamp": "Feb 15, 02:00", "gru": 432, "syd": 556, "jnb": 718, "iad": 80, "ams": 236, "hkg": 351 }, { "timestamp": "Feb 15, 03:00", "jnb": 722, "syd": 491, "ams": 198, "iad": 75, "gru": 416, "hkg": 337 }, { "timestamp": "Feb 15, 04:00", "iad": 78, "ams": 198, "jnb": 665, "syd": 488, "gru": 420, "hkg": 531 }, { "timestamp": "Feb 15, 05:00", "hkg": 356, "gru": 432, "syd": 566, "jnb": 672, "ams": 177, "iad": 79 }, { "timestamp": "Feb 15, 06:00", "hkg": 323, "ams": 196, "iad": 84, "syd": 542, "jnb": 670, "gru": 416 }, { "timestamp": "Feb 15, 07:00", "iad": 94, "ams": 201, "jnb": 694, "syd": 641, "gru": 415, "hkg": 370 }, { "timestamp": "Feb 15, 08:00", "gru": 416, "ams": 214, "iad": 82, "jnb": 691, "syd": 528, "hkg": 349 }, { "timestamp": "Feb 15, 09:00", "gru": 414, "ams": 205, "iad": 82, "syd": 508, "jnb": 652, "hkg": 340 }, { "timestamp": "Feb 15, 10:00", "syd": 581, "jnb": 690, "ams": 268, "iad": 79, "gru": 434, "hkg": 376 }, { "timestamp": "Feb 15, 11:00", "hkg": 333, "jnb": 690, "syd": 523, "iad": 87, "ams": 220, "gru": 423 }, { "timestamp": "Feb 15, 12:00", "hkg": 329, "ams": 231, "iad": 83, "jnb": 671, "syd": 564, "gru": 415 }, { "timestamp": "Feb 15, 13:00", "hkg": 392, "gru": 431, "jnb": 649, "syd": 518, "iad": 84, "ams": 217 }, { "timestamp": "Feb 15, 14:00", "hkg": 409, "gru": 417, "jnb": 660, "syd": 504, "ams": 173, "iad": 77 }, { "timestamp": "Feb 15, 15:00", "hkg": 360, "syd": 511, "jnb": 741, "iad": 84, "ams": 216, "gru": 440 }, { "timestamp": "Feb 15, 16:00", "syd": 476, "jnb": 820, "ams": 277, "iad": 90, "gru": 470, "hkg": 370 }, { "timestamp": "Feb 17, 10:00", "hkg": 280, "iad": 78, "ams": 169, "syd": 467, "jnb": 656, "gru": 415 }, { "timestamp": "Feb 17, 11:00", "gru": 422, "jnb": 659, "syd": 478, "ams": 210, "iad": 86, "hkg": 293 }, { "timestamp": "Feb 17, 12:00", "syd": 466, "jnb": 659, "ams": 172, "iad": 77, "gru": 418, "hkg": 348 }, { "timestamp": "Feb 17, 13:00", "jnb": 665, "syd": 482, "ams": 210, "iad": 82, "gru": 414, "hkg": 314 }, { "timestamp": "Feb 17, 14:00", "hkg": 299, "syd": 467, "jnb": 692, "ams": 173, "iad": 83, "gru": 414 }, { "timestamp": "Feb 17, 15:00", "hkg": 308, "gru": 442, "iad": 82, "ams": 178, "syd": 499, "jnb": 703 }, { "timestamp": "Feb 17, 16:00", "hkg": 290, "jnb": 658, "syd": 476, "iad": 77, "ams": 171, "gru": 416 }, { "timestamp": "Feb 17, 17:00", "hkg": 288, "gru": 416, "syd": 464, "jnb": 657, "ams": 187, "iad": 81 }, { "timestamp": "Feb 17, 18:00", "gru": 414, "syd": 460, "jnb": 657, "iad": 82, "ams": 173, "hkg": 320 }, { "timestamp": "Feb 17, 19:00", "jnb": 682, "syd": 481, "ams": 172, "iad": 76, "gru": 415, "hkg": 305 }, { "timestamp": "Feb 17, 20:00", "hkg": 288, "iad": 80, "ams": 172, "syd": 463, "jnb": 798, "gru": 416 }, { "timestamp": "Feb 17, 21:00", "hkg": 312, "gru": 416, "syd": 553, "jnb": 668, "ams": 172, "iad": 76 }, { "timestamp": "Feb 17, 22:00", "hkg": 286, "gru": 414, "iad": 80, "ams": 180, "syd": 462, "jnb": 664 }, { "timestamp": "Feb 17, 23:00", "gru": 414, "ams": 187, "iad": 80, "jnb": 663, "syd": 490, "hkg": 302 }, { "timestamp": "Feb 18, 00:00", "jnb": 658, "syd": 500, "ams": 174, "iad": 77, "gru": 428, "hkg": 285 }, { "timestamp": "Feb 18, 01:00", "syd": 509, "jnb": 663, "ams": 179, "iad": 78, "gru": 435, "hkg": 327 }, { "timestamp": "Feb 18, 02:00", "gru": 414, "syd": 534, "jnb": 654, "iad": 79, "ams": 178, "hkg": 323 }, { "timestamp": "Feb 18, 03:00", "hkg": 341, "gru": 432, "jnb": 658, "syd": 510, "ams": 191, "iad": 75 }, { "timestamp": "Feb 18, 04:00", "hkg": 324, "ams": 170, "iad": 75, "jnb": 682, "syd": 515, "gru": 415 }, { "timestamp": "Feb 18, 05:00", "gru": 414, "syd": 494, "jnb": 656, "iad": 91, "ams": 177, "hkg": 314 }, { "timestamp": "Feb 18, 06:00", "gru": 414, "ams": 185, "iad": 76, "syd": 471, "jnb": 654, "hkg": 282 }, { "timestamp": "Feb 18, 07:00", "iad": 76, "ams": 198, "jnb": 654, "syd": 484, "gru": 414, "hkg": 286 }, { "timestamp": "Feb 18, 08:00", "hkg": 303, "jnb": 655, "syd": 463, "iad": 76, "ams": 194, "gru": 434 }, { "timestamp": "Feb 18, 09:00", "gru": 550, "ams": 172, "iad": 89, "syd": 457, "jnb": 677, "hkg": 290 }, { "timestamp": "Feb 18, 10:00", "syd": 466, "jnb": 682, "iad": 80, "ams": 180, "gru": 413, "hkg": 318 }, { "timestamp": "Feb 18, 11:00", "gru": 414, "jnb": 658, "syd": 463, "ams": 174, "iad": 80, "hkg": 284 }, { "timestamp": "Feb 18, 12:00", "hkg": 330, "gru": 414, "ams": 179, "iad": 83, "jnb": 653, "syd": 496 }, { "timestamp": "Feb 18, 13:00", "hkg": 287, "jnb": 659, "syd": 512, "iad": 77, "ams": 177, "gru": 413 }, { "timestamp": "Feb 18, 14:00", "hkg": 313, "ams": 172, "iad": 75, "jnb": 655, "syd": 464, "gru": 414 }, { "timestamp": "Feb 18, 15:00", "hkg": 318, "syd": 468, "jnb": 658, "ams": 177, "iad": 77, "gru": 415 }, { "timestamp": "Feb 18, 16:00", "hkg": 313, "gru": 415, "ams": 173, "iad": 77, "syd": 462, "jnb": 666 }, { "timestamp": "Feb 18, 17:00", "jnb": 659, "syd": 491, "ams": 179, "iad": 76, "gru": 414, "hkg": 283 }, { "timestamp": "Feb 18, 18:00", "gru": 414, "jnb": 694, "syd": 476, "iad": 84, "ams": 174, "hkg": 298 } ] }, "metricsByRegion": [ { "region": "syd", "avgLatency": 499, "p75Latency": 532, "p90Latency": 607, "p95Latency": 620, "p99Latency": 728 }, { "region": "gru", "avgLatency": 423, "p75Latency": 417, "p90Latency": 422, "p95Latency": 468, "p99Latency": 541 }, { "region": "iad", "avgLatency": 80, "p75Latency": 79, "p90Latency": 91, "p95Latency": 107, "p99Latency": 131 }, { "region": "ams", "avgLatency": 191, "p75Latency": 181, "p90Latency": 259, "p95Latency": 295, "p99Latency": 316 }, { "region": "hkg", "avgLatency": 325, "p75Latency": 348, "p90Latency": 439, "p95Latency": 443, "p99Latency": 467 }, { "region": "jnb", "avgLatency": 675, "p75Latency": 660, "p90Latency": 739, "p95Latency": 786, "p99Latency": 893 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-latency/render.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Feb 4, 00:00", "ams": 80, "iad": 277, "syd": 515, "jnb": 579, "gru": 393, "hkg": 408 }, { "timestamp": "Feb 4, 01:00", "ams": 109, "iad": 162, "syd": 598, "jnb": 592, "gru": 326, "hkg": 340 }, { "timestamp": "Feb 4, 02:00", "hkg": 303, "ams": 89, "iad": 216, "jnb": 567, "syd": 445, "gru": 442 }, { "timestamp": "Feb 4, 03:00", "hkg": 379, "jnb": 591, "syd": 521, "ams": 86, "iad": 276, "gru": 307 }, { "timestamp": "Feb 4, 04:00", "hkg": 278, "gru": 390, "syd": 417, "jnb": 560, "ams": 84, "iad": 218 }, { "timestamp": "Feb 4, 05:00", "gru": 352, "syd": 443, "jnb": 591, "ams": 80, "iad": 183, "hkg": 384 }, { "timestamp": "Feb 4, 06:00", "jnb": 558, "syd": 532, "ams": 83, "iad": 152, "gru": 416, "hkg": 374 }, { "timestamp": "Feb 4, 07:00", "hkg": 371, "jnb": 583, "syd": 508, "ams": 79, "iad": 214, "gru": 348 }, { "timestamp": "Feb 4, 08:00", "hkg": 364, "gru": 283, "syd": 386, "jnb": 561, "ams": 89, "iad": 173 }, { "timestamp": "Feb 4, 09:00", "hkg": 345, "iad": 158, "ams": 98, "jnb": 590, "syd": 596, "gru": 354 }, { "timestamp": "Feb 4, 10:00", "hkg": 365, "gru": 270, "jnb": 608, "syd": 443, "iad": 176, "ams": 84 }, { "timestamp": "Feb 4, 11:00", "gru": 389, "syd": 412, "jnb": 606, "ams": 92, "iad": 217, "hkg": 431 }, { "timestamp": "Feb 4, 12:00", "gru": 372, "iad": 236, "ams": 103, "syd": 574, "jnb": 571, "hkg": 323 }, { "timestamp": "Feb 4, 13:00", "syd": 504, "jnb": 640, "ams": 105, "iad": 250, "gru": 374, "hkg": 303 }, { "timestamp": "Feb 4, 14:00", "hkg": 350, "gru": 296, "ams": 110, "iad": 148, "jnb": 570, "syd": 392 }, { "timestamp": "Feb 4, 15:00", "hkg": 329, "iad": 278, "ams": 126, "syd": 483, "jnb": 588, "gru": 287 }, { "timestamp": "Feb 4, 16:00", "ams": 118, "iad": 180, "syd": 407, "jnb": 576, "gru": 346, "hkg": 332 }, { "timestamp": "Feb 4, 17:00", "jnb": 712, "syd": 434, "ams": 174, "iad": 182, "gru": 378, "hkg": 306 }, { "timestamp": "Feb 4, 18:00", "gru": 410, "jnb": 610, "syd": 442, "ams": 116, "iad": 250, "hkg": 380 }, { "timestamp": "Feb 4, 19:00", "syd": 430, "jnb": 623, "iad": 162, "ams": 230, "gru": 300, "hkg": 330 }, { "timestamp": "Feb 4, 20:00", "ams": 102, "iad": 209, "syd": 441, "jnb": 611, "gru": 278, "hkg": 368 }, { "timestamp": "Feb 4, 21:00", "hkg": 313, "syd": 404, "jnb": 613, "iad": 234, "ams": 89, "gru": 385 }, { "timestamp": "Feb 4, 22:00", "hkg": 444, "gru": 415, "iad": 215, "ams": 189, "syd": 431, "jnb": 575 }, { "timestamp": "Feb 4, 23:00", "jnb": 582, "syd": 426, "iad": 214, "ams": 97, "gru": 360, "hkg": 270 }, { "timestamp": "Feb 5, 00:00", "gru": 309, "jnb": 585, "syd": 524, "ams": 92, "iad": 223, "hkg": 393 }, { "timestamp": "Feb 5, 01:00", "gru": 284, "iad": 185, "ams": 86, "syd": 536, "jnb": 561, "hkg": 273 }, { "timestamp": "Feb 5, 02:00", "syd": 439, "jnb": 558, "iad": 170, "ams": 81, "gru": 310, "hkg": 324 }, { "timestamp": "Feb 5, 03:00", "gru": 354, "ams": 78, "iad": 185, "jnb": 581, "syd": 452, "hkg": 334 }, { "timestamp": "Feb 5, 04:00", "gru": 308, "jnb": 595, "syd": 510, "iad": 214, "ams": 108, "hkg": 325 }, { "timestamp": "Feb 5, 05:00", "ams": 82, "iad": 268, "jnb": 564, "syd": 513, "gru": 356, "hkg": 387 }, { "timestamp": "Feb 5, 06:00", "hkg": 299, "jnb": 568, "syd": 539, "ams": 80, "iad": 227, "gru": 274 }, { "timestamp": "Feb 5, 07:00", "hkg": 324, "gru": 439, "syd": 480, "jnb": 567, "iad": 148, "ams": 82 }, { "timestamp": "Feb 5, 08:00", "jnb": 568, "syd": 528, "ams": 84, "iad": 174, "gru": 380, "hkg": 333 }, { "timestamp": "Feb 5, 09:00", "ams": 85, "iad": 227, "syd": 612, "jnb": 618, "gru": 346, "hkg": 405 }, { "timestamp": "Feb 5, 10:00", "hkg": 360, "syd": 541, "jnb": 564, "ams": 86, "iad": 177, "gru": 352 }, { "timestamp": "Feb 5, 11:00", "hkg": 417, "gru": 435, "jnb": 583, "syd": 468, "iad": 178, "ams": 102 }, { "timestamp": "Feb 5, 12:00", "hkg": 300, "gru": 358, "ams": 105, "iad": 207, "jnb": 564, "syd": 427 }, { "timestamp": "Feb 5, 13:00", "hkg": 370, "iad": 188, "ams": 114, "jnb": 602, "syd": 420, "gru": 292 }, { "timestamp": "Feb 5, 14:00", "hkg": 375, "gru": 439, "jnb": 580, "syd": 590, "iad": 156, "ams": 91 }, { "timestamp": "Feb 5, 15:00", "gru": 374, "syd": 570, "jnb": 587, "ams": 104, "iad": 223, "hkg": 315 }, { "timestamp": "Feb 5, 16:00", "syd": 421, "jnb": 603, "iad": 203, "ams": 94, "gru": 299, "hkg": 305 }, { "timestamp": "Feb 5, 17:00", "hkg": 306, "syd": 495, "jnb": 639, "iad": 260, "ams": 106, "gru": 436 }, { "timestamp": "Feb 5, 18:00", "hkg": 374, "gru": 294, "jnb": 570, "syd": 496, "iad": 228, "ams": 92 }, { "timestamp": "Feb 5, 19:00", "gru": 356, "ams": 114, "iad": 172, "jnb": 577, "syd": 412, "hkg": 367 }, { "timestamp": "Feb 5, 20:00", "ams": 101, "iad": 261, "syd": 438, "jnb": 623, "gru": 411, "hkg": 275 }, { "timestamp": "Feb 5, 21:00", "ams": 96, "iad": 244, "jnb": 596, "syd": 610, "gru": 317, "hkg": 301 }, { "timestamp": "Feb 5, 22:00", "gru": 369, "syd": 466, "jnb": 616, "ams": 89, "iad": 188, "hkg": 388 }, { "timestamp": "Feb 5, 23:00", "iad": 175, "ams": 86, "syd": 541, "jnb": 735, "gru": 376, "hkg": 291 }, { "timestamp": "Feb 6, 00:00", "hkg": 302, "iad": 220, "ams": 90, "jnb": 572, "syd": 429, "gru": 306 }, { "timestamp": "Feb 6, 01:00", "hkg": 286, "jnb": 596, "syd": 443, "ams": 92, "iad": 240, "gru": 360 }, { "timestamp": "Feb 6, 02:00", "gru": 389, "jnb": 568, "syd": 443, "ams": 82, "iad": 271, "hkg": 304 }, { "timestamp": "Feb 6, 03:00", "iad": 167, "ams": 84, "jnb": 582, "syd": 506, "gru": 344, "hkg": 407 }, { "timestamp": "Feb 6, 04:00", "hkg": 331, "iad": 203, "ams": 79, "syd": 552, "jnb": 563, "gru": 300 }, { "timestamp": "Feb 6, 05:00", "hkg": 481, "iad": 242, "ams": 78, "jnb": 610, "syd": 446, "gru": 297 }, { "timestamp": "Feb 6, 06:00", "hkg": 292, "gru": 319, "iad": 149, "ams": 78, "syd": 446, "jnb": 554 }, { "timestamp": "Feb 6, 07:00", "hkg": 286, "ams": 80, "iad": 185, "syd": 458, "jnb": 586, "gru": 279 }, { "timestamp": "Feb 6, 08:00", "hkg": 322, "gru": 326, "ams": 95, "iad": 237, "jnb": 582, "syd": 530 }, { "timestamp": "Feb 6, 09:00", "hkg": 290, "gru": 306, "jnb": 570, "syd": 456, "ams": 97, "iad": 181 }, { "timestamp": "Feb 6, 10:00", "hkg": 360, "syd": 516, "jnb": 575, "ams": 130, "iad": 214, "gru": 280 }, { "timestamp": "Feb 6, 11:00", "hkg": 283, "syd": 400, "jnb": 566, "ams": 108, "iad": 202, "gru": 332 }, { "timestamp": "Feb 6, 12:00", "hkg": 450, "gru": 335, "jnb": 574, "syd": 426, "ams": 101, "iad": 207 }, { "timestamp": "Feb 6, 13:00", "hkg": 396, "gru": 362, "syd": 522, "jnb": 568, "ams": 98, "iad": 197 }, { "timestamp": "Feb 6, 14:00", "gru": 426, "jnb": 571, "syd": 422, "ams": 97, "iad": 216, "hkg": 364 }, { "timestamp": "Feb 6, 15:00", "iad": 277, "ams": 112, "jnb": 578, "syd": 524, "gru": 320, "hkg": 401 }, { "timestamp": "Feb 6, 16:00", "gru": 574, "syd": 424, "jnb": 600, "iad": 258, "ams": 106, "hkg": 586 }, { "timestamp": "Feb 6, 17:00", "gru": 429, "ams": 125, "iad": 249, "syd": 416, "jnb": 615, "hkg": 310 }, { "timestamp": "Feb 6, 18:00", "hkg": 300, "gru": 356, "ams": 148, "iad": 242, "jnb": 596, "syd": 528 }, { "timestamp": "Feb 6, 19:00", "hkg": 287, "jnb": 590, "syd": 416, "iad": 206, "ams": 111, "gru": 282 }, { "timestamp": "Feb 6, 20:00", "gru": 364, "jnb": 584, "syd": 538, "iad": 189, "ams": 116, "hkg": 319 }, { "timestamp": "Feb 6, 21:00", "gru": 314, "syd": 407, "jnb": 595, "iad": 182, "ams": 115, "hkg": 359 }, { "timestamp": "Feb 6, 22:00", "jnb": 604, "syd": 444, "iad": 263, "ams": 115, "gru": 450, "hkg": 276 }, { "timestamp": "Feb 6, 23:00", "hkg": 459, "ams": 90, "iad": 216, "jnb": 603, "syd": 455, "gru": 371 }, { "timestamp": "Feb 7, 00:00", "hkg": 288, "gru": 335, "ams": 82, "iad": 254, "syd": 618, "jnb": 567 }, { "timestamp": "Feb 7, 01:00", "hkg": 316, "jnb": 603, "syd": 429, "iad": 150, "ams": 84, "gru": 571 }, { "timestamp": "Feb 7, 02:00", "hkg": 3871, "gru": 3649, "jnb": 3905, "syd": 3757, "ams": 3588, "iad": 3716 }, { "timestamp": "Feb 7, 03:00", "gru": 371, "syd": 411, "jnb": 595, "iad": 221, "ams": 84, "hkg": 369 }, { "timestamp": "Feb 7, 04:00", "iad": 228, "ams": 83, "syd": 440, "jnb": 598, "gru": 320, "hkg": 404 }, { "timestamp": "Feb 7, 05:00", "hkg": 5563, "jnb": 5587, "syd": 5626, "iad": 5454, "ams": 5370, "gru": 5528 }, { "timestamp": "Feb 7, 06:00", "hkg": 466, "ams": 81, "iad": 206, "jnb": 553, "syd": 616, "gru": 338 }, { "timestamp": "Feb 7, 07:00", "hkg": 441, "gru": 389, "iad": 238, "ams": 88, "syd": 430, "jnb": 620 }, { "timestamp": "Feb 7, 08:00", "gru": 314, "syd": 608, "jnb": 572, "iad": 204, "ams": 86, "hkg": 459 }, { "timestamp": "Feb 7, 09:00", "gru": 401, "iad": 202, "ams": 101, "jnb": 561, "syd": 406, "hkg": 488 }, { "timestamp": "Feb 7, 10:00", "jnb": 631, "syd": 504, "ams": 99, "iad": 248, "gru": 301, "hkg": 324 }, { "timestamp": "Feb 7, 11:00", "gru": 307, "syd": 477, "jnb": 608, "iad": 216, "ams": 249, "hkg": 530 }, { "timestamp": "Feb 7, 12:00", "hkg": 309, "gru": 335, "iad": 180, "ams": 92, "syd": 856, "jnb": 567 }, { "timestamp": "Feb 7, 13:00", "hkg": 346, "syd": 527, "jnb": 572, "ams": 82, "iad": 296, "gru": 348 }, { "timestamp": "Feb 7, 14:00", "hkg": 300, "iad": 262, "ams": 107, "syd": 536, "jnb": 616, "gru": 453 }, { "timestamp": "Feb 7, 15:00", "gru": 432, "ams": 99, "iad": 222, "syd": 500, "jnb": 593, "hkg": 347 }, { "timestamp": "Feb 7, 16:00", "syd": 433, "jnb": 595, "ams": 91, "iad": 289, "gru": 295, "hkg": 382 }, { "timestamp": "Feb 7, 17:00", "iad": 204, "ams": 117, "jnb": 591, "syd": 437, "gru": 399, "hkg": 285 }, { "timestamp": "Feb 7, 18:00", "gru": 324, "ams": 138, "iad": 224, "jnb": 584, "syd": 525, "hkg": 338 }, { "timestamp": "Feb 7, 19:00", "syd": 508, "jnb": 594, "iad": 252, "ams": 114, "gru": 452, "hkg": 302 }, { "timestamp": "Feb 7, 20:00", "gru": 371, "iad": 290, "ams": 112, "syd": 445, "jnb": 614, "hkg": 284 }, { "timestamp": "Feb 7, 21:00", "hkg": 366, "gru": 367, "syd": 614, "jnb": 603, "ams": 96, "iad": 202 }, { "timestamp": "Feb 7, 22:00", "hkg": 342, "gru": 375, "iad": 260, "ams": 104, "syd": 430, "jnb": 707 }, { "timestamp": "Feb 7, 23:00", "hkg": 341, "ams": 85, "iad": 225, "jnb": 616, "syd": 444, "gru": 422 }, { "timestamp": "Feb 8, 00:00", "hkg": 302, "syd": 442, "jnb": 592, "ams": 92, "iad": 245, "gru": 313 }, { "timestamp": "Feb 8, 01:00", "hkg": 383, "ams": 176, "iad": 267, "jnb": 678, "syd": 455, "gru": 398 }, { "timestamp": "Feb 8, 02:00", "iad": 1828, "ams": 1791, "jnb": 2230, "syd": 2200, "gru": 2043, "hkg": 2009 }, { "timestamp": "Feb 8, 03:00", "gru": 300, "ams": 110, "iad": 279, "syd": 399, "jnb": 589, "hkg": 421 }, { "timestamp": "Feb 8, 04:00", "ams": 79, "iad": 190, "syd": 430, "jnb": 574, "gru": 362, "hkg": 310 }, { "timestamp": "Feb 8, 05:00", "gru": 408, "syd": 409, "jnb": 588, "iad": 266, "ams": 77, "hkg": 368 }, { "timestamp": "Feb 8, 06:00", "gru": 288, "ams": 78, "iad": 195, "syd": 2958, "jnb": 3148, "hkg": 2840 }, { "timestamp": "Feb 8, 07:00", "hkg": 324, "gru": 356, "iad": 246, "ams": 84, "jnb": 589, "syd": 507 }, { "timestamp": "Feb 8, 08:00", "hkg": 383, "jnb": 565, "syd": 423, "iad": 274, "ams": 86, "gru": 320 }, { "timestamp": "Feb 8, 09:00", "gru": 374, "jnb": 574, "syd": 430, "ams": 83, "iad": 236, "hkg": 344 }, { "timestamp": "Feb 8, 10:00", "syd": 566, "jnb": 572, "ams": 101, "iad": 254, "gru": 366, "hkg": 294 }, { "timestamp": "Feb 8, 11:00", "hkg": 338, "syd": 460, "jnb": 570, "ams": 88, "iad": 251, "gru": 363 }, { "timestamp": "Feb 8, 12:00", "hkg": 285, "gru": 275, "jnb": 594, "syd": 478, "ams": 89, "iad": 205 }, { "timestamp": "Feb 8, 13:00", "hkg": 312, "iad": 268, "ams": 84, "syd": 526, "jnb": 626, "gru": 293 }, { "timestamp": "Feb 8, 14:00", "hkg": 321, "gru": 399, "iad": 204, "ams": 116, "jnb": 600, "syd": 604 }, { "timestamp": "Feb 8, 15:00", "hkg": 322, "gru": 318, "jnb": 577, "syd": 438, "iad": 270, "ams": 102 }, { "timestamp": "Feb 8, 16:00", "gru": 318, "syd": 434, "jnb": 596, "iad": 251, "ams": 110, "hkg": 310 }, { "timestamp": "Feb 8, 17:00", "gru": 404, "syd": 376, "jnb": 602, "iad": 201, "ams": 94, "hkg": 396 }, { "timestamp": "Feb 8, 18:00", "hkg": 344, "gru": 313, "syd": 442, "jnb": 573, "ams": 119, "iad": 284 }, { "timestamp": "Feb 8, 19:00", "hkg": 426, "syd": 431, "jnb": 572, "ams": 108, "iad": 219, "gru": 305 }, { "timestamp": "Feb 8, 20:00", "jnb": 591, "syd": 478, "ams": 102, "iad": 210, "gru": 360, "hkg": 294 }, { "timestamp": "Feb 8, 21:00", "gru": 335, "syd": 466, "jnb": 595, "iad": 250, "ams": 104, "hkg": 298 }, { "timestamp": "Feb 8, 22:00", "iad": 285, "ams": 93, "jnb": 574, "syd": 354, "gru": 390, "hkg": 441 }, { "timestamp": "Feb 8, 23:00", "jnb": 557, "syd": 414, "iad": 246, "ams": 99, "gru": 334, "hkg": 374 }, { "timestamp": "Feb 9, 00:00", "gru": 340, "syd": 312, "jnb": 593, "iad": 314, "ams": 88, "hkg": 291 }, { "timestamp": "Feb 9, 01:00", "gru": 292, "syd": 351, "jnb": 558, "ams": 79, "iad": 221, "hkg": 360 }, { "timestamp": "Feb 9, 02:00", "jnb": 560, "syd": 355, "ams": 90, "iad": 220, "gru": 410, "hkg": 288 }, { "timestamp": "Feb 9, 03:00", "hkg": 284, "gru": 363, "syd": 368, "jnb": 555, "ams": 85, "iad": 290 }, { "timestamp": "Feb 9, 04:00", "hkg": 457, "jnb": 609, "syd": 356, "ams": 79, "iad": 226, "gru": 390 }, { "timestamp": "Feb 9, 05:00", "syd": 342, "jnb": 583, "iad": 216, "ams": 86, "gru": 384, "hkg": 293 }, { "timestamp": "Feb 9, 06:00", "gru": 286, "jnb": 562, "syd": 383, "iad": 181, "ams": 72, "hkg": 387 }, { "timestamp": "Feb 9, 07:00", "gru": 372, "iad": 207, "ams": 89, "jnb": 582, "syd": 417, "hkg": 318 }, { "timestamp": "Feb 9, 08:00", "syd": 472, "jnb": 559, "iad": 200, "ams": 91, "gru": 334, "hkg": 340 }, { "timestamp": "Feb 9, 09:00", "syd": 464, "jnb": 560, "iad": 280, "ams": 83, "gru": 333, "hkg": 352 }, { "timestamp": "Feb 9, 10:00", "hkg": 320, "jnb": 557, "syd": 346, "iad": 245, "ams": 80, "gru": 411 }, { "timestamp": "Feb 9, 11:00", "hkg": 309, "gru": 357, "jnb": 564, "syd": 374, "iad": 187, "ams": 97 }, { "timestamp": "Feb 9, 12:00", "hkg": 386, "gru": 338, "ams": 83, "iad": 149, "syd": 396, "jnb": 654 }, { "timestamp": "Feb 9, 13:00", "hkg": 388, "iad": 246, "ams": 82, "jnb": 639, "syd": 431, "gru": 358 }, { "timestamp": "Feb 9, 14:00", "iad": 218, "ams": 102, "syd": 378, "jnb": 621, "gru": 313, "hkg": 387 }, { "timestamp": "Feb 9, 15:00", "syd": 379, "jnb": 595, "iad": 224, "ams": 92, "gru": 332, "hkg": 304 }, { "timestamp": "Feb 9, 16:00", "gru": 268, "jnb": 632, "syd": 491, "iad": 253, "ams": 86, "hkg": 299 }, { "timestamp": "Feb 9, 17:00", "ams": 92, "iad": 275, "syd": 394, "jnb": 599, "gru": 261, "hkg": 462 }, { "timestamp": "Feb 9, 18:00", "gru": 330, "syd": 366, "jnb": 622, "ams": 90, "iad": 214, "hkg": 386 }, { "timestamp": "Feb 9, 19:00", "hkg": 350, "gru": 366, "jnb": 657, "syd": 352, "iad": 248, "ams": 91 }, { "timestamp": "Feb 9, 20:00", "hkg": 420, "jnb": 572, "syd": 335, "ams": 89, "iad": 324, "gru": 362 }, { "timestamp": "Feb 9, 21:00", "gru": 348, "iad": 196, "ams": 101, "syd": 433, "jnb": 574, "hkg": 452 }, { "timestamp": "Feb 9, 22:00", "syd": 311, "jnb": 571, "iad": 232, "ams": 73, "gru": 311, "hkg": 384 }, { "timestamp": "Feb 9, 23:00", "hkg": 393, "jnb": 579, "syd": 334, "ams": 83, "iad": 185, "gru": 280 }, { "timestamp": "Feb 10, 00:00", "hkg": 338, "iad": 280, "ams": 80, "jnb": 566, "syd": 451, "gru": 424 }, { "timestamp": "Feb 10, 01:00", "hkg": 284, "syd": 431, "jnb": 560, "iad": 209, "ams": 76, "gru": 291 }, { "timestamp": "Feb 10, 02:00", "hkg": 2876, "gru": 5579, "syd": 371, "jnb": 3130, "iad": 215, "ams": 75 }, { "timestamp": "Feb 10, 03:00", "hkg": 424, "jnb": 559, "syd": 329, "ams": 74, "iad": 152, "gru": 650 }, { "timestamp": "Feb 10, 04:00", "jnb": 609, "syd": 372, "iad": 185, "ams": 78, "gru": 295, "hkg": 284 }, { "timestamp": "Feb 10, 05:00", "gru": 417, "ams": 76, "iad": 161, "jnb": 605, "syd": 578, "hkg": 349 }, { "timestamp": "Feb 10, 06:00", "hkg": 330, "gru": 312, "syd": 449, "jnb": 602, "iad": 182, "ams": 76 }, { "timestamp": "Feb 10, 07:00", "hkg": 352, "jnb": 593, "syd": 330, "ams": 70, "iad": 158, "gru": 303 }, { "timestamp": "Feb 10, 08:00", "hkg": 368, "iad": 166, "ams": 77, "jnb": 558, "syd": 520, "gru": 332 }, { "timestamp": "Feb 10, 09:00", "hkg": 373, "syd": 389, "jnb": 587, "ams": 76, "iad": 181, "gru": 421 }, { "timestamp": "Feb 10, 10:00", "hkg": 366, "gru": 322, "ams": 86, "iad": 176, "syd": 408, "jnb": 562 }, { "timestamp": "Feb 10, 11:00", "hkg": 350, "jnb": 563, "syd": 348, "iad": 184, "ams": 80, "gru": 357 }, { "timestamp": "Feb 10, 12:00", "hkg": 394, "gru": 414, "jnb": 608, "syd": 472, "ams": 83, "iad": 153 }, { "timestamp": "Feb 10, 13:00", "gru": 307, "iad": 152, "ams": 91, "jnb": 634, "syd": 435, "hkg": 299 }, { "timestamp": "Feb 10, 14:00", "jnb": 569, "syd": 364, "iad": 214, "ams": 85, "gru": 328, "hkg": 374 }, { "timestamp": "Feb 10, 15:00", "hkg": 396, "gru": 288, "jnb": 581, "syd": 372, "ams": 76, "iad": 180 }, { "timestamp": "Feb 10, 16:00", "hkg": 308, "gru": 311, "iad": 159, "ams": 86, "jnb": 578, "syd": 408 }, { "timestamp": "Feb 10, 17:00", "hkg": 405, "gru": 260, "syd": 445, "jnb": 573, "iad": 164, "ams": 91 }, { "timestamp": "Feb 10, 18:00", "gru": 312, "iad": 226, "ams": 100, "syd": 325, "jnb": 578, "hkg": 311 }, { "timestamp": "Feb 10, 19:00", "ams": 93, "iad": 155, "jnb": 591, "syd": 548, "gru": 419, "hkg": 289 }, { "timestamp": "Feb 10, 20:00", "gru": 290, "jnb": 621, "syd": 368, "iad": 193, "ams": 93, "hkg": 276 }, { "timestamp": "Feb 10, 21:00", "ams": 88, "iad": 186, "jnb": 570, "syd": 355, "gru": 380, "hkg": 429 }, { "timestamp": "Feb 10, 22:00", "hkg": 453, "jnb": 558, "syd": 526, "ams": 78, "iad": 189, "gru": 402 }, { "timestamp": "Feb 10, 23:00", "hkg": 282, "gru": 338, "syd": 357, "jnb": 580, "iad": 184, "ams": 74 }, { "timestamp": "Feb 11, 00:00", "hkg": 303, "gru": 467, "ams": 74, "iad": 194, "syd": 449, "jnb": 583 }, { "timestamp": "Feb 11, 01:00", "hkg": 386, "jnb": 555, "syd": 349, "ams": 70, "iad": 159, "gru": 310 }, { "timestamp": "Feb 11, 02:00", "hkg": 323, "gru": 306, "syd": 385, "jnb": 556, "ams": 88, "iad": 184 }, { "timestamp": "Feb 11, 03:00", "gru": 310, "iad": 180, "ams": 123, "syd": 397, "jnb": 607, "hkg": 440 }, { "timestamp": "Feb 11, 04:00", "iad": 240, "ams": 84, "jnb": 554, "syd": 433, "gru": 373, "hkg": 364 }, { "timestamp": "Feb 11, 05:00", "gru": 271, "iad": 156, "ams": 91, "syd": 373, "jnb": 587, "hkg": 298 }, { "timestamp": "Feb 11, 06:00", "iad": 178, "ams": 101, "jnb": 582, "syd": 482, "gru": 674, "hkg": 323 }, { "timestamp": "Feb 11, 07:00", "hkg": 399, "jnb": 659, "syd": 371, "ams": 115, "iad": 210, "gru": 285 }, { "timestamp": "Feb 11, 08:00", "hkg": 426, "gru": 322, "syd": 464, "jnb": 585, "ams": 89, "iad": 146 }, { "timestamp": "Feb 11, 09:00", "hkg": 347, "syd": 357, "jnb": 567, "iad": 180, "ams": 76, "gru": 290 }, { "timestamp": "Feb 11, 10:00", "gru": 390, "syd": 383, "jnb": 567, "iad": 178, "ams": 83, "hkg": 341 }, { "timestamp": "Feb 11, 11:00", "ams": 81, "iad": 186, "syd": 335, "jnb": 598, "gru": 284, "hkg": 516 }, { "timestamp": "Feb 11, 12:00", "hkg": 324, "ams": 166, "iad": 214, "jnb": 595, "syd": 408, "gru": 435 }, { "timestamp": "Feb 11, 13:00", "hkg": 273, "gru": 408, "syd": 366, "jnb": 562, "ams": 96, "iad": 146 }, { "timestamp": "Feb 11, 14:00", "hkg": 355, "ams": 89, "iad": 189, "jnb": 565, "syd": 417, "gru": 273 }, { "timestamp": "Feb 11, 15:00", "hkg": 383, "gru": 389, "jnb": 570, "syd": 342, "iad": 182, "ams": 84 }, { "timestamp": "Feb 11, 16:00", "gru": 474, "syd": 355, "jnb": 591, "iad": 211, "ams": 107, "hkg": 328 }, { "timestamp": "Feb 11, 17:00", "hkg": 414, "gru": 392, "syd": 376, "jnb": 566, "iad": 162, "ams": 105 }, { "timestamp": "Feb 11, 18:00", "hkg": 358, "jnb": 596, "syd": 446, "iad": 215, "ams": 95, "gru": 306 }, { "timestamp": "Feb 11, 19:00", "iad": 156, "ams": 94, "syd": 446, "jnb": 623, "gru": 390, "hkg": 323 }, { "timestamp": "Feb 11, 20:00", "gru": 430, "iad": 160, "ams": 102, "jnb": 597, "syd": 568, "hkg": 2969 }, { "timestamp": "Feb 11, 21:00", "hkg": 382, "gru": 374, "syd": 325, "jnb": 568, "iad": 186, "ams": 132 }, { "timestamp": "Feb 11, 22:00", "hkg": 297, "jnb": 562, "syd": 402, "iad": 185, "ams": 138, "gru": 309 }, { "timestamp": "Feb 11, 23:00", "hkg": 429, "gru": 308, "jnb": 575, "syd": 476, "ams": 120, "iad": 152 }, { "timestamp": "Feb 12, 00:00", "hkg": 370, "syd": 353, "jnb": 588, "ams": 151, "iad": 157, "gru": 434 }, { "timestamp": "Feb 12, 01:00", "hkg": 352, "gru": 366, "syd": 367, "jnb": 562, "iad": 156, "ams": 82 }, { "timestamp": "Feb 12, 02:00", "gru": 315, "jnb": 589, "syd": 438, "iad": 194, "ams": 78, "hkg": 309 }, { "timestamp": "Feb 12, 03:00", "ams": 88, "iad": 154, "jnb": 550, "syd": 428, "gru": 327, "hkg": 298 }, { "timestamp": "Feb 12, 04:00", "hkg": 326, "gru": 403, "ams": 102, "iad": 148, "jnb": 585, "syd": 377 }, { "timestamp": "Feb 12, 05:00", "hkg": 300, "syd": 359, "jnb": 550, "ams": 115, "iad": 159, "gru": 391 }, { "timestamp": "Feb 12, 06:00", "jnb": 574, "syd": 358, "ams": 119, "iad": 146, "gru": 356, "hkg": 342 }, { "timestamp": "Feb 12, 07:00", "gru": 280, "iad": 178, "ams": 82, "jnb": 564, "syd": 507, "hkg": 322 }, { "timestamp": "Feb 12, 08:00", "syd": 382, "jnb": 564, "iad": 178, "ams": 98, "gru": 375, "hkg": 383 }, { "timestamp": "Feb 12, 09:00", "gru": 286, "jnb": 561, "syd": 396, "ams": 79, "iad": 149, "hkg": 289 }, { "timestamp": "Feb 12, 10:00", "gru": 336, "iad": 147, "ams": 87, "jnb": 614, "syd": 528, "hkg": 279 }, { "timestamp": "Feb 12, 11:00", "hkg": 430, "gru": 308, "ams": 92, "iad": 181, "syd": 365, "jnb": 565 }, { "timestamp": "Feb 12, 12:00", "hkg": 369, "syd": 438, "jnb": 588, "ams": 101, "iad": 147, "gru": 340 }, { "timestamp": "Feb 12, 13:00", "hkg": 389, "syd": 467, "jnb": 601, "ams": 107, "iad": 149, "gru": 371 }, { "timestamp": "Feb 12, 14:00", "hkg": 392, "gru": 2931, "syd": 432, "jnb": 596, "iad": 2778, "ams": 140 }, { "timestamp": "Feb 12, 15:00", "gru": 288, "jnb": 642, "syd": 388, "ams": 145, "iad": 157, "hkg": 400 }, { "timestamp": "Feb 12, 16:00", "ams": 120, "iad": 238, "jnb": 598, "syd": 389, "gru": 377, "hkg": 422 }, { "timestamp": "Feb 12, 17:00", "ams": 109, "iad": 176, "syd": 389, "jnb": 618, "gru": 392, "hkg": 319 }, { "timestamp": "Feb 12, 18:00", "syd": 536, "jnb": 604, "iad": 170, "ams": 87, "gru": 332, "hkg": 308 }, { "timestamp": "Feb 12, 19:00", "gru": 379, "jnb": 612, "syd": 374, "ams": 95, "iad": 168, "hkg": 386 }, { "timestamp": "Feb 12, 20:00", "hkg": 361, "gru": 452, "ams": 112, "iad": 158, "jnb": 576, "syd": 470 }, { "timestamp": "Feb 12, 21:00", "hkg": 484, "jnb": 570, "syd": 364, "iad": 191, "ams": 94, "gru": 379 }, { "timestamp": "Feb 12, 22:00", "gru": 298, "syd": 479, "jnb": 642, "ams": 94, "iad": 188, "hkg": 286 }, { "timestamp": "Feb 12, 23:00", "jnb": 584, "syd": 401, "iad": 184, "ams": 84, "gru": 358, "hkg": 316 }, { "timestamp": "Feb 13, 00:00", "hkg": 437, "iad": 155, "ams": 77, "jnb": 578, "syd": 445, "gru": 430 }, { "timestamp": "Feb 13, 01:00", "gru": 294, "syd": 394, "jnb": 579, "ams": 96, "iad": 160, "hkg": 294 }, { "timestamp": "Feb 13, 02:00", "gru": 289, "iad": 158, "ams": 82, "syd": 404, "jnb": 601, "hkg": 318 }, { "timestamp": "Feb 13, 03:00", "jnb": 558, "syd": 362, "ams": 82, "iad": 163, "gru": 304, "hkg": 321 }, { "timestamp": "Feb 13, 04:00", "gru": 462, "ams": 87, "iad": 153, "jnb": 582, "syd": 585, "hkg": 264 }, { "timestamp": "Feb 13, 05:00", "hkg": 348, "gru": 364, "jnb": 567, "syd": 463, "iad": 200, "ams": 124 }, { "timestamp": "Feb 13, 06:00", "hkg": 426, "jnb": 580, "syd": 501, "ams": 88, "iad": 142, "gru": 453 }, { "timestamp": "Feb 13, 07:00", "jnb": 586, "syd": 483, "ams": 78, "iad": 179, "gru": 351, "hkg": 401 }, { "timestamp": "Feb 13, 08:00", "gru": 295, "ams": 92, "iad": 176, "jnb": 563, "syd": 435, "hkg": 344 }, { "timestamp": "Feb 13, 09:00", "hkg": 377, "syd": 403, "jnb": 578, "ams": 97, "iad": 148, "gru": 562 }, { "timestamp": "Feb 13, 10:00", "hkg": 307, "iad": 147, "ams": 94, "syd": 392, "jnb": 565, "gru": 354 }, { "timestamp": "Feb 13, 11:00", "hkg": 402, "gru": 386, "ams": 129, "iad": 182, "jnb": 567, "syd": 552 }, { "timestamp": "Feb 13, 12:00", "hkg": 413, "ams": 111, "iad": 148, "jnb": 620, "syd": 479, "gru": 341 }, { "timestamp": "Feb 13, 13:00", "hkg": 342, "gru": 376, "jnb": 603, "syd": 407, "iad": 187, "ams": 93 }, { "timestamp": "Feb 13, 14:00", "gru": 414, "jnb": 691, "syd": 400, "ams": 106, "iad": 181, "hkg": 297 }, { "timestamp": "Feb 13, 15:00", "syd": 331, "jnb": 585, "iad": 232, "ams": 138, "gru": 376, "hkg": 403 }, { "timestamp": "Feb 13, 16:00", "hkg": 6906, "gru": 6822, "iad": 6801, "ams": 6745, "jnb": 7075, "syd": 6918 }, { "timestamp": "Feb 13, 17:00", "hkg": 342, "ams": 122, "iad": 166, "syd": 413, "jnb": 777, "gru": 439 }, { "timestamp": "Feb 13, 18:00", "ams": 118, "iad": 192, "jnb": 630, "syd": 359, "gru": 342, "hkg": 328 }, { "timestamp": "Feb 13, 19:00", "jnb": 612, "syd": 414, "ams": 126, "iad": 227, "gru": 279, "hkg": 302 }, { "timestamp": "Feb 13, 20:00", "gru": 323, "syd": 426, "jnb": 627, "ams": 234, "iad": 208, "hkg": 300 }, { "timestamp": "Feb 13, 21:00", "iad": 161, "ams": 107, "jnb": 1070, "syd": 494, "gru": 391, "hkg": 373 }, { "timestamp": "Feb 13, 22:00", "gru": 351, "iad": 167, "ams": 117, "syd": 337, "jnb": 709, "hkg": 328 }, { "timestamp": "Feb 13, 23:00", "hkg": 352, "gru": 298, "iad": 163, "ams": 86, "syd": 452, "jnb": 655 }, { "timestamp": "Feb 14, 00:00", "hkg": 319, "iad": 151, "ams": 89, "jnb": 571, "syd": 351, "gru": 299 }, { "timestamp": "Feb 14, 01:00", "hkg": 438, "gru": 394, "iad": 159, "ams": 84, "syd": 373, "jnb": 632 }, { "timestamp": "Feb 14, 02:00", "hkg": 397, "iad": 170, "ams": 93, "jnb": 573, "syd": 350, "gru": 326 }, { "timestamp": "Feb 14, 03:00", "hkg": 441, "jnb": 568, "syd": 472, "iad": 196, "ams": 74, "gru": 377 }, { "timestamp": "Feb 14, 04:00", "syd": 436, "jnb": 557, "iad": 161, "ams": 79, "gru": 332, "hkg": 301 }, { "timestamp": "Feb 14, 05:00", "gru": 4516, "jnb": 4770, "syd": 4708, "ams": 4297, "iad": 4353, "hkg": 4479 }, { "timestamp": "Feb 14, 06:00", "ams": 85, "iad": 180, "syd": 398, "jnb": 585, "gru": 350, "hkg": 370 }, { "timestamp": "Feb 14, 07:00", "gru": 329, "ams": 78, "iad": 147, "syd": 541, "jnb": 593, "hkg": 290 }, { "timestamp": "Feb 14, 08:00", "hkg": 330, "gru": 341, "ams": 84, "iad": 152, "jnb": 574, "syd": 416 }, { "timestamp": "Feb 14, 09:00", "iad": 189, "ams": 95, "jnb": 593, "syd": 415, "gru": 289, "hkg": 349 }, { "timestamp": "Feb 14, 10:00", "hkg": 343, "gru": 391, "iad": 150, "ams": 92, "jnb": 605, "syd": 340 }, { "timestamp": "Feb 14, 11:00", "hkg": 359, "gru": 289, "jnb": 663, "syd": 382, "iad": 182, "ams": 87 }, { "timestamp": "Feb 14, 12:00", "hkg": 386, "syd": 434, "jnb": 606, "iad": 219, "ams": 100, "gru": 394 }, { "timestamp": "Feb 14, 13:00", "jnb": 646, "syd": 460, "ams": 80, "iad": 150, "gru": 277, "hkg": 294 }, { "timestamp": "Feb 14, 14:00", "gru": 362, "syd": 436, "jnb": 667, "ams": 101, "iad": 195, "hkg": 454 }, { "timestamp": "Feb 14, 15:00", "ams": 87, "iad": 205, "syd": 434, "jnb": 632, "gru": 360, "hkg": 320 }, { "timestamp": "Feb 14, 16:00", "gru": 290, "ams": 92, "iad": 164, "jnb": 576, "syd": 326, "hkg": 313 }, { "timestamp": "Feb 14, 17:00", "hkg": 292, "ams": 88, "iad": 153, "jnb": 583, "syd": 329, "gru": 349 }, { "timestamp": "Feb 14, 18:00", "hkg": 527, "gru": 415, "ams": 88, "iad": 213, "syd": 496, "jnb": 636 }, { "timestamp": "Feb 14, 19:00", "hkg": 322, "gru": 384, "syd": 468, "jnb": 589, "ams": 89, "iad": 160 }, { "timestamp": "Feb 14, 20:00", "gru": 356, "syd": 355, "jnb": 599, "iad": 214, "ams": 97, "hkg": 304 }, { "timestamp": "Feb 14, 21:00", "jnb": 703, "syd": 421, "ams": 86, "iad": 192, "gru": 337, "hkg": 440 }, { "timestamp": "Feb 14, 22:00", "hkg": 308, "syd": 446, "jnb": 572, "ams": 96, "iad": 161, "gru": 352 }, { "timestamp": "Feb 14, 23:00", "hkg": 307, "gru": 306, "syd": 316, "jnb": 588, "ams": 87, "iad": 188 }, { "timestamp": "Feb 15, 00:00", "hkg": 355, "ams": 78, "iad": 225, "jnb": 557, "syd": 428, "gru": 397 }, { "timestamp": "Feb 15, 01:00", "hkg": 392, "gru": 366, "iad": 184, "ams": 78, "syd": 450, "jnb": 546 }, { "timestamp": "Feb 15, 02:00", "gru": 286, "syd": 441, "jnb": 589, "iad": 159, "ams": 72, "hkg": 317 }, { "timestamp": "Feb 15, 03:00", "jnb": 558, "syd": 377, "ams": 77, "iad": 189, "gru": 356, "hkg": 310 }, { "timestamp": "Feb 15, 04:00", "iad": 167, "ams": 70, "jnb": 584, "syd": 502, "gru": 327, "hkg": 320 }, { "timestamp": "Feb 15, 05:00", "hkg": 364, "gru": 287, "syd": 346, "jnb": 558, "ams": 76, "iad": 180 }, { "timestamp": "Feb 15, 06:00", "hkg": 410, "ams": 72, "iad": 220, "syd": 344, "jnb": 609, "gru": 307 }, { "timestamp": "Feb 15, 07:00", "iad": 151, "ams": 91, "jnb": 554, "syd": 614, "gru": 492, "hkg": 382 }, { "timestamp": "Feb 15, 08:00", "gru": 368, "ams": 85, "iad": 172, "jnb": 578, "syd": 382, "hkg": 324 }, { "timestamp": "Feb 15, 09:00", "gru": 588, "ams": 85, "iad": 177, "syd": 440, "jnb": 565, "hkg": 387 }, { "timestamp": "Feb 15, 10:00", "syd": 463, "jnb": 598, "ams": 95, "iad": 153, "gru": 304, "hkg": 374 }, { "timestamp": "Feb 15, 11:00", "hkg": 384, "jnb": 608, "syd": 435, "iad": 218, "ams": 90, "gru": 406 }, { "timestamp": "Feb 15, 12:00", "hkg": 358, "ams": 105, "iad": 185, "jnb": 570, "syd": 373, "gru": 348 }, { "timestamp": "Feb 15, 13:00", "hkg": 287, "gru": 366, "jnb": 618, "syd": 414, "iad": 152, "ams": 85 }, { "timestamp": "Feb 15, 14:00", "hkg": 321, "gru": 289, "jnb": 600, "syd": 480, "ams": 84, "iad": 165 }, { "timestamp": "Feb 15, 15:00", "hkg": 369, "syd": 467, "jnb": 576, "iad": 205, "ams": 91, "gru": 299 }, { "timestamp": "Feb 15, 16:00", "syd": 572, "jnb": 770, "ams": 94, "iad": 264, "gru": 378, "hkg": 480 }, { "timestamp": "Feb 17, 10:00", "hkg": 31423, "iad": 31244, "ams": 31251, "syd": 31331, "jnb": 31573, "gru": 31387 }, { "timestamp": "Feb 17, 11:00", "gru": 375, "jnb": 565, "syd": 464, "ams": 108, "iad": 192, "hkg": 363 }, { "timestamp": "Feb 17, 12:00", "syd": 441, "jnb": 586, "ams": 80, "iad": 154, "gru": 321, "hkg": 398 }, { "timestamp": "Feb 17, 13:00", "jnb": 565, "syd": 368, "ams": 89, "iad": 192, "gru": 381, "hkg": 266 }, { "timestamp": "Feb 17, 14:00", "hkg": 297, "syd": 375, "jnb": 575, "ams": 81, "iad": 153, "gru": 345 }, { "timestamp": "Feb 17, 15:00", "hkg": 325, "gru": 380, "iad": 220, "ams": 78, "syd": 347, "jnb": 594 }, { "timestamp": "Feb 17, 16:00", "hkg": 328, "jnb": 608, "syd": 487, "iad": 183, "ams": 83, "gru": 292 }, { "timestamp": "Feb 17, 17:00", "hkg": 290, "gru": 371, "syd": 465, "jnb": 574, "ams": 95, "iad": 163 }, { "timestamp": "Feb 17, 18:00", "gru": 285, "syd": 420, "jnb": 578, "iad": 182, "ams": 106, "hkg": 403 }, { "timestamp": "Feb 17, 19:00", "jnb": 647, "syd": 310, "ams": 111, "iad": 159, "gru": 296, "hkg": 306 }, { "timestamp": "Feb 17, 20:00", "hkg": 364, "iad": 161, "ams": 85, "syd": 482, "jnb": 644, "gru": 292 }, { "timestamp": "Feb 17, 21:00", "hkg": 341, "gru": 404, "syd": 438, "jnb": 562, "ams": 85, "iad": 160 }, { "timestamp": "Feb 17, 22:00", "hkg": 312, "gru": 354, "iad": 160, "ams": 81, "syd": 447, "jnb": 587 }, { "timestamp": "Feb 17, 23:00", "gru": 376, "ams": 79, "iad": 156, "jnb": 584, "syd": 452, "hkg": 504 }, { "timestamp": "Feb 18, 00:00", "jnb": 628, "syd": 495, "ams": 76, "iad": 161, "gru": 310, "hkg": 401 }, { "timestamp": "Feb 18, 01:00", "syd": 481, "jnb": 589, "ams": 78, "iad": 188, "gru": 365, "hkg": 297 }, { "timestamp": "Feb 18, 02:00", "gru": 437, "syd": 519, "jnb": 558, "iad": 154, "ams": 74, "hkg": 363 }, { "timestamp": "Feb 18, 03:00", "hkg": 289, "gru": 393, "jnb": 608, "syd": 460, "ams": 77, "iad": 150 }, { "timestamp": "Feb 18, 04:00", "hkg": 302, "ams": 74, "iad": 152, "jnb": 566, "syd": 370, "gru": 323 }, { "timestamp": "Feb 18, 05:00", "gru": 361, "syd": 335, "jnb": 582, "iad": 186, "ams": 76, "hkg": 338 }, { "timestamp": "Feb 18, 06:00", "gru": 485, "ams": 80, "iad": 185, "syd": 444, "jnb": 554, "hkg": 320 }, { "timestamp": "Feb 18, 07:00", "iad": 150, "ams": 83, "jnb": 563, "syd": 346, "gru": 410, "hkg": 423 }, { "timestamp": "Feb 18, 08:00", "hkg": 303, "jnb": 559, "syd": 416, "iad": 147, "ams": 94, "gru": 365 }, { "timestamp": "Feb 18, 09:00", "gru": 310, "ams": 84, "iad": 149, "syd": 449, "jnb": 564, "hkg": 402 }, { "timestamp": "Feb 18, 10:00", "syd": 508, "jnb": 570, "iad": 177, "ams": 97, "gru": 317, "hkg": 380 }, { "timestamp": "Feb 18, 11:00", "gru": 2272, "jnb": 2497, "syd": 2356, "ams": 2135, "iad": 2157, "hkg": 2367 }, { "timestamp": "Feb 18, 12:00", "hkg": 317, "gru": 504, "ams": 117, "iad": 151, "jnb": 621, "syd": 330 }, { "timestamp": "Feb 18, 13:00", "hkg": 20398, "jnb": 20505, "syd": 20479, "iad": 20369, "ams": 20298, "gru": 20304 }, { "timestamp": "Feb 18, 14:00", "hkg": 321, "ams": 101, "iad": 181, "jnb": 603, "syd": 373, "gru": 312 }, { "timestamp": "Feb 18, 15:00", "hkg": 321, "syd": 578, "jnb": 587, "ams": 97, "iad": 150, "gru": 491 }, { "timestamp": "Feb 18, 16:00", "hkg": 314, "gru": 313, "ams": 101, "iad": 181, "syd": 441, "jnb": 577 }, { "timestamp": "Feb 18, 17:00", "jnb": 589, "syd": 364, "ams": 95, "iad": 158, "gru": 468, "hkg": 439 }, { "timestamp": "Feb 18, 18:00", "gru": 285, "jnb": 588, "syd": 698, "iad": 206, "ams": 104, "hkg": 394 } ] }, "metricsByRegion": [ { "region": "syd", "avgLatency": 949, "p75Latency": 433, "p90Latency": 828, "p95Latency": 945, "p99Latency": 16696 }, { "region": "gru", "avgLatency": 873, "p75Latency": 370, "p90Latency": 663, "p95Latency": 753, "p99Latency": 15979 }, { "region": "iad", "avgLatency": 690, "p75Latency": 165, "p90Latency": 312, "p95Latency": 343, "p99Latency": 16552 }, { "region": "ams", "avgLatency": 607, "p75Latency": 95, "p90Latency": 111, "p95Latency": 127, "p99Latency": 16675 }, { "region": "hkg", "avgLatency": 866, "p75Latency": 384, "p90Latency": 655, "p95Latency": 746, "p99Latency": 16681 }, { "region": "jnb", "avgLatency": 1096, "p75Latency": 587, "p90Latency": 706, "p95Latency": 730, "p99Latency": 16295 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-vercel/vercel-cold.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Mar 10, 18:00", "gru": 956, "ams": 930, "iad": 778, "jnb": 1055, "hkg": 857, "syd": 906 }, { "timestamp": "Mar 10, 19:00", "iad": 759, "ams": 834, "gru": 830, "syd": 875, "hkg": 868, "jnb": 951 }, { "timestamp": "Mar 10, 20:00", "gru": 892, "ams": 876, "iad": 675, "syd": 847, "jnb": 962, "hkg": 878 }, { "timestamp": "Mar 10, 21:00", "syd": 959, "jnb": 968, "hkg": 946, "iad": 772, "gru": 834, "ams": 818 }, { "timestamp": "Mar 10, 22:00", "ams": 986, "gru": 853, "iad": 804, "syd": 874, "hkg": 854, "jnb": 1017 }, { "timestamp": "Mar 10, 23:00", "iad": 731, "ams": 818, "gru": 880, "hkg": 885, "jnb": 1008, "syd": 891 }, { "timestamp": "Mar 11, 00:00", "jnb": 1008, "hkg": 895, "syd": 881, "iad": 762, "gru": 777, "ams": 855 }, { "timestamp": "Mar 11, 01:00", "syd": 867, "jnb": 932, "hkg": 927, "iad": 688, "gru": 1015, "ams": 848 }, { "timestamp": "Mar 11, 02:00", "syd": 861, "hkg": 848, "jnb": 1008, "ams": 846, "gru": 890, "iad": 782 }, { "timestamp": "Mar 11, 03:00", "hkg": 948, "jnb": 1022, "syd": 970, "iad": 814, "ams": 812, "gru": 783 }, { "timestamp": "Mar 11, 04:00", "iad": 714, "gru": 814, "ams": 858, "jnb": 982, "hkg": 895, "syd": 830 }, { "timestamp": "Mar 11, 05:00", "syd": 850, "hkg": 902, "jnb": 1004, "ams": 817, "gru": 756, "iad": 693 }, { "timestamp": "Mar 11, 06:00", "iad": 682, "gru": 624, "ams": 808, "syd": 910, "jnb": 974, "hkg": 880 }, { "timestamp": "Mar 11, 07:00", "ams": 815, "gru": 821, "iad": 661, "hkg": 924, "jnb": 952, "syd": 848 }, { "timestamp": "Mar 11, 08:00", "hkg": 898, "jnb": 976, "syd": 866, "ams": 792, "gru": 829, "iad": 768 }, { "timestamp": "Mar 11, 09:00", "syd": 894, "jnb": 1004, "hkg": 926, "iad": 690, "gru": 815, "ams": 832 }, { "timestamp": "Mar 11, 10:00", "iad": 702, "ams": 828, "gru": 762, "hkg": 885, "jnb": 968, "syd": 874 }, { "timestamp": "Mar 11, 11:00", "iad": 734, "ams": 766, "gru": 814, "hkg": 872, "jnb": 1001, "syd": 866 }, { "timestamp": "Mar 11, 12:00", "iad": 718, "gru": 838, "ams": 784, "syd": 862, "jnb": 1007, "hkg": 942 }, { "timestamp": "Mar 11, 13:00", "ams": 780, "gru": 872, "iad": 708, "hkg": 804, "jnb": 968, "syd": 858 }, { "timestamp": "Mar 11, 14:00", "iad": 729, "ams": 821, "gru": 880, "hkg": 957, "jnb": 956, "syd": 842 }, { "timestamp": "Mar 11, 15:00", "hkg": 912, "jnb": 1019, "syd": 876, "iad": 733, "ams": 827, "gru": 803 }, { "timestamp": "Mar 11, 16:00", "syd": 855, "hkg": 995, "jnb": 970, "iad": 813, "ams": 786, "gru": 814 }, { "timestamp": "Mar 11, 17:00", "syd": 865, "jnb": 1012, "hkg": 920, "iad": 753, "gru": 977, "ams": 806 }, { "timestamp": "Mar 11, 18:00", "jnb": 1100, "hkg": 906, "syd": 917, "iad": 764, "gru": 876, "ams": 878 }, { "timestamp": "Mar 11, 19:00", "iad": 740, "ams": 818, "gru": 880, "hkg": 921, "jnb": 996, "syd": 852 }, { "timestamp": "Mar 11, 20:00", "iad": 754, "ams": 914, "gru": 886, "syd": 872, "hkg": 922, "jnb": 956 }, { "timestamp": "Mar 11, 21:00", "ams": 870, "gru": 850, "iad": 681, "hkg": 942, "jnb": 1062, "syd": 827 }, { "timestamp": "Mar 11, 22:00", "ams": 898, "gru": 838, "iad": 757, "syd": 897, "hkg": 890, "jnb": 986 }, { "timestamp": "Mar 11, 23:00", "syd": 875, "hkg": 897, "jnb": 1002, "ams": 858, "gru": 894, "iad": 748 }, { "timestamp": "Mar 12, 00:00", "syd": 846, "jnb": 983, "hkg": 1010, "gru": 766, "ams": 833, "iad": 750 }, { "timestamp": "Mar 12, 01:00", "syd": 911, "jnb": 1064, "hkg": 940, "iad": 742, "gru": 802, "ams": 895 }, { "timestamp": "Mar 12, 02:00", "hkg": 868, "jnb": 948, "syd": 852, "iad": 652, "ams": 812, "gru": 737 }, { "timestamp": "Mar 12, 03:00", "hkg": 857, "jnb": 930, "syd": 816, "iad": 664, "ams": 813, "gru": 835 }, { "timestamp": "Mar 12, 04:00", "iad": 648, "gru": 839, "ams": 894, "syd": 870, "jnb": 984, "hkg": 913 }, { "timestamp": "Mar 12, 05:00", "gru": 830, "iad": 672, "hkg": 878, "jnb": 1000, "syd": 918, "ams": 816 }, { "timestamp": "Mar 12, 06:00", "gru": 821, "ams": 908, "iad": 802, "syd": 848, "jnb": 989, "hkg": 878 }, { "timestamp": "Mar 12, 07:00", "syd": 836, "jnb": 834, "hkg": 739, "gru": 775, "ams": 806, "iad": 664 }, { "timestamp": "Mar 12, 08:00", "jnb": 1005, "hkg": 927, "syd": 916, "gru": 814, "ams": 792, "iad": 736 }, { "timestamp": "Mar 12, 09:00", "ams": 831, "gru": 738, "iad": 747, "hkg": 852, "jnb": 1082, "syd": 856 }, { "timestamp": "Mar 12, 10:00", "ams": 838, "gru": 854, "iad": 670, "syd": 866, "hkg": 830, "jnb": 932 }, { "timestamp": "Mar 12, 11:00", "gru": 859, "ams": 748, "iad": 662, "syd": 855, "jnb": 937, "hkg": 871 }, { "timestamp": "Mar 12, 12:00", "gru": 910, "ams": 802, "iad": 724, "jnb": 998, "hkg": 934, "syd": 868 }, { "timestamp": "Mar 12, 13:00", "syd": 834, "jnb": 1050, "hkg": 914, "iad": 710, "gru": 902, "ams": 898 }, { "timestamp": "Mar 12, 14:00", "jnb": 1096, "hkg": 880, "syd": 879, "iad": 716, "gru": 880, "ams": 820 }, { "timestamp": "Mar 12, 15:00", "iad": 720, "gru": 824, "ams": 823, "jnb": 688, "hkg": 686, "syd": 816 }, { "timestamp": "Mar 12, 16:00", "iad": 766, "ams": 890, "gru": 812, "syd": 850, "hkg": 950, "jnb": 997 }, { "timestamp": "Mar 12, 17:00", "gru": 1000, "ams": 955, "iad": 740, "jnb": 1070, "hkg": 1034, "syd": 896 }, { "timestamp": "Mar 12, 18:00", "syd": 836, "jnb": 904, "hkg": 883, "gru": 760, "ams": 832, "iad": 746 }, { "timestamp": "Mar 12, 19:00", "hkg": 960, "jnb": 920, "syd": 1010, "iad": 758, "ams": 856, "gru": 852 }, { "timestamp": "Mar 12, 20:00", "hkg": 946, "jnb": 996, "syd": 848, "ams": 788, "gru": 788, "iad": 726 }, { "timestamp": "Mar 12, 21:00", "iad": 770, "ams": 807, "gru": 776, "hkg": 900, "jnb": 1054, "syd": 880 }, { "timestamp": "Mar 12, 22:00", "iad": 760, "ams": 852, "gru": 986, "hkg": 906, "jnb": 1069, "syd": 932 }, { "timestamp": "Mar 12, 23:00", "gru": 752, "ams": 798, "iad": 718, "syd": 893, "jnb": 979, "hkg": 888 }, { "timestamp": "Mar 13, 00:00", "syd": 862, "hkg": 1028, "jnb": 957, "iad": 694, "ams": 812, "gru": 853 }, { "timestamp": "Mar 13, 01:00", "iad": 700, "ams": 860, "gru": 842, "hkg": 896, "jnb": 1004, "syd": 932 }, { "timestamp": "Mar 13, 02:00", "jnb": 896, "hkg": 910, "syd": 977, "iad": 717, "gru": 786, "ams": 816 }, { "timestamp": "Mar 13, 03:00", "hkg": 922, "jnb": 994, "syd": 865, "iad": 708, "ams": 841, "gru": 767 }, { "timestamp": "Mar 13, 04:00", "jnb": 976, "hkg": 894, "syd": 886, "gru": 906, "ams": 830, "iad": 710 }, { "timestamp": "Mar 13, 05:00", "iad": 684, "ams": 887, "gru": 814, "hkg": 890, "jnb": 907, "syd": 835 }, { "timestamp": "Mar 13, 06:00", "jnb": 966, "hkg": 964, "syd": 871, "iad": 784, "gru": 872, "ams": 990 }, { "timestamp": "Mar 13, 07:00", "gru": 814, "ams": 880, "iad": 722, "syd": 925, "jnb": 910, "hkg": 938 }, { "timestamp": "Mar 13, 08:00", "syd": 848, "hkg": 872, "jnb": 1026, "iad": 714, "ams": 850, "gru": 864 }, { "timestamp": "Mar 13, 09:00", "gru": 785, "ams": 869, "iad": 706, "jnb": 960, "hkg": 936, "syd": 824 }, { "timestamp": "Mar 13, 10:00", "jnb": 1010, "hkg": 896, "syd": 880, "gru": 770, "ams": 820, "iad": 725 }, { "timestamp": "Mar 13, 11:00", "syd": 838, "hkg": 822, "jnb": 1019, "iad": 713, "ams": 843, "gru": 834 }, { "timestamp": "Mar 13, 12:00", "hkg": 890, "jnb": 988, "syd": 944, "ams": 770, "gru": 808, "iad": 710 }, { "timestamp": "Mar 13, 13:00", "iad": 710, "ams": 838, "gru": 880, "hkg": 874, "jnb": 1024, "syd": 858 }, { "timestamp": "Mar 13, 14:00", "hkg": 882, "jnb": 978, "syd": 912, "iad": 784, "ams": 852, "gru": 843 }, { "timestamp": "Mar 13, 15:00", "syd": 878, "jnb": 904, "hkg": 894, "gru": 868, "ams": 840, "iad": 732 }, { "timestamp": "Mar 13, 16:00", "iad": 714, "ams": 882, "gru": 934, "syd": 872, "hkg": 922, "jnb": 1030 }, { "timestamp": "Mar 13, 17:00", "iad": 742, "ams": 858, "gru": 836, "hkg": 892, "jnb": 1008, "syd": 836 }, { "timestamp": "Mar 13, 18:00", "jnb": 954, "hkg": 878, "syd": 851, "iad": 792, "gru": 794, "ams": 792 }, { "timestamp": "Mar 13, 19:00", "syd": 835, "jnb": 1072, "hkg": 1044, "gru": 1083, "ams": 934, "iad": 814 }, { "timestamp": "Mar 13, 20:00", "syd": 961, "hkg": 944, "jnb": 981, "iad": 704, "ams": 840, "gru": 865 } ] }, "metricsByRegion": [ { "region": "syd", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 866, "p75Latency": 892, "p90Latency": 957, "p95Latency": 996, "p99Latency": 1044 }, { "region": "gru", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 823, "p75Latency": 894, "p90Latency": 954, "p95Latency": 994, "p99Latency": 1173 }, { "region": "iad", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 719, "p75Latency": 761, "p90Latency": 802, "p95Latency": 822, "p99Latency": 894 }, { "region": "ams", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 832, "p75Latency": 870, "p90Latency": 919, "p95Latency": 958, "p99Latency": 1015 }, { "region": "hkg", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 901, "p75Latency": 934, "p90Latency": 976, "p95Latency": 1024, "p99Latency": 1073 }, { "region": "jnb", "count": 147, "ok": 147, "lastTimestamp": 1710358249474, "p50Latency": 991, "p75Latency": 1016, "p90Latency": 1066, "p95Latency": 1128, "p99Latency": 1211 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-vercel/vercel-edge.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Mar 10, 18:00", "gru": 106, "ams": 145, "iad": 117, "jnb": 128, "hkg": 89, "syd": 78 }, { "timestamp": "Mar 10, 19:00", "iad": 126, "ams": 156, "gru": 120, "syd": 87, "hkg": 96, "jnb": 117 }, { "timestamp": "Mar 10, 20:00", "gru": 94, "ams": 160, "iad": 123, "syd": 86, "jnb": 125, "hkg": 94 }, { "timestamp": "Mar 10, 21:00", "syd": 83, "jnb": 119, "hkg": 81, "iad": 116, "gru": 116, "ams": 160 }, { "timestamp": "Mar 10, 22:00", "ams": 135, "gru": 112, "iad": 112, "syd": 96, "hkg": 94, "jnb": 108 }, { "timestamp": "Mar 10, 23:00", "iad": 109, "ams": 107, "gru": 110, "hkg": 94, "jnb": 119, "syd": 98 }, { "timestamp": "Mar 11, 00:00", "jnb": 99, "hkg": 92, "syd": 101, "iad": 116, "gru": 134, "ams": 103 }, { "timestamp": "Mar 11, 01:00", "syd": 94, "jnb": 112, "hkg": 98, "iad": 112, "gru": 132, "ams": 100 }, { "timestamp": "Mar 11, 02:00", "syd": 102, "hkg": 101, "jnb": 101, "ams": 105, "gru": 116, "iad": 116 }, { "timestamp": "Mar 11, 03:00", "hkg": 105, "jnb": 97, "syd": 108, "iad": 108, "ams": 100, "gru": 121 }, { "timestamp": "Mar 11, 04:00", "iad": 102, "gru": 89, "ams": 107, "jnb": 107, "hkg": 113, "syd": 112 }, { "timestamp": "Mar 11, 05:00", "syd": 112, "hkg": 104, "jnb": 120, "ams": 102, "gru": 76, "iad": 100 }, { "timestamp": "Mar 11, 06:00", "iad": 94, "gru": 78, "ams": 111, "syd": 102, "jnb": 130, "hkg": 112 }, { "timestamp": "Mar 11, 07:00", "ams": 108, "gru": 82, "iad": 100, "hkg": 115, "jnb": 134, "syd": 100 }, { "timestamp": "Mar 11, 08:00", "hkg": 108, "jnb": 124, "syd": 96, "ams": 122, "gru": 77, "iad": 104 }, { "timestamp": "Mar 11, 09:00", "syd": 98, "jnb": 128, "hkg": 114, "iad": 120, "gru": 87, "ams": 118 }, { "timestamp": "Mar 11, 10:00", "iad": 102, "ams": 142, "gru": 80, "hkg": 115, "jnb": 146, "syd": 105 }, { "timestamp": "Mar 11, 11:00", "iad": 124, "ams": 150, "gru": 119, "hkg": 118, "jnb": 134, "syd": 95 }, { "timestamp": "Mar 11, 12:00", "iad": 100, "gru": 55, "ams": 140, "syd": 102, "jnb": 130, "hkg": 106 }, { "timestamp": "Mar 11, 13:00", "ams": 156, "gru": 110, "iad": 132, "hkg": 122, "jnb": 153, "syd": 96 }, { "timestamp": "Mar 11, 14:00", "iad": 119, "ams": 143, "gru": 98, "hkg": 130, "jnb": 134, "syd": 97 }, { "timestamp": "Mar 11, 15:00", "hkg": 128, "jnb": 178, "syd": 82, "iad": 151, "ams": 148, "gru": 108 }, { "timestamp": "Mar 11, 16:00", "syd": 90, "hkg": 128, "jnb": 138, "iad": 130, "ams": 140, "gru": 94 }, { "timestamp": "Mar 11, 17:00", "syd": 84, "jnb": 135, "hkg": 119, "iad": 127, "gru": 133, "ams": 146 }, { "timestamp": "Mar 11, 18:00", "jnb": 146, "hkg": 96, "syd": 86, "iad": 122, "gru": 108, "ams": 139 }, { "timestamp": "Mar 11, 19:00", "iad": 104, "ams": 180, "gru": 118, "hkg": 99, "jnb": 127, "syd": 85 }, { "timestamp": "Mar 11, 20:00", "iad": 136, "ams": 137, "gru": 109, "syd": 86, "hkg": 90, "jnb": 128 }, { "timestamp": "Mar 11, 21:00", "ams": 138, "gru": 134, "iad": 94, "hkg": 92, "jnb": 115, "syd": 94 }, { "timestamp": "Mar 11, 22:00", "ams": 126, "gru": 132, "iad": 124, "syd": 98, "hkg": 90, "jnb": 162 }, { "timestamp": "Mar 11, 23:00", "syd": 104, "hkg": 92, "jnb": 110, "ams": 123, "gru": 118, "iad": 124 }, { "timestamp": "Mar 12, 00:00", "syd": 97, "jnb": 106, "hkg": 92, "gru": 157, "ams": 112, "iad": 121 }, { "timestamp": "Mar 12, 01:00", "syd": 113, "jnb": 102, "hkg": 106, "iad": 120, "gru": 171, "ams": 108 }, { "timestamp": "Mar 12, 02:00", "hkg": 101, "jnb": 76, "syd": 106, "iad": 113, "ams": 97, "gru": 132 }, { "timestamp": "Mar 12, 03:00", "hkg": 118, "jnb": 111, "syd": 110, "iad": 126, "ams": 104, "gru": 112 }, { "timestamp": "Mar 12, 04:00", "iad": 104, "gru": 90, "ams": 100, "syd": 112, "jnb": 94, "hkg": 114 }, { "timestamp": "Mar 12, 05:00", "gru": 91, "iad": 106, "hkg": 126, "jnb": 104, "syd": 116, "ams": 110 }, { "timestamp": "Mar 12, 06:00", "gru": 86, "ams": 110, "iad": 94, "syd": 108, "jnb": 120, "hkg": 116 }, { "timestamp": "Mar 12, 07:00", "syd": 104, "jnb": 102, "hkg": 116, "gru": 89, "ams": 119, "iad": 90 }, { "timestamp": "Mar 12, 08:00", "jnb": 121, "hkg": 113, "syd": 100, "gru": 84, "ams": 138, "iad": 97 }, { "timestamp": "Mar 12, 09:00", "ams": 154, "gru": 80, "iad": 104, "hkg": 126, "jnb": 114, "syd": 106 }, { "timestamp": "Mar 12, 10:00", "ams": 128, "gru": 63, "iad": 102, "syd": 112, "hkg": 115, "jnb": 148 }, { "timestamp": "Mar 12, 11:00", "gru": 112, "ams": 144, "iad": 100, "syd": 110, "jnb": 114, "hkg": 106 }, { "timestamp": "Mar 12, 12:00", "gru": 87, "ams": 134, "iad": 126, "jnb": 134, "hkg": 120, "syd": 104 }, { "timestamp": "Mar 12, 13:00", "syd": 88, "jnb": 143, "hkg": 115, "iad": 114, "gru": 106, "ams": 142 }, { "timestamp": "Mar 12, 14:00", "jnb": 142, "hkg": 134, "syd": 88, "iad": 124, "gru": 112, "ams": 142 }, { "timestamp": "Mar 12, 15:00", "iad": 149, "gru": 114, "ams": 160, "jnb": 132, "hkg": 146, "syd": 89 }, { "timestamp": "Mar 12, 16:00", "iad": 110, "ams": 143, "gru": 108, "syd": 85, "hkg": 116, "jnb": 96 }, { "timestamp": "Mar 12, 17:00", "gru": 170, "ams": 152, "iad": 144, "jnb": 142, "hkg": 120, "syd": 82 }, { "timestamp": "Mar 12, 18:00", "syd": 88, "jnb": 125, "hkg": 105, "gru": 153, "ams": 152, "iad": 119 }, { "timestamp": "Mar 12, 19:00", "hkg": 116, "jnb": 149, "syd": 84, "iad": 137, "ams": 159, "gru": 134 }, { "timestamp": "Mar 12, 20:00", "hkg": 94, "jnb": 127, "syd": 90, "ams": 160, "gru": 130, "iad": 128 }, { "timestamp": "Mar 12, 21:00", "iad": 134, "ams": 182, "gru": 116, "hkg": 96, "jnb": 136, "syd": 87 }, { "timestamp": "Mar 12, 22:00", "iad": 134, "ams": 136, "gru": 126, "hkg": 92, "jnb": 110, "syd": 96 }, { "timestamp": "Mar 12, 23:00", "gru": 118, "ams": 110, "iad": 128, "syd": 104, "jnb": 122, "hkg": 98 }, { "timestamp": "Mar 13, 00:00", "syd": 100, "hkg": 98, "jnb": 102, "iad": 128, "ams": 107, "gru": 126 }, { "timestamp": "Mar 13, 01:00", "iad": 130, "ams": 100, "gru": 172, "hkg": 108, "jnb": 124, "syd": 114 }, { "timestamp": "Mar 13, 02:00", "jnb": 102, "hkg": 114, "syd": 109, "iad": 112, "gru": 116, "ams": 102 }, { "timestamp": "Mar 13, 03:00", "hkg": 118, "jnb": 100, "syd": 113, "iad": 120, "ams": 109, "gru": 136 }, { "timestamp": "Mar 13, 04:00", "jnb": 102, "hkg": 120, "syd": 96, "gru": 151, "ams": 102, "iad": 108 }, { "timestamp": "Mar 13, 05:00", "iad": 108, "ams": 104, "gru": 128, "hkg": 114, "jnb": 80, "syd": 106 }, { "timestamp": "Mar 13, 06:00", "jnb": 113, "hkg": 127, "syd": 108, "iad": 106, "gru": 102, "ams": 114 }, { "timestamp": "Mar 13, 07:00", "gru": 84, "ams": 120, "iad": 102, "syd": 101, "jnb": 120, "hkg": 120 }, { "timestamp": "Mar 13, 08:00", "syd": 106, "hkg": 119, "jnb": 134, "iad": 104, "ams": 122, "gru": 96 }, { "timestamp": "Mar 13, 09:00", "gru": 92, "ams": 136, "iad": 114, "jnb": 140, "hkg": 120, "syd": 113 }, { "timestamp": "Mar 13, 10:00", "jnb": 138, "hkg": 116, "syd": 106, "gru": 136, "ams": 133, "iad": 90 }, { "timestamp": "Mar 13, 11:00", "syd": 113, "hkg": 118, "jnb": 148, "iad": 114, "ams": 140, "gru": 116 }, { "timestamp": "Mar 13, 12:00", "hkg": 129, "jnb": 170, "syd": 102, "ams": 136, "gru": 126, "iad": 117 }, { "timestamp": "Mar 13, 13:00", "iad": 116, "ams": 140, "gru": 116, "hkg": 136, "jnb": 138, "syd": 85 }, { "timestamp": "Mar 13, 14:00", "hkg": 142, "jnb": 134, "syd": 94, "iad": 130, "ams": 140, "gru": 126 }, { "timestamp": "Mar 13, 15:00", "syd": 88, "jnb": 157, "hkg": 152, "gru": 229, "ams": 177, "iad": 148 }, { "timestamp": "Mar 13, 16:00", "iad": 152, "ams": 163, "gru": 127, "syd": 85, "hkg": 121, "jnb": 130 }, { "timestamp": "Mar 13, 17:00", "iad": 126, "ams": 144, "gru": 140, "hkg": 127, "jnb": 140, "syd": 91 }, { "timestamp": "Mar 13, 18:00", "jnb": 154, "hkg": 116, "syd": 87, "iad": 122, "gru": 152, "ams": 153 }, { "timestamp": "Mar 13, 19:00", "syd": 87, "jnb": 164, "hkg": 106, "gru": 161, "ams": 151, "iad": 138 }, { "timestamp": "Mar 13, 20:00", "syd": 88, "hkg": 95, "jnb": 126, "iad": 130, "ams": 158, "gru": 163 } ] }, "metricsByRegion": [ { "region": "syd", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 96, "p75Latency": 110, "p90Latency": 124, "p95Latency": 146, "p99Latency": 347 }, { "region": "gru", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 112, "p75Latency": 144, "p90Latency": 195, "p95Latency": 240, "p99Latency": 348 }, { "region": "iad", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 116, "p75Latency": 133, "p90Latency": 152, "p95Latency": 168, "p99Latency": 259 }, { "region": "ams", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 132, "p75Latency": 152, "p90Latency": 177, "p95Latency": 203, "p99Latency": 373 }, { "region": "hkg", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 111, "p75Latency": 125, "p90Latency": 148, "p95Latency": 162, "p99Latency": 272 }, { "region": "jnb", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 125, "p75Latency": 148, "p90Latency": 186, "p95Latency": 210, "p99Latency": 349 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-vercel/vercel-roulette.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Mar 10, 18:00", "gru": 179, "ams": 179, "iad": 61, "jnb": 317, "hkg": 292, "syd": 249 }, { "timestamp": "Mar 10, 19:00", "iad": 460, "ams": 238, "gru": 484, "syd": 286, "hkg": 402, "jnb": 357 }, { "timestamp": "Mar 10, 20:00", "gru": 526, "ams": 557, "iad": 396, "syd": 562, "jnb": 678, "hkg": 588 }, { "timestamp": "Mar 10, 21:00", "syd": 599, "jnb": 629, "hkg": 710, "iad": 390, "gru": 506, "ams": 526 }, { "timestamp": "Mar 10, 22:00", "ams": 504, "gru": 504, "iad": 424, "syd": 560, "hkg": 584, "jnb": 662 }, { "timestamp": "Mar 10, 23:00", "iad": 413, "ams": 534, "gru": 478, "hkg": 562, "jnb": 692, "syd": 531 }, { "timestamp": "Mar 11, 00:00", "jnb": 375, "hkg": 286, "syd": 266, "iad": 88, "gru": 284, "ams": 226 }, { "timestamp": "Mar 11, 01:00", "syd": 281, "jnb": 374, "hkg": 286, "iad": 133, "gru": 228, "ams": 292 }, { "timestamp": "Mar 11, 02:00", "syd": 518, "hkg": 586, "jnb": 672, "ams": 522, "gru": 502, "iad": 429 }, { "timestamp": "Mar 11, 03:00", "hkg": 548, "jnb": 663, "syd": 561, "iad": 426, "ams": 520, "gru": 515 }, { "timestamp": "Mar 11, 04:00", "iad": 129, "gru": 194, "ams": 222, "jnb": 384, "hkg": 282, "syd": 260 }, { "timestamp": "Mar 11, 05:00", "syd": 266, "hkg": 304, "jnb": 374, "ams": 194, "gru": 244, "iad": 107 }, { "timestamp": "Mar 11, 06:00", "iad": 110, "gru": 263, "ams": 216, "syd": 252, "jnb": 416, "hkg": 299 }, { "timestamp": "Mar 11, 07:00", "ams": 572, "gru": 462, "iad": 428, "hkg": 606, "jnb": 614, "syd": 562 }, { "timestamp": "Mar 11, 08:00", "hkg": 604, "jnb": 690, "syd": 548, "ams": 513, "gru": 469, "iad": 414 }, { "timestamp": "Mar 11, 09:00", "syd": 772, "jnb": 598, "hkg": 581, "iad": 406, "gru": 528, "ams": 498 }, { "timestamp": "Mar 11, 10:00", "iad": 440, "ams": 520, "gru": 518, "hkg": 609, "jnb": 683, "syd": 560 }, { "timestamp": "Mar 11, 11:00", "iad": 116, "ams": 243, "gru": 285, "hkg": 272, "jnb": 628, "syd": 272 }, { "timestamp": "Mar 11, 12:00", "iad": 414, "gru": 459, "ams": 666, "syd": 602, "jnb": 672, "hkg": 590 }, { "timestamp": "Mar 11, 13:00", "ams": 230, "gru": 458, "iad": 140, "hkg": 502, "jnb": 422, "syd": 254 }, { "timestamp": "Mar 11, 14:00", "iad": 110, "ams": 202, "gru": 230, "hkg": 306, "jnb": 388, "syd": 266 }, { "timestamp": "Mar 11, 15:00", "hkg": 586, "jnb": 644, "syd": 536, "iad": 430, "ams": 536, "gru": 512 }, { "timestamp": "Mar 11, 16:00", "syd": 269, "hkg": 300, "jnb": 440, "iad": 122, "ams": 222, "gru": 230 }, { "timestamp": "Mar 11, 17:00", "syd": 256, "jnb": 332, "hkg": 300, "iad": 100, "gru": 429, "ams": 216 }, { "timestamp": "Mar 11, 18:00", "jnb": 390, "hkg": 265, "syd": 246, "iad": 65, "gru": 211, "ams": 190 }, { "timestamp": "Mar 11, 19:00", "iad": 118, "ams": 259, "gru": 300, "hkg": 350, "jnb": 389, "syd": 256 }, { "timestamp": "Mar 11, 20:00", "iad": 114, "ams": 218, "gru": 176, "syd": 313, "hkg": 286, "jnb": 332 }, { "timestamp": "Mar 11, 21:00", "ams": 238, "gru": 174, "iad": 110, "hkg": 294, "jnb": 662, "syd": 293 }, { "timestamp": "Mar 11, 22:00", "ams": 230, "gru": 226, "iad": 118, "syd": 247, "hkg": 289, "jnb": 384 }, { "timestamp": "Mar 11, 23:00", "syd": 250, "hkg": 302, "jnb": 391, "ams": 212, "gru": 172, "iad": 104 }, { "timestamp": "Mar 12, 00:00", "syd": 278, "jnb": 330, "hkg": 288, "gru": 220, "ams": 210, "iad": 96 }, { "timestamp": "Mar 12, 01:00", "syd": 284, "jnb": 405, "hkg": 294, "iad": 120, "gru": 514, "ams": 242 }, { "timestamp": "Mar 12, 02:00", "hkg": 288, "jnb": 380, "syd": 256, "iad": 80, "ams": 233, "gru": 181 }, { "timestamp": "Mar 12, 03:00", "hkg": 297, "jnb": 373, "syd": 260, "iad": 92, "ams": 202, "gru": 189 }, { "timestamp": "Mar 12, 04:00", "iad": 134, "gru": 186, "ams": 236, "syd": 251, "jnb": 400, "hkg": 271 }, { "timestamp": "Mar 12, 05:00", "gru": 175, "iad": 112, "hkg": 272, "jnb": 404, "syd": 258, "ams": 222 }, { "timestamp": "Mar 12, 06:00", "gru": 220, "ams": 202, "iad": 86, "syd": 252, "jnb": 383, "hkg": 306 }, { "timestamp": "Mar 12, 07:00", "syd": 246, "jnb": 406, "hkg": 288, "gru": 171, "ams": 185, "iad": 64 }, { "timestamp": "Mar 12, 08:00", "jnb": 333, "hkg": 280, "syd": 244, "gru": 266, "ams": 188, "iad": 86 }, { "timestamp": "Mar 12, 09:00", "ams": 226, "gru": 174, "iad": 82, "hkg": 283, "jnb": 351, "syd": 264 }, { "timestamp": "Mar 12, 10:00", "ams": 200, "gru": 172, "iad": 94, "syd": 248, "hkg": 277, "jnb": 392 }, { "timestamp": "Mar 12, 11:00", "gru": 278, "ams": 252, "iad": 116, "syd": 268, "jnb": 657, "hkg": 296 }, { "timestamp": "Mar 12, 12:00", "gru": 276, "ams": 211, "iad": 109, "jnb": 382, "hkg": 289, "syd": 270 }, { "timestamp": "Mar 12, 13:00", "syd": 324, "jnb": 381, "hkg": 302, "iad": 108, "gru": 525, "ams": 243 }, { "timestamp": "Mar 12, 14:00", "jnb": 388, "hkg": 264, "syd": 256, "iad": 112, "gru": 248, "ams": 244 }, { "timestamp": "Mar 12, 15:00", "iad": 101, "gru": 232, "ams": 326, "jnb": 387, "hkg": 260, "syd": 262 }, { "timestamp": "Mar 12, 16:00", "iad": 132, "ams": 244, "gru": 238, "syd": 256, "hkg": 535, "jnb": 374 }, { "timestamp": "Mar 12, 17:00", "gru": 439, "ams": 255, "iad": 120, "jnb": 382, "hkg": 308, "syd": 247 }, { "timestamp": "Mar 12, 18:00", "syd": 248, "jnb": 390, "hkg": 308, "gru": 294, "ams": 185, "iad": 120 }, { "timestamp": "Mar 12, 19:00", "hkg": 268, "jnb": 555, "syd": 259, "iad": 81, "ams": 231, "gru": 276 }, { "timestamp": "Mar 12, 20:00", "hkg": 270, "jnb": 390, "syd": 253, "ams": 172, "gru": 225, "iad": 73 }, { "timestamp": "Mar 12, 21:00", "iad": 122, "ams": 206, "gru": 176, "hkg": 302, "jnb": 373, "syd": 275 }, { "timestamp": "Mar 12, 22:00", "iad": 108, "ams": 196, "gru": 294, "hkg": 280, "jnb": 366, "syd": 262 }, { "timestamp": "Mar 12, 23:00", "gru": 188, "ams": 184, "iad": 88, "syd": 259, "jnb": 379, "hkg": 262 }, { "timestamp": "Mar 13, 00:00", "syd": 254, "hkg": 266, "jnb": 592, "iad": 82, "ams": 182, "gru": 242 }, { "timestamp": "Mar 13, 01:00", "iad": 110, "ams": 222, "gru": 332, "hkg": 292, "jnb": 377, "syd": 264 }, { "timestamp": "Mar 13, 02:00", "jnb": 378, "hkg": 314, "syd": 278, "iad": 104, "gru": 236, "ams": 202 }, { "timestamp": "Mar 13, 03:00", "hkg": 302, "jnb": 384, "syd": 285, "iad": 132, "ams": 228, "gru": 276 }, { "timestamp": "Mar 13, 04:00", "jnb": 354, "hkg": 364, "syd": 250, "gru": 380, "ams": 220, "iad": 109 }, { "timestamp": "Mar 13, 05:00", "iad": 128, "ams": 213, "gru": 278, "hkg": 300, "jnb": 362, "syd": 252 }, { "timestamp": "Mar 13, 06:00", "jnb": 362, "hkg": 278, "syd": 258, "iad": 73, "gru": 226, "ams": 186 }, { "timestamp": "Mar 13, 07:00", "gru": 238, "ams": 700, "iad": 169, "syd": 274, "jnb": 412, "hkg": 328 }, { "timestamp": "Mar 13, 08:00", "syd": 248, "hkg": 299, "jnb": 398, "iad": 104, "ams": 254, "gru": 336 }, { "timestamp": "Mar 13, 09:00", "gru": 236, "ams": 194, "iad": 80, "jnb": 398, "hkg": 318, "syd": 258 }, { "timestamp": "Mar 13, 10:00", "jnb": 398, "hkg": 274, "syd": 250, "gru": 273, "ams": 270, "iad": 96 }, { "timestamp": "Mar 13, 11:00", "syd": 250, "hkg": 284, "jnb": 386, "iad": 104, "ams": 202, "gru": 270 }, { "timestamp": "Mar 13, 12:00", "hkg": 282, "jnb": 380, "syd": 254, "ams": 206, "gru": 300, "iad": 98 }, { "timestamp": "Mar 13, 13:00", "iad": 89, "ams": 221, "gru": 358, "hkg": 299, "jnb": 356, "syd": 252 }, { "timestamp": "Mar 13, 14:00", "hkg": 290, "jnb": 373, "syd": 251, "iad": 142, "ams": 269, "gru": 242 }, { "timestamp": "Mar 13, 15:00", "syd": 266, "jnb": 426, "hkg": 304, "gru": 282, "ams": 216, "iad": 102 }, { "timestamp": "Mar 13, 16:00", "iad": 110, "ams": 216, "gru": 275, "syd": 258, "hkg": 300, "jnb": 379 }, { "timestamp": "Mar 13, 17:00", "iad": 92, "ams": 221, "gru": 243, "hkg": 300, "jnb": 388, "syd": 248 }, { "timestamp": "Mar 13, 18:00", "jnb": 335, "hkg": 302, "syd": 258, "iad": 172, "gru": 288, "ams": 202 }, { "timestamp": "Mar 13, 19:00", "syd": 350, "jnb": 600, "hkg": 306, "gru": 264, "ams": 241, "iad": 111 }, { "timestamp": "Mar 13, 20:00", "syd": 258, "hkg": 290, "jnb": 334, "iad": 144, "ams": 230, "gru": 353 } ] }, "metricsByRegion": [ { "region": "syd", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 258, "p75Latency": 516, "p90Latency": 881, "p95Latency": 914, "p99Latency": 1027 }, { "region": "gru", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 269, "p75Latency": 673, "p90Latency": 851, "p95Latency": 916, "p99Latency": 1040 }, { "region": "iad", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 113, "p75Latency": 216, "p90Latency": 743, "p95Latency": 777, "p99Latency": 831 }, { "region": "ams", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 225, "p75Latency": 338, "p90Latency": 837, "p95Latency": 872, "p99Latency": 986 }, { "region": "hkg", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 295, "p75Latency": 504, "p90Latency": 909, "p95Latency": 948, "p99Latency": 1063 }, { "region": "jnb", "count": 447, "ok": 447, "lastTimestamp": 1710359410395, "p50Latency": 385, "p75Latency": 803, "p90Latency": 991, "p95Latency": 1027, "p99Latency": 1139 } ] } ================================================ FILE: apps/web/public/assets/posts/monitoring-vercel/vercel-warm.json ================================================ { "regions": ["ams", "iad", "hkg", "jnb", "syd", "gru"], "data": { "regions": ["ams", "gru", "hkg", "iad", "jnb", "syd"], "data": [ { "timestamp": "Mar 10, 18:00", "gru": 198, "ams": 202, "iad": 68, "jnb": 388, "hkg": 302, "syd": 247 }, { "timestamp": "Mar 10, 19:00", "iad": 68, "ams": 172, "gru": 164, "syd": 245, "hkg": 271, "jnb": 384 }, { "timestamp": "Mar 10, 20:00", "gru": 184, "ams": 178, "iad": 63, "syd": 248, "jnb": 374, "hkg": 296 }, { "timestamp": "Mar 10, 21:00", "syd": 252, "jnb": 372, "hkg": 268, "iad": 62, "gru": 172, "ams": 170 }, { "timestamp": "Mar 10, 22:00", "ams": 174, "gru": 162, "iad": 62, "syd": 243, "hkg": 292, "jnb": 368 }, { "timestamp": "Mar 10, 23:00", "iad": 58, "ams": 170, "gru": 181, "hkg": 294, "jnb": 374, "syd": 246 }, { "timestamp": "Mar 11, 00:00", "jnb": 343, "hkg": 274, "syd": 247, "iad": 70, "gru": 184, "ams": 166 }, { "timestamp": "Mar 11, 01:00", "syd": 246, "jnb": 370, "hkg": 316, "iad": 96, "gru": 167, "ams": 168 }, { "timestamp": "Mar 11, 02:00", "syd": 248, "hkg": 277, "jnb": 370, "ams": 166, "gru": 267, "iad": 64 }, { "timestamp": "Mar 11, 03:00", "hkg": 268, "jnb": 371, "syd": 256, "iad": 62, "ams": 168, "gru": 174 }, { "timestamp": "Mar 11, 04:00", "iad": 60, "gru": 168, "ams": 173, "jnb": 335, "hkg": 282, "syd": 250 }, { "timestamp": "Mar 11, 05:00", "syd": 246, "hkg": 287, "jnb": 374, "ams": 170, "gru": 264, "iad": 67 }, { "timestamp": "Mar 11, 06:00", "iad": 55, "gru": 169, "ams": 170, "syd": 242, "jnb": 368, "hkg": 294 }, { "timestamp": "Mar 11, 07:00", "ams": 213, "gru": 268, "iad": 86, "hkg": 279, "jnb": 360, "syd": 263 }, { "timestamp": "Mar 11, 08:00", "hkg": 270, "jnb": 370, "syd": 250, "ams": 172, "gru": 172, "iad": 72 }, { "timestamp": "Mar 11, 09:00", "syd": 247, "jnb": 372, "hkg": 291, "iad": 56, "gru": 184, "ams": 178 }, { "timestamp": "Mar 11, 10:00", "iad": 57, "ams": 170, "gru": 167, "hkg": 266, "jnb": 368, "syd": 246 }, { "timestamp": "Mar 11, 11:00", "iad": 58, "ams": 192, "gru": 172, "hkg": 268, "jnb": 378, "syd": 252 }, { "timestamp": "Mar 11, 12:00", "iad": 86, "gru": 240, "ams": 178, "syd": 249, "jnb": 374, "hkg": 281 }, { "timestamp": "Mar 11, 13:00", "ams": 173, "gru": 223, "iad": 80, "hkg": 284, "jnb": 380, "syd": 246 }, { "timestamp": "Mar 11, 14:00", "iad": 72, "ams": 193, "gru": 180, "hkg": 296, "jnb": 388, "syd": 254 }, { "timestamp": "Mar 11, 15:00", "hkg": 286, "jnb": 382, "syd": 256, "iad": 64, "ams": 169, "gru": 191 }, { "timestamp": "Mar 11, 16:00", "syd": 258, "hkg": 339, "jnb": 420, "iad": 80, "ams": 186, "gru": 174 }, { "timestamp": "Mar 11, 17:00", "syd": 259, "jnb": 383, "hkg": 301, "iad": 76, "gru": 184, "ams": 209 }, { "timestamp": "Mar 11, 18:00", "jnb": 384, "hkg": 294, "syd": 258, "iad": 126, "gru": 214, "ams": 222 }, { "timestamp": "Mar 11, 19:00", "iad": 64, "ams": 168, "gru": 188, "hkg": 292, "jnb": 358, "syd": 250 }, { "timestamp": "Mar 11, 20:00", "iad": 60, "ams": 175, "gru": 260, "syd": 245, "hkg": 294, "jnb": 352 }, { "timestamp": "Mar 11, 21:00", "ams": 191, "gru": 173, "iad": 65, "hkg": 286, "jnb": 387, "syd": 278 }, { "timestamp": "Mar 11, 22:00", "ams": 179, "gru": 222, "iad": 65, "syd": 248, "hkg": 273, "jnb": 376 }, { "timestamp": "Mar 11, 23:00", "syd": 256, "hkg": 303, "jnb": 374, "ams": 170, "gru": 222, "iad": 63 }, { "timestamp": "Mar 12, 00:00", "syd": 254, "jnb": 362, "hkg": 294, "gru": 271, "ams": 180, "iad": 70 }, { "timestamp": "Mar 12, 01:00", "syd": 252, "jnb": 414, "hkg": 300, "iad": 92, "gru": 182, "ams": 184 }, { "timestamp": "Mar 12, 02:00", "hkg": 263, "jnb": 372, "syd": 246, "iad": 57, "ams": 168, "gru": 216 }, { "timestamp": "Mar 12, 03:00", "hkg": 290, "jnb": 368, "syd": 248, "iad": 58, "ams": 172, "gru": 218 }, { "timestamp": "Mar 12, 04:00", "iad": 56, "gru": 167, "ams": 166, "syd": 247, "jnb": 370, "hkg": 274 }, { "timestamp": "Mar 12, 05:00", "gru": 262, "iad": 57, "hkg": 265, "jnb": 323, "syd": 246, "ams": 170 }, { "timestamp": "Mar 12, 06:00", "gru": 193, "ams": 164, "iad": 56, "syd": 252, "jnb": 338, "hkg": 284 }, { "timestamp": "Mar 12, 07:00", "syd": 245, "jnb": 379, "hkg": 296, "gru": 176, "ams": 174, "iad": 56 }, { "timestamp": "Mar 12, 08:00", "jnb": 369, "hkg": 294, "syd": 252, "gru": 263, "ams": 176, "iad": 60 }, { "timestamp": "Mar 12, 09:00", "ams": 175, "gru": 164, "iad": 60, "hkg": 280, "jnb": 384, "syd": 258 }, { "timestamp": "Mar 12, 10:00", "ams": 175, "gru": 189, "iad": 56, "syd": 251, "hkg": 264, "jnb": 381 }, { "timestamp": "Mar 12, 11:00", "gru": 262, "ams": 172, "iad": 56, "syd": 244, "jnb": 378, "hkg": 271 }, { "timestamp": "Mar 12, 12:00", "gru": 266, "ams": 168, "iad": 58, "jnb": 384, "hkg": 280, "syd": 248 }, { "timestamp": "Mar 12, 13:00", "syd": 250, "jnb": 357, "hkg": 289, "iad": 58, "gru": 222, "ams": 170 }, { "timestamp": "Mar 12, 14:00", "jnb": 351, "hkg": 288, "syd": 256, "iad": 58, "gru": 196, "ams": 168 }, { "timestamp": "Mar 12, 15:00", "iad": 64, "gru": 244, "ams": 175, "jnb": 376, "hkg": 284, "syd": 242 }, { "timestamp": "Mar 12, 16:00", "iad": 62, "ams": 180, "gru": 268, "syd": 242, "hkg": 286, "jnb": 378 }, { "timestamp": "Mar 12, 17:00", "gru": 276, "ams": 180, "iad": 64, "jnb": 378, "hkg": 280, "syd": 248 }, { "timestamp": "Mar 12, 18:00", "syd": 242, "jnb": 374, "hkg": 275, "gru": 178, "ams": 164, "iad": 60 }, { "timestamp": "Mar 12, 19:00", "hkg": 268, "jnb": 385, "syd": 247, "iad": 66, "ams": 174, "gru": 175 }, { "timestamp": "Mar 12, 20:00", "hkg": 279, "jnb": 374, "syd": 254, "ams": 168, "gru": 178, "iad": 62 }, { "timestamp": "Mar 12, 21:00", "iad": 88, "ams": 200, "gru": 286, "hkg": 302, "jnb": 378, "syd": 252 }, { "timestamp": "Mar 12, 22:00", "iad": 67, "ams": 166, "gru": 275, "hkg": 282, "jnb": 374, "syd": 249 }, { "timestamp": "Mar 12, 23:00", "gru": 272, "ams": 195, "iad": 60, "syd": 255, "jnb": 370, "hkg": 294 }, { "timestamp": "Mar 13, 00:00", "syd": 253, "hkg": 290, "jnb": 339, "iad": 72, "ams": 208, "gru": 266 }, { "timestamp": "Mar 13, 01:00", "iad": 67, "ams": 170, "gru": 228, "hkg": 348, "jnb": 332, "syd": 252 }, { "timestamp": "Mar 13, 02:00", "jnb": 374, "hkg": 299, "syd": 250, "iad": 58, "gru": 274, "ams": 163 }, { "timestamp": "Mar 13, 03:00", "hkg": 296, "jnb": 374, "syd": 247, "iad": 56, "ams": 167, "gru": 264 }, { "timestamp": "Mar 13, 04:00", "jnb": 374, "hkg": 282, "syd": 257, "gru": 220, "ams": 172, "iad": 56 }, { "timestamp": "Mar 13, 05:00", "iad": 56, "ams": 165, "gru": 272, "hkg": 268, "jnb": 348, "syd": 252 }, { "timestamp": "Mar 13, 06:00", "jnb": 383, "hkg": 306, "syd": 247, "iad": 64, "gru": 170, "ams": 217 }, { "timestamp": "Mar 13, 07:00", "gru": 272, "ams": 179, "iad": 58, "syd": 250, "jnb": 352, "hkg": 284 }, { "timestamp": "Mar 13, 08:00", "syd": 240, "hkg": 256, "jnb": 378, "iad": 61, "ams": 187, "gru": 271 }, { "timestamp": "Mar 13, 09:00", "gru": 262, "ams": 178, "iad": 60, "jnb": 382, "hkg": 261, "syd": 250 }, { "timestamp": "Mar 13, 10:00", "jnb": 376, "hkg": 272, "syd": 248, "gru": 269, "ams": 172, "iad": 56 }, { "timestamp": "Mar 13, 11:00", "syd": 250, "hkg": 280, "jnb": 382, "iad": 60, "ams": 176, "gru": 240 }, { "timestamp": "Mar 13, 12:00", "hkg": 275, "jnb": 372, "syd": 246, "ams": 174, "gru": 266, "iad": 57 }, { "timestamp": "Mar 13, 13:00", "iad": 70, "ams": 178, "gru": 276, "hkg": 286, "jnb": 371, "syd": 246 }, { "timestamp": "Mar 13, 14:00", "hkg": 298, "jnb": 393, "syd": 246, "iad": 80, "ams": 196, "gru": 173 }, { "timestamp": "Mar 13, 15:00", "syd": 246, "jnb": 382, "hkg": 272, "gru": 219, "ams": 178, "iad": 60 }, { "timestamp": "Mar 13, 16:00", "iad": 62, "ams": 184, "gru": 271, "syd": 243, "hkg": 278, "jnb": 346 }, { "timestamp": "Mar 13, 17:00", "iad": 66, "ams": 180, "gru": 253, "hkg": 299, "jnb": 380, "syd": 244 }, { "timestamp": "Mar 13, 18:00", "jnb": 371, "hkg": 294, "syd": 250, "iad": 62, "gru": 240, "ams": 176 }, { "timestamp": "Mar 13, 19:00", "syd": 247, "jnb": 338, "hkg": 272, "gru": 178, "ams": 169, "iad": 66 }, { "timestamp": "Mar 13, 20:00", "syd": 250, "hkg": 292, "jnb": 374, "iad": 66, "ams": 170, "gru": 224 } ] }, "metricsByRegion": [ { "region": "syd", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 248, "p75Latency": 260, "p90Latency": 294, "p95Latency": 347, "p99Latency": 886 }, { "region": "gru", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 190, "p75Latency": 275, "p90Latency": 296, "p95Latency": 369, "p99Latency": 705 }, { "region": "iad", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 62, "p75Latency": 77, "p90Latency": 122, "p95Latency": 358, "p99Latency": 767 }, { "region": "ams", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 173, "p75Latency": 191, "p90Latency": 261, "p95Latency": 782, "p99Latency": 869 }, { "region": "hkg", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 287, "p75Latency": 308, "p90Latency": 431, "p95Latency": 470, "p99Latency": 959 }, { "region": "jnb", "count": 891, "ok": 891, "lastTimestamp": 1710359730476, "p50Latency": 374, "p75Latency": 390, "p90Latency": 439, "p95Latency": 522, "p99Latency": 1003 } ] } ================================================ FILE: apps/web/public/llms.txt ================================================ # openstatus Openstatus is an open-source uptime monitoring and status page platform founded in 2023 by Thibault Le Ouay Ducasse and Maximilian Kaske. It monitors websites, APIs, and services from 28 regions globally across multiple cloud providers (Fly.io, Koyeb, Railway). Openstatus is bootstrapped, not VC-funded, and available both as a managed SaaS and for self-hosting. ## Who it's for - Development teams that want transparent incident communication - Companies that need multi-region uptime monitoring - Teams that prefer infrastructure-as-code workflows (monitoring as code via YAML) - Organizations that require self-hosted monitoring behind a firewall (private locations) - Open-source projects and startups looking for a free or affordable monitoring solution ## Pricing - **Hobby** — $0/month: 1 monitor, 6 regions, 10m check interval, 1 status page, 3 page components, 14-day data retention - **Starter** — $30/month: 20 monitors, 28 regions, 1m check interval, 1 status page, 20 components, 3-month retention, subscribers, custom domain, WhatsApp/SMS/PagerDuty alerts - **Pro** — $100/month: 50 monitors, 28 regions, 30s check interval, 5 status pages, 50 components, 12-month retention, private locations, OTel exporter, 20 notification channels Pricing is available in USD, EUR, and INR. ## Key Features - **28-region monitoring**: Parallel checks across Europe, North America, South America, Asia, Africa, and Oceania — no round-robin, all regions fire simultaneously - **Multi-cloud**: Monitors run on Fly.io, Koyeb, and Railway for true cloud diversity - **Status Pages**: Branded public or password-protected pages with custom domains, themes, maintenance windows, and subscriber notifications (email, RSS, SSH) - **API Monitoring**: Assertions, thresholds, status code checks, header and body validation - **Monitoring as Code**: Define monitors in YAML, manage via CLI or GitHub Actions - **Private Locations**: 8.5MB Docker image for monitoring internal services behind firewalls - **Alerting**: Email, Slack, Discord, webhook, WhatsApp, SMS, PagerDuty, OpsGenie, Grafana OnCall - **OpenTelemetry**: Export synthetic check metrics to any OTLP endpoint - **SDK**: Node.js SDK available on JSR (@openstatus/sdk-node) - **Open-source**: AGPL-3.0-licensed, self-hostable, 8k+ GitHub stars ## Key Differentiators - Open-source and bootstrapped (no VC funding) - Parallel scheduling strategy — all selected regions check simultaneously (vs. round-robin competitors) - Unlimited team members on paid plans - Status page subscribers included (not a paid add-on) - Private status pages included in team plan (not an additional charge) - Self-hosting option with full feature parity ## Comparisons - [openstatus vs BetterStack](https://www.openstatus.dev/compare/betterstack) - [openstatus vs Checkly](https://www.openstatus.dev/compare/checkly) - [openstatus vs UptimeRobot](https://www.openstatus.dev/compare/uptime-robot) - [openstatus vs Uptime Kuma](https://www.openstatus.dev/compare/uptime-kuma) ## Links - [About](https://www.openstatus.dev/about) - [Pricing](https://www.openstatus.dev/pricing) - [Changelog](https://www.openstatus.dev/changelog) - [Global Speed Checker](https://www.openstatus.dev/play/checker) - [Theme Store](https://themes.openstatus.dev) - [Dashboard](https://app.openstatus.dev) - [Documentation](https://docs.openstatus.dev/) - [Documentation llms docs](https://docs.openstatus.dev/llms.txt) - [Documentation llms docs full](https://docs.openstatus.dev/llms-full.txt) - [GitHub](https://github.com/openstatushq/openstatus) - [SDK](https://jsr.io/@openstatus/sdk-node) - [API](https://api.openstatus.dev/openapi) ================================================ FILE: apps/web/sentry.edge.config.ts ================================================ // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). // The config you add here will be used whenever one of the edge features is loaded. // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; import { env } from "@/env"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/web/sentry.server.config.ts ================================================ // This file configures the initialization of Sentry on the server. // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; import { TRPCError } from "@trpc/server"; import { env } from "@/env"; // tRPC error codes that should not be reported to Sentry (expected client errors) const IGNORED_TRPC_CODES: TRPCError["code"][] = [ "UNAUTHORIZED", "NOT_FOUND", "BAD_REQUEST", ]; Sentry.init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 0.2, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], beforeSend(event, hint) { if ( hint.originalException instanceof TRPCError && IGNORED_TRPC_CODES.includes(hint.originalException.code) ) { return null; } return event; }, }); ================================================ FILE: apps/web/src/app/(landing)/(redirect)/bsky/page.tsx ================================================ import { redirect } from "next/navigation"; export default function BlueskyRedirect() { return redirect("https://bsky.app/profile/openstatus.dev"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/cal/page.tsx ================================================ import { redirect } from "next/navigation"; export default function CalRedirect() { return redirect("https://cal.com/team/openstatus/30min"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/discord/page.tsx ================================================ import { redirect } from "next/navigation"; export default function DiscordRedirect() { return redirect("https://discord.gg/dHD4JtSfsn"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/docs/page.tsx ================================================ import { redirect } from "next/navigation"; export default function DiscordRedirect() { return redirect("https://docs.openstatus.dev"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/github/page.tsx ================================================ import { redirect } from "next/navigation"; export default function GithubRedirect() { return redirect("https://github.com/openstatusHQ/openstatus"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/linkedin/page.tsx ================================================ import { redirect } from "next/navigation"; export default function LinkedinRedirect() { return redirect("https://www.linkedin.com/company/openstatus"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/schema.json/page.tsx ================================================ import { redirect } from "next/navigation"; export default function SchemaJsonRedirect() { return redirect( "https://github.com/openstatusHQ/json-schema/releases/latest/download/schema.json", ); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/twitter/page.tsx ================================================ import { redirect } from "next/navigation"; export default function TwitterRedirect() { return redirect("https://twitter.com/openstatusHQ"); } ================================================ FILE: apps/web/src/app/(landing)/(redirect)/youtube/page.tsx ================================================ import { redirect } from "next/navigation"; export default function YoutubeRedirect() { return redirect("https://www.youtube.com/@OpenStatusHQ"); } ================================================ FILE: apps/web/src/app/(landing)/[slug]/page.tsx ================================================ import { CustomMDX } from "@/content/mdx"; import { getMainPages } from "@/content/utils"; import { getPageMetadata } from "@/lib/metadata/shared-metadata"; import { createJsonLDGraph, getJsonLDFAQPage, getJsonLDHowTo, getJsonLDOrganization, getJsonLDProduct, getJsonLDSoftwareApplication, getJsonLDWebPage, } from "@/lib/metadata/structured-data"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; export const dynamicParams = false; export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; const page = getMainPages().find((page) => page.slug === slug); if (!page) { return; } const metadata = getPageMetadata(page); return metadata; } export async function generateStaticParams() { const pages = getMainPages(); return pages.map((page) => ({ slug: page.slug, })); } export default async function Page({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const page = getMainPages().find((page) => page.slug === slug); if (!page) { notFound(); } const jsonLDGraph = createJsonLDGraph([ getJsonLDOrganization(), getJsonLDSoftwareApplication(), getJsonLDProduct(), getJsonLDWebPage(page), getJsonLDHowTo(page), getJsonLDFAQPage(page), ]); return (