@agent-native/core 0.90.11 → 0.91.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +34 -0
- package/corpus/core/docs/content/toolkit-settings.mdx +56 -3
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/run-ownership.ts +29 -1
- package/corpus/core/src/changelog/parse.ts +78 -0
- package/corpus/core/src/chat-threads/schema.ts +43 -0
- package/corpus/core/src/chat-threads/store.ts +144 -12
- package/corpus/core/src/cli/design-connect.ts +53 -9
- package/corpus/core/src/cli/workspace-dev.ts +25 -5
- package/corpus/core/src/client/AgentPanel.tsx +79 -14
- package/corpus/core/src/client/AssistantChat.tsx +359 -425
- package/corpus/core/src/client/analytics-session.ts +70 -0
- package/corpus/core/src/client/analytics.ts +175 -49
- package/corpus/core/src/client/chat/message-components.tsx +22 -2
- package/corpus/core/src/client/components/ui/message-scroller.tsx +131 -0
- package/corpus/core/src/client/conversation/AgentConversation.tsx +63 -41
- package/corpus/core/src/client/error-capture.ts +616 -0
- package/corpus/core/src/client/index.ts +12 -0
- package/corpus/core/src/client/session-replay.ts +57 -7
- package/corpus/core/src/client/settings/SettingsPanel.tsx +123 -1
- package/corpus/core/src/client/settings/SettingsTabsPage.tsx +292 -45
- package/corpus/core/src/client/settings/index.ts +1 -0
- package/corpus/core/src/client/sharing/ShareButton.tsx +14 -11
- package/corpus/core/src/client/sharing/ShareDialog.tsx +13 -10
- package/corpus/core/src/mcp/builtin-tools.ts +29 -2
- package/corpus/core/src/server/agent-chat-plugin.ts +174 -79
- package/corpus/core/src/server/onboarding-html.ts +28 -16
- package/corpus/core/src/sharing/actions/list-resource-shares.ts +45 -1
- package/corpus/core/src/sharing/actions/share-resource.ts +9 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
- package/corpus/core/src/vite/client.ts +217 -35
- package/corpus/templates/analytics/.agents/skills/adhoc-analysis/SKILL.md +8 -7
- package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +14 -10
- package/corpus/templates/analytics/.agents/skills/bigquery/SKILL.md +15 -15
- package/corpus/templates/analytics/.agents/skills/cross-source-analysis/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -22
- package/corpus/templates/analytics/.agents/skills/data-programs/SKILL.md +114 -61
- package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +7 -7
- package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/prometheus/SKILL.md +1 -1
- package/corpus/templates/analytics/.builder/skills/bigquery/SKILL.md +17 -17
- package/corpus/templates/analytics/.builder/skills/gcloud/SKILL.md +5 -5
- package/corpus/templates/analytics/.builder/skills/github/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/gong/SKILL.md +12 -12
- package/corpus/templates/analytics/.builder/skills/grafana/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/sentry/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/slack/SKILL.md +6 -6
- package/corpus/templates/analytics/.builder/skills/stripe/SKILL.md +7 -7
- package/corpus/templates/analytics/AGENTS.md +47 -2
- package/corpus/templates/analytics/actions/add-status-page-monitor.ts +36 -0
- package/corpus/templates/analytics/actions/capture-test-error.ts +33 -0
- package/corpus/templates/analytics/actions/delete-monitor.ts +24 -0
- package/corpus/templates/analytics/actions/delete-status-page.ts +23 -0
- package/corpus/templates/analytics/actions/get-error-issue.ts +36 -0
- package/corpus/templates/analytics/actions/get-monitor-stats.ts +55 -0
- package/corpus/templates/analytics/actions/get-monitor.ts +27 -0
- package/corpus/templates/analytics/actions/get-public-status-page.ts +41 -0
- package/corpus/templates/analytics/actions/get-status-page.ts +29 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +40 -0
- package/corpus/templates/analytics/actions/list-monitors.ts +21 -0
- package/corpus/templates/analytics/actions/list-status-pages.ts +21 -0
- package/corpus/templates/analytics/actions/match-error-issues.ts +51 -0
- package/corpus/templates/analytics/actions/navigate.ts +58 -4
- package/corpus/templates/analytics/actions/remove-status-page-monitor.ts +22 -0
- package/corpus/templates/analytics/actions/reorder-status-page-monitors.ts +25 -0
- package/corpus/templates/analytics/actions/resolve-error-issue.ts +38 -0
- package/corpus/templates/analytics/actions/run-monitor-check.ts +23 -0
- package/corpus/templates/analytics/actions/save-analysis.ts +1 -0
- package/corpus/templates/analytics/actions/save-monitor.ts +145 -0
- package/corpus/templates/analytics/actions/save-status-page.ts +47 -0
- package/corpus/templates/analytics/actions/view-screen.ts +212 -0
- package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -0
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +10 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +134 -39
- package/corpus/templates/analytics/app/components/monitoring/PublicStatusView.tsx +351 -0
- package/corpus/templates/analytics/app/components/monitoring/ResponseTimeChart.tsx +174 -0
- package/corpus/templates/analytics/app/components/monitoring/UptimeStatCards.tsx +85 -0
- package/corpus/templates/analytics/app/components/monitoring/UptimeTimelineBars.tsx +106 -0
- package/corpus/templates/analytics/app/components/monitoring/chart-utils.ts +101 -0
- package/corpus/templates/analytics/app/components/monitoring/index.ts +28 -0
- package/corpus/templates/analytics/app/components/monitoring/types.ts +42 -0
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +34 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +6 -0
- package/corpus/templates/analytics/app/i18n-data.ts +33 -0
- package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +77 -9
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +3 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +7 -0
- package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +205 -0
- package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +100 -0
- package/corpus/templates/analytics/app/pages/monitoring/UptimePanel.tsx +475 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/IssueDetail.tsx +372 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/IssueList.tsx +360 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/Sparkline.tsx +55 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +106 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/types.ts +75 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/utils.ts +132 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +535 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorFormPage.tsx +1026 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorList.tsx +311 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/i18n.ts +235 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPageEditor.tsx +661 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPagesView.tsx +355 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/i18n.ts +106 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/slug.ts +24 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/types.ts +20 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-summary.ts +87 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +161 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/utils.ts +210 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +76 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +74 -9
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +197 -20
- package/corpus/templates/analytics/app/root.tsx +33 -1
- package/corpus/templates/analytics/app/routes/monitoring._index.tsx +10 -0
- package/corpus/templates/analytics/app/routes/status.$slug.tsx +118 -0
- package/corpus/templates/analytics/changelog/2026-07-08-add-uptime-monitors-that-ping-your-urls-and-alert-you-when-t.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-analytics-chats-keep-large-data-dictionaries-compact-so-prov.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-create-public-status-pages-that-share-the-live-health-of-cho.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-dashboard-extension-panels-use-clearer-open-copy.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-error-capture.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-fixed-single-panel-dashboard-drags-so-they-no-longer-rewrite.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-jump-straight-from-an-error-in-a-session-recording-to-its-fu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-monitor-detail-and-list-now-show-colorful-uptime-timelines-r.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-pinned-ask-chats-now-show-a-pin-icon-in-the-sidebar.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-publish-a-public-uptime-status-page-with-colorful-uptime-tim.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-sessions-now-hide-visitor-emails-in-demo-mode-and-show-clean.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +5 -0
- package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +5 -0
- package/corpus/templates/analytics/changelog/2026-07-08-the-uptime-monitor-list-now-has-a-current-status-overview-wi.md +6 -0
- package/corpus/templates/analytics/docs/error-capture.md +172 -0
- package/corpus/templates/analytics/docs/uptime-monitoring.md +150 -0
- package/corpus/templates/analytics/server/db/schema-errors.ts +150 -0
- package/corpus/templates/analytics/server/db/schema-monitoring.ts +177 -0
- package/corpus/templates/analytics/server/db/schema.ts +6 -0
- package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +123 -0
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +41 -0
- package/corpus/templates/analytics/server/lib/data-dictionary-context.ts +55 -12
- package/corpus/templates/analytics/server/lib/error-capture.ts +1331 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +47 -1
- package/corpus/templates/analytics/server/lib/monitor-stats.ts +494 -0
- package/corpus/templates/analytics/server/lib/status-pages.ts +798 -0
- package/corpus/templates/analytics/server/lib/uptime-monitors.ts +1665 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -37
- package/corpus/templates/analytics/server/plugins/auth.ts +11 -1
- package/corpus/templates/analytics/server/plugins/db.ts +408 -0
- package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +63 -0
- package/corpus/templates/assets/app/routes/_index.tsx +1 -1
- package/corpus/templates/assets/app/routes/settings.tsx +22 -1
- package/corpus/templates/assets/changelog/2026-07-08-recent-drafts-keep-comfortable-side-padding.md +6 -0
- package/corpus/templates/assets/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/brain/app/routes/settings.tsx +43 -5
- package/corpus/templates/brain/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/calendar/app/pages/Settings.tsx +44 -6
- package/corpus/templates/calendar/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/chat/app/routes/settings.tsx +16 -1
- package/corpus/templates/chat/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +79 -37
- package/corpus/templates/clips/app/components/player/player-controls.tsx +35 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +162 -18
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +63 -6
- package/corpus/templates/clips/app/routes/record.tsx +18 -1
- package/corpus/templates/clips/changelog/2026-07-07-shared-clips-start-playback-reliably-from-the-first-play-click.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-camera-and-microphone-menus-now-show-which-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-copies-new-clip-links-after-saving.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-recordings-now-continue-after-granting-came.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-mobile-shared-clips-now-have-loom-style-tap-and-skip-controls.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-recordings-and-uploads-copy-links-after-saving.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-settings-are-cleaner-searchable-and-collapse-s3-with-builder.md +5 -0
- package/corpus/templates/clips/changelog/2026-07-08-transcripts-are-more-reliable-for-longer-desktop-recordings-.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +162 -13
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +14 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +36 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +99 -25
- package/corpus/templates/clips/chrome-extension/src/permission.ts +63 -14
- package/corpus/templates/clips/chrome-extension/src/popup.ts +123 -23
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +47 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +33 -4
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +214 -29
- package/corpus/templates/content/actions/attach-content-database-source.ts +3 -0
- package/corpus/templates/content/actions/change-content-database-source-role.ts +1 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +42 -32
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +352 -81
- package/corpus/templates/content/app/components/editor/database/settings.tsx +49 -24
- package/corpus/templates/content/app/routes/_app.settings.tsx +19 -1
- package/corpus/templates/content/changelog/2026-07-07-builder-source-tag-fields-now-import-as-multi-select.md +6 -0
- package/corpus/templates/content/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/content/shared/api.ts +3 -0
- package/corpus/templates/content/shared/properties.ts +42 -0
- package/corpus/templates/design/AGENTS.md +9 -0
- package/corpus/templates/design/actions/generate-design.ts +3 -0
- package/corpus/templates/design/actions/get-design-system.ts +147 -1
- package/corpus/templates/design/actions/request-localhost-write-consent.ts +141 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -19
- package/corpus/templates/design/app/routes/settings.tsx +16 -1
- package/corpus/templates/design/changelog/2026-07-08-design-generation-now-follows-hydrated-builder-design-system.md +6 -0
- package/corpus/templates/design/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/design/server/lib/verify-write-grant.ts +4 -2
- package/corpus/templates/dispatch/app/routes/settings.tsx +23 -2
- package/corpus/templates/dispatch/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/forms/app/routes/_app.settings.tsx +16 -1
- package/corpus/templates/forms/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/macros/app/routes/settings.tsx +16 -1
- package/corpus/templates/macros/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/mail/app/components/settings/GmailFiltersSection.tsx +1 -1
- package/corpus/templates/mail/app/components/settings/SnippetsSection.tsx +1 -1
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +123 -105
- package/corpus/templates/mail/changelog/2026-07-08-settings-redesigned-with-a-consistent-searchable-layout.md +5 -0
- package/corpus/templates/plan/app/routes/settings.tsx +23 -2
- package/corpus/templates/plan/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/slides/actions/add-slide.ts +1 -0
- package/corpus/templates/slides/actions/get-design-system.ts +147 -1
- package/corpus/templates/slides/app/pages/Index.tsx +46 -1
- package/corpus/templates/slides/app/routes/settings.tsx +16 -1
- package/corpus/templates/slides/changelog/2026-07-08-deck-generation-now-follows-hydrated-builder-design-system-g.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/dist/agent/run-ownership.d.ts +6 -0
- package/dist/agent/run-ownership.d.ts.map +1 -1
- package/dist/agent/run-ownership.js +11 -1
- package/dist/agent/run-ownership.js.map +1 -1
- package/dist/changelog/parse.d.ts +7 -0
- package/dist/changelog/parse.d.ts.map +1 -1
- package/dist/changelog/parse.js +64 -0
- package/dist/changelog/parse.js.map +1 -1
- package/dist/chat-threads/schema.d.ts +445 -0
- package/dist/chat-threads/schema.d.ts.map +1 -0
- package/dist/chat-threads/schema.js +39 -0
- package/dist/chat-threads/schema.js.map +1 -0
- package/dist/chat-threads/store.d.ts +12 -0
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +99 -10
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +40 -10
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/workspace-dev.d.ts.map +1 -1
- package/dist/cli/workspace-dev.js +20 -5
- package/dist/cli/workspace-dev.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +35 -7
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +105 -183
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics-session.d.ts +3 -0
- package/dist/client/analytics-session.d.ts.map +1 -0
- package/dist/client/analytics-session.js +66 -0
- package/dist/client/analytics-session.js.map +1 -0
- package/dist/client/analytics.d.ts +32 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +116 -46
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +2 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +16 -6
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/components/ui/message-scroller.d.ts +16 -0
- package/dist/client/components/ui/message-scroller.d.ts.map +1 -0
- package/dist/client/components/ui/message-scroller.js +25 -0
- package/dist/client/components/ui/message-scroller.js.map +1 -0
- package/dist/client/conversation/AgentConversation.d.ts +1 -1
- package/dist/client/conversation/AgentConversation.d.ts.map +1 -1
- package/dist/client/conversation/AgentConversation.js +3 -8
- package/dist/client/conversation/AgentConversation.js.map +1 -1
- package/dist/client/error-capture.d.ts +99 -0
- package/dist/client/error-capture.d.ts.map +1 -0
- package/dist/client/error-capture.js +437 -0
- package/dist/client/error-capture.js.map +1 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/session-replay.d.ts +22 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +45 -4
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
- package/dist/client/settings/SettingsPanel.js +100 -5
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/SettingsTabsPage.d.ts +49 -1
- package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
- package/dist/client/settings/SettingsTabsPage.js +131 -19
- package/dist/client/settings/SettingsTabsPage.js.map +1 -1
- package/dist/client/settings/index.d.ts +1 -1
- package/dist/client/settings/index.d.ts.map +1 -1
- package/dist/client/settings/index.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +13 -8
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/sharing/ShareDialog.d.ts.map +1 -1
- package/dist/client/sharing/ShareDialog.js +12 -7
- package/dist/client/sharing/ShareDialog.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +21 -2
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/org/schema.d.ts +15 -15
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +109 -73
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +28 -16
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/sharing/actions/list-resource-shares.js +30 -1
- package/dist/sharing/actions/list-resource-shares.js.map +1 -1
- package/dist/sharing/actions/share-resource.js +6 -0
- package/dist/sharing/actions/share-resource.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +192 -30
- package/dist/vite/client.js.map +1 -1
- package/docs/content/toolkit-settings.mdx +56 -3
- package/package.json +2 -1
- package/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uptime monitoring engine.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the analytics-alerts engine (server/lib/analytics-alerts.ts) but for
|
|
5
|
+
* synthetic HTTP checks: it pings a user-defined URL on a schedule and alerts
|
|
6
|
+
* when the target is down, returns an unexpected status, is too slow, or its
|
|
7
|
+
* body is missing expected / contains forbidden text.
|
|
8
|
+
*
|
|
9
|
+
* Design notes:
|
|
10
|
+
* - Data lives in the ownable tables defined in ../db/schema-monitoring.ts.
|
|
11
|
+
* Every read/write is scoped by owner_email + org_id (see `ownerWhere`).
|
|
12
|
+
* - Physical table creation + indexes live in the app migration list
|
|
13
|
+
* (server/plugins/db.ts, versions 92+) so the db.spec.ts "every schema
|
|
14
|
+
* column has a migration" guard stays green and boot handles it.
|
|
15
|
+
* - `runMonitorCheck` fetches through an SSRF-safe path: private/loopback/
|
|
16
|
+
* link-local/metadata addresses are blocked (unless an explicit opt-in env
|
|
17
|
+
* flag is set), the scheme must be http/https, redirects are validated per
|
|
18
|
+
* hop, and the response body is capped before text assertions run.
|
|
19
|
+
* - `evaluateAssertions` / `evaluateCheck` / `matchesStatus` are pure and
|
|
20
|
+
* unit-tested (uptime-monitors.spec.ts).
|
|
21
|
+
*/
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createSsrfSafeDispatcher,
|
|
26
|
+
isBlockedExtensionUrl,
|
|
27
|
+
isBlockedExtensionUrlWithDns,
|
|
28
|
+
} from "@agent-native/core/extensions/url-safety";
|
|
29
|
+
import { notifyWithDelivery } from "@agent-native/core/notifications";
|
|
30
|
+
import { recordChange } from "@agent-native/core/server";
|
|
31
|
+
import {
|
|
32
|
+
and,
|
|
33
|
+
asc,
|
|
34
|
+
count,
|
|
35
|
+
desc,
|
|
36
|
+
eq,
|
|
37
|
+
gte,
|
|
38
|
+
isNull,
|
|
39
|
+
lte,
|
|
40
|
+
or,
|
|
41
|
+
sql,
|
|
42
|
+
} from "drizzle-orm";
|
|
43
|
+
|
|
44
|
+
import { getDb, schema } from "../db/index.js";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Types
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export type MonitorMethod =
|
|
51
|
+
| "GET"
|
|
52
|
+
| "HEAD"
|
|
53
|
+
| "POST"
|
|
54
|
+
| "PUT"
|
|
55
|
+
| "PATCH"
|
|
56
|
+
| "DELETE"
|
|
57
|
+
| "OPTIONS";
|
|
58
|
+
|
|
59
|
+
export type MonitorSeverity = "warning" | "critical";
|
|
60
|
+
|
|
61
|
+
/** Runtime status of a monitor / a single check. */
|
|
62
|
+
export type MonitorStatus =
|
|
63
|
+
| "up"
|
|
64
|
+
| "down"
|
|
65
|
+
| "degraded"
|
|
66
|
+
| "error"
|
|
67
|
+
| "unknown"
|
|
68
|
+
| "running";
|
|
69
|
+
|
|
70
|
+
export type AssertionType =
|
|
71
|
+
| "body_contains"
|
|
72
|
+
| "body_absent"
|
|
73
|
+
| "header_contains"
|
|
74
|
+
| "header_equals"
|
|
75
|
+
| "max_latency_ms";
|
|
76
|
+
|
|
77
|
+
export interface Assertion {
|
|
78
|
+
type: AssertionType;
|
|
79
|
+
value: string | number;
|
|
80
|
+
/** Header name for header_* assertions. */
|
|
81
|
+
header?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type StatusMatcher =
|
|
85
|
+
| { mode: "class"; classes: string[] }
|
|
86
|
+
| { mode: "list"; codes: number[] }
|
|
87
|
+
| { mode: "range"; min: number; max: number };
|
|
88
|
+
|
|
89
|
+
export interface MonitorInput {
|
|
90
|
+
id?: string;
|
|
91
|
+
name: string;
|
|
92
|
+
url: string;
|
|
93
|
+
method?: MonitorMethod;
|
|
94
|
+
requestHeaders?: Record<string, string>;
|
|
95
|
+
requestBody?: string | null;
|
|
96
|
+
intervalSeconds?: number;
|
|
97
|
+
timeoutMs?: number;
|
|
98
|
+
expectedStatus?: StatusMatcher;
|
|
99
|
+
assertions?: Assertion[];
|
|
100
|
+
followRedirects?: boolean;
|
|
101
|
+
severity?: MonitorSeverity;
|
|
102
|
+
channels?: string[];
|
|
103
|
+
emailRecipients?: string[];
|
|
104
|
+
cooldownMinutes?: number;
|
|
105
|
+
enabled?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Monitor {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
url: string;
|
|
112
|
+
method: MonitorMethod;
|
|
113
|
+
requestHeaders: Record<string, string>;
|
|
114
|
+
requestBody: string | null;
|
|
115
|
+
intervalSeconds: number;
|
|
116
|
+
timeoutMs: number;
|
|
117
|
+
expectedStatus: StatusMatcher;
|
|
118
|
+
assertions: Assertion[];
|
|
119
|
+
followRedirects: boolean;
|
|
120
|
+
severity: MonitorSeverity;
|
|
121
|
+
channels: string[];
|
|
122
|
+
emailRecipients: string[];
|
|
123
|
+
cooldownMinutes: number;
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
lastStatus: MonitorStatus | null;
|
|
126
|
+
lastCheckedAt: string | null;
|
|
127
|
+
lastSuccessAt: string | null;
|
|
128
|
+
lastError: string | null;
|
|
129
|
+
lastLatencyMs: number | null;
|
|
130
|
+
lastStatusCode: number | null;
|
|
131
|
+
consecutiveFailures: number;
|
|
132
|
+
createdAt: string;
|
|
133
|
+
updatedAt: string;
|
|
134
|
+
ownerEmail: string;
|
|
135
|
+
orgId: string | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface MonitorUptime {
|
|
139
|
+
uptime24h: number | null;
|
|
140
|
+
uptime7d: number | null;
|
|
141
|
+
checks24h: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type MonitorSummary = Monitor & MonitorUptime;
|
|
145
|
+
|
|
146
|
+
export interface MonitorCheckResult {
|
|
147
|
+
id: string;
|
|
148
|
+
monitorId: string;
|
|
149
|
+
checkedAt: string;
|
|
150
|
+
ok: boolean;
|
|
151
|
+
status: MonitorStatus;
|
|
152
|
+
statusCode: number | null;
|
|
153
|
+
latencyMs: number | null;
|
|
154
|
+
error: string | null;
|
|
155
|
+
failedAssertions: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface MonitorIncident {
|
|
159
|
+
id: string;
|
|
160
|
+
monitorId: string;
|
|
161
|
+
startedAt: string;
|
|
162
|
+
resolvedAt: string | null;
|
|
163
|
+
status: MonitorStatus;
|
|
164
|
+
severity: MonitorSeverity;
|
|
165
|
+
cause: string;
|
|
166
|
+
lastError: string | null;
|
|
167
|
+
notificationId: string | null;
|
|
168
|
+
checksFailed: number;
|
|
169
|
+
createdAt: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Result of a single probe (before persistence). */
|
|
173
|
+
export interface CheckOutcome {
|
|
174
|
+
checkedAt: string;
|
|
175
|
+
status: MonitorStatus;
|
|
176
|
+
ok: boolean;
|
|
177
|
+
statusCode: number | null;
|
|
178
|
+
latencyMs: number | null;
|
|
179
|
+
error: string | null;
|
|
180
|
+
failedAssertions: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface AssertionFailure {
|
|
184
|
+
type: AssertionType;
|
|
185
|
+
message: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface AccessCtx {
|
|
189
|
+
email: string;
|
|
190
|
+
orgId: string | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Constants
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
const MAX_RESPONSE_BODY_BYTES = 512 * 1024; // 512 KB read cap for text assertions
|
|
198
|
+
const MAX_REDIRECT_HOPS = 5;
|
|
199
|
+
const MONITOR_RUNNING_STALE_MS = 5 * 60 * 1000;
|
|
200
|
+
const DEFAULT_RESULT_RETENTION_DAYS = 30;
|
|
201
|
+
const DEFAULT_MONITOR_LIMIT_PER_OWNER = 100;
|
|
202
|
+
const MAX_REQUEST_HEADER_COUNT = 20;
|
|
203
|
+
const MAX_REQUEST_HEADER_NAME_LENGTH = 128;
|
|
204
|
+
const MAX_REQUEST_HEADER_VALUE_BYTES = 2048;
|
|
205
|
+
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
|
|
206
|
+
const MAX_ASSERTIONS_PER_MONITOR = 20;
|
|
207
|
+
const MAX_ASSERTION_VALUE_BYTES = 2048;
|
|
208
|
+
const MAX_ASSERTION_HEADER_LENGTH = 128;
|
|
209
|
+
|
|
210
|
+
const MIN_INTERVAL_SECONDS = 30;
|
|
211
|
+
const MAX_INTERVAL_SECONDS = 24 * 60 * 60;
|
|
212
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
213
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
214
|
+
|
|
215
|
+
const ASSERTION_TYPES: AssertionType[] = [
|
|
216
|
+
"body_contains",
|
|
217
|
+
"body_absent",
|
|
218
|
+
"header_contains",
|
|
219
|
+
"header_equals",
|
|
220
|
+
"max_latency_ms",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Small helpers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function nowIso(): string {
|
|
228
|
+
return new Date().toISOString();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeJsonParse<T>(raw: unknown, fallback: T): T {
|
|
232
|
+
if (typeof raw !== "string" || !raw.trim()) return fallback;
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(raw) as T;
|
|
235
|
+
return parsed ?? fallback;
|
|
236
|
+
} catch {
|
|
237
|
+
return fallback;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function clampInt(value: unknown, min: number, max: number, fallback: number) {
|
|
242
|
+
const normalized = Math.floor(Number(value));
|
|
243
|
+
if (!Number.isFinite(normalized)) return fallback;
|
|
244
|
+
return Math.max(min, Math.min(max, normalized));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function boolEnv(name: string): boolean {
|
|
248
|
+
const raw = process.env[name]?.trim().toLowerCase();
|
|
249
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function byteLength(value: string): number {
|
|
253
|
+
return new TextEncoder().encode(value).length;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Opt-in escape hatch for monitoring internal/private hosts (dev, self-host). */
|
|
257
|
+
export function monitorAllowPrivateHosts(): boolean {
|
|
258
|
+
return boolEnv("UPTIME_MONITOR_ALLOW_PRIVATE_HOSTS");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resultRetentionDays(): number {
|
|
262
|
+
const raw = process.env.UPTIME_MONITOR_RESULT_RETENTION_DAYS?.trim();
|
|
263
|
+
if (!raw) return DEFAULT_RESULT_RETENTION_DAYS;
|
|
264
|
+
const parsed = Number.parseInt(raw, 10);
|
|
265
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
266
|
+
? Math.min(parsed, 365)
|
|
267
|
+
: DEFAULT_RESULT_RETENTION_DAYS;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function monitorLimitPerOwner(): number {
|
|
271
|
+
const raw = process.env.UPTIME_MONITOR_LIMIT_PER_OWNER?.trim();
|
|
272
|
+
if (!raw) return DEFAULT_MONITOR_LIMIT_PER_OWNER;
|
|
273
|
+
const parsed = Number.parseInt(raw, 10);
|
|
274
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
275
|
+
? Math.min(parsed, 1000)
|
|
276
|
+
: DEFAULT_MONITOR_LIMIT_PER_OWNER;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function hostFromUrl(url: string): string {
|
|
280
|
+
try {
|
|
281
|
+
return new URL(url).host;
|
|
282
|
+
} catch {
|
|
283
|
+
return url;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Normalization / validation
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function badRequest(message: string): Error {
|
|
292
|
+
return Object.assign(new Error(message), { statusCode: 400 });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeName(name: string): string {
|
|
296
|
+
const normalized = (name ?? "").trim();
|
|
297
|
+
if (!normalized) throw badRequest("Monitor name is required");
|
|
298
|
+
return normalized.slice(0, 120);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeUrl(url: string): string {
|
|
302
|
+
const normalized = (url ?? "").trim();
|
|
303
|
+
if (!normalized) throw badRequest("Monitor URL is required");
|
|
304
|
+
let parsed: URL;
|
|
305
|
+
try {
|
|
306
|
+
parsed = new URL(normalized);
|
|
307
|
+
} catch {
|
|
308
|
+
throw badRequest("Monitor URL is not a valid URL");
|
|
309
|
+
}
|
|
310
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
311
|
+
throw badRequest("Monitor URL must use http or https");
|
|
312
|
+
}
|
|
313
|
+
return normalized;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeMethod(method: string | undefined): MonitorMethod {
|
|
317
|
+
const upper = (method ?? "GET").toUpperCase();
|
|
318
|
+
const allowed: MonitorMethod[] = [
|
|
319
|
+
"GET",
|
|
320
|
+
"HEAD",
|
|
321
|
+
"POST",
|
|
322
|
+
"PUT",
|
|
323
|
+
"PATCH",
|
|
324
|
+
"DELETE",
|
|
325
|
+
"OPTIONS",
|
|
326
|
+
];
|
|
327
|
+
return (allowed as string[]).includes(upper)
|
|
328
|
+
? (upper as MonitorMethod)
|
|
329
|
+
: "GET";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeHeaders(
|
|
333
|
+
headers: Record<string, string> | undefined,
|
|
334
|
+
): Record<string, string> {
|
|
335
|
+
const out: Record<string, string> = {};
|
|
336
|
+
if (!headers || typeof headers !== "object") return out;
|
|
337
|
+
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
|
338
|
+
if (Object.keys(out).length >= MAX_REQUEST_HEADER_COUNT) {
|
|
339
|
+
throw badRequest(
|
|
340
|
+
`Monitor request headers are limited to ${MAX_REQUEST_HEADER_COUNT}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const key = String(rawKey).trim();
|
|
344
|
+
if (!key) continue;
|
|
345
|
+
if (key.length > MAX_REQUEST_HEADER_NAME_LENGTH) {
|
|
346
|
+
throw badRequest(
|
|
347
|
+
`Monitor request header names are limited to ${MAX_REQUEST_HEADER_NAME_LENGTH} characters`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const value = String(rawValue ?? "");
|
|
351
|
+
if (byteLength(value) > MAX_REQUEST_HEADER_VALUE_BYTES) {
|
|
352
|
+
throw badRequest(
|
|
353
|
+
`Monitor request header values are limited to ${MAX_REQUEST_HEADER_VALUE_BYTES} bytes`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
out[key] = value;
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function normalizeStatusMatcher(input: unknown): StatusMatcher {
|
|
362
|
+
const fallback: StatusMatcher = { mode: "class", classes: ["2xx"] };
|
|
363
|
+
if (!input || typeof input !== "object") return fallback;
|
|
364
|
+
const raw = input as Record<string, unknown>;
|
|
365
|
+
if (raw.mode === "list" && Array.isArray(raw.codes)) {
|
|
366
|
+
const codes = raw.codes
|
|
367
|
+
.map((c) => Math.floor(Number(c)))
|
|
368
|
+
.filter((c) => Number.isFinite(c) && c >= 100 && c <= 599);
|
|
369
|
+
return codes.length ? { mode: "list", codes } : fallback;
|
|
370
|
+
}
|
|
371
|
+
if (raw.mode === "range") {
|
|
372
|
+
const min = clampInt(raw.min, 100, 599, 200);
|
|
373
|
+
const max = clampInt(raw.max, 100, 599, 299);
|
|
374
|
+
return { mode: "range", min: Math.min(min, max), max: Math.max(min, max) };
|
|
375
|
+
}
|
|
376
|
+
// Default / mode === "class"
|
|
377
|
+
const classes = Array.isArray(raw.classes)
|
|
378
|
+
? raw.classes
|
|
379
|
+
.map((c) => String(c).toLowerCase())
|
|
380
|
+
.filter((c) => /^[1-5]xx$/.test(c))
|
|
381
|
+
: [];
|
|
382
|
+
return classes.length ? { mode: "class", classes } : fallback;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function normalizeAssertions(input: unknown): Assertion[] {
|
|
386
|
+
if (!Array.isArray(input)) return [];
|
|
387
|
+
const out: Assertion[] = [];
|
|
388
|
+
for (const raw of input) {
|
|
389
|
+
if (out.length >= MAX_ASSERTIONS_PER_MONITOR) {
|
|
390
|
+
throw badRequest(
|
|
391
|
+
`Monitors are limited to ${MAX_ASSERTIONS_PER_MONITOR} assertions`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (!raw || typeof raw !== "object") continue;
|
|
395
|
+
const entry = raw as Record<string, unknown>;
|
|
396
|
+
const type = String(entry.type ?? "") as AssertionType;
|
|
397
|
+
if (!ASSERTION_TYPES.includes(type)) continue;
|
|
398
|
+
if (type === "max_latency_ms") {
|
|
399
|
+
// A latency budget of 0 (or negative/NaN) is meaningless — drop it
|
|
400
|
+
// rather than clamping up to the floor, which would silently create a
|
|
401
|
+
// "1ms" assertion the user never asked for.
|
|
402
|
+
const raw = Math.floor(Number(entry.value));
|
|
403
|
+
if (!Number.isFinite(raw) || raw <= 0) continue;
|
|
404
|
+
out.push({ type, value: Math.min(raw, 600_000) });
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const value = String(entry.value ?? "").trim();
|
|
408
|
+
if (!value) continue;
|
|
409
|
+
if (byteLength(value) > MAX_ASSERTION_VALUE_BYTES) {
|
|
410
|
+
throw badRequest(
|
|
411
|
+
`Monitor assertion values are limited to ${MAX_ASSERTION_VALUE_BYTES} bytes`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (type === "header_contains" || type === "header_equals") {
|
|
415
|
+
const header = String(entry.header ?? "").trim();
|
|
416
|
+
if (!header) continue;
|
|
417
|
+
if (header.length > MAX_ASSERTION_HEADER_LENGTH) {
|
|
418
|
+
throw badRequest(
|
|
419
|
+
`Monitor assertion header names are limited to ${MAX_ASSERTION_HEADER_LENGTH} characters`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
out.push({ type, value, header });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
out.push({ type, value });
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normalizeChannels(
|
|
431
|
+
channels: string[] | undefined,
|
|
432
|
+
emailRecipients: string[],
|
|
433
|
+
): string[] {
|
|
434
|
+
const source =
|
|
435
|
+
channels && channels.length
|
|
436
|
+
? channels
|
|
437
|
+
: emailRecipients.length
|
|
438
|
+
? ["inbox", "email"]
|
|
439
|
+
: ["inbox"];
|
|
440
|
+
const seen = new Set<string>();
|
|
441
|
+
const normalized: string[] = [];
|
|
442
|
+
for (const raw of source) {
|
|
443
|
+
const channel = String(raw ?? "").trim();
|
|
444
|
+
if (!channel || seen.has(channel)) continue;
|
|
445
|
+
seen.add(channel);
|
|
446
|
+
normalized.push(channel);
|
|
447
|
+
}
|
|
448
|
+
return normalized.length ? normalized : ["inbox"];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function normalizeEmailRecipients(recipients: string[] | undefined): string[] {
|
|
452
|
+
const seen = new Set<string>();
|
|
453
|
+
const normalized: string[] = [];
|
|
454
|
+
for (const raw of recipients ?? []) {
|
|
455
|
+
const email = String(raw ?? "")
|
|
456
|
+
.trim()
|
|
457
|
+
.toLowerCase();
|
|
458
|
+
if (!email || seen.has(email)) continue;
|
|
459
|
+
seen.add(email);
|
|
460
|
+
normalized.push(email);
|
|
461
|
+
}
|
|
462
|
+
return normalized;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function ensureInboxChannel(channels: string[]): string[] {
|
|
466
|
+
const normalized = channels.map((c) => c.trim()).filter(Boolean);
|
|
467
|
+
return normalized.includes("inbox") ? normalized : ["inbox", ...normalized];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// Pure evaluation core (unit-tested)
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
export function matchesStatus(
|
|
475
|
+
statusCode: number | null,
|
|
476
|
+
matcher: StatusMatcher,
|
|
477
|
+
): boolean {
|
|
478
|
+
if (statusCode == null || !Number.isFinite(statusCode)) return false;
|
|
479
|
+
if (matcher.mode === "list") {
|
|
480
|
+
return matcher.codes.includes(statusCode);
|
|
481
|
+
}
|
|
482
|
+
if (matcher.mode === "range") {
|
|
483
|
+
return statusCode >= matcher.min && statusCode <= matcher.max;
|
|
484
|
+
}
|
|
485
|
+
const cls = `${Math.floor(statusCode / 100)}xx`;
|
|
486
|
+
return matcher.classes.map((c) => c.toLowerCase()).includes(cls);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export interface AssertionContext {
|
|
490
|
+
statusCode: number | null;
|
|
491
|
+
latencyMs: number | null;
|
|
492
|
+
bodyText: string;
|
|
493
|
+
headers: Record<string, string>;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function evaluateAssertions(
|
|
497
|
+
assertions: Assertion[],
|
|
498
|
+
ctx: AssertionContext,
|
|
499
|
+
): AssertionFailure[] {
|
|
500
|
+
const failures: AssertionFailure[] = [];
|
|
501
|
+
for (const assertion of assertions) {
|
|
502
|
+
switch (assertion.type) {
|
|
503
|
+
case "body_contains": {
|
|
504
|
+
const needle = String(assertion.value);
|
|
505
|
+
if (!ctx.bodyText.includes(needle)) {
|
|
506
|
+
failures.push({
|
|
507
|
+
type: assertion.type,
|
|
508
|
+
message: `Body is missing expected text: "${needle}"`,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case "body_absent": {
|
|
514
|
+
const needle = String(assertion.value);
|
|
515
|
+
if (ctx.bodyText.includes(needle)) {
|
|
516
|
+
failures.push({
|
|
517
|
+
type: assertion.type,
|
|
518
|
+
message: `Body contains forbidden text: "${needle}"`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case "header_contains": {
|
|
524
|
+
const name = (assertion.header ?? "").toLowerCase();
|
|
525
|
+
const actual = ctx.headers[name];
|
|
526
|
+
const needle = String(assertion.value);
|
|
527
|
+
if (actual == null || !actual.includes(needle)) {
|
|
528
|
+
failures.push({
|
|
529
|
+
type: assertion.type,
|
|
530
|
+
message: `Header "${assertion.header}" does not contain "${needle}"`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case "header_equals": {
|
|
536
|
+
const name = (assertion.header ?? "").toLowerCase();
|
|
537
|
+
const actual = ctx.headers[name];
|
|
538
|
+
const expected = String(assertion.value);
|
|
539
|
+
if (actual !== expected) {
|
|
540
|
+
failures.push({
|
|
541
|
+
type: assertion.type,
|
|
542
|
+
message:
|
|
543
|
+
actual == null
|
|
544
|
+
? `Header "${assertion.header}" is missing`
|
|
545
|
+
: `Header "${assertion.header}" did not match the expected value`,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case "max_latency_ms": {
|
|
551
|
+
const max = Number(assertion.value);
|
|
552
|
+
if (
|
|
553
|
+
ctx.latencyMs != null &&
|
|
554
|
+
Number.isFinite(max) &&
|
|
555
|
+
ctx.latencyMs > max
|
|
556
|
+
) {
|
|
557
|
+
failures.push({
|
|
558
|
+
type: assertion.type,
|
|
559
|
+
message: `Response took ${ctx.latencyMs}ms (max ${max}ms)`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return failures;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export interface EvaluateCheckParams {
|
|
570
|
+
statusCode: number | null;
|
|
571
|
+
latencyMs: number | null;
|
|
572
|
+
bodyText: string;
|
|
573
|
+
headers: Record<string, string>;
|
|
574
|
+
matcher: StatusMatcher;
|
|
575
|
+
assertions: Assertion[];
|
|
576
|
+
/** Non-null when the request could not complete. */
|
|
577
|
+
fetchError?: string | null;
|
|
578
|
+
/** "config" → misconfiguration (status "error"); "network" → down. */
|
|
579
|
+
errorKind?: "config" | "network" | null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Pure classifier. Turns a probe's raw signals into a status + failure list:
|
|
584
|
+
* - fetchError present → "error" (config) or "down" (network/timeout)
|
|
585
|
+
* - status mismatch OR a body/header assertion fails → "down"
|
|
586
|
+
* - only a latency assertion fails → "degraded"
|
|
587
|
+
* - otherwise → "up"
|
|
588
|
+
*/
|
|
589
|
+
export function evaluateCheck(params: EvaluateCheckParams): {
|
|
590
|
+
status: MonitorStatus;
|
|
591
|
+
ok: boolean;
|
|
592
|
+
failedAssertions: string[];
|
|
593
|
+
} {
|
|
594
|
+
if (params.fetchError) {
|
|
595
|
+
return {
|
|
596
|
+
status: params.errorKind === "config" ? "error" : "down",
|
|
597
|
+
ok: false,
|
|
598
|
+
failedAssertions: [params.fetchError],
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const messages: string[] = [];
|
|
603
|
+
const statusMatched = matchesStatus(params.statusCode, params.matcher);
|
|
604
|
+
if (!statusMatched) {
|
|
605
|
+
messages.push(`Unexpected status ${params.statusCode ?? "n/a"}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const assertionFailures = evaluateAssertions(params.assertions, {
|
|
609
|
+
statusCode: params.statusCode,
|
|
610
|
+
latencyMs: params.latencyMs,
|
|
611
|
+
bodyText: params.bodyText,
|
|
612
|
+
headers: params.headers,
|
|
613
|
+
});
|
|
614
|
+
for (const failure of assertionFailures) messages.push(failure.message);
|
|
615
|
+
|
|
616
|
+
const hardFailure =
|
|
617
|
+
!statusMatched ||
|
|
618
|
+
assertionFailures.some((f) => f.type !== "max_latency_ms");
|
|
619
|
+
const latencyFailure = assertionFailures.some(
|
|
620
|
+
(f) => f.type === "max_latency_ms",
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
let status: MonitorStatus = "up";
|
|
624
|
+
if (hardFailure) status = "down";
|
|
625
|
+
else if (latencyFailure) status = "degraded";
|
|
626
|
+
|
|
627
|
+
return { status, ok: status === "up", failedAssertions: messages };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// SSRF-safe fetch + probe
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
async function safeMonitorFetch(
|
|
635
|
+
url: string,
|
|
636
|
+
init: RequestInit,
|
|
637
|
+
opts: {
|
|
638
|
+
followRedirects: boolean;
|
|
639
|
+
maxRedirects: number;
|
|
640
|
+
allowPrivateHosts: boolean;
|
|
641
|
+
},
|
|
642
|
+
): Promise<Response> {
|
|
643
|
+
const dispatcher = opts.allowPrivateHosts
|
|
644
|
+
? undefined
|
|
645
|
+
: ((await createSsrfSafeDispatcher()) ?? undefined);
|
|
646
|
+
|
|
647
|
+
let currentUrl = url;
|
|
648
|
+
const maxHops = opts.followRedirects ? opts.maxRedirects : 0;
|
|
649
|
+
|
|
650
|
+
for (let hop = 0; hop <= maxHops; hop++) {
|
|
651
|
+
if (
|
|
652
|
+
!opts.allowPrivateHosts &&
|
|
653
|
+
(await isBlockedExtensionUrlWithDns(currentUrl))
|
|
654
|
+
) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`SSRF blocked: refusing to fetch private/internal address (${currentUrl})`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const fetchOpts: RequestInit & { dispatcher?: unknown } = {
|
|
660
|
+
...init,
|
|
661
|
+
redirect: "manual",
|
|
662
|
+
};
|
|
663
|
+
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
|
664
|
+
|
|
665
|
+
const response = await fetch(currentUrl, fetchOpts as RequestInit);
|
|
666
|
+
const isRedirect = response.status >= 300 && response.status < 400;
|
|
667
|
+
if (opts.followRedirects && isRedirect) {
|
|
668
|
+
const location = response.headers.get("location");
|
|
669
|
+
if (!location) return response;
|
|
670
|
+
currentUrl = new URL(location, currentUrl).href;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
return response;
|
|
674
|
+
}
|
|
675
|
+
throw new Error(
|
|
676
|
+
`SSRF blocked: too many redirects (>${opts.maxRedirects}) while fetching ${url}`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function readCappedText(res: Response, cap: number): Promise<string> {
|
|
681
|
+
const body = res.body;
|
|
682
|
+
if (!body) {
|
|
683
|
+
try {
|
|
684
|
+
const text = await res.text();
|
|
685
|
+
return text.length > cap ? text.slice(0, cap) : text;
|
|
686
|
+
} catch {
|
|
687
|
+
return "";
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const reader = body.getReader();
|
|
691
|
+
const chunks: Uint8Array[] = [];
|
|
692
|
+
let total = 0;
|
|
693
|
+
try {
|
|
694
|
+
while (total < cap) {
|
|
695
|
+
const { done, value } = await reader.read();
|
|
696
|
+
if (done) break;
|
|
697
|
+
if (value) {
|
|
698
|
+
chunks.push(value);
|
|
699
|
+
total += value.length;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
// Partial body is fine for text assertions.
|
|
704
|
+
} finally {
|
|
705
|
+
try {
|
|
706
|
+
await reader.cancel();
|
|
707
|
+
} catch {
|
|
708
|
+
// ignore
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const size = Math.min(total, cap);
|
|
712
|
+
const merged = new Uint8Array(size);
|
|
713
|
+
let offset = 0;
|
|
714
|
+
for (const chunk of chunks) {
|
|
715
|
+
if (offset >= size) break;
|
|
716
|
+
const slice =
|
|
717
|
+
chunk.length > size - offset ? chunk.subarray(0, size - offset) : chunk;
|
|
718
|
+
merged.set(slice, offset);
|
|
719
|
+
offset += slice.length;
|
|
720
|
+
}
|
|
721
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(merged);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function headersToObject(headers: Headers): Record<string, string> {
|
|
725
|
+
const out: Record<string, string> = {};
|
|
726
|
+
headers.forEach((value, key) => {
|
|
727
|
+
out[key.toLowerCase()] = value;
|
|
728
|
+
});
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Execute one probe for `monitor`. Performs an SSRF-safe fetch with an
|
|
734
|
+
* AbortController timeout, measures latency, caps the response body, and
|
|
735
|
+
* classifies the result. Never throws — failures are captured in the outcome.
|
|
736
|
+
*/
|
|
737
|
+
export async function runMonitorCheck(
|
|
738
|
+
monitor: Pick<
|
|
739
|
+
Monitor,
|
|
740
|
+
| "url"
|
|
741
|
+
| "method"
|
|
742
|
+
| "requestHeaders"
|
|
743
|
+
| "requestBody"
|
|
744
|
+
| "timeoutMs"
|
|
745
|
+
| "expectedStatus"
|
|
746
|
+
| "assertions"
|
|
747
|
+
| "followRedirects"
|
|
748
|
+
>,
|
|
749
|
+
opts: { allowPrivateHosts?: boolean } = {},
|
|
750
|
+
): Promise<CheckOutcome> {
|
|
751
|
+
const checkedAt = nowIso();
|
|
752
|
+
const matcher = monitor.expectedStatus;
|
|
753
|
+
const assertions = monitor.assertions;
|
|
754
|
+
const allowPrivateHosts =
|
|
755
|
+
opts.allowPrivateHosts ?? monitorAllowPrivateHosts();
|
|
756
|
+
|
|
757
|
+
// Fast, deterministic pre-flight guard (scheme + literal private hosts).
|
|
758
|
+
if (!allowPrivateHosts && isBlockedExtensionUrl(monitor.url)) {
|
|
759
|
+
return {
|
|
760
|
+
checkedAt,
|
|
761
|
+
statusCode: null,
|
|
762
|
+
latencyMs: null,
|
|
763
|
+
...evaluateCheck({
|
|
764
|
+
statusCode: null,
|
|
765
|
+
latencyMs: null,
|
|
766
|
+
bodyText: "",
|
|
767
|
+
headers: {},
|
|
768
|
+
matcher,
|
|
769
|
+
assertions,
|
|
770
|
+
fetchError: `SSRF blocked: ${monitor.url} is a private, internal, or non-http(s) address`,
|
|
771
|
+
errorKind: "config",
|
|
772
|
+
}),
|
|
773
|
+
error: "SSRF blocked: private/internal or non-http(s) address",
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const controller = new AbortController();
|
|
778
|
+
const timeoutMs = clampInt(
|
|
779
|
+
monitor.timeoutMs,
|
|
780
|
+
MIN_TIMEOUT_MS,
|
|
781
|
+
MAX_TIMEOUT_MS,
|
|
782
|
+
15000,
|
|
783
|
+
);
|
|
784
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
785
|
+
const start = Date.now();
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
const method = normalizeMethod(monitor.method);
|
|
789
|
+
const hasBody =
|
|
790
|
+
method !== "GET" &&
|
|
791
|
+
method !== "HEAD" &&
|
|
792
|
+
monitor.requestBody != null &&
|
|
793
|
+
monitor.requestBody !== "";
|
|
794
|
+
const init: RequestInit = {
|
|
795
|
+
method,
|
|
796
|
+
headers: monitor.requestHeaders,
|
|
797
|
+
signal: controller.signal,
|
|
798
|
+
body: hasBody ? (monitor.requestBody ?? undefined) : undefined,
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const response = await safeMonitorFetch(monitor.url, init, {
|
|
802
|
+
followRedirects: monitor.followRedirects,
|
|
803
|
+
maxRedirects: MAX_REDIRECT_HOPS,
|
|
804
|
+
allowPrivateHosts,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const bodyText =
|
|
808
|
+
method === "HEAD"
|
|
809
|
+
? ""
|
|
810
|
+
: await readCappedText(response, MAX_RESPONSE_BODY_BYTES);
|
|
811
|
+
const latencyMs = Date.now() - start;
|
|
812
|
+
const statusCode = response.status;
|
|
813
|
+
const headers = headersToObject(response.headers);
|
|
814
|
+
|
|
815
|
+
const outcome = evaluateCheck({
|
|
816
|
+
statusCode,
|
|
817
|
+
latencyMs,
|
|
818
|
+
bodyText,
|
|
819
|
+
headers,
|
|
820
|
+
matcher,
|
|
821
|
+
assertions,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
checkedAt,
|
|
826
|
+
statusCode,
|
|
827
|
+
latencyMs,
|
|
828
|
+
status: outcome.status,
|
|
829
|
+
ok: outcome.ok,
|
|
830
|
+
failedAssertions: outcome.failedAssertions,
|
|
831
|
+
error: outcome.failedAssertions.length
|
|
832
|
+
? outcome.failedAssertions.join("; ")
|
|
833
|
+
: null,
|
|
834
|
+
};
|
|
835
|
+
} catch (err) {
|
|
836
|
+
const isTimeout =
|
|
837
|
+
(err as { name?: string })?.name === "AbortError" ||
|
|
838
|
+
controller.signal.aborted;
|
|
839
|
+
const message =
|
|
840
|
+
err instanceof Error ? err.message : String(err ?? "check failed");
|
|
841
|
+
const isConfig = message.startsWith("SSRF blocked");
|
|
842
|
+
const errorText = isTimeout
|
|
843
|
+
? `Timed out after ${timeoutMs}ms`
|
|
844
|
+
: message.slice(0, 500);
|
|
845
|
+
const outcome = evaluateCheck({
|
|
846
|
+
statusCode: null,
|
|
847
|
+
latencyMs: null,
|
|
848
|
+
bodyText: "",
|
|
849
|
+
headers: {},
|
|
850
|
+
matcher,
|
|
851
|
+
assertions,
|
|
852
|
+
fetchError: errorText,
|
|
853
|
+
errorKind: isConfig ? "config" : "network",
|
|
854
|
+
});
|
|
855
|
+
return {
|
|
856
|
+
checkedAt,
|
|
857
|
+
statusCode: null,
|
|
858
|
+
latencyMs: null,
|
|
859
|
+
status: outcome.status,
|
|
860
|
+
ok: outcome.ok,
|
|
861
|
+
failedAssertions: outcome.failedAssertions,
|
|
862
|
+
error: errorText,
|
|
863
|
+
};
|
|
864
|
+
} finally {
|
|
865
|
+
clearTimeout(timer);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
// Row mapping + scoping
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
|
|
873
|
+
function rowToMonitor(row: any): Monitor {
|
|
874
|
+
return {
|
|
875
|
+
id: row.id,
|
|
876
|
+
name: row.name,
|
|
877
|
+
url: row.url,
|
|
878
|
+
method: normalizeMethod(row.method),
|
|
879
|
+
requestHeaders: safeJsonParse<Record<string, string>>(
|
|
880
|
+
row.requestHeaders,
|
|
881
|
+
{},
|
|
882
|
+
),
|
|
883
|
+
requestBody: row.requestBody ?? null,
|
|
884
|
+
intervalSeconds: Number(row.intervalSeconds ?? 300),
|
|
885
|
+
timeoutMs: Number(row.timeoutMs ?? 15000),
|
|
886
|
+
expectedStatus: normalizeStatusMatcher(
|
|
887
|
+
safeJsonParse<unknown>(row.expectedStatus, null),
|
|
888
|
+
),
|
|
889
|
+
assertions: normalizeAssertions(safeJsonParse<unknown>(row.assertions, [])),
|
|
890
|
+
followRedirects: row.followRedirects === true || row.followRedirects === 1,
|
|
891
|
+
severity: row.severity === "warning" ? "warning" : "critical",
|
|
892
|
+
channels: safeJsonParse<string[]>(row.channels, ["inbox"]),
|
|
893
|
+
emailRecipients: safeJsonParse<string[]>(row.emailRecipients, []),
|
|
894
|
+
cooldownMinutes: Number(row.cooldownMinutes ?? 15),
|
|
895
|
+
enabled: row.enabled === true || row.enabled === 1,
|
|
896
|
+
lastStatus: (row.lastStatus ?? null) as MonitorStatus | null,
|
|
897
|
+
lastCheckedAt: row.lastCheckedAt ?? null,
|
|
898
|
+
lastSuccessAt: row.lastSuccessAt ?? null,
|
|
899
|
+
lastError: row.lastError ?? null,
|
|
900
|
+
lastLatencyMs: row.lastLatencyMs ?? null,
|
|
901
|
+
lastStatusCode: row.lastStatusCode ?? null,
|
|
902
|
+
consecutiveFailures: Number(row.consecutiveFailures ?? 0),
|
|
903
|
+
createdAt: row.createdAt,
|
|
904
|
+
updatedAt: row.updatedAt,
|
|
905
|
+
ownerEmail: row.ownerEmail,
|
|
906
|
+
orgId: row.orgId ?? null,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function rowToResult(row: any): MonitorCheckResult {
|
|
911
|
+
return {
|
|
912
|
+
id: row.id,
|
|
913
|
+
monitorId: row.monitorId,
|
|
914
|
+
checkedAt: row.checkedAt,
|
|
915
|
+
ok: row.ok === true || row.ok === 1,
|
|
916
|
+
status: (row.status ?? "up") as MonitorStatus,
|
|
917
|
+
statusCode: row.statusCode ?? null,
|
|
918
|
+
latencyMs: row.latencyMs ?? null,
|
|
919
|
+
error: row.error ?? null,
|
|
920
|
+
failedAssertions: safeJsonParse<string[]>(row.failedAssertions, []),
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function rowToIncident(row: any): MonitorIncident {
|
|
925
|
+
return {
|
|
926
|
+
id: row.id,
|
|
927
|
+
monitorId: row.monitorId,
|
|
928
|
+
startedAt: row.startedAt,
|
|
929
|
+
resolvedAt: row.resolvedAt ?? null,
|
|
930
|
+
status: (row.status ?? "down") as MonitorStatus,
|
|
931
|
+
severity: row.severity === "warning" ? "warning" : "critical",
|
|
932
|
+
cause: row.cause ?? "",
|
|
933
|
+
lastError: row.lastError ?? null,
|
|
934
|
+
notificationId: row.notificationId ?? null,
|
|
935
|
+
checksFailed: Number(row.checksFailed ?? 1),
|
|
936
|
+
createdAt: row.createdAt,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function ownerWhere(ctx: AccessCtx, id?: string) {
|
|
941
|
+
const table = schema.monitors;
|
|
942
|
+
const clauses = [
|
|
943
|
+
sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
|
|
944
|
+
ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
|
|
945
|
+
];
|
|
946
|
+
if (id) clauses.push(eq(table.id, id));
|
|
947
|
+
return and(...clauses);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function resultsOwnerWhere(ctx: AccessCtx) {
|
|
951
|
+
const table = schema.monitorCheckResults;
|
|
952
|
+
return and(
|
|
953
|
+
sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
|
|
954
|
+
ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function incidentsOwnerWhere(ctx: AccessCtx) {
|
|
959
|
+
const table = schema.monitorIncidents;
|
|
960
|
+
return and(
|
|
961
|
+
sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
|
|
962
|
+
ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// CRUD
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
export async function listMonitors(ctx: AccessCtx): Promise<MonitorSummary[]> {
|
|
971
|
+
const db = getDb() as any;
|
|
972
|
+
const rows = await db
|
|
973
|
+
.select()
|
|
974
|
+
.from(schema.monitors)
|
|
975
|
+
.where(ownerWhere(ctx))
|
|
976
|
+
.orderBy(asc(schema.monitors.name));
|
|
977
|
+
const monitors = rows.map(rowToMonitor);
|
|
978
|
+
const uptime = await computeUptime(
|
|
979
|
+
ctx,
|
|
980
|
+
monitors.map((m: Monitor) => m.id),
|
|
981
|
+
);
|
|
982
|
+
return monitors.map((monitor: Monitor) => ({
|
|
983
|
+
...monitor,
|
|
984
|
+
...(uptime.get(monitor.id) ?? {
|
|
985
|
+
uptime24h: null,
|
|
986
|
+
uptime7d: null,
|
|
987
|
+
checks24h: 0,
|
|
988
|
+
}),
|
|
989
|
+
}));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export async function getMonitor(
|
|
993
|
+
id: string,
|
|
994
|
+
ctx: AccessCtx,
|
|
995
|
+
): Promise<{
|
|
996
|
+
monitor: MonitorSummary;
|
|
997
|
+
recentResults: MonitorCheckResult[];
|
|
998
|
+
incidents: MonitorIncident[];
|
|
999
|
+
} | null> {
|
|
1000
|
+
const db = getDb() as any;
|
|
1001
|
+
const [row] = await db
|
|
1002
|
+
.select()
|
|
1003
|
+
.from(schema.monitors)
|
|
1004
|
+
.where(ownerWhere(ctx, id));
|
|
1005
|
+
if (!row) return null;
|
|
1006
|
+
const monitor = rowToMonitor(row);
|
|
1007
|
+
|
|
1008
|
+
const [resultRows, incidentRows, uptime] = await Promise.all([
|
|
1009
|
+
db
|
|
1010
|
+
.select()
|
|
1011
|
+
.from(schema.monitorCheckResults)
|
|
1012
|
+
.where(
|
|
1013
|
+
and(
|
|
1014
|
+
resultsOwnerWhere(ctx),
|
|
1015
|
+
eq(schema.monitorCheckResults.monitorId, id),
|
|
1016
|
+
),
|
|
1017
|
+
)
|
|
1018
|
+
.orderBy(desc(schema.monitorCheckResults.checkedAt))
|
|
1019
|
+
.limit(100),
|
|
1020
|
+
db
|
|
1021
|
+
.select()
|
|
1022
|
+
.from(schema.monitorIncidents)
|
|
1023
|
+
.where(
|
|
1024
|
+
and(
|
|
1025
|
+
incidentsOwnerWhere(ctx),
|
|
1026
|
+
eq(schema.monitorIncidents.monitorId, id),
|
|
1027
|
+
),
|
|
1028
|
+
)
|
|
1029
|
+
.orderBy(desc(schema.monitorIncidents.startedAt))
|
|
1030
|
+
.limit(50),
|
|
1031
|
+
computeUptime(ctx, [id]),
|
|
1032
|
+
]);
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
monitor: {
|
|
1036
|
+
...monitor,
|
|
1037
|
+
...(uptime.get(id) ?? {
|
|
1038
|
+
uptime24h: null,
|
|
1039
|
+
uptime7d: null,
|
|
1040
|
+
checks24h: 0,
|
|
1041
|
+
}),
|
|
1042
|
+
},
|
|
1043
|
+
recentResults: resultRows.map(rowToResult),
|
|
1044
|
+
incidents: incidentRows.map(rowToIncident),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function computeUptime(
|
|
1049
|
+
ctx: AccessCtx,
|
|
1050
|
+
ids: string[],
|
|
1051
|
+
): Promise<Map<string, MonitorUptime>> {
|
|
1052
|
+
const result = new Map<string, MonitorUptime>();
|
|
1053
|
+
if (ids.length === 0) return result;
|
|
1054
|
+
const db = getDb() as any;
|
|
1055
|
+
const table = schema.monitorCheckResults;
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
const since24h = new Date(now - 24 * 60 * 60 * 1000).toISOString();
|
|
1058
|
+
const since7d = new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
1059
|
+
|
|
1060
|
+
const rows = await db
|
|
1061
|
+
.select({
|
|
1062
|
+
monitorId: table.monitorId,
|
|
1063
|
+
total24h: sql<number>`sum(case when ${table.checkedAt} >= ${since24h} then 1 else 0 end)`,
|
|
1064
|
+
ok24h: sql<number>`sum(case when ${table.checkedAt} >= ${since24h} and ${table.ok} then 1 else 0 end)`,
|
|
1065
|
+
total7d: sql<number>`sum(case when ${table.checkedAt} >= ${since7d} then 1 else 0 end)`,
|
|
1066
|
+
ok7d: sql<number>`sum(case when ${table.checkedAt} >= ${since7d} and ${table.ok} then 1 else 0 end)`,
|
|
1067
|
+
})
|
|
1068
|
+
.from(table)
|
|
1069
|
+
.where(and(resultsOwnerWhere(ctx), gte(table.checkedAt, since7d)))
|
|
1070
|
+
.groupBy(table.monitorId);
|
|
1071
|
+
|
|
1072
|
+
const wanted = new Set(ids);
|
|
1073
|
+
for (const row of rows) {
|
|
1074
|
+
if (!wanted.has(row.monitorId)) continue;
|
|
1075
|
+
const total24h = Number(row.total24h ?? 0);
|
|
1076
|
+
const ok24h = Number(row.ok24h ?? 0);
|
|
1077
|
+
const total7d = Number(row.total7d ?? 0);
|
|
1078
|
+
const ok7d = Number(row.ok7d ?? 0);
|
|
1079
|
+
result.set(row.monitorId, {
|
|
1080
|
+
uptime24h: total24h > 0 ? (ok24h / total24h) * 100 : null,
|
|
1081
|
+
uptime7d: total7d > 0 ? (ok7d / total7d) * 100 : null,
|
|
1082
|
+
checks24h: total24h,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
return result;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
export async function saveMonitor(
|
|
1089
|
+
input: MonitorInput,
|
|
1090
|
+
ctx: AccessCtx,
|
|
1091
|
+
): Promise<Monitor> {
|
|
1092
|
+
const db = getDb() as any;
|
|
1093
|
+
const updatedAt = nowIso();
|
|
1094
|
+
const id = input.id || randomUUID();
|
|
1095
|
+
|
|
1096
|
+
const name = normalizeName(input.name);
|
|
1097
|
+
const url = normalizeUrl(input.url);
|
|
1098
|
+
const method = normalizeMethod(input.method);
|
|
1099
|
+
const requestHeaders = normalizeHeaders(input.requestHeaders);
|
|
1100
|
+
const requestBody = input.requestBody?.trim() ? input.requestBody : null;
|
|
1101
|
+
if (requestBody && byteLength(requestBody) > MAX_REQUEST_BODY_BYTES) {
|
|
1102
|
+
throw badRequest(
|
|
1103
|
+
`Monitor request bodies are limited to ${MAX_REQUEST_BODY_BYTES} bytes`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
const intervalSeconds = clampInt(
|
|
1107
|
+
input.intervalSeconds ?? 300,
|
|
1108
|
+
MIN_INTERVAL_SECONDS,
|
|
1109
|
+
MAX_INTERVAL_SECONDS,
|
|
1110
|
+
300,
|
|
1111
|
+
);
|
|
1112
|
+
const timeoutMs = clampInt(
|
|
1113
|
+
input.timeoutMs ?? 15000,
|
|
1114
|
+
MIN_TIMEOUT_MS,
|
|
1115
|
+
MAX_TIMEOUT_MS,
|
|
1116
|
+
15000,
|
|
1117
|
+
);
|
|
1118
|
+
const expectedStatus = normalizeStatusMatcher(input.expectedStatus);
|
|
1119
|
+
const assertions = normalizeAssertions(input.assertions);
|
|
1120
|
+
const followRedirects = input.followRedirects ?? true;
|
|
1121
|
+
const severity = input.severity === "warning" ? "warning" : "critical";
|
|
1122
|
+
const emailRecipients = normalizeEmailRecipients(input.emailRecipients);
|
|
1123
|
+
const channels = normalizeChannels(input.channels, emailRecipients);
|
|
1124
|
+
const cooldownMinutes = clampInt(input.cooldownMinutes ?? 15, 0, 24 * 60, 15);
|
|
1125
|
+
const enabled = input.enabled ?? true;
|
|
1126
|
+
|
|
1127
|
+
const shared = {
|
|
1128
|
+
name,
|
|
1129
|
+
url,
|
|
1130
|
+
method,
|
|
1131
|
+
requestHeaders: JSON.stringify(requestHeaders),
|
|
1132
|
+
requestBody,
|
|
1133
|
+
intervalSeconds,
|
|
1134
|
+
timeoutMs,
|
|
1135
|
+
expectedStatus: JSON.stringify(expectedStatus),
|
|
1136
|
+
assertions: JSON.stringify(assertions),
|
|
1137
|
+
followRedirects,
|
|
1138
|
+
severity,
|
|
1139
|
+
channels: JSON.stringify(channels),
|
|
1140
|
+
emailRecipients: JSON.stringify(emailRecipients),
|
|
1141
|
+
cooldownMinutes,
|
|
1142
|
+
enabled,
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
if (input.id) {
|
|
1146
|
+
const existing = await getMonitor(input.id, ctx);
|
|
1147
|
+
if (!existing) {
|
|
1148
|
+
throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
|
|
1149
|
+
}
|
|
1150
|
+
await db
|
|
1151
|
+
.update(schema.monitors)
|
|
1152
|
+
.set({ ...shared, updatedAt })
|
|
1153
|
+
.where(ownerWhere(ctx, id));
|
|
1154
|
+
} else {
|
|
1155
|
+
const [{ total = 0 } = { total: 0 }] = await db
|
|
1156
|
+
.select({ total: count() })
|
|
1157
|
+
.from(schema.monitors)
|
|
1158
|
+
.where(ownerWhere(ctx));
|
|
1159
|
+
const limit = monitorLimitPerOwner();
|
|
1160
|
+
if (Number(total) >= limit) {
|
|
1161
|
+
throw Object.assign(new Error(`Monitor limit reached (${limit})`), {
|
|
1162
|
+
statusCode: 429,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
await db.insert(schema.monitors).values({
|
|
1166
|
+
id,
|
|
1167
|
+
...shared,
|
|
1168
|
+
lastStatus: "unknown",
|
|
1169
|
+
consecutiveFailures: 0,
|
|
1170
|
+
createdAt: updatedAt,
|
|
1171
|
+
updatedAt,
|
|
1172
|
+
ownerEmail: ctx.email,
|
|
1173
|
+
orgId: ctx.orgId,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const [row] = await db
|
|
1178
|
+
.select()
|
|
1179
|
+
.from(schema.monitors)
|
|
1180
|
+
.where(ownerWhere(ctx, id));
|
|
1181
|
+
if (!row) throw new Error("Failed to save monitor");
|
|
1182
|
+
const saved = rowToMonitor(row);
|
|
1183
|
+
recordChange({
|
|
1184
|
+
source: "monitors",
|
|
1185
|
+
type: "change",
|
|
1186
|
+
key: saved.id,
|
|
1187
|
+
owner: saved.ownerEmail,
|
|
1188
|
+
orgId: saved.orgId ?? undefined,
|
|
1189
|
+
});
|
|
1190
|
+
return saved;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
export async function deleteMonitor(id: string, ctx: AccessCtx): Promise<void> {
|
|
1194
|
+
const [row] = await (getDb() as any)
|
|
1195
|
+
.select()
|
|
1196
|
+
.from(schema.monitors)
|
|
1197
|
+
.where(ownerWhere(ctx, id));
|
|
1198
|
+
if (!row) {
|
|
1199
|
+
throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
|
|
1200
|
+
}
|
|
1201
|
+
const monitor = rowToMonitor(row);
|
|
1202
|
+
const db = getDb() as any;
|
|
1203
|
+
await db.delete(schema.monitors).where(ownerWhere(ctx, id));
|
|
1204
|
+
await db
|
|
1205
|
+
.delete(schema.monitorCheckResults)
|
|
1206
|
+
.where(
|
|
1207
|
+
and(resultsOwnerWhere(ctx), eq(schema.monitorCheckResults.monitorId, id)),
|
|
1208
|
+
);
|
|
1209
|
+
await db
|
|
1210
|
+
.delete(schema.monitorIncidents)
|
|
1211
|
+
.where(
|
|
1212
|
+
and(incidentsOwnerWhere(ctx), eq(schema.monitorIncidents.monitorId, id)),
|
|
1213
|
+
);
|
|
1214
|
+
recordChange({
|
|
1215
|
+
source: "monitors",
|
|
1216
|
+
type: "delete",
|
|
1217
|
+
key: id,
|
|
1218
|
+
owner: monitor.ownerEmail,
|
|
1219
|
+
orgId: monitor.orgId ?? undefined,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ---------------------------------------------------------------------------
|
|
1224
|
+
// Sweep helpers: claim / due selection
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
|
|
1227
|
+
function monitorNotRunningWhere(now: Date) {
|
|
1228
|
+
const table = schema.monitors;
|
|
1229
|
+
const staleBefore = new Date(
|
|
1230
|
+
now.getTime() - MONITOR_RUNNING_STALE_MS,
|
|
1231
|
+
).toISOString();
|
|
1232
|
+
return or(
|
|
1233
|
+
isNull(table.lastStatus),
|
|
1234
|
+
sql`${table.lastStatus} <> 'running'`,
|
|
1235
|
+
isNull(table.lastCheckedAt),
|
|
1236
|
+
lte(table.lastCheckedAt, staleBefore),
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function monitorPreviousCheckWhere(monitor: Monitor) {
|
|
1241
|
+
const table = schema.monitors;
|
|
1242
|
+
return monitor.lastCheckedAt
|
|
1243
|
+
? eq(table.lastCheckedAt, monitor.lastCheckedAt)
|
|
1244
|
+
: isNull(table.lastCheckedAt);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** True when enough time has elapsed since the last check to run again. */
|
|
1248
|
+
export function isMonitorDue(
|
|
1249
|
+
monitor: Monitor,
|
|
1250
|
+
now: Date = new Date(),
|
|
1251
|
+
): boolean {
|
|
1252
|
+
if (!monitor.lastCheckedAt) return true;
|
|
1253
|
+
const last = Date.parse(monitor.lastCheckedAt);
|
|
1254
|
+
if (!Number.isFinite(last)) return true;
|
|
1255
|
+
return now.getTime() - last >= monitor.intervalSeconds * 1000;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export async function listDueMonitors(options: {
|
|
1259
|
+
limit: number;
|
|
1260
|
+
ownerEmail?: string;
|
|
1261
|
+
orgId?: string | null;
|
|
1262
|
+
now?: Date;
|
|
1263
|
+
}): Promise<Monitor[]> {
|
|
1264
|
+
const db = getDb() as any;
|
|
1265
|
+
const table = schema.monitors;
|
|
1266
|
+
const now = options.now ?? new Date();
|
|
1267
|
+
const clauses: any[] = [eq(table.enabled, true), monitorNotRunningWhere(now)];
|
|
1268
|
+
if (options.ownerEmail) {
|
|
1269
|
+
clauses.push(
|
|
1270
|
+
sql`lower(${table.ownerEmail}) = ${options.ownerEmail.toLowerCase()}`,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (options.orgId !== undefined) {
|
|
1274
|
+
clauses.push(
|
|
1275
|
+
options.orgId ? eq(table.orgId, options.orgId) : isNull(table.orgId),
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
const limit = clampInt(options.limit, 1, 500, 100);
|
|
1279
|
+
const rows = await db
|
|
1280
|
+
.select()
|
|
1281
|
+
.from(table)
|
|
1282
|
+
.where(and(...clauses))
|
|
1283
|
+
.orderBy(
|
|
1284
|
+
sql`case when ${table.lastCheckedAt} is null then 0 else 1 end`,
|
|
1285
|
+
asc(table.lastCheckedAt),
|
|
1286
|
+
asc(table.createdAt),
|
|
1287
|
+
)
|
|
1288
|
+
.limit(Math.min(limit * 5, 500));
|
|
1289
|
+
return rows
|
|
1290
|
+
.map(rowToMonitor)
|
|
1291
|
+
.filter((m: Monitor) => isMonitorDue(m, now))
|
|
1292
|
+
.slice(0, limit);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Atomically claim a monitor for this sweep so concurrent sweeps don't
|
|
1297
|
+
* double-run it. Mirrors claimAnalyticsAlertRuleEvaluation.
|
|
1298
|
+
*/
|
|
1299
|
+
export async function claimMonitorRun(
|
|
1300
|
+
monitor: Monitor,
|
|
1301
|
+
now: Date = new Date(),
|
|
1302
|
+
): Promise<boolean> {
|
|
1303
|
+
const db = getDb() as any;
|
|
1304
|
+
const table = schema.monitors;
|
|
1305
|
+
const claimedAt = now.toISOString();
|
|
1306
|
+
const rows = await db
|
|
1307
|
+
.update(table)
|
|
1308
|
+
.set({ lastStatus: "running", lastCheckedAt: claimedAt })
|
|
1309
|
+
.where(
|
|
1310
|
+
and(
|
|
1311
|
+
eq(table.id, monitor.id),
|
|
1312
|
+
eq(table.enabled, true),
|
|
1313
|
+
monitorNotRunningWhere(now),
|
|
1314
|
+
monitorPreviousCheckWhere(monitor),
|
|
1315
|
+
),
|
|
1316
|
+
)
|
|
1317
|
+
.returning({ id: table.id });
|
|
1318
|
+
return rows.length > 0;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ---------------------------------------------------------------------------
|
|
1322
|
+
// Persist result + status
|
|
1323
|
+
// ---------------------------------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
export async function recordMonitorResult(
|
|
1326
|
+
monitor: Monitor,
|
|
1327
|
+
outcome: CheckOutcome,
|
|
1328
|
+
): Promise<void> {
|
|
1329
|
+
const db = getDb() as any;
|
|
1330
|
+
await db.insert(schema.monitorCheckResults).values({
|
|
1331
|
+
id: randomUUID(),
|
|
1332
|
+
monitorId: monitor.id,
|
|
1333
|
+
checkedAt: outcome.checkedAt,
|
|
1334
|
+
ok: outcome.ok,
|
|
1335
|
+
status: outcome.status,
|
|
1336
|
+
statusCode: outcome.statusCode,
|
|
1337
|
+
latencyMs: outcome.latencyMs,
|
|
1338
|
+
error: outcome.error,
|
|
1339
|
+
failedAssertions: JSON.stringify(outcome.failedAssertions),
|
|
1340
|
+
createdAt: outcome.checkedAt,
|
|
1341
|
+
ownerEmail: monitor.ownerEmail,
|
|
1342
|
+
orgId: monitor.orgId,
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
const consecutiveFailures = outcome.ok
|
|
1346
|
+
? 0
|
|
1347
|
+
: (monitor.consecutiveFailures ?? 0) + 1;
|
|
1348
|
+
// Note: no updatedAt bump — status writes must not churn the config
|
|
1349
|
+
// timestamp (mirrors analytics-alerts markRuleStatus).
|
|
1350
|
+
await db
|
|
1351
|
+
.update(schema.monitors)
|
|
1352
|
+
.set({
|
|
1353
|
+
lastStatus: outcome.status,
|
|
1354
|
+
lastCheckedAt: outcome.checkedAt,
|
|
1355
|
+
lastError: outcome.error,
|
|
1356
|
+
lastLatencyMs: outcome.latencyMs,
|
|
1357
|
+
lastStatusCode: outcome.statusCode,
|
|
1358
|
+
lastSuccessAt: outcome.ok ? outcome.checkedAt : monitor.lastSuccessAt,
|
|
1359
|
+
consecutiveFailures,
|
|
1360
|
+
})
|
|
1361
|
+
.where(eq(schema.monitors.id, monitor.id));
|
|
1362
|
+
|
|
1363
|
+
// Emit a change so open UIs invalidate via useDbSync() after each probe —
|
|
1364
|
+
// this is the only sync signal for background-sweep results.
|
|
1365
|
+
recordChange({
|
|
1366
|
+
source: "monitors",
|
|
1367
|
+
type: "change",
|
|
1368
|
+
key: monitor.id,
|
|
1369
|
+
owner: monitor.ownerEmail,
|
|
1370
|
+
orgId: monitor.orgId ?? undefined,
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ---------------------------------------------------------------------------
|
|
1375
|
+
// Incident management + notifications
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
|
|
1378
|
+
function describeCause(outcome: CheckOutcome): string {
|
|
1379
|
+
if (outcome.error) return outcome.error.slice(0, 300);
|
|
1380
|
+
if (outcome.statusCode != null) return `HTTP ${outcome.statusCode}`;
|
|
1381
|
+
return "Check failed";
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
async function getOpenIncident(
|
|
1385
|
+
monitorId: string,
|
|
1386
|
+
ctx: AccessCtx,
|
|
1387
|
+
): Promise<MonitorIncident | null> {
|
|
1388
|
+
const db = getDb() as any;
|
|
1389
|
+
const [row] = await db
|
|
1390
|
+
.select()
|
|
1391
|
+
.from(schema.monitorIncidents)
|
|
1392
|
+
.where(
|
|
1393
|
+
and(
|
|
1394
|
+
incidentsOwnerWhere(ctx),
|
|
1395
|
+
eq(schema.monitorIncidents.monitorId, monitorId),
|
|
1396
|
+
isNull(schema.monitorIncidents.resolvedAt),
|
|
1397
|
+
),
|
|
1398
|
+
)
|
|
1399
|
+
.orderBy(desc(schema.monitorIncidents.startedAt))
|
|
1400
|
+
.limit(1);
|
|
1401
|
+
return row ? rowToIncident(row) : null;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
async function recentlyResolvedWithinCooldown(
|
|
1405
|
+
monitor: Monitor,
|
|
1406
|
+
ctx: AccessCtx,
|
|
1407
|
+
now: Date,
|
|
1408
|
+
): Promise<boolean> {
|
|
1409
|
+
if (monitor.cooldownMinutes <= 0) return false;
|
|
1410
|
+
const db = getDb() as any;
|
|
1411
|
+
const [row] = await db
|
|
1412
|
+
.select({ resolvedAt: schema.monitorIncidents.resolvedAt })
|
|
1413
|
+
.from(schema.monitorIncidents)
|
|
1414
|
+
.where(
|
|
1415
|
+
and(
|
|
1416
|
+
incidentsOwnerWhere(ctx),
|
|
1417
|
+
eq(schema.monitorIncidents.monitorId, monitor.id),
|
|
1418
|
+
),
|
|
1419
|
+
)
|
|
1420
|
+
.orderBy(desc(schema.monitorIncidents.startedAt))
|
|
1421
|
+
.limit(1);
|
|
1422
|
+
if (!row?.resolvedAt) return false;
|
|
1423
|
+
const resolved = Date.parse(row.resolvedAt);
|
|
1424
|
+
if (!Number.isFinite(resolved)) return false;
|
|
1425
|
+
return now.getTime() - resolved < monitor.cooldownMinutes * 60 * 1000;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function notifyMonitorDown(monitor: Monitor, outcome: CheckOutcome) {
|
|
1429
|
+
const host = hostFromUrl(monitor.url);
|
|
1430
|
+
const label = outcome.status === "degraded" ? "degraded" : "down";
|
|
1431
|
+
const severity: "warning" | "critical" =
|
|
1432
|
+
outcome.status === "degraded" ? "warning" : monitor.severity;
|
|
1433
|
+
const detail = outcome.error ? ` — ${outcome.error}` : "";
|
|
1434
|
+
const latency =
|
|
1435
|
+
outcome.latencyMs != null ? ` Latency ${outcome.latencyMs}ms.` : "";
|
|
1436
|
+
return notifyWithDelivery(
|
|
1437
|
+
{
|
|
1438
|
+
severity,
|
|
1439
|
+
title: `Monitor ${label}: ${monitor.name}`,
|
|
1440
|
+
body: `${host} is ${label}${detail}.${latency}`,
|
|
1441
|
+
channels: ensureInboxChannel(monitor.channels),
|
|
1442
|
+
metadata: {
|
|
1443
|
+
kind: "uptime_monitor",
|
|
1444
|
+
monitorId: monitor.id,
|
|
1445
|
+
monitorName: monitor.name,
|
|
1446
|
+
url: monitor.url,
|
|
1447
|
+
status: outcome.status,
|
|
1448
|
+
statusCode: outcome.statusCode,
|
|
1449
|
+
latencyMs: outcome.latencyMs,
|
|
1450
|
+
failedAssertions: outcome.failedAssertions,
|
|
1451
|
+
emailRecipients: monitor.emailRecipients,
|
|
1452
|
+
requestedChannels: monitor.channels,
|
|
1453
|
+
},
|
|
1454
|
+
},
|
|
1455
|
+
{ owner: monitor.ownerEmail },
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
async function notifyMonitorRecovered(
|
|
1460
|
+
monitor: Monitor,
|
|
1461
|
+
outcome: CheckOutcome,
|
|
1462
|
+
incident: MonitorIncident,
|
|
1463
|
+
) {
|
|
1464
|
+
const host = hostFromUrl(monitor.url);
|
|
1465
|
+
const startedMs = Date.parse(incident.startedAt);
|
|
1466
|
+
const downFor = Number.isFinite(startedMs)
|
|
1467
|
+
? humanizeDuration(Date.parse(outcome.checkedAt) - startedMs)
|
|
1468
|
+
: null;
|
|
1469
|
+
const latency =
|
|
1470
|
+
outcome.latencyMs != null ? ` Latency ${outcome.latencyMs}ms.` : "";
|
|
1471
|
+
return notifyWithDelivery(
|
|
1472
|
+
{
|
|
1473
|
+
severity: "info",
|
|
1474
|
+
title: `Monitor recovered: ${monitor.name}`,
|
|
1475
|
+
body: `${host} is back up${downFor ? ` after ${downFor} of downtime` : ""}.${latency}`,
|
|
1476
|
+
channels: ensureInboxChannel(monitor.channels),
|
|
1477
|
+
metadata: {
|
|
1478
|
+
kind: "uptime_monitor",
|
|
1479
|
+
monitorId: monitor.id,
|
|
1480
|
+
monitorName: monitor.name,
|
|
1481
|
+
url: monitor.url,
|
|
1482
|
+
status: "up",
|
|
1483
|
+
statusCode: outcome.statusCode,
|
|
1484
|
+
latencyMs: outcome.latencyMs,
|
|
1485
|
+
incidentId: incident.id,
|
|
1486
|
+
emailRecipients: monitor.emailRecipients,
|
|
1487
|
+
requestedChannels: monitor.channels,
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
{ owner: monitor.ownerEmail },
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function humanizeDuration(ms: number): string {
|
|
1495
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
1496
|
+
const minutes = Math.round(ms / 60000);
|
|
1497
|
+
if (minutes < 1) return "less than a minute";
|
|
1498
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1499
|
+
const hours = Math.floor(minutes / 60);
|
|
1500
|
+
const rem = minutes % 60;
|
|
1501
|
+
if (hours < 24) return rem ? `${hours}h ${rem}m` : `${hours}h`;
|
|
1502
|
+
const days = Math.floor(hours / 24);
|
|
1503
|
+
return `${days}d ${hours % 24}h`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export interface EvaluateMonitorResult {
|
|
1507
|
+
status: MonitorStatus;
|
|
1508
|
+
incidentId?: string;
|
|
1509
|
+
notified: boolean;
|
|
1510
|
+
recovered?: boolean;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Open / update / resolve incidents based on the latest outcome and send
|
|
1515
|
+
* notifications. On a transition into failure it opens an incident and
|
|
1516
|
+
* notifies (respecting an anti-flap cooldown); on recovery it resolves the
|
|
1517
|
+
* open incident and sends a recovery notice.
|
|
1518
|
+
*/
|
|
1519
|
+
export async function evaluateAndNotifyMonitor(
|
|
1520
|
+
monitor: Monitor,
|
|
1521
|
+
outcome: CheckOutcome,
|
|
1522
|
+
ctx: AccessCtx,
|
|
1523
|
+
now: Date = new Date(),
|
|
1524
|
+
): Promise<EvaluateMonitorResult> {
|
|
1525
|
+
const db = getDb() as any;
|
|
1526
|
+
const open = await getOpenIncident(monitor.id, ctx);
|
|
1527
|
+
|
|
1528
|
+
if (!outcome.ok) {
|
|
1529
|
+
const cause = describeCause(outcome);
|
|
1530
|
+
if (open) {
|
|
1531
|
+
const nextStatus =
|
|
1532
|
+
open.status === "down" ? "down" : (outcome.status as MonitorStatus);
|
|
1533
|
+
await db
|
|
1534
|
+
.update(schema.monitorIncidents)
|
|
1535
|
+
.set({
|
|
1536
|
+
checksFailed: (open.checksFailed ?? 1) + 1,
|
|
1537
|
+
lastError: outcome.error,
|
|
1538
|
+
cause,
|
|
1539
|
+
status: nextStatus,
|
|
1540
|
+
})
|
|
1541
|
+
.where(eq(schema.monitorIncidents.id, open.id));
|
|
1542
|
+
return { status: outcome.status, incidentId: open.id, notified: false };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const suppressed = await recentlyResolvedWithinCooldown(monitor, ctx, now);
|
|
1546
|
+
let notificationId: string | undefined;
|
|
1547
|
+
if (!suppressed) {
|
|
1548
|
+
try {
|
|
1549
|
+
const delivery = await notifyMonitorDown(monitor, outcome);
|
|
1550
|
+
notificationId = delivery.notification?.id;
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
console.error(
|
|
1553
|
+
`[uptime-monitors] notify failed for ${monitor.id}:`,
|
|
1554
|
+
err,
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const incidentId = randomUUID();
|
|
1559
|
+
await db.insert(schema.monitorIncidents).values({
|
|
1560
|
+
id: incidentId,
|
|
1561
|
+
monitorId: monitor.id,
|
|
1562
|
+
startedAt: outcome.checkedAt,
|
|
1563
|
+
resolvedAt: null,
|
|
1564
|
+
status: outcome.status === "degraded" ? "degraded" : "down",
|
|
1565
|
+
severity: monitor.severity,
|
|
1566
|
+
cause,
|
|
1567
|
+
lastError: outcome.error,
|
|
1568
|
+
notificationId: notificationId ?? null,
|
|
1569
|
+
checksFailed: 1,
|
|
1570
|
+
createdAt: outcome.checkedAt,
|
|
1571
|
+
ownerEmail: monitor.ownerEmail,
|
|
1572
|
+
orgId: monitor.orgId,
|
|
1573
|
+
});
|
|
1574
|
+
return {
|
|
1575
|
+
status: outcome.status,
|
|
1576
|
+
incidentId,
|
|
1577
|
+
notified: Boolean(notificationId),
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Recovery.
|
|
1582
|
+
if (open) {
|
|
1583
|
+
await db
|
|
1584
|
+
.update(schema.monitorIncidents)
|
|
1585
|
+
.set({ resolvedAt: outcome.checkedAt })
|
|
1586
|
+
.where(eq(schema.monitorIncidents.id, open.id));
|
|
1587
|
+
let notified = false;
|
|
1588
|
+
try {
|
|
1589
|
+
await notifyMonitorRecovered(monitor, outcome, open);
|
|
1590
|
+
notified = true;
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
console.error(
|
|
1593
|
+
`[uptime-monitors] recovery notify failed for ${monitor.id}:`,
|
|
1594
|
+
err,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
return { status: "up", incidentId: open.id, notified, recovered: true };
|
|
1598
|
+
}
|
|
1599
|
+
return { status: "up", notified: false };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Run one monitor end-to-end: probe, persist the result + status, then
|
|
1604
|
+
* open/resolve incidents and notify. Used by the sweep job and the on-demand
|
|
1605
|
+
* run-monitor-check action.
|
|
1606
|
+
*/
|
|
1607
|
+
export async function runAndProcessMonitor(
|
|
1608
|
+
monitor: Monitor,
|
|
1609
|
+
ctx: AccessCtx,
|
|
1610
|
+
opts: { allowPrivateHosts?: boolean } = {},
|
|
1611
|
+
): Promise<CheckOutcome> {
|
|
1612
|
+
const outcome = await runMonitorCheck(monitor, opts);
|
|
1613
|
+
// recordMonitorResult() emits the "monitors" change for the UI.
|
|
1614
|
+
await recordMonitorResult(monitor, outcome);
|
|
1615
|
+
await evaluateAndNotifyMonitor(monitor, outcome, ctx);
|
|
1616
|
+
return outcome;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Run one check now for a specific monitor id (on-demand). Returns the outcome
|
|
1621
|
+
* and the refreshed monitor detail.
|
|
1622
|
+
*/
|
|
1623
|
+
export async function runMonitorNow(
|
|
1624
|
+
id: string,
|
|
1625
|
+
ctx: AccessCtx,
|
|
1626
|
+
): Promise<CheckOutcome> {
|
|
1627
|
+
const [row] = await (getDb() as any)
|
|
1628
|
+
.select()
|
|
1629
|
+
.from(schema.monitors)
|
|
1630
|
+
.where(ownerWhere(ctx, id));
|
|
1631
|
+
if (!row) {
|
|
1632
|
+
throw Object.assign(new Error("Monitor not found"), { statusCode: 404 });
|
|
1633
|
+
}
|
|
1634
|
+
const monitor = rowToMonitor(row);
|
|
1635
|
+
const claimed = await claimMonitorRun(monitor);
|
|
1636
|
+
if (!claimed) {
|
|
1637
|
+
throw Object.assign(new Error("Monitor check is already running"), {
|
|
1638
|
+
statusCode: 409,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return runAndProcessMonitor(monitor, ctx);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// ---------------------------------------------------------------------------
|
|
1645
|
+
// Retention
|
|
1646
|
+
// ---------------------------------------------------------------------------
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Delete check results older than the retention window so the table can't grow
|
|
1650
|
+
* unbounded. This is a global maintenance prune by age (not a per-user read),
|
|
1651
|
+
* so it intentionally isn't owner-scoped.
|
|
1652
|
+
*/
|
|
1653
|
+
export async function pruneOldCheckResults(
|
|
1654
|
+
now: Date = new Date(),
|
|
1655
|
+
): Promise<number> {
|
|
1656
|
+
const cutoff = new Date(
|
|
1657
|
+
now.getTime() - resultRetentionDays() * 24 * 60 * 60 * 1000,
|
|
1658
|
+
).toISOString();
|
|
1659
|
+
const db = getDb() as any;
|
|
1660
|
+
const deleted = await db
|
|
1661
|
+
.delete(schema.monitorCheckResults)
|
|
1662
|
+
.where(lte(schema.monitorCheckResults.checkedAt, cutoff))
|
|
1663
|
+
.returning({ id: schema.monitorCheckResults.id });
|
|
1664
|
+
return Array.isArray(deleted) ? deleted.length : 0;
|
|
1665
|
+
}
|