Compare commits

..
Author SHA1 Message Date
gorkem-bwl f85903adc9 chore(docs): trigger Vercel rebuild 2026-05-22 14:30:52 -04:00
gorkem-bwl a7895cce32 fix(docs): serve docs site at root of Vercel domain
Astro's base option was set to /docs to anticipate the reverse-proxy
URL at checkmate.so/docs. Vercel serves the dist folder at the root,
which made every page 404 because Astro had baked /docs/ into every
internal link and asset URL.

## Changes
- Remove base: "/docs" from astro.config.mjs — site now serves at root
- Update Releases nav link to /releases
- Update README's reverse-proxy snippet to strip the /docs prefix
  before forwarding to Vercel

## Final URL behaviour
- Direct: https://<vercel-domain>/                  → Introduction
- Direct: https://<vercel-domain>/releases/         → Releases
- Proxy:  https://checkmate.so/docs/                → strip /docs → Introduction
- Proxy:  https://checkmate.so/docs/releases/       → strip /docs → Releases
2026-05-22 14:26:51 -04:00
gorkem-bwl 8e4e9dd87c fix(docs): make the build self-contained for Vercel
Vercel's "Root Directory" setting limits the upload to docs/api/, so
the build cannot read sibling paths like ../../server/openapi.json or
../api-content/. A small prebuild script copies both into a vendored
folder inside the project tree (gitignored).

## Changes
- Add scripts/sync-sources.mjs to copy /docs/api-content/ and
  /server/openapi.json into docs/api/src/vendored/ before dev/build
- Wire predev/prebuild npm scripts so the sync runs automatically
- Update astro.config.mjs and src/content.config.ts to read from the
  vendored paths
- Gitignore src/vendored/ — contributors still edit the originals
2026-05-22 14:20:10 -04:00
gorkem-bwl 0d25973efb fix(docs): make the Starlight build pass
## Changes
- Bump @astrojs/starlight to ^0.32.0 so starlight-openapi 0.13's plugin
  contract (top-level setup hook) is supported
- Replace the no-arg docsLoader call with Astro's glob loader pointing
  at ../api-content so prose stays outside the Astro project tree
- Rename introduction.md → index.md and update the sidebar slug so the
  /docs/ root resolves instead of returning 404
- Commit package-lock.json so Vercel installs deterministically

## Verification
- npm run build produces 96 pages (7 narrative + 88 OpenAPI operations + 1
  releases timeline)
- /docs/, /docs/releases/, /docs/api-reference/operations/authlogin/ and
  every other route return 200 locally
- 14 release cards render with the brand-green "Latest" badge on v3.7.1
2026-05-22 14:04:52 -04:00
gorkem-bwl e69f40fdd0 docs(api): add releases timeline page
## Changes
- Add a Releases tab to the top navigation via a custom Starlight
  Header override (HeaderLinks.astro)
- Add ReleaseCard.astro — Vercel/Linear-style entry with sticky date
  + version on the left, headline and feature bullets on the right
- Curate 14 release entries spanning v1.0 (Sept 2024) through v3.7.1
  (April 2026), each focused on the most important user-facing
  features rather than every commit
- Newest release carries a "Latest" badge in the brand color
- Mobile layout collapses the two-column timeline to a single column

## Benefits
- Visitors can see at a glance what shipped recently and what
  Checkmate has matured into over time
- The data file (src/data/releases.ts) is the only thing that needs
  editing when a new version ships — no template or styling churn
- Built on the same Starlight site as the API docs, so look and feel
  stay consistent
2026-05-22 10:25:06 -04:00
gorkem-bwl 9aa2594df7 docs(api): add public API documentation site (Astro Starlight)
## Changes
- Add narrative API documentation in docs/api-content/ (introduction,
  quickstart, authentication, conventions, errors, rate limiting)
- Scaffold Astro Starlight site at docs/api/ that consumes prose from
  the sibling api-content folder and auto-generates the endpoint
  reference from server/openapi.json via starlight-openapi
- Wire Checkmate brand color (#13715B) into Starlight via custom CSS
- Add vercel.json so the docs site deploys cleanly as an Astro project
  with root directory docs/api/

## Benefits
- Public API docs ready to publish at checkmate.so/docs
- Endpoint reference stays in sync with the OpenAPI spec automatically
- Zero third-party SaaS dependency: site is fully self-hosted on Vercel
- Contributors edit plain markdown; no Astro knowledge needed for prose
2026-05-22 10:21:10 -04:00
ashu130698 9027707c92 chore: clean up deprecated FloatingActionBar component 2026-05-14 16:24:51 +05:30
ashu130698 9e69d863df fix(ui): remove unused SPACING import in monitors index 2026-05-14 15:54:55 +05:30
ashu130698 c740fb1aca fix(ui): resolve PR feedback regarding type casts and hardcoded spacing 2026-05-14 15:50:56 +05:30
ashu130698 3c05b9df98 fix(i18n): migrate and insert missing translation keys for bulk edit notifications 2026-05-14 15:49:16 +05:30
ashu130698 d5d79a1eda fix(ux): replace buggy margin-based centering with reliable flexbox wrapper to permanently center floating action bars 2026-05-14 15:49:16 +05:30
ashu130698 2097bc477a fix(ux): replace overwritten transform on floating action bars to fix overflow centering on mobile devices 2026-05-14 15:49:16 +05:30
ashu130698 744d8c8dcf style: fix code format issues 2026-05-14 15:49:16 +05:30
ashu130698 7a5ee6ba05 fix(monitors): address PR feedback on mobile card views and modal selection state 2026-05-14 15:48:55 +05:30
ashu130698 eeee6db94e fix(monitors): add missing page prop to MonitorTable 2026-05-14 15:46:55 +05:30
ashu130698 656c04d0e4 fix(monitors): PR feedback -layout rhythm, pagination memory leak, error alerts, and mobile cards 2026-05-14 15:46:33 +05:30
ashu130698 010ef9661f fix: corrected TypeScript notification property name 2026-05-14 15:45:30 +05:30
ashu130698 ff0ef928a6 feat: added bulk edit notification UI to uptime monitors 2026-05-14 15:45:11 +05:30
ashu130698 eb29a725c2 feat: add table row selection and floating action bar MVP 2026-05-14 15:41:50 +05:30
Alexander Holliday 72f23ca025 Merge pull request #3547 from ZeinebMegaadi/fix/maintenance-recurring-edit-validation
Fix/maintenance recurring edit validation
2026-05-13 11:41:38 -07:00
Alexander Holliday ae9416641c Merge pull request #3652 from bluewave-labs/fix/findByIdsWithChecks
fix: update query
2026-05-13 11:29:02 -07:00
Alex Holliday b7844f8b10 update query 2026-05-13 11:26:25 -07:00
Alexander Holliday ffb563f36d Merge pull request #3650 from bluewave-labs/feat/fe/maintenance-window-groups
Feat/fe/maintenance window groups
2026-05-12 11:21:05 -07:00
Alexander Holliday ae5115a38f Merge pull request #3649 from bluewave-labs/feat/maintenance-window-groups
feat: maintenance window groups
2026-05-12 11:20:51 -07:00
Alex Holliday b925434078 tighten validation, add tests 2026-05-12 11:18:51 -07:00
Alex Holliday f713698572 update maintenacne window hook and form 2026-05-12 11:06:26 -07:00
Alex Holliday 47d2b62e92 update type 2026-05-12 10:58:03 -07:00
Alex Holliday 780189e4a1 add migrations 2026-05-12 10:53:40 -07:00
Alex Holliday f86ceae0c6 update tests 2026-05-12 10:41:30 -07:00
Alex Holliday e78524d1c9 udpate service to use monitorIds instead of monitorId 2026-05-12 10:39:05 -07:00
Alex Holliday 7a2dab6cc4 update repository toEntity method 2026-05-12 10:38:47 -07:00
Alex Holliday c90442b49f update maintenance window type to match DB model 2026-05-12 10:38:22 -07:00
Alex Holliday bdecc1a5e2 update maintenance window model to use array of IDs instead of ID 2026-05-12 10:37:59 -07:00
Alexander Holliday c1fc537479 Merge pull request #3648 from bluewave-labs/fix/histogram-response-time
fix: histogram-response-times
2026-05-12 09:52:49 -07:00
Alex Holliday 6f0540fb33 properly type payload, avgResponseTime -> originalAvgResponseTime 2026-05-12 09:41:58 -07:00
Alexander Holliday 3876aae484 Merge pull request #3645 from bluewave-labs/fix/husky-merge-guard
fix: husky guard pre-commit against merge/rebase state
2026-05-11 11:39:36 -07:00
AlexandClaude Opus 4.7 2f646ee154 fix(husky): guard pre-commit against merge/rebase state
lint-staged uses git stash internally to isolate staged files for
linting. During a merge/rebase/cherry-pick/revert, that stash machinery
interacts catastrophically with MERGE_HEAD and friends — on any lint
failure it resets, silently wiping MERGE_HEAD and your conflict
resolutions. The user ends up in a loop where every merge attempt
leaves a clean tree with no merge commit.

This adds an early-exit guard for the four mid-operation states, and
also anchors the script to the repo root + scopes each lint-staged run
in a subshell so a subdir commit or per-package failure can't poison
the cwd for the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:37:23 -07:00
Alexander Holliday 01da5847f0 Merge pull request #3470 from bluewave-labs/feat/pagespeed-mobile-strat
feat: adding mobile/desktop pagespeed strategy
2026-05-11 11:34:11 -07:00
Alex 4b888c1ed1 bump dependencies 2026-05-11 11:30:58 -07:00
Alex 6b63091d89 tighten validation, add tests 2026-05-11 11:28:39 -07:00
Alex 78fdc548bb Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat 2026-05-11 11:11:13 -07:00
Alexander Holliday bbdbe75255 Merge pull request #3584 from Iriome-Santana/fix/non-root-container-support
fix: allow mono and arm containers to run as non-root user (UID 1000)
2026-05-11 10:38:42 -07:00
Alexander Holliday 74bc5accb6 Merge pull request #3644 from bluewave-labs/feat/fe/dns-monitors
feat: fe/dns monitors
2026-05-11 10:32:39 -07:00
Alex 60770a4544 udpate monitor path map 2026-05-11 10:29:59 -07:00
Alex 766d847561 tighten validation, replace negative type checks with allowed list 2026-05-11 10:27:41 -07:00
Alex 08043a5ea0 tighten validation, add tests 2026-05-11 10:12:27 -07:00
Alex 272e4b5014 update server services to support DNS type queries 2026-05-11 10:01:42 -07:00
Alex 98d2bbfbb9 add DNS monitor to create monitor form 2026-05-11 10:01:23 -07:00
Alex 427f8c632f update validation to include dnsServer and dnsRecordType 2026-05-11 10:01:04 -07:00
Alex 9a004ab1fb udpate monitor types to include DNS, add dnsRecordType 2026-05-11 09:59:53 -07:00
Alexander Holliday c9b9c4a53f Merge pull request #3643 from adosaiguas/develop
i18n: add Catalan language
2026-05-09 09:41:08 -07:00
Agustí Dosaiguas db746b0ff2 Merge branch 'bluewave-labs:develop' into develop 2026-05-09 18:15:31 +02:00
Agustí Dosaiguas 80fca45ce8 i18n: add Catalan language 2026-05-09 18:13:45 +02:00
Alexander Holliday 182ff390e9 Merge pull request #3642 from bluewave-labs/feat/dns-provider
feat: dns provider
2026-05-08 13:17:52 -07:00
Alex fc711e8e24 per call resolver 2026-05-08 13:15:45 -07:00
Alex 0f662e1a65 update tests 2026-05-08 13:04:18 -07:00
Alex 4b4fae91c2 use enums for validation 2026-05-08 12:56:56 -07:00
Alex 172d36ba1d use injected Resolver 2026-05-08 12:56:47 -07:00
Alex 0c36c8e625 tests 2026-05-08 12:44:58 -07:00
Alex 2a7bde9574 update toEntity methods to extract dnsServer and dnsRecord 2026-05-08 12:44:44 -07:00
Alex cb9cc101c0 add dnsServer and dnsRecordType to monitor model 2026-05-08 12:44:18 -07:00
Alex 06ef850577 update validation to allow for dnsServer and dnsRecord 2026-05-08 12:43:33 -07:00
Alex 0797008ac9 add dns record type 2026-05-08 12:43:02 -07:00
Alex Holliday fda9d9edfe add tests 2026-05-08 08:59:31 -07:00
Alex Holliday c6d86d78b8 Add DNS provider 2026-05-08 08:59:25 -07:00
Alex Holliday 0d72071019 add dns types 2026-05-08 08:59:11 -07:00
Alexander Holliday 84533119c9 Merge pull request #3640 from bluewave-labs/fix/maintenance-window-validation
fix(maintenance): reject past-dated one-time windows at creation
2026-05-07 11:44:13 -07:00
Alex 60cded78a6 validation tests 2026-05-07 11:43:00 -07:00
gorkem-bwl c45bcfaa2f revert(maintenance): keep one-time TTL cleanup, narrow PR to validator-only
Per review feedback: auto-deleting completed one-time maintenance windows is
intentional cleanup, not a bug. The actual bug is past-dated end values being
accepted at creation time, which the validator refines now reject up front.

Reverts:
- Restore expiry field + TTL index on MaintenanceWindow schema
- Restore repository's auto-set of expiry = end for repeat=0
- Drop migration 0007 (no longer needed)
- Restore expiry properties in openapi.json
- Restore expiry in create/edit validators (still optional)

Kept from this PR:
- .strict() on create/edit body schemas
- end > start refine on create/edit
- end > now refine on create when repeat=0 (the real fix)
- DurationUnits const tuple + reuse (rules 5/8/10)
- Client no longer sends redundant expiry (server still derives it)
2026-05-07 13:38:10 -04:00
Alexander Holliday 747f31477d Merge pull request #3638 from bluewave-labs/feat/status-code-down-label
feat(checks): render status code 5000 as "Down" with subtype tooltip
2026-05-07 09:58:26 -07:00
Alex 713c3ea34f add span for ref 2026-05-07 09:56:56 -07:00
Alex 1d90b8d8d9 refactor all call sites 2026-05-07 09:54:58 -07:00
Alex 0827d4f528 add new component to barrel export 2026-05-07 09:54:50 -07:00
Alex 7c14d81a24 refactor utils 2026-05-07 09:54:22 -07:00
Alex 7906f2f615 add stauts code label component 2026-05-07 09:54:03 -07:00
gorkem-bwl b462ba7562 refactor(maintenance): centralize DurationUnit + sync OpenAPI spec
Convention pass per docs/coding-conventions.md (rules 5/8/10/11):
- Promote DurationUnit from a raw union type to a const tuple + derived type.
- Mongoose schema enum imports DurationUnits instead of inlining the values.
- Both validators import DurationUnits instead of inlining z.enum twice.
- Drop stale 'expiry' entries from openapi.json (spec is now strict-mode-correct).
2026-05-07 12:38:54 -04:00
gorkem-bwl bd059da94a refactor(maintenance): tighten validator + migration per /simplify
- Collapse two refines into a single superRefine in createMaintenanceWindowBodyValidation; same on edit.
- Match peer migration convention: use mongoose.connection.db directly, not Model.collection.
- Tighten TTL index detection: require expireAfterSeconds + single-field key.
- Add Starting log at migration top to match peers 0003-0006.
2026-05-07 12:33:03 -04:00
gorkem-bwl 4db95cfa44 fix(maintenance): stop silent deletion of one-time maintenance windows
Maintenance windows could disappear seconds after creation when the API received
a payload with end dates already in the past. The repository auto-set
expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the
expiry field then deleted the row on its next sweep. This was particularly
visible with automation tools sending payloads in non-UTC timezones.

## Changes

### Validator hardening
- Add .strict() to create + edit body schemas so unknown keys return 400
  instead of being silently dropped.
- Refine end > start on create.
- Refine end > now for one-time create payloads (repeat = 0).
- Refine end > start on edit when both are provided.
- Remove the accepted expiry field from both schemas.

### Stop the auto-delete behaviour
- Repository.create no longer copies end into expiry.
- Schema's expiry field + TTL index removed.
- Migration 0007 drops the live TTL index and unsets stale expiry values on
  existing rows so legacy data is no longer at risk.

### Client
- Form submission no longer includes expiry.
2026-05-07 12:29:26 -04:00
gorkem-bwl 602769b3e7 feat(checks): render status code 5000 as "Down" with subtype tooltip
Status code 5000 is Checkmate's internal sentinel for "no HTTP response
received" (DNS failure, connection refused, timeout, TLS error, etc.).
Showing the raw number to admins is confusing — the value isn't a real
HTTP code and means nothing without context.

This change replaces "5000" with "Down" across all check/incident
surfaces, and adds a hover tooltip that explains the underlying cause
based on the stored error message. Real HTTP codes (2xx/3xx/4xx/5xx)
are unchanged.

## Changes
- Add client/src/Utils/statusCode.ts with formatStatusCode,
  getStatusCodeTooltip, getStatusCodeValueType, extractErrorCode, and a
  message-to-subtype parser (timeout / dns / refused / tls / reset).
- Wire into 6 render sites: Checks table, Uptime details Checks table,
  GeoChecks table + map popup, Incidents table, Incident details dialog.
- Add common.statusCode.* i18n keys in en.json with subtype-specific
  descriptions. Tooltip appends the raw error code (ECONNREFUSED,
  ETIMEDOUT, CERT_HAS_EXPIRED, etc.) when present in the message.

## Tooltip examples
- "queryA ECONNREFUSED www.example.com" →
  "The server refused the connection (port closed or service not listening). (ECONNREFUSED)"
- "connect ETIMEDOUT 1.2.3.4:443" →
  "The server didn't respond in time (request timed out). (ETIMEDOUT)"

## Notes
- No DB or server changes; statusCode field stays 5000 in storage.
- Other locales fall back to English keys until translated.
2026-05-07 01:13:09 -04:00
Gorkem Cetin a69f8a5ab5 Merge pull request #3637 from bluewave-labs/docs/coding-conventions
docs: rename frontend-conventions to coding-conventions, add backend rules
2026-05-06 14:06:53 -04:00
gorkem-bwl 940fc38056 docs: rename frontend-conventions to coding-conventions, add backend rules
The conventions doc grew up as an FE-only thing. After the DNS provider
PR series highlighted real backend patterns that get repeatedly missed
in code review (DI, AppError envelope, schema enum, test setup style,
centralized Zod enums, OpenAPI response schemas), it makes sense to
have a single doc that covers both halves of the codebase.

Changes:

- Rename docs/frontend-conventions.md → docs/coding-conventions.md.
- Add a new top-level rule 0: look at peer implementations and follow
  the established pattern. Applies to both FE and BE. Includes a
  4-step "how to apply" and a self-check for PR authors.
- Keep the 5 frontend rules unchanged (rules 1-5).
- Add 6 backend rules (rules 6-11):
  - Controller → Service → Repository → Mongoose layering.
  - Provider skeleton (DI, SERVICE_NAME / TIMEOUT_MS, timeRequest,
    inline setTimeout race, outer AppError catch).
  - Mongoose enum reusing types/* const tuples.
  - Provider test conventions (file location, @jest/globals, contract
    test, inline setup() per test).
  - Centralize Zod enums in types/* — don't duplicate.
  - New entity fields must land in *ResponseSchema for OpenAPI.
- Pre-PR checklist split into Universal / Frontend / Backend.
- CLAUDE.md updated to reference the new file and summarize all 11
  rules + rule 0.
2026-05-06 13:55:58 -04:00
Alexander Holliday bf369aa546 Merge pull request #3626 from harsh-aghara/feat/bulk-pause-resume
feat/bulk pause resume
2026-05-06 09:15:30 -07:00
harsh-aghara c72287cb21 fix(monitor): Resolve build fail.
Fixes #3602
2026-05-06 00:09:29 +05:30
harsh-aghara ba2d23fd82 refactor(monitors): optimize bulkTogglePause from 3 to 2 DB queries
Use find + updateMany with timestamps:false instead of find IDs +
updateMany + fetch updated. Manually set updatedAt and edit
in-memory documents to avoid a third DB round trip.

Fixes #3602
2026-05-05 23:43:06 +05:30
harsh-aghara e725128b10 feat(monitors): track and report bulk action queue failures
Addresses PR feedback:
- Upgrades bulk pausing from Promise.all to Promise.allSettled
- Logs individual job queue sync failures with monitor ID context
- Surfaces failedCount to the frontend via controller response
- Adds unit tests to verify partial failure handling

Fixes #3602
2026-05-05 17:08:25 +05:30
harsh-aghara baf7344e2d fix(ui): enforce strict type safety in bulk action error handling
Addresses PR feedback:
- Replaces forbidden any type in catch block with unknown
- Adds type discrimination using axios.isAxiosError and instanceof Error
- Narrows error type for logger.error compatibility

Fixes #3602
2026-05-05 17:07:13 +05:30
harsh-aghara 387cb35494 refactor(ui): simplify table selection and clarify mobile label rendering
Addresses PR feedback:
- Removes over-abstracted useTableSelection hook, inlines selection logic
- Replaces triple-condition with explicit hideMobileLabel bool flag
- Removes unsafe Record<string, never> casting

Fixes #3602
2026-05-05 17:06:48 +05:30
Alexander Holliday 6a8f97ae65 Merge pull request #3633 from bluewave-labs/fix/queue-table
fix: queue table
2026-05-04 12:19:39 -07:00
Alex Holliday 3365a8f7da extract colors to theme 2026-05-04 12:11:57 -07:00
Alex Holliday 2e9b87ba32 normalize tabqueue 2026-05-04 11:59:02 -07:00
Alex Holliday 1fd56610ea update tests 2026-05-04 11:55:39 -07:00
Alex Holliday 22230b8a08 UI fixes for job queue table 2026-05-04 11:53:12 -07:00
Alex Holliday f648ab1b24 add geoInterval, monitorActive, and repeat to jobs data 2026-05-04 11:52:45 -07:00
Alexander Holliday 281fc99136 Merge pull request #3632 from bluewave-labs/fix/pause-resume-geo-jobs
fix: pause resume geo jobs
2026-05-04 10:59:48 -07:00
Alex Holliday 6f0cc307dc log error on geo check pause/resume fail, update tests 2026-05-04 10:51:53 -07:00
Alex Holliday cb218a46a9 fix pause/resume logic 2026-05-04 10:21:28 -07:00
Alexander Holliday 39807c2de1 Merge pull request #3629 from bluewave-labs/feat/infrastructure-form-helper-text
feat(infrastructure): clarify capture endpoint and authorization secret on monitor form
2026-05-02 10:48:33 -07:00
Alexander Holliday 59d97f0d25 Merge pull request #3630 from bluewave-labs/fix/status-page-timezone-defaults
fix(status-page): use operator-configured timezone on public pages
2026-05-02 10:47:44 -07:00
harsh-aghara 657a35e948 feat(i18n): implement strict pluralization for bulk action notifications
Removes generic '(s)' syntax from backend responses. Adds proper _one and _other pluralization keys to all 16 locales with native grammar translations, resolving 0-count edge cases.

Fixes #3602
2026-05-02 14:28:15 +05:30
harsh-aghara 1b0b17b589 fix(ui): correct table header alignment glitch caused by checkbox injection
Adds explicit 'align' support to Table headers to override strict index-based alignment. Restores exact center-alignment spacer boxes to Uptime and Infrastructure tables to match develop branch styling.

Fixes #3602
2026-05-02 14:28:13 +05:30
harsh-aghara ff34c872c8 refactor(ui): convert BulkActionsBar to modular bottom pill using children pattern
Resolves maintainer feedback regarding UI/UX layout and prevents code collisions with other PRs by extracting action logic out of the bar component.

Fixes #3602
2026-05-02 14:28:12 +05:30
gorkem-bwl 1458cf4e4a fix(status-page): use operator-configured timezone on public pages
Anonymous visitors to a public status page have no persisted Redux
state, so ThemedHeatmap and ThemedHistogram tooltips always rendered
timestamps in the hardcoded "America/Toronto" default — regardless of
the timezone the operator configured. Even on the same domain,
ThemedHeatmap and ThemedHistogram had no code path to consume the
operator-configured statusPage.timezone returned by the API.

- Add SYSTEM_TIMEZONE module constant and a small resolveTimezone()
  helper to TimeUtils.ts. formatDateWithTz's timezone parameter is now
  optional and defaults to the system tz, so callers don't need to
  rebuild the prop -> browser -> UTC chain inline.
- Add a `timezone` field to StatusPageThemeContextValue so any themed
  component on the public page can read the page-wide tz without prop
  drilling (parallels how `theme` and `mode` are already exposed).
- Wire statusPage.timezone into StatusPageThemeProvider from
  Pages/StatusPage/Status/index.tsx (both the public route and the
  admin preview).
- ThemedHeatmap and ThemedHistogram now read tz from the context
  instead of useSelector(state.ui.timezone) — that selector was
  unreliable for anonymous visitors and removing it cuts a Redux
  subscription on the public page.
- Replace the inline `data?.timezone || Intl.DateTimeFormat()...`
  expression in useStatusPageForm.ts with resolveTimezone().

Closes #3628.
2026-05-01 22:28:12 -04:00
gorkem-bwl cb285bbb66 i18n: machine-translate new captureUrl key and hardware description
Translates captureUrl.label ("Capture endpoint") and the new long
description.hardware copy into all 15 non-English locales (ar, cs, de,
es, fi, fr, ja, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW). The literal
URL example and the token name "Bearer" are kept untranslated.
2026-05-01 22:06:03 -04:00
gorkem-bwl 13b13b6818 i18n: stop clobbering translated hardware description
The previous commit overwrote description.hardware with English in 15
non-English locales. Restore each locale's existing translation; only
the en.json copy carries the new path/Bearer guidance. Translators will
pick up the English text on the next PoEditor sync.
2026-05-01 21:51:31 -04:00
gorkem-bwl 63c1a60b06 refactor(infrastructure): move capture-form guidance to ConfigBox subtitle
The previous commit added inline helperText under the URL and Secret
fields, but no other form in the codebase uses helperText for static
guidance — only for validation errors. The project's pattern for setup
guidance is the ConfigBox subtitle text on the left side of the form,
populated from pages.createMonitor.form.general.description.<type>.

Move the guidance there so this form follows the same convention as
every other monitor type.

- Remove urlHelperText / secretHelperText from GeneralSettingsConfig
- Remove the new helperText props on the URL and Secret TextFields
- Drop captureUrl.helperText and secret.helperText keys from all
  16 locales
- Beef up description.hardware to mention the /api/v1/metrics path
  requirement and the no-Bearer-prefix rule

The realistic captureUrl placeholder (added in the previous commit)
stays — it does most of the URL-disambiguation work on its own.
2026-05-01 18:01:44 -04:00
Iriome 53c58eb945 fix: make non-root support opt-in, keep root as default
Remove USER node directive so existing deployments are unaffected.
The chown ensures --user 1000 works without changing the default behaviour.
2026-05-01 21:56:40 +01:00
gorkem-bwl d5cd957bbd feat(infrastructure): add helper text to capture endpoint and secret fields
Two real users hit the same confusion: the Capture endpoint URL needs
the full path /api/v1/metrics, and the authorization secret should not
include a "Bearer" prefix. The form previously gave no guidance on
either — same generic placeholder as HTTP monitors and no helper text.

- Replace the URL field's generic label/placeholder with hardware-
  specific copy ("Capture endpoint", realistic 192.168.1.10:5001/...
  example) and add helper text under the field.
- Add helper text under the authorization secret field clarifying that
  only the raw token belongs there — Bearer is added automatically.
- Wire optional urlHelperText / secretHelperText through
  GeneralSettingsConfig so other monitor types are unaffected.
- Add three new i18n keys (captureUrl.label/placeholder/helperText)
  and one (secret.helperText) to all 16 locales. Non-English locales
  carry the English copy as a placeholder until translators pick the
  keys up via the existing PoEditor pipeline.
2026-05-01 16:46:46 -04:00
Alexander Holliday 5b25666054 Merge pull request #3627 from bluewave-labs/feat/generate-api-docs-from-schema
feat: Generate API docs from zod schema
2026-05-01 12:20:25 -07:00
Alex Holliday 34feb6d3fa replace inline objects with defined constants 2026-05-01 11:31:52 -07:00
Alex Holliday a57da284d7 remove unused import 2026-05-01 11:31:52 -07:00
Alex Hollidayandgorkem-bwl 3f9019f34c move openapi out of src dir
Adapted from #3579 by @gorkem-bwl. The openapi/registry, helpers, and
per-resource routes/* files are largely their work; this commit relocates
the directory out of src/ so the build-time generator code is not emitted
into dist/, and shifts spec generation from runtime to a build step.

Co-authored-by: gorkem-bwl <gorkem.cetin@bluewavelabs.ca>
2026-05-01 11:31:48 -07:00
Retr01-sudo 4e284c1468 feat(monitors): integrate bulk actions into uptime and infrastructure tables
- Mounts selection checkboxes in UptimeMonitorsTable and MonitorsTable
- Enhances global Table.tsx to cleanly render checkboxes on mobile cards via mobileLabel
- Injects BulkActionsBar into main list views

Fixes #3602
2026-05-01 19:44:07 +05:30
Retr01-sudo 6b9759e455 feat(monitors): create bulk actions ui components and selection hooks
- Extracts generic table selection logic into useTableSelection hook
- Creates BulkActionsBar component with Collapse animations
- Implements useBulkMonitorActions to handle API logic and toasts
- Adds i18n translation strings across all 16 locales

Fixes #3602
2026-05-01 19:43:27 +05:30
Retr01-sudo 094def117a feat(monitors): add bulk pause/resume api endpoints and service logic
- Validates bulk monitor IDs using zod
- Implements optimized Mongo $in/$ne queries for performance
- Orchestrates background job pausing/resuming via BullMQ
- Added comprehensive unit tests for monitor service logic

Fixes #3602
2026-05-01 19:42:51 +05:30
Gorkem Cetin 28baeaf218 Remove OpenSSF Best Practices badge
Removed OpenSSF Best Practices badge from README.
2026-04-30 20:14:25 -04:00
Alexander Holliday c535ba8c1a Merge pull request #3625 from bluewave-labs/fix/status-page-theme-refactor
fix: extract duplicate code, add slot components
2026-04-30 14:47:40 -07:00
Alex 4c04294f71 extract duplicate code, add slot components 2026-04-30 14:42:19 -07:00
Alexander Holliday 212c1fec52 Merge pull request #3623 from bluewave-labs/feat/raise-max-recent-checks
feat: raise max recent checks
2026-04-30 14:12:44 -07:00
Alex Holliday 05798914a8 Merge branch 'develop' into feat/raise-max-recent-checks 2026-04-30 11:01:27 -07:00
Alexander Holliday 6a4d8a3f6b Merge pull request #3624 from bluewave-labs/fix/status-page-mutate-on-save
fix: mutate status page on save
2026-04-30 11:01:01 -07:00
Alex Holliday e906b237a0 mutate status page on save 2026-04-30 10:57:01 -07:00
Alex Holliday bacbf8404e refactor tests 2026-04-30 10:36:46 -07:00
Alex Holliday d47cc65fed Increase max recent checks limit from 25 -> 50, extract constants 2026-04-30 10:30:54 -07:00
Alexander Holliday e077fe52e7 Merge commit from fork
Remove Docker-Compose Port mapping on dist-arm
2026-04-30 09:59:12 -07:00
Alexander Holliday f39029a37f Merge pull request #3622 from bluewave-labs/chore/bump-fast-xml-parser
chore(deps): bump fast-xml-parser (transitive)
2026-04-30 09:42:01 -07:00
Alexander Holliday c8c54ce49e Merge pull request #3621 from bluewave-labs/chore/bump-protocol-buffers-schema
chore(deps): bump protocol-buffers-schema (transitive)
2026-04-30 09:41:42 -07:00
Alexander Holliday 021eda098d Merge pull request #3620 from bluewave-labs/chore/bump-yaml
chore(deps): bump yaml (transitive)
2026-04-30 09:41:27 -07:00
Alexander Holliday 8993ecadce Merge pull request #3619 from bluewave-labs/chore/bump-postcss-server
chore(deps): bump postcss (transitive)
2026-04-30 09:41:13 -07:00
Alexander Holliday f92e0fae3a Merge pull request #3618 from bluewave-labs/chore/bump-postcss-client
chore(deps): bump postcss (transitive)
2026-04-30 09:40:55 -07:00
Alexander Holliday a4b73bc4ed Merge pull request #3617 from bluewave-labs/chore/bump-follow-redirects
chore(deps): bump follow-redirects (transitive)
2026-04-30 09:40:38 -07:00
Alexander Holliday 87b7ff7452 Merge pull request #3616 from bluewave-labs/chore/bump-vite
chore(deps): bump vite to ^6.4.2
2026-04-30 09:40:22 -07:00
Alexander Holliday 61de273a73 Merge pull request #3615 from bluewave-labs/chore/bump-flatted
chore(deps): bump flatted (transitive)
2026-04-30 09:40:00 -07:00
Alexander Holliday 04332c47d5 Merge pull request #3614 from bluewave-labs/chore/bump-picomatch
chore(deps): bump picomatch (transitive)
2026-04-30 09:39:45 -07:00
Alexander Holliday f263c78a4d Merge pull request #3613 from bluewave-labs/chore/bump-lodash
chore(deps): bump lodash to 4.18.1 (transitive)
2026-04-30 09:39:24 -07:00
Alexander Holliday de67ea9ee6 Merge pull request #3612 from bluewave-labs/chore/bump-multer-lts
chore(deps): bump multer to 1.4.5-lts.2
2026-04-30 09:39:04 -07:00
Alexander Holliday c9c6c42116 Merge pull request #3611 from bluewave-labs/chore/bump-axios
chore(deps): bump axios to 1.15.2
2026-04-30 09:38:41 -07:00
gorkem-bwl 79d2eb68fa chore(deps): bump vite to ^6.4.2
Patches CVE-2026-39365 (medium — path traversal in optimized deps .map handling).
Relaxes pinned version 6.4.1 to ^6.4.2 patch range; semver-compatible.
2026-04-30 01:48:16 -04:00
gorkem-bwl bc9441bcd0 chore(deps): bump fast-xml-parser (transitive)
Patches CVE-2026-41650 (medium — XML comment and CDATA injection via unescaped delimiters in XMLBuilder).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:47:42 -04:00
gorkem-bwl d868f6931d chore(deps): bump protocol-buffers-schema (transitive)
Patches CVE-2026-5758 (medium — prototype pollution in protocol-buffers-schema).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:47:34 -04:00
gorkem-bwl 29dbebd228 chore(deps): bump yaml (transitive)
Patches CVE-2026-33532 (medium — stack overflow via deeply nested YAML collections).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:47:08 -04:00
gorkem-bwl 906d306827 chore(deps): bump postcss (transitive)
Patches CVE-2026-41305 (medium — XSS via unescaped </style> in CSS stringify output).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:46:46 -04:00
gorkem-bwl aaf7d7e47d chore(deps): bump postcss (transitive)
Patches CVE-2026-41305 (medium — XSS via unescaped </style> in CSS stringify output).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:46:28 -04:00
gorkem-bwl c40f4e9f1c chore(deps): bump follow-redirects (transitive)
Patches GHSA-r4q5-vmmm-2653 (medium — leaks Custom-Authorization headers to cross-domain redirect targets).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:46:07 -04:00
gorkem-bwl 35daed605f chore(deps): bump flatted (transitive)
Patches CVE-2026-33228 (high — prototype pollution via parse() in flatted).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:45:33 -04:00
gorkem-bwl 6434afe2c9 chore(deps): bump picomatch (transitive)
Patches CVE-2026-33671 (high — ReDoS via extglob quantifiers) and CVE-2026-33672 (medium — method injection via POSIX character classes).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:44:39 -04:00
gorkem-bwl 788ca78234 chore(deps): bump lodash to 4.18.1 (transitive)
Patches CVE-2026-4800 (high — code injection via _.template) and
CVE-2026-2950 (medium — prototype pollution via array path bypass).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:44:00 -04:00
gorkem-bwl 3adadf0d07 chore(deps): bump multer to 1.4.5-lts.2 in server
## Changes
- Pin multer from ^1.4.5-lts.1 to ^1.4.5-lts.2

## Benefits
- Picks up the latest 1.x LTS patch (released 2025-03-20)
- Patch-level bump within existing semver range; no API changes
- Two routes use multer (authRoute, statusPageRoute) and both rely only on the stable basic API
2026-04-30 01:27:14 -04:00
gorkem-bwl 55b9702fc3 chore(deps): bump axios to 1.15.2 in client and server
## Changes
- Bump axios from ^1.7.x to ^1.15.2 in both client/ and server/

## Benefits
- Patches CVE-2025-62718 (NO_PROXY hostname normalization SSRF)
- Patches CVE-2026-40175 (cloud metadata exfiltration via header injection)
- Minor bump within existing ^1.x semver range; no API changes
2026-04-30 01:26:14 -04:00
Iriome 49fcab2ee3 fix: allow containers to run as non-root user (UID 1000)
- chown public/, dist/, and scripts/ to node:node after build
- add USER node directive before EXPOSE in dist-mono and dist-arm Dockerfiles
- update CMD to exec form in dist-mono for consistency

Fixes running the container with --user 1000 in environments that
don't allow containers to run as root. The sed -i in inject-vars.sh
requires write permission on ./public/assets/, which previously
failed with 'Permission denied' when running as non-root.

The node user (UID 1000) is built into the node:20-slim base image.
Existing root deployments are unaffected.
2026-04-27 14:40:51 +01:00
ZeinebMegaadi d41d9a3dab fix: skip future date validation when editing maintenance window 2026-04-18 12:55:10 +01:00
ZeinebMegaadi 1a54a24fd5 fix: skip future date validation when editing maintenance window 2026-04-18 12:54:13 +01:00
mannilakash ac51dfeab7 fix: removing default value for strategy - monitors 2026-04-13 13:17:24 -04:00
mannilakash 99a5aa1722 fix: badge and timescaleDB changes 2026-04-13 08:29:12 -04:00
mannilakash 5791bdd5e7 fix: adding timescaledb changes 2026-04-07 07:25:35 -04:00
mannilakash f7514bc938 fix: format errors 2026-04-07 07:14:17 -04:00
mannilakash 2bbaf7d1eb feat: adding mobile/desktop pagespeed strategy 2026-04-07 07:02:44 -04:00
Anton Mrosek 726e4de6ab Compose dist-arm: Removed mongodb port mapping 2026-02-16 12:31:44 +01:00
168 changed files with 26784 additions and 5613 deletions

No files matched your search

Regular → Executable
+21 -2
View File
@@ -1,4 +1,23 @@
#!/bin/sh
# Skip during merge/rebase/cherry-pick/revert. lint-staged's internal
# git-stash mechanism is incompatible with these states and will silently
# wipe MERGE_HEAD and your conflict resolutions on commit failure.
git_dir=$(git rev-parse --git-dir)
if [ -e "$git_dir/MERGE_HEAD" ] \
|| [ -e "$git_dir/CHERRY_PICK_HEAD" ] \
|| [ -e "$git_dir/REVERT_HEAD" ] \
|| [ -d "$git_dir/rebase-merge" ] \
|| [ -d "$git_dir/rebase-apply" ]; then
echo "Skipping lint-staged (mid merge/rebase/cherry-pick/revert)"
exit 0
fi
# Anchor to repo root so commits made from a subdirectory still work, and
# run each package's lint-staged in a subshell so cwd is scoped (a failure
# inside one package can't poison the cwd for the next).
cd "$(git rev-parse --show-toplevel)" || exit 1
echo "Running lint-staged..."
cd client && npx lint-staged && cd ..
cd server && npx lint-staged && cd ..
(cd client && npx lint-staged) || exit 1
(cd server && npx lint-staged) || exit 1
+14 -2
View File
@@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not
## Code Conventions
### Frontend conventions (mandatory for `client/src`)
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
### Coding conventions (mandatory)
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
**Frontend (`client/src`):**
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
**Backend (`server/src`):**
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
### Internationalization
All user-facing strings must use the translation function:
```javascript
-1
View File
@@ -7,7 +7,6 @@
![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate)
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
+146 -131
View File
@@ -18,7 +18,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"axios": "^1.15.2",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -59,7 +59,7 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.4.1"
"vite": "^6.4.2"
}
},
"node_modules/@babel/code-frame": {
@@ -1635,9 +1635,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1648,9 +1648,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1661,9 +1661,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1674,9 +1674,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1687,9 +1687,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1700,9 +1700,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1713,9 +1713,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1726,9 +1726,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1739,9 +1739,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1752,9 +1752,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1765,9 +1765,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1778,9 +1778,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1791,9 +1791,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -1804,9 +1804,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1817,9 +1817,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1830,9 +1830,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1843,9 +1843,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1856,9 +1856,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1869,9 +1869,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1882,9 +1882,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -1895,9 +1895,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1908,9 +1908,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1921,9 +1921,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1934,9 +1934,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1947,9 +1947,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -2925,14 +2925,14 @@
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -3239,9 +3239,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
@@ -4191,12 +4191,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -5313,7 +5317,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -5470,9 +5476,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5881,7 +5887,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5912,7 +5920,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"funding": [
{
"type": "opencollective",
@@ -6004,14 +6014,19 @@
"license": "MIT"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -6419,9 +6434,9 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6434,31 +6449,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -7365,9 +7380,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -7648,9 +7663,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"devOptional": true,
"license": "ISC",
"bin": {
+2 -2
View File
@@ -27,7 +27,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"axios": "^1.15.2",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -68,6 +68,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.4.1"
"vite": "^6.4.2"
}
}
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25);
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
if (latestChecks.length !== MAX_RECENT_CHECKS) {
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
data = latestChecks;
}
return (
<Box sx={{ width: "100%" }}>
<Box width={"100%"}>
<Box
width={"100%"}
display={"grid"}
gap={gap}
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
alignItems: "stretch",
}}
>
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
check={check}
>
<Box
display={"grid"}
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
display={"flex"}
gap={theme.spacing(2)}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
width={"100%"}
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
bgcolor={statusBg}
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
width={"100%"}
bgcolor={respBg}
borderRadius={theme.shape.borderRadius}
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
@@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -57,8 +58,11 @@ export const HistogramResponseTime = ({
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
checks.length !== MAX_RECENT_CHECKS
? [
...checks,
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
@@ -86,17 +90,17 @@ export const HistogramResponseTime = ({
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
sx={{ width: "100%" }}
width={"100%"}
>
<Box sx={{ flex: 1 }}>
<Box
width={"100%"}
display={"grid"}
gap={gridGap}
height={chartHeight}
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
@@ -108,23 +112,23 @@ export const HistogramResponseTime = ({
: theme.palette.error.light;
const bar = (
<Box
width={"100%"}
height={"100%"}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
width={"100%"}
height={isPlaceholder ? 0 : heightPct}
bgcolor={barColor}
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
@@ -133,8 +137,8 @@ export const HistogramResponseTime = ({
return isPlaceholder ? (
<Box
height={"100%"}
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
@@ -1,12 +1,13 @@
import type { Monitor } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot, Dot } from "@/Components/design-elements";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -28,7 +29,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
@@ -41,6 +42,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
>
{formatUrl(monitor?.url)}
</Typography>
{monitor.type === "pagespeed" && monitor.strategy && (
<>
<Dot />
<StrategyBadge strategy={monitor.strategy} />
</>
)}
{!isSmall && (
<>
<Dot />
@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import {
formatStatusCode,
getStatusCodeTooltip,
getStatusCodeValueType,
} from "@/Utils/statusCode";
import { ValueLabel } from "./StatusLabel";
import { Tooltip } from "./Tooltip";
export type StatusCodeLabelProps = {
statusCode: number | null | undefined;
message?: string | null;
fallback?: string;
};
export const StatusCodeLabel = ({
statusCode,
message,
fallback = "N/A",
}: StatusCodeLabelProps) => {
const { t } = useTranslation();
if (!statusCode) {
return <>{fallback}</>;
}
const text = formatStatusCode(statusCode, t);
const tooltip = getStatusCodeTooltip(statusCode, message, t);
const body = (
<ValueLabel
value={getStatusCodeValueType(statusCode)}
text={text}
/>
);
if (!tooltip) return body;
return (
<Tooltip title={tooltip}>
<span>{body}</span>
</Tooltip>
);
};
@@ -0,0 +1,32 @@
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { PageSpeedStrategy } from "@/Types/Monitor";
import { SPACING } from "@/Utils/Theme/constants";
interface StrategyBadgeProps {
strategy: PageSpeedStrategy | undefined;
variant?: "caption" | "body2";
}
export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!strategy) {
return null;
}
return (
<Typography
variant={variant}
px={SPACING.XXL}
py={SPACING.SM}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.secondary.light}
color={theme.palette.text.primary}
>
{t(`pages.pageSpeed.strategy.${strategy}`)}
</Typography>
);
};
@@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
mobileLabel?: React.ReactNode;
hideMobileLabel?: boolean;
align?: "left" | "center" | "right" | "justify" | "inherit";
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
@@ -109,6 +112,24 @@ export function DataTable<
key={key}
>
{headers.map((header) => {
if (header.hideMobileLabel) {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={12}
display="flex"
alignItems="center"
justifyContent="flex-end"
>
{header.render(row)}
</Grid2>
</Grid2>
);
}
return (
<Grid2
container
@@ -121,9 +142,9 @@ export function DataTable<
>
<Typography
component="div"
color={theme.palette.text.primary}
color={theme.palette.text.secondary}
>
{header.content}
{header.mobileLabel ?? header.content}
</Typography>
</Grid2>
<Grid2
@@ -193,7 +214,7 @@ export function DataTable<
{headers.map((header, idx) => {
return (
<TableCell
align={idx === 0 ? "left" : "center"}
align={header.align ?? (idx === 0 ? "left" : "center")}
key={header.id}
>
{header.content}
@@ -222,7 +243,7 @@ export function DataTable<
{headers.map((header, index) => {
return (
<TableCell
align={index === 0 ? "left" : "center"}
align={header.align ?? (index === 0 ? "left" : "center")}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
@@ -25,3 +25,5 @@ export * from "./TextLink";
export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
@@ -10,6 +10,7 @@ import type { RootState } from "@/Types/state";
export const languageNames: Record<string, string> = {
en: "English",
de: "Deutsch",
ca: "Català",
es: "Español",
fr: "Français",
"pt-BR": "Português (BR)",
@@ -0,0 +1,102 @@
import React from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Slide from "@mui/material/Slide";
import IconButton from "@mui/material/IconButton";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar";
interface BulkActionsBarProps {
selectedCount: number;
onCancel: () => void;
children?: React.ReactNode;
}
export const BulkActionsBar = ({
selectedCount,
onCancel,
children,
}: BulkActionsBarProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isOpen = selectedCount > 0;
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { width, collapsedWidth, transition } = useSidebar();
return (
<Box
sx={{
position: "fixed",
bottom: theme.spacing(LAYOUT.XS),
left: isSmall ? collapsedWidth : width,
right: 0,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
zIndex: theme.zIndex.snackbar,
transition: transition.replace("width", "left"),
}}
>
<Slide
direction="up"
in={isOpen}
mountOnEnter
unmountOnExit
>
<Paper
elevation={6}
sx={{
pointerEvents: "auto",
px: LAYOUT.MD,
py: LAYOUT.XS,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
gap: LAYOUT.XS,
boxShadow: theme.shadows[8],
}}
>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Typography
variant="body1"
fontWeight={600}
color={theme.palette.text.primary}
>
{t("pages.common.monitors.actions.bulkSelected", {
count: selectedCount,
})}
</Typography>
<IconButton
size="small"
onClick={onCancel}
aria-label={t("common.buttons.cancel")}
>
<X size={18} />
</IconButton>
</Stack>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
{children}
</Stack>
</Paper>
</Slide>
</Box>
);
};
@@ -0,0 +1,138 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { Dialog, Select, RadioWithDescription } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import RadioGroup from "@mui/material/RadioGroup";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Alert from "@mui/material/Alert";
import { useGet, usePatch } from "@/Hooks/UseApi";
import type { Notification } from "@/Types/Notification";
interface BulkEditNotificationsModalProps {
open: boolean;
onClose: () => void;
selectedMonitors: string[];
onComplete: (success: boolean) => void;
}
export const BulkEditNotificationsModal: React.FC<BulkEditNotificationsModalProps> = ({
open,
onClose,
selectedMonitors,
onComplete,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [action, setAction] = useState<"add" | "remove" | "set">("add");
const [notificationIds, setNotificationIds] = useState<string[]>([]);
// Fetch available notifications
const { data: notifications } = useGet<Notification[]>(
"/notifications/team",
undefined,
{
keepPreviousData: true,
}
);
const { patch, loading: isPatching, error } = usePatch();
// Reset state when modal opens
useEffect(() => {
if (open) {
setAction("add");
setNotificationIds([]);
}
}, [open]);
const handleConfirm = async () => {
const res = await patch("/monitors/notifications", {
monitorIds: selectedMonitors,
notificationIds,
action,
});
onComplete(!!res);
};
// Prevent submitting empty arrays unless the action is 'set'
const isMissingSelection = action !== "set" && notificationIds.length === 0;
return (
<Dialog
open={open}
title={t("pages.common.monitors.bulkEdit.title")}
onCancel={onClose}
onConfirm={isMissingSelection ? undefined : handleConfirm}
loading={isPatching}
maxWidth="sm"
fullWidth
>
<Stack
spacing={theme.spacing(LAYOUT.XS)}
mt={theme.spacing(SPACING.LG)}
>
{error && <Alert severity="error">{error}</Alert>}
<Typography variant="body1">
{t("pages.common.monitors.bulkEdit.selectedText", {
count: selectedMonitors.length,
})}
</Typography>
<RadioGroup
value={action}
onChange={(e) => {
const val = e.target.value;
if (val === "add" || val === "remove" || val === "set") {
setAction(val);
}
}}
>
<RadioWithDescription
value="add"
label={t("pages.common.monitors.bulkEdit.actionAdd")}
description={t("pages.common.monitors.bulkEdit.actionAddDesc")}
/>
<RadioWithDescription
value="remove"
label={t("pages.common.monitors.bulkEdit.actionRemove")}
description={t("pages.common.monitors.bulkEdit.actionRemoveDesc")}
/>
<RadioWithDescription
value="set"
label={t("pages.common.monitors.bulkEdit.actionSet")}
description={t("pages.common.monitors.bulkEdit.actionSetDesc")}
/>
</RadioGroup>
<Select
multiple
value={notificationIds}
onChange={(e) => {
const val = e.target.value;
if (typeof val === "string") {
setNotificationIds(val.split(","));
} else if (Array.isArray(val)) {
setNotificationIds(val.filter((v) => typeof v === "string"));
}
}}
fieldLabel={t("pages.common.monitors.bulkEdit.selectLabel")}
placeholder={t("pages.common.monitors.bulkEdit.selectPlaceholder")}
>
{notifications?.map((n) => (
<MenuItem
key={n.id}
value={n.id}
>
{n.notificationName} ({n.type.toUpperCase()})
</MenuItem>
))}
</Select>
</Stack>
</Dialog>
);
};
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { t } = useTranslation();
const mapPopupStyles = (
<GlobalStyles
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
{formatStatusCode(selectedCheck.statusCode, t)}
</Typography>
<Typography variant="body2">
<Typography
@@ -9,6 +9,8 @@ import {
ResponsiveContainer,
Text,
} from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
@@ -18,13 +20,14 @@ import {
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: any };
payload: { value: string };
range: string;
};
@@ -46,12 +49,9 @@ export const XTick = ({ x, y, payload, range }: XTickProps) => {
);
};
type ResponseTimeToolTipProps = {
active?: boolean | undefined;
payload?: any[];
label?: string | number;
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: any;
theme: Theme;
uiTimezone: string;
};
@@ -68,7 +68,8 @@ const ResponseTimeToolTip = ({
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
+2
View File
@@ -14,3 +14,5 @@ export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./BulkEditNotificationsModal";
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { post } from "@/Utils/ApiClient";
import { useToast } from "@/Hooks/UseToast";
import { useTranslation } from "react-i18next";
import { logger } from "@/Utils/logger";
import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: Monitor[];
}
interface UseBulkMonitorActionsReturn {
selectedRows: string[];
setSelectedRows: (rows: string[]) => void;
handleBulkPause: () => Promise<void>;
handleBulkResume: () => Promise<void>;
handleCancelSelection: () => void;
}
export const useBulkMonitorActions = (
refetch: () => void,
page?: number
): UseBulkMonitorActionsReturn => {
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const { toastSuccess, toastError, toastInfo } = useToast();
const { t } = useTranslation();
// Clear selection when page changes
useEffect(() => {
setSelectedRows([]);
}, [page]);
const executeBulkAction = async (pause: boolean) => {
try {
const res = await post<ApiResponse>("/monitors/bulk/pause", {
monitorIds: selectedRows,
pause,
});
const affectedCount = res.data?.data?.length ?? 0;
if (affectedCount === 0) {
const key = pause
? "pages.common.monitors.bulkPause.alreadyPaused"
: "pages.common.monitors.bulkPause.alreadyRunning";
toastInfo(t(key, { count: selectedRows.length }));
} else {
const key = pause
? "pages.common.monitors.bulkPause.paused"
: "pages.common.monitors.bulkPause.resumed";
toastSuccess(t(key, { count: affectedCount }));
}
setSelectedRows([]);
refetch();
} catch (err: unknown) {
let errMsg = "An error occurred";
if (axios.isAxiosError(err)) {
errMsg = err.response?.data?.msg || err.message || errMsg;
} else if (err instanceof Error) {
errMsg = err.message;
}
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
pause,
});
toastError(errMsg);
}
};
const handleBulkPause = async () => {
await executeBulkAction(true);
};
const handleBulkResume = async () => {
await executeBulkAction(false);
};
const handleCancelSelection = () => {
setSelectedRows([]);
};
return {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
};
};
+6 -2
View File
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import dayjs from "dayjs";
import {
maintenanceWindowSchema,
maintenanceWindowEditSchema,
repeatOptions,
type MaintenanceWindowFormData,
} from "@/Validation/maintenanceWindow";
@@ -32,7 +33,7 @@ export const useMaintenanceWindowForm = ({
startTime: startDate.format("HH:mm"),
duration: data.duration ?? 0,
durationUnit: data.durationUnit ?? "minutes",
monitors: [data.monitorId],
monitors: data.monitorIds,
};
} else {
const now = dayjs();
@@ -47,6 +48,9 @@ export const useMaintenanceWindowForm = ({
};
}
return { schema: maintenanceWindowSchema, defaults };
return {
schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema,
defaults,
};
}, [data]);
};
+11
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -88,6 +89,7 @@ export const useMonitorForm = ({
...base,
type: "pagespeed",
url: data?.url || "",
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
};
break;
case "hardware":
@@ -111,6 +113,15 @@ export const useMonitorForm = ({
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "dns":
defaults = {
...base,
type: "dns",
url: data?.url || "",
dnsServer: data?.dnsServer || "",
dnsRecordType: data?.dnsRecordType || "A",
};
break;
default:
defaults = {
...base,
+2 -1
View File
@@ -6,6 +6,7 @@ import {
DEFAULT_STATUS_PAGE_THEME_MODE,
} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
import { resolveTimezone } from "@/Utils/TimeUtils";
interface UseStatusPageFormOptions {
data?: StatusPage | null;
@@ -30,7 +31,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
monitors: data?.monitors || [],
@@ -1,8 +1,12 @@
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements/Table";
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Check } from "@/Types/Check";
@@ -29,7 +33,7 @@ export const ChecksTable = ({
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const getHeaders = (t: Function, uiTimezone: string) => {
const getHeaders = () => {
const headers: Header<Check>[] = [
{
id: "monitorName",
@@ -63,13 +67,10 @@ export const ChecksTable = ({
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
@@ -85,7 +86,7 @@ export const ChecksTable = ({
return headers;
};
const headers = getHeaders(t, uiTimezone);
const headers = getHeaders();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
+148 -3
View File
@@ -37,6 +37,7 @@ import {
type MonitorType,
type GamesMap,
supportsGeoCheck,
DefaultPageSpeedStrategy,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor";
@@ -51,6 +52,9 @@ interface GeneralSettingsConfig {
showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
showStrategy: boolean;
showDnsServer: boolean;
showDnsRecordType: boolean;
}
const getGeneralSettingsConfig = (
@@ -68,6 +72,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -79,6 +86,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -90,6 +100,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -101,6 +114,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -112,6 +128,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -123,6 +142,9 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: true,
showIgnoreTls: true,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -134,10 +156,13 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: true,
showDnsServer: false,
showDnsRecordType: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
@@ -145,6 +170,9 @@ const getGeneralSettingsConfig = (
showSecret: true,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
@@ -156,6 +184,23 @@ const getGeneralSettingsConfig = (
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: true,
showStrategy: false,
showDnsServer: false,
showDnsRecordType: false,
},
dns: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
showDnsServer: true,
showDnsRecordType: true,
},
};
return configs[type] || configs.http;
@@ -349,6 +394,13 @@ const CreateMonitorPage = () => {
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
<RadioWithDescription
value="dns"
label={t("pages.common.monitors.monitorTypes.optionDns")}
description={t(
"pages.createMonitor.form.type.optionDnsDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
@@ -395,6 +447,31 @@ const CreateMonitorPage = () => {
/>
)}
{/* Strategy field - only for pagespeed type */}
{generalSettingsConfig.showStrategy && (
<Controller
name="strategy"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? DefaultPageSpeedStrategy}
fieldLabel={t(
"pages.createMonitor.form.general.option.strategy.label"
)}
error={!!fieldState.error}
>
<MenuItem value="desktop">
{t("pages.createMonitor.form.general.option.strategy.desktop")}
</MenuItem>
<MenuItem value="mobile">
{t("pages.createMonitor.form.general.option.strategy.mobile")}
</MenuItem>
</Select>
)}
/>
)}
{/* Port field - only for port and game types */}
{generalSettingsConfig.showPort && (
<Controller
@@ -495,6 +572,74 @@ const CreateMonitorPage = () => {
)}
/>
)}
{generalSettingsConfig.showDnsServer && (
<Controller
name="dnsServer"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t(
"pages.createMonitor.form.general.option.dnsServer.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.dnsServer.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{generalSettingsConfig.showDnsRecordType && (
<Controller
name="dnsRecordType"
control={control}
defaultValue="A"
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? "A"}
fieldLabel={t(
"pages.createMonitor.form.general.option.dnsRecordType.label"
)}
error={!!fieldState.error}
>
<MenuItem value={"A"}>
{t("pages.createMonitor.form.general.option.dnsRecordType.value.A")}
</MenuItem>
<MenuItem value={"AAAA"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.AAAA"
)}
</MenuItem>
<MenuItem value={"CNAME"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.CNAME"
)}
</MenuItem>
<MenuItem value={"MX"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.MX"
)}
</MenuItem>
<MenuItem value={"NS"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.NS"
)}
</MenuItem>
<MenuItem value={"TXT"}>
{t(
"pages.createMonitor.form.general.option.dnsRecordType.value.TXT"
)}
</MenuItem>
</Select>
)}
/>
)}
<Controller
name="name"
@@ -900,7 +1045,7 @@ const CreateMonitorPage = () => {
/>
<Typography
component="span"
color="text.secondary"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
<Trans
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
return (
<Stack
gap={theme.spacing(LAYOUT.MD)}
@@ -123,7 +124,12 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
</Grid>
<Grid size={4}>
<Cell>{incident.statusCode ?? "N/A"}</Cell>
<Cell>
<StatusCodeLabel
statusCode={incident.statusCode}
message={incident.message}
/>
</Cell>
</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
@@ -1,8 +1,6 @@
import { Table } from "@/Components/design-elements";
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import type { Header } from "@/Components/design-elements/Table";
import { ValueLabel } from "@/Components/design-elements";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { ActionsMenu } from "@/Components/actions-menu";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main,
}}
color={
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main
}
textTransform={"capitalize"}
>
{row.resolutionType}
</Typography>
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
let value: ValueType = "neutral";
if (code < 300) value = "positive";
else if (code >= 400) value = "negative";
return (
<ValueLabel
value={value}
text={String(code)}
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
@@ -1,4 +1,5 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
monitors: Monitor[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const InfraMonitorsTable = ({
monitors,
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
selectedRows = [],
onSelectionChange,
}: InfraMonitorsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const {
post,
// loading: isPatching,
// error: postError,
} = usePost<any, Monitor>();
const { post } = usePost<Record<string, never>, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
setRowsPerPage(value);
};
const handleSort = (e: any, field: string) => {
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -101,13 +123,6 @@ export const InfraMonitorsTable = ({
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
@@ -152,8 +167,35 @@ export const InfraMonitorsTable = ({
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("common.table.headers.name"),
content: (
<Typography
component="div"
@@ -173,18 +215,20 @@ export const InfraMonitorsTable = ({
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Typography>
</Stack>
),
render: (row) => {
return <StatusLabel status={row.status} />;
@@ -248,6 +292,11 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { TextField, Dialog } from "@/Components/inputs";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const { t } = useTranslation();
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
@@ -159,6 +175,29 @@ const InfrastructureMonitors = () => {
}}
/>
</Stack>
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
refetch={refetch}
@@ -180,6 +219,8 @@ const InfrastructureMonitors = () => {
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
+11 -25
View File
@@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/inputs";
import { EmptyState } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => {
const theme = useTheme();
@@ -19,7 +20,7 @@ export const TabQueue = () => {
isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
const { post, loading: isFlushing } = usePost();
@@ -49,32 +50,22 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color="text.secondary"
color={theme.palette.text.secondary}
>
{t("pages.logs.jobQueue")}
</Typography>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
<Typography color={theme.palette.text.secondary}>
<Trans
i18nKey="pages.logs.jobQueueExplainer"
components={{
highlight: (
<Box
component="span"
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(19, 113, 91, 0.18)"
: "#ECF7F2",
color: theme.palette.text.primary,
px: 1,
py: 0.25,
borderRadius: 0.5,
}}
bgcolor={theme.palette.rowStatus.running}
color={theme.palette.text.primary}
px={SPACING.SM}
py={SPACING.XXS}
borderRadius={theme.shape.borderRadius}
/>
),
}}
@@ -87,16 +78,11 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color="text.secondary"
color={theme.palette.text.secondary}
>
{t("pages.logs.failedJobs")}
</Typography>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.failedJobsExplainer")}
</Typography>
</Stack>
+41 -10
View File
@@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
};
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
return t("common.labels.active");
}
if (row.monitorActive === false) {
return t("common.labels.paused");
}
return t("common.labels.idle");
},
},
{
id: "id",
content: t("common.table.headers.monitorId"),
@@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
@@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
},
{
id: "lastRun",
@@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return (
<Typography title={v} sx={cellSx}>
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography>
);
@@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography title={v} sx={cellSx}>
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography>
);
@@ -98,16 +129,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
},
];
const isDark = theme.palette.mode === "dark";
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
return (
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: runningBg },
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
}),
})}
/>
@@ -22,6 +22,7 @@ import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow";
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
import { useForm, Controller } from "react-hook-form";
@@ -85,10 +86,6 @@ const CreateMaintenanceWindowPage = () => {
repeat,
};
if (repeat === 0) {
payload.expiry = endDateTime.toISOString();
}
return payload;
};
@@ -103,6 +100,9 @@ const CreateMaintenanceWindowPage = () => {
}
if (result?.success) {
await mutate(
(key) => typeof key === "string" && key.startsWith("/maintenance-window")
);
navigate("/maintenance");
}
};
@@ -1,7 +1,13 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StrategyBadge,
} from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -179,7 +185,19 @@ export const PageSpeedMonitorsTable = ({
</Stack>
),
render: (row) => {
return row.name;
return (
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XS}
>
<Typography>{row.name}</Typography>
<StrategyBadge
strategy={row.strategy}
variant="caption"
/>
</Stack>
);
},
},
{
+2 -1
View File
@@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import type { CheckSnapshot } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface DummyChartProps {
chartType: string;
@@ -10,7 +11,7 @@ interface DummyChartProps {
const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < 25; i++) {
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
+9 -3
View File
@@ -32,6 +32,7 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
@@ -49,6 +50,9 @@ interface TimezoneOption {
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
const CreateStatusPage = () => {
const theme = useTheme();
const { t } = useTranslation();
@@ -59,9 +63,7 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
@@ -181,6 +183,10 @@ const CreateStatusPage = () => {
}
if (result) {
await mutate(buildStatusPageKey(data.url));
if (!isCreate && url !== data.url) {
await mutate(buildStatusPageKey(url));
}
navigate(`/status/${data.url}`);
}
};
+48 -24
View File
@@ -8,35 +8,56 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPage,
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
import { BrowserFrame } from "./themes/BrowserFrame";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
BaseStatusPage,
type ThemeConfig,
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
type ThemedRendererProps = {
statusPage: StatusPage;
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
const THEMED_RENDERERS: Record<
StatusPageTheme,
React.ComponentType<ThemedRendererProps>
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
bold: BoldStatusPage,
editorial: EditorialStatusPage,
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
createStyles: refinedStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
modern: {
createStyles: modernStyles,
HeaderSlot: ModernHeader,
HeroSlot: ModernHero,
overallStatusOptions: { iconSize: 20 },
},
bold: {
createStyles: boldStyles,
HeaderSlot: BoldHeader,
HeroSlot: BoldHero,
overallStatusOptions: { iconSize: 18 },
},
editorial: {
createStyles: editorialStyles,
HeaderSlot: EditorialHeader,
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
};
const StatusPageView = () => {
@@ -90,11 +111,12 @@ const StatusPageView = () => {
);
}
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<ThemedRenderer
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
@@ -104,6 +126,7 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
@@ -127,6 +150,7 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
@@ -13,6 +13,7 @@ import {
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { resolveTimezone } from "@/Utils/TimeUtils";
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
@@ -21,6 +22,7 @@ interface StatusPageThemeContextValue {
theme: StatusPageTheme;
mode: ResolvedMode;
tokens: StatusPageThemeTokens;
timezone: string;
}
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
@@ -33,6 +35,7 @@ const resolveSystemMode = (): ResolvedMode => {
interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
@@ -54,12 +57,14 @@ interface Props {
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
paintBody = false,
transparent = false,
children,
}: Props) => {
const resolvedTheme = resolveStatusPageTheme(theme);
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const resolvedTimezone = resolveTimezone(timezone);
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
@@ -107,8 +112,13 @@ export const StatusPageThemeProvider = ({
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
[resolvedTheme, resolvedMode, tokens]
() => ({
theme: resolvedTheme,
mode: resolvedMode,
tokens,
timezone: resolvedTimezone,
}),
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
);
return (
@@ -136,6 +146,7 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => {
theme: DEFAULT_STATUS_PAGE_THEME,
mode: resolveSystemMode(),
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
timezone: resolveTimezone(),
};
}
return ctx;
@@ -0,0 +1,20 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -0,0 +1,32 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
);
};
@@ -1,207 +0,0 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { boldStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -2,6 +2,7 @@ import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
@@ -297,7 +298,7 @@ export const boldStyles = (
heatmap: {
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
height: 48,
},
@@ -313,7 +314,7 @@ export const boldStyles = (
histogram: {
padding: "0 28px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
alignItems: "flex-end",
height: 48,
@@ -0,0 +1,36 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
);
};
@@ -0,0 +1,46 @@
import { useMemo } from "react";
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHero = ({
overall,
monitorCount,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
return (
<>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</>
);
};
@@ -1,224 +0,0 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { editorialStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => editorialStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
const overall = resolveOverallStatus(monitors, t, {
allUpKey: "pages.statusPages.editorial.allUp",
});
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
</Box>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,6 +6,7 @@ import {
MONO_STACK,
SERIF_STACK,
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
@@ -50,7 +51,10 @@ export interface EditorialStyles {
footer: SxProps<Theme>;
}
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
export const editorialStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): EditorialStyles => {
const heatCellBg: Record<EditorialHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
@@ -245,7 +249,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles
heatmap: {
mt: "16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
height: 40,
},
@@ -257,7 +261,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles
histogram: {
mt: "16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
alignItems: "flex-end",
height: 40,
@@ -0,0 +1,24 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<ModernStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -0,0 +1,34 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHero = ({
overall,
monitorCount,
styles,
}: SlotProps<ModernStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -3,6 +3,7 @@ import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
@@ -320,7 +321,7 @@ export const modernStyles = (
heatmap: {
padding: "0 24px 20px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
height: 46,
},
@@ -335,7 +336,7 @@ export const modernStyles = (
histogram: {
padding: "0 24px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
alignItems: "flex-end",
height: 46,
@@ -0,0 +1,29 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<RefinedStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
);
@@ -0,0 +1,34 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHero = ({
overall,
monitorCount,
styles,
}: SlotProps<RefinedStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -1,210 +0,0 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { refinedStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => refinedStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,6 +6,7 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export interface RefinedStyles {
page: SxProps<Theme>;
@@ -62,7 +63,10 @@ const pillBase = {
fontWeight: 600,
};
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
export const refinedStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): RefinedStyles => {
const heatCellBg: Record<RefinedHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
@@ -259,7 +263,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
heatmap: {
padding: "0 20px 16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
height: 42,
},
@@ -274,7 +278,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
histogram: {
padding: "0 20px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
alignItems: "flex-end",
height: 42,
@@ -2,80 +2,125 @@ import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { SxProps, Theme } from "@mui/material/styles";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import {
ThemedHeatmap,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import {
ThemedHistogram,
type BarKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import {
ThemedInfrastructure,
type GaugeFillLevel,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
import {
type OverallStatus,
type OverallTone,
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { modernStyles } from "./styles";
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
export interface BaseStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export interface SlotProps<S extends BaseStyles = BaseStyles> {
statusPage: StatusPage;
logoSrc: string | null;
overall: OverallStatus;
monitorCount: number;
styles: S;
}
export interface ThemeConfig<S extends BaseStyles = BaseStyles> {
createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S;
HeaderSlot: React.ComponentType<SlotProps<S>>;
HeroSlot: React.ComponentType<SlotProps<S>>;
overallStatusOptions?: { iconSize?: number; allUpKey?: string };
}
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
config: ThemeConfig<any>;
}
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
const styles = useMemo(
() => config.createStyles(tokens, mode === "dark"),
[config, tokens, mode]
);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
const { HeaderSlot, HeroSlot } = config;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
<HeaderSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<HeroSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
@@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = 25;
const CELLS = MAX_RECENT_CHECKS;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
@@ -29,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
@@ -67,11 +67,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
@@ -5,12 +5,11 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
const CELLS = 25;
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
const MIN_HEIGHT_PCT = 6;
export type BarKind = "up" | "down" | "empty";
@@ -34,7 +33,7 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { timezone } = useStatusPageTheme();
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
@@ -83,7 +82,7 @@ export const ThemedHistogram = ({
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
timezone
)}
</Typography>
</Stack>
@@ -1,4 +1,9 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { Check } from "@/Types/Check";
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
const getHeaders = (t: Function, uiTimezone: string) => {
export const ChecksTable = ({
checks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
checks: Check[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers: Header<Check>[] = [
{
id: "status",
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
return (
<StatusCodeLabel
statusCode={row.statusCode}
message={row.message}
/>
);
},
},
];
return headers;
};
export const ChecksTable = ({
checks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
checks: Check[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -1,4 +1,9 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import {
Table,
Pagination,
StatusLabel,
StatusCodeLabel,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers: Header<FlatGeoCheck>[] = [
{
id: "status",
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
return <StatusCodeLabel statusCode={row.statusCode} />;
},
},
{
@@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -13,6 +13,7 @@ import { ArrowDown, ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useSelector } from "react-redux";
@@ -20,6 +21,25 @@ import { useSelector } from "react-redux";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
interface MonitorTableProps {
monitors: Monitor[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const MonitorTable = ({
monitors,
@@ -34,31 +54,35 @@ export const MonitorTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
selectedRows = [],
onSelectionChange,
}: MonitorTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
const {
post,
// loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
const { post } = usePost<Record<string, never>, Monitor>();
const handleSort = (e: any, field: string) => {
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -110,13 +134,6 @@ export const MonitorTable = ({
navigate(`/uptime/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
@@ -160,11 +177,38 @@ export const MonitorTable = ({
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("common.table.headers.name"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
alignItems={"center"}
onClick={(e) => handleSort(e, "name")}
@@ -181,16 +225,17 @@ export const MonitorTable = ({
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
<Box width={theme.spacing(LAYOUT.MD)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
@@ -212,16 +257,17 @@ export const MonitorTable = ({
},
{
id: "type",
mobileLabel: t("common.table.headers.type"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
<Box width={theme.spacing(LAYOUT.MD)} />
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
</Stack>
@@ -245,12 +291,41 @@ export const MonitorTable = ({
return (
<Box>
{isSmall && (
<Box
px={SPACING.SM}
pb={SPACING.LG}
>
<Stack
direction="row"
alignItems="center"
spacing={SPACING.SM}
>
<Checkbox
checked={isAllSelected}
indeterminate={isSomeSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
<Typography
variant="body2"
color="text.secondary"
>
{t("pages.common.monitors.bulkEdit.selectAll", "Select All")}
</Typography>
</Stack>
</Box>
)}
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
+75 -6
View File
@@ -1,9 +1,15 @@
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { TextField, Dialog } from "@/Components/inputs";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import { BulkEditNotificationsModal } from "@/Components/monitors";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -16,6 +22,8 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { LAYOUT } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
@@ -37,6 +45,7 @@ const UptimeMonitorsPage = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -70,7 +79,7 @@ const UptimeMonitorsPage = () => {
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
@@ -102,6 +111,15 @@ const UptimeMonitorsPage = () => {
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
@@ -120,6 +138,14 @@ const UptimeMonitorsPage = () => {
refetch();
};
const handleBulkEditComplete = (success: boolean) => {
setIsBulkEditModalOpen(false);
if (success) {
setSelectedRows([]);
refetch();
}
};
const handleCancel = () => {
setSelectedMonitor(null);
};
@@ -158,7 +184,7 @@ const UptimeMonitorsPage = () => {
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
>
<ControlsFilter
selectedTypes={selectedTypes}
@@ -177,13 +203,47 @@ const UptimeMonitorsPage = () => {
}}
/>
</Stack>
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
<Button
size="small"
onClick={() => setIsBulkEditModalOpen(true)}
>
{t("pages.common.monitors.bulkEdit.editButton", {
defaultValue: "Edit Notifications",
})}
</Button>
</BulkActionsBar>
)}
<MonitorTable
page={page}
monitors={monitorsWithChecks || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
setPage={(newPage) => {
setPage(newPage);
setSelectedRows([]);
}}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
@@ -197,7 +257,10 @@ const UptimeMonitorsPage = () => {
})
);
setPage(0);
setSelectedRows([]);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
@@ -207,6 +270,12 @@ const UptimeMonitorsPage = () => {
onCancel={handleCancel}
loading={isDeleting}
/>
<BulkEditNotificationsModal
open={isBulkEditModalOpen}
onClose={() => setIsBulkEditModalOpen(false)}
selectedMonitors={selectedRows}
onComplete={handleBulkEditComplete}
/>
</MonitorBasePageWithStates>
);
};
+1 -1
View File
@@ -2,7 +2,7 @@ export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
export interface MaintenanceWindow {
id: string;
monitorId: string;
monitorIds: string[];
teamId: string;
active: boolean;
name: string;
+13
View File
@@ -3,6 +3,8 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MAX_RECENT_CHECKS = 50;
export const MonitorTypes = [
"http",
"ping",
@@ -13,10 +15,14 @@ export const MonitorTypes = [
"game",
"grpc",
"websocket",
"dns",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
export type DnsRecordType = (typeof DnsRecordTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
@@ -38,6 +44,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export interface Monitor {
id: string;
userId: string;
@@ -72,10 +82,13 @@ export interface Monitor {
selectedDisks: string[];
gameId?: string;
grpcServiceName?: string;
strategy?: PageSpeedStrategy;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
+3
View File
@@ -23,6 +23,8 @@ export interface QueueJobSummary {
monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -32,6 +34,7 @@ export interface QueueJobSummary {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
}
export interface QueueData {
+1
View File
@@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => {
game: "uptime",
grpc: "uptime",
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
+13
View File
@@ -1,3 +1,5 @@
import { alpha } from "@mui/material/styles";
const typographyBase = 13;
export const typographyLevels = {
@@ -17,6 +19,9 @@ export const colors = {
gray850: "#1c1c21",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
yellow200: "#FFF4E5",
orange500: "#FFA500",
};
export const lightPalette = {
@@ -29,6 +34,10 @@ export const lightPalette = {
sidebar: {
accent: colors.brandGreen,
},
rowStatus: {
running: colors.green200,
paused: colors.yellow200,
},
};
export const darkPalette = {
@@ -45,4 +54,8 @@ export const darkPalette = {
sidebar: {
accent: colors.brandGreenLight,
},
rowStatus: {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
},
};
+2
View File
@@ -15,9 +15,11 @@ declare module "@mui/material/styles" {
}
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
}
interface PaletteOptions {
sidebar?: { accent: string };
rowStatus?: { running: string; paused: string };
}
}
+1
View File
@@ -1,4 +1,5 @@
export const SPACING = {
XXS: 0.25,
XS: 0.5,
SM: 1,
MD: 1.5,
+20 -2
View File
@@ -15,12 +15,30 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
export const MS_PER_DAY = 24 * MS_PER_HOUR;
export const MS_PER_WEEK = MS_PER_DAY * 7;
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
const resolveSystemTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
};
export const SYSTEM_TIMEZONE = resolveSystemTimezone();
export const resolveTimezone = (preferred?: string | null): string =>
preferred?.trim() || SYSTEM_TIMEZONE;
export const formatDateWithTz = (
timestamp: string,
format: string,
timezone?: string
) => {
if (!timestamp) {
return "Unknown time";
}
const tz = resolveTimezone(timezone);
try {
return dayjs(timestamp).tz(timezone).format(format);
return dayjs(timestamp).tz(tz).format(format);
} catch {
return dayjs(timestamp).utc().format(format);
}
+76
View File
@@ -0,0 +1,76 @@
import type { ValueType } from "@/Components/design-elements/StatusLabel";
export const NETWORK_ERROR_CODE = 5000;
export const NETWORK_ERROR_SUBTYPES = [
"timeout",
"dns",
"refused",
"tls",
"reset",
"unknown",
] as const;
export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number];
type TranslateFn = (key: string) => string;
export const isNetworkError = (code?: number | null): boolean =>
code === NETWORK_ERROR_CODE;
const ERROR_CODE_PATTERN =
/\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/;
export const extractErrorCode = (message?: string | null): string | null => {
if (!message) return null;
const match = message.match(ERROR_CODE_PATTERN);
return match ? match[1] : null;
};
export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => {
if (!message) return "unknown";
const m = message.toUpperCase();
if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT"))
return "timeout";
if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS"))
return "dns";
if (m.includes("ECONNREFUSED")) return "refused";
if (
m.includes("CERT_") ||
m.includes("UNABLE_TO_VERIFY") ||
m.includes("DEPTH_ZERO_SELF_SIGNED") ||
m.includes("SELF_SIGNED") ||
m.includes("TLS") ||
m.includes("SSL")
)
return "tls";
if (m.includes("ECONNRESET")) return "reset";
return "unknown";
};
export const formatStatusCode = (
code: number | null | undefined,
t: TranslateFn
): string => {
if (isNetworkError(code)) return t("common.statusCode.down");
return String(code);
};
export const getStatusCodeTooltip = (
code: number | null | undefined,
message: string | null | undefined,
t: TranslateFn
): string | null => {
if (!isNetworkError(code)) return null;
const description = t(
`common.statusCode.networkError.${getNetworkErrorSubtype(message)}`
);
const errorCode = extractErrorCode(message);
return errorCode ? `${description} (${errorCode})` : description;
};
export const getStatusCodeValueType = (code: number): ValueType => {
if (isNetworkError(code)) return "negative";
if (code < 300) return "positive";
if (code < 400) return "neutral";
return "negative";
};
+27 -25
View File
@@ -14,30 +14,32 @@ export const durationUnitOptions = [
{ id: "days", name: "days", multiplier: 86400000 },
] as const;
export const maintenanceWindowSchema = z
.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be at most 100 characters"),
repeat: z.string(),
startDate: z.string().min(1, "Start date is required"),
startTime: z.string().min(1, "Start time is required"),
duration: z.number().int().min(1, "Duration must be at least 1"),
durationUnit: z.string(),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
})
.refine(
(data) => {
const startDateTime = dayjs(data.startDate)
.set("hour", parseInt(data.startTime.split(":")[0], 10))
.set("minute", parseInt(data.startTime.split(":")[1], 10));
return startDateTime.isAfter(dayjs());
},
{
message: "Start date and time must be in the future",
path: ["startDate"],
}
);
const maintenanceWindowBaseSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be at most 100 characters"),
repeat: z.string(),
startDate: z.string().min(1, "Start date is required"),
startTime: z.string().min(1, "Start time is required"),
duration: z.number().int().min(1, "Duration must be at least 1"),
durationUnit: z.string(),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
});
export const maintenanceWindowSchema = maintenanceWindowBaseSchema.refine(
(data) => {
const startDateTime = dayjs(data.startDate)
.set("hour", parseInt(data.startTime.split(":")[0], 10))
.set("minute", parseInt(data.startTime.split(":")[1], 10));
return startDateTime.isAfter(dayjs());
},
{
message: "Start date and time must be in the future",
path: ["startDate"],
}
);
export const maintenanceWindowEditSchema = maintenanceWindowBaseSchema;
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
+26
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
import { DnsRecordTypes, PageSpeedStrategies } from "@/Types/Monitor";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -89,6 +90,7 @@ const grpcSchema = baseSchema.extend({
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
url: urlSchema,
strategy: z.enum(PageSpeedStrategies),
});
// Hardware/Infrastructure monitor schema
@@ -122,6 +124,29 @@ const websocketSchema = baseSchema.extend({
ignoreTlsErrors: z.boolean(),
});
// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
// No scheme, port, path, or whitespace. Total length ≤ 253.
const hostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
// DNS monitor schema
const dnsSchema = baseSchema.extend({
type: z.literal("dns"),
url: z
.string()
.min(1, "Domain is required")
.regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"),
dnsServer: z
.string()
.min(1, "DNS server is required")
.refine(
(v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success,
"Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"
),
dnsRecordType: z.enum(DnsRecordTypes),
});
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
@@ -133,6 +158,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
pagespeedSchema,
hardwareSchema,
websocketSchema,
dnsSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "دعوة عضو",
"openSite": "فتح الموقع",
"pause": "إيقاف مؤقت",
"resume": "استئناف"
"resume": "استئناف",
"selectAll": "تحديد جميع الشاشات",
"selectMonitor": "تحديد {{name}}",
"bulkSelected_one": "{{count}} شاشة محددة",
"bulkSelected_other": "{{count}} شاشات محددة"
},
"statBoxes": {
"activeFor": "نشط منذ",
@@ -449,6 +453,16 @@
"optionHardware": "البنية التحتية",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
"resumed_one": "تم استئناف {{count}} شاشة",
"resumed_other": "تم استئناف {{count}} شاشات",
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "رابط WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "نقطة نهاية Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "الإعدادات العامة",
@@ -546,7 +564,7 @@
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
}
},
"ignoreTls": {
File diff suppressed because it is too large. Load diff
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Pozvat člena",
"openSite": "Otevřít stránku",
"pause": "Pozastavit",
"resume": "Obnovit"
"resume": "Obnovit",
"selectAll": "Vybrat všechny monitory",
"selectMonitor": "Vybrat {{name}}",
"bulkSelected_one": "Vybrán {{count}} monitor",
"bulkSelected_other": "Vybráno {{count}} monitorů"
},
"statBoxes": {
"activeFor": "Aktivní po dobu",
@@ -449,6 +453,16 @@
"optionHardware": "Infrastruktura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pozastaven",
"paused_other": "{{count}} monitorů pozastaveno",
"resumed_one": "{{count}} monitor obnoven",
"resumed_other": "{{count}} monitorů obnoveno",
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
"alreadyRunning_one": "Vybraný monitor již běží",
"alreadyRunning_other": "Všechny vybrané monitory již běží"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Obecná nastavení",
@@ -546,7 +564,7 @@
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury."
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Mitglied einladen",
"openSite": "Seite öffnen",
"pause": "Pausieren",
"resume": "Fortsetzen"
"resume": "Fortsetzen",
"selectAll": "Alle Monitore auswählen",
"selectMonitor": "{{name}} auswählen",
"bulkSelected_one": "{{count}} Monitor ausgewählt",
"bulkSelected_other": "{{count}} Monitore ausgewählt"
},
"statBoxes": {
"activeFor": "Aktiv seit",
@@ -449,6 +453,16 @@
"optionHardware": "Infrastruktur",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} Monitor pausiert",
"paused_other": "{{count}} Monitore pausiert",
"resumed_one": "{{count}} Monitor fortgesetzt",
"resumed_other": "{{count}} Monitore fortgesetzt",
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket-URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-Endpunkt",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Allgemeine Einstellungen",
@@ -546,7 +564,7 @@
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
"hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
}
},
"ignoreTls": {
+78 -4
View File
@@ -14,11 +14,23 @@
}
},
"appName": "Checkmate",
"statusCode": {
"down": "Down",
"networkError": {
"timeout": "The server didn't respond in time (request timed out).",
"dns": "Couldn't resolve the hostname (DNS lookup failed).",
"refused": "The server refused the connection (port closed or service not listening).",
"tls": "TLS or SSL handshake failed (certificate or encryption issue).",
"reset": "The connection was reset before a response was received.",
"unknown": "No HTTP response was received from the target."
}
},
"breadcrumbs": {
"details": "Details",
"home": "Home"
},
"buttons": {
"selected": "selected",
"addMember": "Add member",
"cancel": "Cancel",
"close": "Close",
@@ -70,7 +82,9 @@
},
"labels": {
"active": "Active",
"paused": "paused",
"running": "Running",
"paused": "Paused",
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time"
@@ -412,6 +426,20 @@
},
"common": {
"monitors": {
"bulkEdit": {
"selectAll": "Select All",
"editButton": "Edit Notifications",
"title": "Edit Notifications",
"selectedText": "You are applying changes to {{count}} monitor(s).",
"actionAdd": "Add",
"actionAddDesc": "Adds the selected notifications to these monitors without removing existing ones.",
"actionRemove": "Remove",
"actionRemoveDesc": "Removes exclusively the selected notifications from these monitors.",
"actionSet": "Replace all",
"actionSetDesc": "Replaces all existing notifications. Leave empty to clear all notifications.",
"selectLabel": "Notification Channels",
"selectPlaceholder": "Select channels..."
},
"actions": {
"configure": "Configure",
"delete": "Delete",
@@ -421,7 +449,21 @@
"inviteMember": "Invite member",
"openSite": "Open site",
"pause": "Pause",
"resume": "Resume"
"resume": "Resume",
"bulkSelected_one": "{{count}} monitor selected",
"bulkSelected_other": "{{count}} monitors selected",
"selectAll": "Select all monitors",
"selectMonitor": "Select {{name}}"
},
"bulkPause": {
"alreadyPaused_one": "The selected monitor is already paused",
"alreadyPaused_other": "All selected monitors are already paused",
"alreadyRunning_one": "The selected monitor is already running",
"alreadyRunning_other": "All selected monitors are already running",
"paused_one": "{{count}} monitor paused",
"paused_other": "{{count}} monitors paused",
"resumed_one": "{{count}} monitor resumed",
"resumed_other": "{{count}} monitors resumed"
},
"statBoxes": {
"activeFor": "Active for",
@@ -448,7 +490,8 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
}
}
},
@@ -534,6 +577,30 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"strategy": {
"label": "Strategy",
"desktop": "Desktop",
"mobile": "Mobile"
},
"captureUrl": {
"label": "Capture endpoint",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsServer": {
"label": "DNS server",
"placeholder": "8.8.8.8"
},
"dnsRecordType": {
"label": "DNS record type",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
}
},
"title": "General settings",
@@ -546,7 +613,8 @@
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.",
"dns": "Monitor if a specific DNS server is responding."
}
},
"ignoreTls": {
@@ -583,6 +651,7 @@
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPortDescription": "Monitor if a specific port on a server is open.",
"optionDnsDescription": "Monitor if a specific DNS server is responding.",
"title": "Type"
},
"thresholds": {
@@ -883,6 +952,7 @@
},
"table": {
"headers": {
"state": "State",
"timestamp": "Timestamp",
"level": "Level",
"service": "Service",
@@ -1052,6 +1122,10 @@
}
},
"pageSpeed": {
"strategy": {
"desktop": "Desktop",
"mobile": "Mobile"
},
"charts": {
"common": {
"cls": "Cumulative Layout Shift (CLS)",
+20 -2
View File
@@ -428,7 +428,11 @@
"inviteMember": "Invitar miembro",
"openSite": "Abrir sitio",
"pause": "Pausar",
"resume": "Reanudar"
"resume": "Reanudar",
"selectAll": "Seleccionar todos los monitores",
"selectMonitor": "Seleccionar {{name}}",
"bulkSelected_one": "{{count}} monitor seleccionado",
"bulkSelected_other": "{{count}} monitores seleccionados"
},
"statBoxes": {
"activeFor": "Activo desde hace",
@@ -456,6 +460,16 @@
"optionHardware": "Infraestructura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor reanudado",
"resumed_other": "{{count}} monitores reanudados",
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
}
}
},
@@ -541,6 +555,10 @@
"wsUrl": {
"label": "URL de WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint de Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configuración general",
@@ -553,7 +571,7 @@
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Kutsu jäsen",
"openSite": "Avaa sivusto",
"pause": "Keskeytä",
"resume": "Jatka"
"resume": "Jatka",
"selectAll": "Valitse kaikki näytöt",
"selectMonitor": "Valitse {{name}}",
"bulkSelected_one": "{{count}} näyttö valittu",
"bulkSelected_other": "{{count}} näyttöä valittu"
},
"statBoxes": {
"activeFor": "Aktiivinen",
@@ -449,6 +453,16 @@
"optionHardware": "Infrastruktuuri",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} näyttö keskeytetty",
"paused_other": "{{count}} näyttöä keskeytetty",
"resumed_one": "{{count}} näyttö jatkettu",
"resumed_other": "{{count}} näyttöä jatkettu",
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-päätepiste",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Yleiset asetukset",
@@ -546,7 +564,7 @@
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Inviter un membre",
"openSite": "Ouvrir le site",
"pause": "Pause",
"resume": "Reprendre"
"resume": "Reprendre",
"selectAll": "Sélectionner tous les moniteurs",
"selectMonitor": "Sélectionner {{name}}",
"bulkSelected_one": "{{count}} moniteur sélectionné",
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
},
"statBoxes": {
"activeFor": "Actif depuis",
@@ -449,6 +453,16 @@
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} moniteur en pause",
"paused_other": "{{count}} moniteurs en pause",
"resumed_one": "{{count}} moniteur repris",
"resumed_other": "{{count}} moniteurs repris",
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Point de terminaison Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Paramètres généraux",
@@ -546,7 +564,7 @@
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
"hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "メンバーを招待",
"openSite": "サイトを開く",
"pause": "一時停止",
"resume": "再開"
"resume": "再開",
"selectAll": "すべてのモニターを選択",
"selectMonitor": "{{name}} を選択",
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
},
"statBoxes": {
"activeFor": "稼働期間",
@@ -449,6 +453,16 @@
"optionHardware": "インフラストラクチャ",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} 個のモニターを一時停止しました",
"paused_other": "{{count}} 個のモニターを一時停止しました",
"resumed_one": "{{count}} 個のモニターを再開しました",
"resumed_other": "{{count}} 個のモニターを再開しました",
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
"alreadyRunning_one": "選択したモニターはすでに実行中です",
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture エンドポイント",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -546,7 +564,7 @@
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Convidar membro",
"openSite": "Abrir site",
"pause": "Pausar",
"resume": "Retomar"
"resume": "Retomar",
"selectAll": "Selecionar todos os monitores",
"selectMonitor": "Selecionar {{name}}",
"bulkSelected_one": "{{count}} monitor selecionado",
"bulkSelected_other": "{{count}} monitores selecionados"
},
"statBoxes": {
"activeFor": "Ativo há",
@@ -449,6 +453,16 @@
"optionHardware": "Infraestrutura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor retomado",
"resumed_other": "{{count}} monitores retomados",
"alreadyPaused_one": "O monitor selecionado já está pausado",
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
"alreadyRunning_one": "O monitor selecionado já está em execução",
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "URL do WebSocket",
"placeholder": "wss://exemplo.com/socket"
},
"captureUrl": {
"label": "Endpoint do Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configurações gerais",
@@ -546,7 +564,7 @@
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura."
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Пригласить участника",
"openSite": "Открыть сайт",
"pause": "Пауза",
"resume": "Продолжить"
"resume": "Продолжить",
"selectAll": "Выбрать все мониторы",
"selectMonitor": "Выбрать {{name}}",
"bulkSelected_one": "Выбран {{count}} монитор",
"bulkSelected_other": "Выбрано {{count}} мониторов"
},
"statBoxes": {
"activeFor": "Активен",
@@ -449,6 +453,16 @@
"optionHardware": "Инфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монитор приостановлен",
"paused_other": "{{count}} мониторов приостановлено",
"resumed_one": "{{count}} монитор возобновлен",
"resumed_other": "{{count}} мониторов возобновлено",
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
"alreadyRunning_one": "Выбранный монитор уже запущен",
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Эндпоинт Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Основные настройки",
@@ -546,7 +564,7 @@
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "เชิญสมาชิก",
"openSite": "เปิดเว็บไซต์",
"pause": "หยุดชั่วคราว",
"resume": "ดำเนินการต่อ"
"resume": "ดำเนินการต่อ",
"selectAll": "เลือกจอภาพทั้งหมด",
"selectMonitor": "เลือก {{name}}",
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
},
"statBoxes": {
"activeFor": "ทำงานมาแล้ว",
@@ -449,6 +453,16 @@
"optionHardware": "โครงสร้างพื้นฐาน",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "URL ของ WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "ปลายทาง Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "ตั้งค่าทั่วไป",
@@ -546,7 +564,7 @@
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Üye davet et",
"openSite": "Siteyi aç",
"pause": "Duraklat",
"resume": "Devam et"
"resume": "Devam et",
"selectAll": "Tüm monitörleri seç",
"selectMonitor": "{{name}}'i seç",
"bulkSelected_one": "{{count}} monitör seçildi",
"bulkSelected_other": "{{count}} monitör seçildi"
},
"statBoxes": {
"activeFor": "Aktif süresi",
@@ -449,6 +453,16 @@
"optionHardware": "Altyapı",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitör duraklatıldı",
"paused_other": "{{count}} monitör duraklatıldı",
"resumed_one": "{{count}} monitör sürdürüldü",
"resumed_other": "{{count}} monitör sürdürüldü",
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture uç noktası",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Genel ayarlar",
@@ -546,7 +564,7 @@
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
"hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Запросити учасника",
"openSite": "Відкрити сайт",
"pause": "Призупинити",
"resume": "Відновити"
"resume": "Відновити",
"selectAll": "Вибрати всі монітори",
"selectMonitor": "Вибрати {{name}}",
"bulkSelected_one": "Вибрано {{count}} монітор",
"bulkSelected_other": "Вибрано {{count}} моніторів"
},
"statBoxes": {
"activeFor": "Активний протягом",
@@ -449,6 +453,16 @@
"optionHardware": "Інфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монітор призупинено",
"paused_other": "{{count}} моніторів призупинено",
"resumed_one": "{{count}} монітор відновлено",
"resumed_other": "{{count}} моніторів відновлено",
"alreadyPaused_one": "Вибраний монітор уже призупинено",
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
"alreadyRunning_one": "Вибраний монітор уже запущено",
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Точка доступу Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Загальні налаштування",
@@ -546,7 +564,7 @@
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "Mời thành viên",
"openSite": "Mở trang web",
"pause": "Tạm dừng",
"resume": "Tiếp tục"
"resume": "Tiếp tục",
"selectAll": "Chọn tất cả màn hình",
"selectMonitor": "Chọn {{name}}",
"bulkSelected_one": "Đã chọn {{count}} màn hình",
"bulkSelected_other": "Đã chọn {{count}} màn hình"
},
"statBoxes": {
"activeFor": "Hoạt động từ",
@@ -449,6 +453,16 @@
"optionHardware": "Hạ tầng",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "Đã tạm dừng {{count}} màn hình",
"paused_other": "Đã tạm dừng {{count}} màn hình",
"resumed_one": "Đã tiếp tục {{count}} màn hình",
"resumed_other": "Đã tiếp tục {{count}} màn hình",
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Điểm cuối Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Cài đặt chung",
@@ -546,7 +564,7 @@
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
"hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
"hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'."
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "邀请成员",
"openSite": "打开站点",
"pause": "暂停",
"resume": "恢复"
"resume": "恢复",
"selectAll": "选择所有监视器",
"selectMonitor": "选择 {{name}}",
"bulkSelected_one": "已选择 {{count}} 个监视器",
"bulkSelected_other": "已选择 {{count}} 个监视器"
},
"statBoxes": {
"activeFor": "已活跃",
@@ -449,6 +453,16 @@
"optionHardware": "基础设施",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暂停 {{count}} 个监视器",
"paused_other": "已暂停 {{count}} 个监视器",
"resumed_one": "已恢复 {{count}} 个监视器",
"resumed_other": "已恢复 {{count}} 个监视器",
"alreadyPaused_one": "所选监视器已暂停",
"alreadyPaused_other": "所有选定的监视器均已暂停",
"alreadyRunning_one": "所选监视器已经在运行",
"alreadyRunning_other": "所有选定的监视器均已在运行"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端点",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "常规设置",
@@ -546,7 +564,7 @@
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
}
},
"ignoreTls": {
+20 -2
View File
@@ -421,7 +421,11 @@
"inviteMember": "邀請成員",
"openSite": "開啟網站",
"pause": "暫停",
"resume": "恢復"
"resume": "恢復",
"selectAll": "選擇所有監視器",
"selectMonitor": "選擇 {{name}}",
"bulkSelected_one": "已選擇 {{count}} 個監視器",
"bulkSelected_other": "已選擇 {{count}} 個監視器"
},
"statBoxes": {
"activeFor": "已啟用",
@@ -449,6 +453,16 @@
"optionHardware": "基礎架構",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暫停 {{count}} 個監視器",
"paused_other": "已暫停 {{count}} 個監視器",
"resumed_one": "已恢復 {{count}} 個監視器",
"resumed_other": "已恢復 {{count}} 個監視器",
"alreadyPaused_one": "所選監視器已暫停",
"alreadyPaused_other": "所有選定的監視器均已暫停",
"alreadyRunning_one": "所選監視器已經在運行",
"alreadyRunning_other": "所有選定的監視器均已在運行"
}
}
},
@@ -534,6 +548,10 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端點",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -546,7 +564,7 @@
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
}
},
"ignoreTls": {
-2
View File
@@ -18,8 +18,6 @@ services:
image: mongo:6.0
restart: always
command: ["mongod", "--quiet", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
- ./mongo/data:/data/db
healthcheck:
+5
View File
@@ -40,6 +40,11 @@ RUN npm run build
COPY --from=frontend-build /app/client/dist ./public
RUN chown -R node:node ./public \
&& chown -R node:node ./dist \
&& chown -R node:node ./scripts
EXPOSE 52345
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
+6 -2
View File
@@ -26,8 +26,12 @@ RUN cp -r src/templates dist/templates
COPY --from=frontend-build /app/client/dist ./public
RUN chmod +x ./scripts/inject-vars.sh
RUN chmod +x ./scripts/inject-vars.sh \
&& chown -R node:node ./public \
&& chown -R node:node ./dist \
&& chown -R node:node ./scripts
EXPOSE 52345
CMD ./scripts/inject-vars.sh && node ./dist/index.js
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
+114
View File
@@ -0,0 +1,114 @@
# Checkmate API documentation
Public-facing API documentation that powers **https://checkmate.so/docs**.
The site is built with [Astro Starlight](https://starlight.astro.build/) — a static-site generator that produces a fast, modern docs site with zero JavaScript by default. Starlight is open source and self-hostable; there is no third-party account required to publish.
## Repository layout
```
docs/
├── api-content/ ← markdown prose (this folder — edit here)
│ ├── introduction.md
│ ├── quickstart.md
│ ├── authentication.md
│ ├── conventions.md
│ ├── errors.md
│ ├── rate-limiting.md
│ └── api-reference/
│ └── overview.md
└── api/ ← Astro Starlight project (build tool — leave alone unless changing the site)
├── package.json
├── astro.config.mjs
├── src/content.config.ts
└── src/styles/checkmate.css
```
**Contributors editing prose only need to touch `docs/api-content/`**. Astro's content loader picks up the markdown automatically.
The endpoint reference (every URL + schema) is **auto-generated from [`server/openapi.json`](../../server/openapi.json)** via the `starlight-openapi` plugin. Adding or changing an endpoint means updating that JSON; the public docs pick it up on the next Vercel build.
## Local preview
```bash
cd docs/api
npm install
npm run dev
```
Visit http://localhost:4321/docs. Hot-reload works for markdown changes in `docs/api-content/`.
To produce the static site:
```bash
npm run build # outputs docs/api/dist/
npm run preview # serve the built output locally
```
## Deploying to checkmate.so/docs
Astro Starlight builds a static site. Deploy on Vercel and reverse-proxy `checkmate.so/docs/*` to it.
### One-time Vercel setup
1. Go to https://vercel.com → New Project → import the `bluewave-labs/Checkmate` GitHub repo.
2. **Root Directory**: set to `docs/api`.
3. **Framework Preset**: Vercel auto-detects Astro from `vercel.json`.
4. **Build & Output**: leave defaults (`npm run build` → `dist/`).
5. **Production Branch**: `develop`.
6. Deploy. Vercel returns a URL like `checkmate-docs.vercel.app`.
7. Verify the site renders correctly.
The deployment auto-rebuilds on every push to `develop` and on every PR (as a preview deploy).
### Reverse-proxy /docs to Vercel
In whichever edge proxy fronts `checkmate.so`, route `/docs/*` to the Vercel deployment.
**Cloudflare Workers:**
```javascript
addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
const target = new URL(url.pathname, "https://checkmate-docs.vercel.app");
target.search = url.search;
return event.respondWith(fetch(target.toString(), event.request));
}
});
```
**nginx:**
```nginx
location /docs/ {
proxy_pass https://checkmate-docs.vercel.app/docs/;
proxy_set_header Host checkmate-docs.vercel.app;
proxy_ssl_server_name on;
}
```
The `base: "/docs"` setting in `astro.config.mjs` ensures every internal link and asset URL is prefixed correctly so the site works at `checkmate.so/docs/...` rather than the root.
## Updating the docs
| Task | What to edit |
|---|---|
| Fix a typo, clarify wording | `docs/api-content/*.md` |
| Add a new narrative page | Add the file to `docs/api-content/`, then add a `slug` entry to `astro.config.mjs` → `sidebar` |
| Fix something in the API reference | Edit `server/openapi.json` (or the route spec it's built from) — never edit the generated reference pages |
| Change branding / colors | `docs/api/src/styles/checkmate.css` |
| Change site config (title, logo, social) | `docs/api/astro.config.mjs` |
Two rules:
1. **Never hand-edit the API reference.** It's regenerated on every build. Fix the spec.
2. **Test code samples against a real instance before merging.** Wrong code in docs is worse than missing code.
## Why Astro Starlight (and not Mintlify, Docusaurus, etc.)
- **No third-party account required.** Self-hostable on any static host.
- **Best looking** of the modern docs frameworks per multiple 2026 reviews — Distr, among others, migrated away from Docusaurus specifically for Starlight's design.
- **Zero JavaScript by default.** Pages are pure HTML — fast load, perfect Lighthouse scores.
- **OpenAPI integration is first-class** via `starlight-openapi`.
- **Markdown is the source of truth.** No proprietary formats. Easy to migrate later if needed.
@@ -0,0 +1,47 @@
---
title: API reference overview
description: All endpoints, grouped by resource. Auto-generated from the OpenAPI spec.
---
# API reference
The full machine-readable specification is at [`server/openapi.json`](https://github.com/bluewave-labs/Checkmate/blob/develop/server/openapi.json). Mintlify renders an interactive reference from it — each endpoint below is fully documented with request/response schemas, examples, and a built-in "Try it" form.
## Endpoint groups
| Group | Description |
|---|---|
| **auth** | Register, log in, password recovery, manage users |
| **invite** | Send and redeem team invites |
| **monitors** | Create, update, pause, and delete monitors |
| **checks** | Read individual check results and team-wide summaries |
| **incidents** | List, filter, and resolve downtime incidents |
| **maintenance-window** | Schedule maintenance windows to silence checks |
| **notifications** | Configure email, Slack, Discord, and webhook channels |
| **status-page** | Manage public status pages |
| **geo-checks** | Multi-region check results |
| **logs** | Audit and operational logs |
| **settings** | Global Checkmate settings |
| **queue** | Background-job queue health (operational) |
| **diagnostic** | System health and version info |
## Authentication
Every endpoint except the unauthenticated auth/recovery routes requires:
```
Authorization: Bearer <jwt>
```
See [Authentication](/api/authentication) for how to obtain a token.
## Conventions recap
- Base URL: `https://your-checkmate-host/api/v1`
- Response envelope: `{ success, msg, data }`
- IDs: 24-character MongoDB ObjectIds
- Timestamps: ISO 8601 UTC
- Durations: integers in milliseconds
- Most resources are team-scoped — pass `teamId` where the path includes `/team/...`
See [Conventions](/api/conventions) for full details.
+137
View File
@@ -0,0 +1,137 @@
---
title: Authentication
description: Obtain a JWT from /auth/login and send it as a Bearer token on every subsequent request.
---
# Authentication
Checkmate uses **JWT bearer tokens**. There are no API keys, no OAuth, and no client secrets. Every API user is a regular Checkmate user — the credentials you log in to the web UI with are the credentials you use to obtain a token.
## The flow
1. **Get a token** by calling `POST /auth/login` with your email and password.
2. **Send the token** as `Authorization: Bearer <token>` on every subsequent request.
3. **Refresh** by calling `POST /auth/login` again before the token expires.
## Obtaining a token
```bash
curl -X POST https://your-checkmate-host/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"password": "YourPass1!"
}'
```
A successful response returns the token plus the authenticated user record:
```json
{
"success": true,
"msg": "OK",
"data": {
"user": {
"_id": "65f1c2a4d8b9e0123456789a",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "you@example.com",
"role": ["admin"],
"teamId": "65f1c2a4d8b9e01234567890",
"createdAt": "2026-04-01T10:00:00.000Z",
"updatedAt": "2026-04-15T14:30:00.000Z"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
```
Store both `token` (for the `Authorization` header) and `user.teamId` (most resource endpoints are scoped by team).
## Using a token
Every authenticated request must include:
```
Authorization: Bearer <token>
```
```bash
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
```javascript
const res = await fetch("https://your-checkmate-host/api/v1/monitors/team/<teamId>", {
headers: { Authorization: `Bearer ${token}` },
});
```
## Token lifetime
Tokens expire after the duration configured by the operator via the `TOKEN_TTL` environment variable. The default is **99 days**.
There is no separate refresh-token endpoint. When a token is close to expiry — or after a 401 response — call `POST /auth/login` again to obtain a new one.
## Roles
The `user.role` claim is an array. Available roles:
| Role | Capability |
|---|---|
| `superadmin` | Full access to all teams and global settings. Can promote other users. |
| `admin` | Full access within their team. |
| `user` | Read access within their team. |
| `demo` | Limited demo-mode access. |
A user can hold multiple roles simultaneously.
## Registering the first user
A fresh Checkmate instance has no users. The first call to `POST /auth/register` creates the first **superadmin** and provisions a team for them:
```bash
curl -X POST https://your-checkmate-host/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"password": "Hunter2!",
"role": ["superadmin"],
"teamId": ""
}'
```
After the first user exists, additional users are usually created via the invite flow rather than `POST /auth/register`:
```
POST /invite/send # superadmin/admin sends an invite
POST /auth/register # invitee redeems the invite token
```
You can check whether a superadmin already exists with:
```bash
curl https://your-checkmate-host/api/v1/auth/users/superadmin
```
```json
{ "success": true, "data": true }
```
## Password recovery
The password-recovery flow is three calls, all unauthenticated:
```
POST /auth/recovery/request # user requests a recovery email
POST /auth/recovery/validate # client validates the recovery token
POST /auth/recovery/reset # user sets a new password
```
## Logging out
JWTs are stateless — there is no server-side logout. Drop the token from your client storage. If you need to invalidate a token before its expiry (e.g. a compromised device), the operator must rotate the `JWT_SECRET` environment variable, which invalidates every outstanding token.
Next: [Conventions](/api/conventions).
+122
View File
@@ -0,0 +1,122 @@
---
title: Conventions
description: Response envelope, request format, identifiers, pagination, and team scoping.
---
# Conventions
A small number of conventions apply to every endpoint. Once you've internalized them, the per-resource reference is mostly self-explanatory.
## Request format
- **Content-Type**: `application/json` for all POST/PUT/PATCH bodies, except multipart uploads (logos, profile images), which use `multipart/form-data`.
- **Charset**: UTF-8.
- **Method semantics**:
- `GET` — read, never mutates state.
- `POST` — create, or action that doesn't fit GET/PATCH/DELETE.
- `PATCH` — partial update.
- `PUT` — full replace.
- `DELETE` — delete.
## Response envelope
Every JSON response uses the same shape:
```json
{
"success": true,
"msg": "OK",
"data": { /* endpoint-specific payload */ }
}
```
| Field | Type | Meaning |
|---|---|---|
| `success` | boolean | `true` on 2xx, `false` on 4xx/5xx |
| `msg` | string | Human-readable summary — safe to surface in UIs |
| `data` | object \| array \| null | Endpoint-specific payload. Absent or `null` for `204 No Content`. |
Error responses use the same envelope with `success: false` and `data` typically omitted. See [Errors](/api/errors).
## Identifiers
- All resource IDs are **MongoDB ObjectIds** (24-character hex strings):
```
65f1c2a4d8b9e0123456789a
```
- Treat IDs as opaque strings. Do not attempt to parse the timestamp or counter components.
## Team scoping
Most resources belong to a **team**. After login, the user record contains a `teamId`; pass it as a path parameter wherever the endpoint name includes `/team/...`:
```
GET /monitors/team/65f1c2a4d8b9e01234567890
GET /incidents/team
GET /checks/team
```
Users only see resources belonging to their own team, except for `superadmin`, who can read across teams.
## Date and time
- All timestamps in JSON are **ISO 8601** strings in UTC:
```
"createdAt": "2026-04-01T10:00:00.000Z"
```
- Durations are typically integers in **milliseconds** (intervals, response times, time windows).
## Pagination
Endpoints that return collections accept these optional query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `limit` | integer | endpoint-dependent | Maximum rows to return. |
| `page` | integer | `0` | Zero-indexed page number. |
| `sortBy` | string | endpoint-dependent | Field to sort by. |
| `sortOrder` | `asc` \| `desc` | `desc` | Sort direction. |
| `filter` | string | — | Endpoint-specific filter (e.g. status). |
When a collection supports pagination, the response `data` includes the rows plus the total count:
```json
{
"success": true,
"msg": "OK",
"data": {
"monitors": [ /* … */ ],
"filteredMonitors": [ /* … */ ],
"summary": { /* counters */ }
}
}
```
Exact shapes vary per endpoint. See the [API reference](/api/reference) for each endpoint's response schema.
## File uploads
Two endpoints accept files:
- Status page logo: `POST /status-page` (multipart, field `logo`)
- User profile picture: `POST /auth/register`, `PATCH /auth/user/:id` (multipart, field `profileImage`)
Use `multipart/form-data` and include the JSON fields as separate form parts.
## CORS
The server's CORS policy is configured via the `CLIENT_HOST` environment variable. Only requests whose `Origin` matches `CLIENT_HOST` are accepted, and credentials (cookies) are allowed.
If you're calling the API from a browser on a different origin, the operator must add that origin to `CLIENT_HOST`. Server-to-server calls (curl, your backend) are not subject to CORS.
## Versioning
The base path includes the API version:
```
/api/v1/...
```
Within `v1`, additive changes (new endpoints, new optional fields) ship without notice. Breaking changes (removed endpoints, changed field types) only ship under a new major version.
Next: [Errors](/api/errors).
+124
View File
@@ -0,0 +1,124 @@
---
title: Errors
description: HTTP status codes Checkmate returns, what they mean, and how to handle them.
---
# Errors
Failure responses share the same envelope as successful ones, with `success: false` and `data` omitted (or `null`):
```json
{
"success": false,
"msg": "Validation failed: email must be a valid email"
}
```
Always treat the **HTTP status code** as the source of truth for handling logic, and surface `msg` to humans.
## Status codes
| Status | Meaning | When you'll see it |
|---|---|---|
| `200 OK` | Request succeeded with a body | Most GETs, successful mutations that return the new resource |
| `204 No Content` | Request succeeded with no body | DELETEs, successful actions like `/status-page/:url/unlock` |
| `400 Bad Request` | Malformed request | Missing required path param, malformed JSON, invalid file upload |
| `401 Unauthorized` | Missing or invalid JWT | Token expired, token missing, token signed with a different secret |
| `403 Forbidden` | Authenticated, but not allowed | Accessing a resource that belongs to a different team, or insufficient role |
| `404 Not Found` | Resource doesn't exist | Wrong ID, deleted resource, mistyped path |
| `409 Conflict` | Operation would violate a constraint | Duplicate email on register, slug already taken on status page |
| `413 Payload Too Large` | File upload exceeded limit | Logo or profile image too big |
| `422 Unprocessable Entity` | Request was well-formed but failed validation | Zod / Joi schema rejected the body |
| `429 Too Many Requests` | Rate limit exceeded | See [Rate limiting](/api/rate-limiting) |
| `500 Internal Server Error` | Unexpected server error | A bug — report it with the request ID |
## Common shapes
### 401 — missing or invalid token
```json
{
"success": false,
"msg": "No auth token provided"
}
```
Re-authenticate by calling `POST /auth/login`.
### 422 — validation error
```json
{
"success": false,
"msg": "\"email\" must be a valid email"
}
```
The `msg` describes the first failing field. Re-send the request with the fix.
### 403 — forbidden
```json
{
"success": false,
"msg": "Forbidden"
}
```
You're authenticated but the resource isn't visible to you. Common cause: passing a `teamId` that isn't your team's.
### 429 — rate limited
```json
{
"success": false,
"msg": "Too many requests"
}
```
Inspect `RateLimit-*` response headers and back off. See [Rate limiting](/api/rate-limiting).
## Error handling pattern
Client code should branch on status code, not on `msg`:
```javascript
const res = await fetch(url, opts);
const body = await res.json().catch(() => ({}));
switch (res.status) {
case 200:
case 204:
return body.data;
case 401:
// Token expired or missing — re-authenticate, then retry once.
await reauthenticate();
return retry();
case 422:
// Surface the validation error to the user.
throw new ValidationError(body.msg);
case 429:
// Back off using Retry-After if present.
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
await sleep(retryAfter * 1000);
return retry();
default:
throw new Error(`Checkmate API ${res.status}: ${body.msg ?? res.statusText}`);
}
```
## Reporting bugs
If you see a `500`, capture:
- The exact request (method, path, body — **redact secrets**)
- The full response body and headers
- A timestamp in UTC
Open an issue at https://github.com/bluewave-labs/Checkmate/issues with that information.
Next: [Rate limiting](/api/rate-limiting).
+84
View File
@@ -0,0 +1,84 @@
---
title: Introduction
description: Use the Checkmate REST API to manage monitors, incidents, status pages, notifications, and users programmatically.
---
# Checkmate API
Checkmate exposes a REST API that mirrors everything the web UI can do. Use it to provision monitors from infrastructure-as-code, build internal dashboards, automate incident workflows, or integrate Checkmate into your existing alerting stack.
The web UI you log in to is itself just a client of this same API.
## Base URL
```
https://<your-checkmate-host>/api/v1
```
Replace `<your-checkmate-host>` with the domain where you deployed Checkmate. For local development:
```
http://localhost:52345/api/v1
```
All endpoints are relative to this base. Every path on this page is shown without the `/api/v1` prefix — prepend it when making requests.
## API stability
The API is at version **`v1`** and is considered stable. Breaking changes will only ship under a new major version (`/api/v2`).
## What you can do
| Resource | What it covers |
|---|---|
| `monitors` | Create, update, pause, and delete monitors of any type (HTTP, Ping, Port, DNS, Docker, PageSpeed, gRPC, WebSocket, Game, Hardware) |
| `checks` | Read individual check results and aggregated stats |
| `incidents` | List, filter, and resolve downtime incidents |
| `notifications` | Manage notification channels (email, Slack, Discord, webhooks) |
| `status-page` | Configure public status pages |
| `maintenance-window` | Schedule maintenance windows so checks don't fire incidents |
| `geo-checks` | Multi-region check results |
| `auth` | Register, log in, manage users |
| `invite` | Invite teammates |
| `logs` | Audit and operational logs |
| `settings` | Tune global Checkmate settings |
| `queue` | Inspect the background-job queue (operational) |
| `diagnostic` | Health and system info |
## What you'll need
1. A running Checkmate instance you administer.
2. A user account on that instance.
3. A JWT obtained by calling `POST /auth/login` — see [Authentication](/api/authentication).
There is no separate "API key" concept in Checkmate. The same JWT that authenticates the web UI authenticates every API call.
## Response shape
Every endpoint returns the same envelope:
```json
{
"success": true,
"msg": "OK",
"data": { /* endpoint-specific payload */ }
}
```
See [Conventions](/api/conventions) for the full contract and [Errors](/api/errors) for failure shapes.
## Quick example
```bash
# 1. Log in to get a token
TOKEN=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"YourPass1!"}' \
| jq -r '.data.token')
# 2. List your team's monitors
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
-H "Authorization: Bearer $TOKEN"
```
Next: [Authentication](/api/authentication).
+117
View File
@@ -0,0 +1,117 @@
---
title: Quickstart
description: From zero to a working monitor in under five minutes.
---
# Quickstart
This walkthrough creates a Checkmate user, logs in, provisions an HTTP monitor, and reads its checks — all via the API.
It assumes you have a Checkmate instance running and reachable. Substitute `https://your-checkmate-host` with your actual host throughout.
## 1. Register the first user
If your instance is fresh, the first user becomes a superadmin:
```bash
curl -X POST https://your-checkmate-host/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"password": "Hunter2!Hunter2!",
"role": ["superadmin"],
"teamId": ""
}'
```
The response includes a JWT and a freshly-created `teamId`. Save both.
If a superadmin already exists, skip to step 2 and use existing credentials.
## 2. Log in
If you already have an account:
```bash
TOKEN=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"Hunter2!Hunter2!"}' \
| jq -r '.data.token')
TEAM_ID=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"Hunter2!Hunter2!"}' \
| jq -r '.data.user.teamId')
echo "Token: $TOKEN"
echo "Team: $TEAM_ID"
```
## 3. Create a monitor
This creates an HTTP monitor that probes example.com every 60 seconds:
```bash
curl -X POST https://your-checkmate-host/api/v1/monitors \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"example.com\",
\"type\": \"http\",
\"url\": \"https://example.com\",
\"interval\": 60000,
\"teamId\": \"$TEAM_ID\",
\"isActive\": true
}"
```
A successful response returns the new monitor including its `_id`.
## 4. List your team's monitors
```bash
curl https://your-checkmate-host/api/v1/monitors/team/$TEAM_ID \
-H "Authorization: Bearer $TOKEN"
```
You should see the monitor you just created. Within a minute, the background worker will have run a few checks against it.
## 5. Read recent checks
Replace `<MONITOR_ID>` with the `_id` from step 3:
```bash
curl "https://your-checkmate-host/api/v1/checks/<MONITOR_ID>?limit=10" \
-H "Authorization: Bearer $TOKEN"
```
You'll see an array of check results — status code, response time, timestamp, region (if applicable).
## 6. Pause and delete the monitor
Pause:
```bash
curl -X PATCH https://your-checkmate-host/api/v1/monitors/<MONITOR_ID> \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"isActive": false}'
```
Delete:
```bash
curl -X DELETE https://your-checkmate-host/api/v1/monitors/<MONITOR_ID> \
-H "Authorization: Bearer $TOKEN"
```
## What's next
- **Set up notifications** — `POST /notifications` to wire email / Slack / Discord / webhook channels, then attach them to monitors.
- **Build a status page** — `POST /status-page` to publish your monitors publicly.
- **Schedule maintenance** — `POST /maintenance-window` to silence checks during planned downtime.
- **Browse the full API reference** — see every endpoint with request/response schemas.
See the [reference](/api/reference) for the complete list of endpoints.
+83
View File
@@ -0,0 +1,83 @@
---
title: Rate limiting
description: Per-IP request limits, headers, and how to back off.
---
# Rate limiting
Checkmate applies two per-IP rate limiters, both with a **60-second window**:
| Limiter | Applies to | Limit |
|---|---|---|
| General | All requests under `/api/v1/*` | **600 requests / 60 seconds** |
| Auth-sensitive | `/auth/login`, `/auth/register`, `/auth/recovery/*`, `/status-page/:url/unlock` | **15 requests / 60 seconds** |
The two limiters are **independent**. Auth-sensitive endpoints count against both buckets — you can hammer `/auth/login` 15 times in a minute before being rate-limited there, but a single client can never exceed 600 total requests per minute regardless of which endpoints are called.
## Headers
Every response includes the standard `RateLimit-*` headers so you can pace yourself proactively:
```
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42
```
| Header | Meaning |
|---|---|
| `RateLimit-Limit` | The cap for the current window. |
| `RateLimit-Remaining` | Requests left before you hit `429`. |
| `RateLimit-Reset` | Seconds until the window resets. |
## When you exceed the limit
You receive `HTTP 429 Too Many Requests`:
```json
{
"success": false,
"msg": "Too many requests"
}
```
The response also includes `Retry-After`:
```
Retry-After: 42
```
Wait that many seconds before retrying. If `Retry-After` is missing, fall back to the `RateLimit-Reset` header.
## Recommended backoff
For one-off scripts, simple sleep-then-retry is fine:
```javascript
async function callWithRetry(url, opts, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
const retry = Number(res.headers.get("Retry-After") ?? 60);
await new Promise(r => setTimeout(r, retry * 1000));
}
throw new Error("Rate limit retry exhausted");
}
```
For higher-throughput integrations, watch `RateLimit-Remaining` and pace your sender to stay below the limit rather than waiting for `429`.
## Limits and proxies
Rate-limiting is per **client IP**, derived from the connection — or, when Checkmate is behind a reverse proxy with `trust proxy` configured, from the leftmost public IP in `X-Forwarded-For`.
If many of your clients share an egress IP (NAT, corporate proxy), they share the bucket. In that case, an operator should either:
- Increase the limits (server code change required), or
- Make sure each client connects from a distinct IP.
## Limits are not contractual
The numbers above reflect the current defaults. An operator can change them at any time (they're hardcoded in `server/src/middleware/rateLimiter.ts`). If you're building an integration that depends on a higher limit, coordinate with the operator first.
Next: [API reference](/api/reference) — auto-generated from the OpenAPI spec.
+21
View File
@@ -0,0 +1,21 @@
# Astro build
dist/
.astro/
# Sources copied in at build time by scripts/sync-sources.mjs.
# Edit /docs/api-content/ and /server/openapi.json instead.
src/vendored/
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Env
.env
.env.production
.vercel
+83
View File
@@ -0,0 +1,83 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import starlightOpenAPI, { openAPISidebarGroups } from "starlight-openapi";
// The OpenAPI spec is vendored from server/openapi.json by
// scripts/sync-sources.mjs (runs as predev/prebuild). We point at the
// vendored copy so the build is self-contained — Vercel only uploads
// the docs/api/ subtree, so paths outside this directory are not
// available at build time.
const OPENAPI_PATH = "./src/vendored/openapi.json";
export default defineConfig({
// The deployed site serves at the root of its Vercel domain. The
// reverse proxy that maps checkmate.so/docs/* → Vercel must strip
// the /docs/ prefix before forwarding (the Cloudflare Worker snippet
// in docs/api-content/README.md does this).
site: "https://checkmate.so",
integrations: [
starlight({
title: "Checkmate API",
description: "Use the Checkmate REST API to manage monitors, incidents, status pages, notifications, and users programmatically.",
// Prose markdown is loaded from /docs/api-content via the custom
// docsLoader in src/content.config.ts. Keeping content outside the
// Astro project tree means contributors can edit it without dealing
// with Astro / Starlight tooling.
logo: {
src: "./src/assets/logo.svg",
replacesTitle: true,
},
favicon: "/favicon.svg",
customCss: ["./src/styles/checkmate.css"],
social: {
github: "https://github.com/bluewave-labs/Checkmate",
},
// Inject a "Releases" link into the site header.
components: {
SocialIcons: "./src/components/HeaderLinks.astro",
},
sidebar: [
{
label: "Getting started",
items: [
{ label: "Introduction", slug: "" },
{ label: "Quickstart", slug: "quickstart" },
{ label: "Authentication", slug: "authentication" },
],
},
{
label: "Core concepts",
items: [
{ label: "Conventions", slug: "conventions" },
{ label: "Errors", slug: "errors" },
{ label: "Rate limiting", slug: "rate-limiting" },
],
},
{
label: "API reference",
items: [
{ label: "Overview", slug: "api-reference/overview" },
// The OpenAPI plugin appends every endpoint under this group.
...openAPISidebarGroups,
],
},
],
plugins: [
starlightOpenAPI([
{
base: "api-reference",
label: "REST API",
schema: OPENAPI_PATH,
},
]),
],
}),
],
});
+7983
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more