Compare commits

...
302 Commits
Author SHA1 Message Date
Alexander Holliday 021f99e0be Merge pull request #3889 from bluewave-labs/develop
develop -> master
2026-08-26 12:08:53 -07:00
Alexander Holliday 54ffd74048 Merge pull request #3887 from bluewave-labs/feat/heatmap-color-interpolation
feat: interpoalte colors for severity of downtime
2026-08-26 12:07:52 -07:00
Alex Holliday 4bc71a3d18 interpoalte colors for severity of downtime 2026-08-26 11:48:33 -07:00
Alexander Holliday d03765c5fd Merge pull request #3886 from bluewave-labs/fix/proxy-id-empty-string
fix: deal with empty strings for proxyId
2026-08-26 11:21:18 -07:00
Alex Holliday f30085bae3 deal with empty strings for proxyId 2026-08-26 11:19:08 -07:00
Alexander Holliday adc6af83e3 Merge pull request #3885 from bluewave-labs/develop
Develop -> Master
2026-08-26 10:45:29 -07:00
Alexander Holliday c24440f8c9 Merge pull request #3883 from bluewave-labs/chore/swr-policy
chore: set keep previous data
2026-08-26 10:42:00 -07:00
Alex Holliday d8ce2dfdc8 set keep previous data 2026-08-26 10:40:02 -07:00
Alexander Holliday 28f8542bce Merge pull request #3882 from bluewave-labs/chore/dry-run-check
chore: add lockfile check
2026-08-26 10:06:53 -07:00
Gorkem Cetin 2e258fa39a Update README with installation options
Added 1-click installation options for Checkmate.
2026-08-26 12:51:40 +03:00
Gorkem Cetin 7dedcca41d Revise README for contributor stats and project description
Updated the README to reflect recent achievements and contributions.
2026-08-26 12:47:27 +03:00
Alex Holliday 97d6222297 add a lockfile check 2026-08-25 15:20:57 -07:00
Alexander Holliday 14ddfc97b0 Merge pull request #3876 from bluewave-labs/fix/node-version
Fix/node version
2026-08-25 15:17:41 -07:00
Alex Holliday b23dbfeae0 regen lockfile 2026-08-25 15:16:54 -07:00
Alex Holliday 68679bc0bf lockfile script 2026-08-25 15:16:12 -07:00
Alexander Holliday 959a434d21 Merge pull request #3875 from bluewave-labs/fix/package-lock-sync
fix package lock sync issue
2026-08-25 15:06:03 -07:00
Alex Holliday 4298397e3e fix package lock sync issue 2026-08-25 15:05:28 -07:00
Alexander Holliday fc3fab4905 Merge pull request #3873 from bluewave-labs/chore/version-bump
minor version bump
2026-08-25 14:52:58 -07:00
Alex Holliday 0b54b5c6cd minor version bump 2026-08-25 14:49:52 -07:00
Alexander Holliday 358b1deec7 Merge pull request #3872 from bluewave-labs/fix/csp
fix: logo image preview CSP
2026-08-25 14:48:00 -07:00
Alex Holliday 9839d48ca0 add img-src blob: to CSP, stop refetching blob 2026-08-25 14:44:01 -07:00
Alexander Holliday 83c2df4390 Merge pull request #3863 from eralpozcan/feat/status-page-monitor-tags
feat(status-page): show monitor tags in status page selector
2026-08-25 14:16:33 -07:00
Alexander Holliday 31f6727519 Merge pull request #3871 from bluewave-labs/fix/remove-dead-workflows
fix: remove dead workflows
2026-08-25 14:12:02 -07:00
Alex Holliday 96f8bf4a66 Merge branch 'develop' into fix/remove-dead-workflows 2026-08-25 14:10:18 -07:00
Alexander Holliday 06bf5e45f5 Merge pull request #3870 from bluewave-labs/fix/package-lock
fix: regen package lock
2026-08-25 14:09:34 -07:00
Alex Holliday d0fc402683 format 2026-08-25 14:08:11 -07:00
Alex Holliday b922654cfc remove poeditor workflows 2026-08-25 14:07:13 -07:00
Alex Holliday d7dce5d7a3 regen package lock 2026-08-25 14:03:35 -07:00
Alexander Holliday 5d2aa28570 Merge pull request #3869 from bluewave-labs/fix/proxy-scoping
fix: proxy scoping
2026-08-25 13:54:05 -07:00
Alex Holliday af3c72b055 add tests 2026-08-25 11:19:33 -07:00
Alex Holliday b3e98ac95d update openapi docs 2026-08-25 11:14:50 -07:00
Alex Holliday 3f62c8ec6a resolve scoping issue by fetching proxy summary 2026-08-25 11:13:12 -07:00
Alex Holliday b2dca7b62f add getAllProxies route 2026-08-25 11:02:37 -07:00
Alexander Holliday f731648007 Merge pull request #3868 from bluewave-labs/feat/proxy-frontend
add global proxy setting config and translation keys
2026-08-25 10:48:04 -07:00
Alexander Holliday a1199f957d Merge pull request #3867 from bluewave-labs/feat/proxy-frontend
feat: proxy frontend
2026-08-25 10:44:46 -07:00
Alex Holliday ea8ad88ae9 add global proxy setting config and translation keys 2026-08-25 10:29:19 -07:00
Alex Holliday 6e271f834c select -> radio, update descriptions 2026-08-25 09:55:34 -07:00
Alex Holliday 0c21dcdd2a add settings response type, destructure 2026-08-25 09:39:56 -07:00
eralpozcan 9bb95e1f0c feat: show monitor tags in status page selector 2026-08-22 14:05:44 +03:00
Alex Holliday ccfda938d5 Add proxies to create/edit monitors page 2026-08-21 15:13:34 -07:00
Alex Holliday cc137e1d06 add proxies CRUD pages 2026-08-21 15:13:17 -07:00
Alexander Holliday 41859f241a Merge pull request #3860 from bluewave-labs/feat/proxy-resolver
feat: proxy resolver
2026-08-21 12:34:22 -07:00
Alex Holliday 252067142e apply proxy agents to http monitors 2026-08-21 12:19:23 -07:00
Alex Holliday 1152a70afb wire up proxy resolver 2026-08-21 11:41:26 -07:00
Alex Holliday ffd7ea7517 add tests 2026-08-21 11:13:22 -07:00
Alex Holliday a56bff2305 add proxy resolver 2026-08-21 11:06:42 -07:00
Alexander Holliday db05db0c0c Merge pull request #3859 from bluewave-labs/feat/outbound-proxy-server-crud
feat: outbound proxy server crud
2026-08-21 10:54:42 -07:00
Alex Holliday cd520dacc5 add tests 2026-08-20 14:38:18 -07:00
Alex Holliday 637bb5cca9 add db migration for proxy mode inherit 2026-08-20 14:31:23 -07:00
Alex Holliday ede93add64 add clear username flag 2026-08-20 13:57:24 -07:00
Alex Holliday 4443a53a23 strip password 2026-08-20 13:52:14 -07:00
Alex Holliday 1b2d2887af add proxy routes/controller 2026-08-20 13:35:11 -07:00
Alex Holliday 54690fc73c add/update validations 2026-08-20 13:34:56 -07:00
Alex Holliday 6e3252aec8 wire repo and service 2026-08-20 11:31:51 -07:00
Alex Holliday edd9335e96 add gloabl proxy to app settings 2026-08-20 11:16:07 -07:00
Alex Holliday cf13bc329e add proxy service 2026-08-20 11:00:05 -07:00
Alex Holliday 80097afb71 update monitor model/repos 2026-08-20 10:59:40 -07:00
Alex Holliday 5435167e73 add proxy repository and interface 2026-08-20 10:49:12 -07:00
Alex Holliday 059dc2737b add proxy type and model 2026-08-20 10:28:50 -07:00
Alexander Holliday 158f2fffbe Merge pull request #3858 from bluewave-labs/feat/uptime-percentage-two-decimals
feat(client): show uptime percentages with two decimals
2026-08-20 09:39:56 -07:00
gorkem-bwl 68c1f2e281 feat(client): show uptime percentages with two decimals
Requested by a user who found xx.xx% more trustworthy than xx.x% on their
status page. The presentational argument holds, but the stronger reason is
that one decimal was actively misleading.

Intl rounds to the displayed precision, so every value above 99.95% rendered
as "100.0%" — a claim of zero downtime for a period that contained real
outages. On a public status page that is the opposite of reassuring: the
number reads as flawless during a month with a visible incident.

One decimal also collapsed the range operators care about most. 99.9%,
99.95% and 99.99% are three very different commitments (8.7 hours, 4.4 hours
and 52 minutes of annual downtime) and the first rendered as 99.9% while the
other two were indistinguishable at 100.0%.

Everything flows through the shared formatter in FormatUtils, so this covers
both surfaces at once: the monitor list and diagnostics gauges in the web
app, and the uptime figure, chart cells and hover tooltip on status pages.
Infrastructure gauges are untouched — they format separately at zero
decimals, where CPU at "43.00%" would only add noise.

Server-side precision was already sufficient; the aggregation divides
without rounding, so no API change is needed.

Adds tests pinning the regression: 7 of the 10 fail against the previous
one-decimal formatter.

Note: this restores the client Vitest setup. PR #3844 introduced it, but the
config, scripts and logger tests were dropped when that branch merged into
develop, leaving the client with no test runner again.
2026-08-20 09:38:07 -07:00
Alexander Holliday a719edb0fc Merge pull request #3856 from bluewave-labs/feat/theme-button
feat: add theme mode to auth footer menu
2026-08-19 12:05:25 -07:00
Alexander Holliday 53702ec04b Merge pull request #3855 from bluewave-labs/fix/keyboard-navigation
feat: improve keyboard navigation on create monitor page
2026-08-19 12:04:12 -07:00
Alex Holliday 84a73d9616 add theme mode to auth footer menu 2026-08-19 12:02:45 -07:00
Alex Holliday 7b2358bd48 improve keyboard navigation on create monitor page 2026-08-19 11:28:46 -07:00
Alexander Holliday 79957693fe Merge pull request #3854 from bluewave-labs/fix/infra-instant-gauge
fix: add recent checks for infra monitors
2026-08-19 10:29:44 -07:00
Alex Holliday 7fd368def3 update tests 2026-08-19 10:26:17 -07:00
Alex Holliday dd44e6c34f add recent checks for infra monitors 2026-08-19 10:20:21 -07:00
Alexander Holliday 092f890f7f Merge pull request #3851 from bluewave-labs/chore/dependency-bump
chore: bump server dependencies
2026-08-18 11:25:03 -07:00
Alex Holliday 2468d3e287 bump dependencies 2026-08-18 11:21:20 -07:00
Alexander Holliday bc25c15c34 Merge pull request #3844 from bluewave-labs/fix/redact-sensitive-fields-client-logs
fix(security): redact sensitive fields from client-side logs
2026-08-18 10:49:47 -07:00
Alex Holliday 1b6218ea56 remove tests and harness 2026-08-18 10:46:57 -07:00
Alexander Holliday 6eba53d716 Merge pull request #3837 from egeoztass/fix/email-error-propagation
fix: propagate SMTP transport errors from EmailService.sendEmail
2026-08-18 10:36:02 -07:00
Alexander Holliday 8bfb3a7de5 Merge pull request #3838 from egeoztass/fix/matrix-notifications
fix: resolve Matrix room aliases and encode room IDs in the send path
2026-08-18 10:32:45 -07:00
Alexander Holliday 5cca0fb2d3 Merge pull request #3839 from egeoztass/feat/ntfy-authentication
feat: add authentication support to ntfy notifications
2026-08-18 10:30:08 -07:00
Alexander Holliday 617ac3a9bb Merge pull request #3847 from bluewave-labs/fix/dns-monitors-status-page-picker
fix(status-page): allow DNS monitors to be added to status pages
2026-08-18 10:21:17 -07:00
Alexander Holliday e92fe39e3b Merge pull request #3849 from bluewave-labs/feat/status-page-ranges-client
feat: status page ranges client
2026-08-18 10:03:12 -07:00
Alex Holliday 248dad01d4 add retention hint 2026-08-18 10:01:05 -07:00
Alex Holliday 840c936501 remove unused vars 2026-08-18 09:38:34 -07:00
gorkem-bwl 7096afcdf2 fix(status-page): allow DNS monitors to be added to status pages
The monitor picker on the status page create/configure form fetched
candidates using a hardcoded list of monitor types that omitted "dns", so
DNS monitors were filtered out server-side and never appeared in the
dialog. Everything else was already in place: the DNS provider stores
checks like any other monitor type, and the status page renders them once
attached.

Rather than appending "dns" to the hardcoded list, derive the query from
the existing SelectableMonitorTypes tuple in @/Types/Monitor. The two lists
had silently drifted apart, and deriving prevents the next new monitor type
from hitting the same bug. "hardware" is appended explicitly because
infrastructure monitors are created on their own page and so are absent
from the selectable set.

Net effect on the requested types: gains "dns", loses none.

Fixes #3721
2026-08-18 02:37:54 -04:00
Alex Holliday c15d6e0851 wire client 2026-08-17 14:56:53 -07:00
Alex Holliday 506133af29 add daily tooltip 2026-08-17 13:55:01 -07:00
Alex Holliday 03f168fa84 add daily bucket type 2026-08-17 13:54:38 -07:00
Alex Holliday 4b02bbec37 add helper methods 2026-08-17 13:54:14 -07:00
Alex Holliday 42aae20fa3 add param to URL 2026-08-17 13:30:03 -07:00
Alex Holliday c71c833b77 add range to query 2026-08-17 13:20:36 -07:00
Alex Holliday cee7db64ee add range types 2026-08-17 13:16:55 -07:00
Alexander Holliday 40e06a2f2b Merge pull request #3845 from bluewave-labs/feat/status-page-ranges-server
feat: status page ranges server
2026-08-17 13:14:40 -07:00
Alex Holliday 7478bd7d17 remove duplicate validation 2026-08-17 13:08:43 -07:00
Alex Holliday 7b1d6c763f tighten types, remove duplicate calls 2026-08-17 13:05:03 -07:00
Alex Holliday ff2e1fd648 use reduce 2026-08-17 12:59:23 -07:00
Alex Holliday 9001a8d294 add tests 2026-08-17 12:58:56 -07:00
Alex Holliday ab7f2cd8f4 update openapi docs 2026-08-17 12:36:39 -07:00
Alex Holliday c1f0bf3dd4 wire controller 2026-08-17 12:31:01 -07:00
Alex Holliday 3fc7020a90 move methods to checks, wire up service 2026-08-17 12:26:49 -07:00
Alex Holliday 6f9642c298 tighten timezone validation 2026-08-17 11:48:56 -07:00
Alex Holliday 3a22d3e819 add range buckets for status pages 2026-08-17 11:48:44 -07:00
Alexander Holliday 697574a2a9 Merge pull request #3834 from colinjtsen2010/feat/domain-monitoring
feat: add domain registration expiry monitoring
2026-08-17 10:51:11 -07:00
Alex Holliday 0cbd9ab8a2 point lockfile at official npm registry 2026-08-17 10:49:39 -07:00
Alexander Holliday 9032fcd7f0 Merge pull request #3831 from mdiniz97/feat/rocket-chat-rich-notifications
feat: enrich Rocket.Chat notifications
2026-08-17 10:22:38 -07:00
gorkem-bwl ed268b3099 test(client): cover credential redaction in the logger
Adds a regression suite for #3843 so the leak cannot return silently. The
client had no test runner, so this introduces Vitest, which reuses the
existing Vite resolution and the `@/` alias rather than needing a separate
transform pipeline. jsdom is required because the logger reads
window.location and navigator.userAgent.

The tests drive the exported `logger` rather than the private redaction
helpers, so they cover the wiring as well as the logic. That distinction
matters here: the first attempt at this fix redacted the JSON branch but
left the debug branch printing raw objects, a gap that testing the helper
in isolation would not have caught. Each case runs at both error and debug
level for the same reason.

Covered: the reported login payload, credentials carried on an AxiosError
(config.data and the Authorization header), non-JSON request bodies,
passwords on class instances, key-matching variants, and circular payloads.
One asserts non-sensitive context survives, so redaction cannot regress
into blanket suppression.

Verified by reverting the fix: 16 of 23 tests fail against the original
behaviour, including a pre-existing crash on circular payloads.
2026-08-17 12:35:59 -04:00
gorkem-bwl e26825ef99 fix(security): close redaction gaps found in review
Follow-up to the initial redaction pass, addressing four defects:

Class instances bypassed redaction entirely. The prototype guard was meant
to skip FormData/Blob but caught every non-literal object, returning it by
reference — so a credential on a DTO or form-state instance was logged
verbatim, and the deep-copy contract was violated. The check is now an
explicit deny-list of opaque types; everything else is walked.

The raw Error was still logged by the debug branch. An AxiosError carries
config.data (the serialized request body) and config.headers.Authorization
(set for every request in ApiClient), so the password redacted from the
context was printed straight back out via the error object. Errors now go
through toSafeError(). Because Axios serializes the body to a string, which
key-based redaction cannot see into, it is parsed before redaction; a body
that is not JSON is replaced wholesale rather than logged raw.

The Blob guard was gated on `typeof FormData !== "undefined"`, so in an
environment with FormData but no Blob the instanceof check threw a
ReferenceError — from inside a catch block, turning a handled API error
into an unhandled crash. Each constructor is now resolved defensively.

Also widened the key list (cookie, jwt, privateKey, sessionId, csrf, pwd)
and dropped a redundant type annotation.

Verified with 18 assertions covering the reported payload, class instances,
cycles, shared siblings, axios error shapes, and a missing Blob global.
2026-08-17 12:31:40 -04:00
gorkem-bwl 741ee7a92a fix(security): redact sensitive fields from client-side logs
Failed requests logged the full request body to the browser console, so a
failing POST to /auth/login wrote the user's plaintext password where it
could be captured by error-reporting tools, screen shares, or support
sessions.

Redaction is applied centrally in the logger rather than at the call sites,
so every current and future caller is covered:

- createLogData() redacts context before it is serialized, covering the
  JSON branches of error() and warn().
- The debug-level branches of error() and warn() printed the raw context
  object directly, bypassing createLogData(); they now print the redacted
  copy.
- debug() and info() redact their free-form data argument.

Keys are matched case-insensitively on a substring after stripping dashes
and underscores, so password, newPassword, X-Api-Key and Authorization are
all caught. The walk copies rather than mutates the caller's object, cuts
genuine cycles without mislabelling repeated siblings, and renders
FormData/Blob as a type marker instead of walking them.

Affected paths were POST (/auth/login, /auth/register) and PATCH/PUT, which
carry monitor secrets. Error-level logging is on by default, so this was
not limited to debug builds.

Fixes #3843
2026-08-17 12:27:02 -04:00
egeoztass 099eaa38c3 fix: resolve Matrix room aliases and encode room IDs in the send path
Matrix notifications failed for anyone who entered a room alias. The send
endpoint only accepts a room ID, and an unresolved alias broke the request
outright: the leading "#" opens a URI fragment, so the PUT landed on
/_matrix/client/v3/rooms/ instead of the send endpoint. Element shows users
the alias, so this is the value most people paste in.

- Resolve "#alias:server" through GET /_matrix/client/v3/directory/room/{alias}
  before sending, per the client-server spec
- Percent-encode the room ID in the send path; ":" was previously sent raw
- Strip trailing slashes from the homeserver URL to avoid "//_matrix/..."

Alias resolution failures return false and log, matching how the provider
already handles send failures.

Closes #3478
2026-08-14 17:55:36 +03:00
egeoztass c35b22e1ac fix: propagate SMTP transport errors from EmailService.sendEmail
sendEmail caught transport failures internally and resolved to false or
undefined, so every caller's .catch() was dead code and send failures were
silently suppressed. Password recovery in particular reported "email sent
successfully" while the user received nothing.

sendEmail now throws an AppError carrying the underlying cause, and returns
Promise<string> instead of Promise<string | false | undefined>.

Call sites updated for the new contract:
- UserService.registerUser: the existing .catch() now actually fires
- UserService.requestRecovery: the failure reaches the client instead of a
  misleading success response
- InviteService and the test-email endpoint: keep their user-facing messages
  and attach the SMTP cause
- EmailProvider: catches and returns false, because NotificationsService fans
  providers out through Promise.all and a rejection there would abort the
  other channels' alerts

Closes #3757
2026-08-14 14:11:16 +03:00
egeoztass 1c9739d1da feat: add authentication support to ntfy notifications
ntfy servers that require auth rejected every Checkmate alert because the
provider posted without an Authorization header, so alerts to protected
topics failed silently.

Adds ntfyAuthType ("none" | "token" | "basic"):
- token: Authorization: Bearer <accessToken>
- basic: Authorization: Basic base64(ntfyUsername:accessToken)

Both types store their secret in the existing accessToken field rather than
introducing a new secret, so credential handling stays in one place.

Incomplete credentials fail the send instead of falling back to an anonymous
post, and validation rejects partial auth config up front so a channel can't
be saved looking authenticated while posting anonymously.

Backend only; frontend follows once this lands.

Closes #3717
2026-08-14 14:01:06 +03:00
Marcos Paulo Diniz d4eaad6170 fix(notifications): route Rocket.Chat links by monitor type
Build title_link from the monitor type (hardware -> infrastructure,
pagespeed -> pagespeed, uptime types -> uptime) and omit it when
clientHost is not configured, instead of always linking to the
infrastructure page.
2026-08-13 11:56:28 -03:00
colinjtsen2010 75516a220d fix: handle IPs, avoid wrong parent-domain lookups, cache whois results
- Bare IP addresses and non-domain hosts (localhost, IPv6) now return a
  null expiry instead of throwing, so they no longer spam error logs.
- Extract the registrable domain with tldts instead of walking up
  hostname labels, so a subdomain can never accidentally return its
  parent domain's expiry date.
- Cache WHOIS lookups per registrable domain (1h positive, 10min
  negative) so visiting details pages does not re-query the registry
  every time.

Addresses review feedback on #3834.
2026-08-13 12:02:49 +08:00
Alexander Holliday f97ca8565a Merge pull request #3829 from bluewave-labs/chore/form-refactor
chore: form refactor
2026-08-12 10:24:24 -07:00
Alex Holliday b5f8faf1e8 add missing rocket chat label 2026-08-12 10:22:56 -07:00
Alexander Holliday b088101160 Merge branch 'develop' into chore/form-refactor 2026-08-12 10:15:10 -07:00
Alex Holliday 521827e1d0 memoize options 2026-08-11 17:50:47 -07:00
Alex Holliday 7dd378f085 remove duplicates 2026-08-11 17:26:15 -07:00
Alex Holliday a7fb235c56 fix monitor types 2026-08-11 17:24:03 -07:00
Alex Holliday 7f68d23566 fix non sortable styling 2026-08-11 17:21:51 -07:00
Alex Holliday caebb93748 derive options from roles, wire error state 2026-08-11 17:15:15 -07:00
Alex Holliday 5640068907 fix placeholder 2026-08-11 17:10:48 -07:00
Alex Holliday 441ecddf92 fix unresolved IDs 2026-08-11 17:08:18 -07:00
colinjtsen2010 e5959ca91d feat: add domain registration expiry monitoring
Adds a GET /monitors/domain/:monitorId endpoint that looks up the
registrable domain's WHOIS expiry date (walking up from subdomains),
and displays it next to the certificate expiry on the uptime details
page.

Fixes #2094
2026-08-11 00:34:21 +08:00
Alex Holliday 2765ebfc3b bug fixes 2026-08-07 11:50:47 -07:00
Marcos Paulo Diniz 8d1fd61bb4 fix(notifications): omit empty incident links 2026-08-07 14:55:05 -03:00
Marcos Paulo Diniz 865fa7cce6 feat(notifications): add rich alert fields 2026-08-07 14:55:05 -03:00
Marcos Paulo Diniz 2c780b8101 feat(notifications): add Rocket.Chat attachments 2026-08-07 14:55:05 -03:00
Marcos Paulo Diniz ee25c6b479 feat(notifications): enrich Rocket.Chat tests 2026-08-07 14:55:05 -03:00
Alex Holliday d0c8da1653 remove dead default values 2026-08-07 10:41:16 -07:00
Alex Holliday 5916d0ca33 refactor recovery and set new password 2026-08-07 10:32:01 -07:00
Alex Holliday 69db716f0b refactor login page 2026-08-07 10:29:46 -07:00
Alex Holliday 350083d179 refactor Profile tab 2026-08-07 10:25:04 -07:00
Alex Holliday 11a6ee71e9 refacotr add team/invite team member 2026-08-07 10:22:43 -07:00
Alex Holliday eaf93668f5 refactor password tab 2026-08-07 10:13:56 -07:00
Alex Holliday 59267ef451 refactor edit user 2026-08-07 10:08:27 -07:00
Alex Holliday 35f9381573 refactor register page 2026-08-07 10:06:09 -07:00
Alex Holliday 3df85b7522 refacotr add team member dialog 2026-08-07 09:54:08 -07:00
Alex Holliday 012e09d30c refactor tags create page 2026-08-07 09:47:11 -07:00
Alexander Holliday 4b921f4986 Merge pull request #3826 from Glazzze/docs/clarify-mongodb-deployment-3823
docs(readme): clarify MongoDB requirement for single-image deployment
2026-08-07 09:27:11 -07:00
Alex Holliday 0eff052258 refactor maintenance create page 2026-08-06 15:05:16 -07:00
Alex Holliday 52c80b9a39 refactor status page create page 2026-08-06 14:40:21 -07:00
Alex Holliday 6ceb28d3a1 remove dead keys 2026-08-06 12:33:35 -07:00
Alex Holliday 08bd2d56f3 refactor settings page 2026-08-06 12:31:20 -07:00
Alex Holliday 05f265743b remove unsued 2026-08-06 11:55:19 -07:00
Alex Holliday bc2115f578 refacotr notification create page 2026-08-06 11:54:46 -07:00
Alex Holliday c1e9fd6079 refactor sliders 2026-08-06 11:00:37 -07:00
Alex Holliday 226864079c refactor sliders 2026-08-06 10:50:37 -07:00
Alex Holliday 7cab326ad3 refactor selects 2026-08-06 10:17:32 -07:00
Glazzze 94f1f7e576 docs(readme): clarify MongoDB requirement for single-image deployment 2026-08-06 11:41:01 +08:00
Alex Holliday 20d1d1407b remove dead key 2026-08-05 11:53:49 -07:00
Alex Holliday da7e8a1ae8 add missing remove button label 2026-08-05 11:52:47 -07:00
Alex Holliday c578387261 fix label and description regression 2026-08-05 11:51:20 -07:00
Alex Holliday c5ef61e64c refactor radio and multi select autocomplete 2026-08-05 11:44:47 -07:00
Alex Holliday 6e1e5dd1a5 create bound components for text input and radio input 2026-08-05 10:51:48 -07:00
Alexander Holliday 514ee0d30b Merge pull request #3822 from mdiniz97/feat/rocket-chat-notifications
feat: Rocket.Chat notifications
2026-08-05 09:33:42 -07:00
Alexander Holliday 623db938f5 Merge pull request #3825 from bluewave-labs/fix/check-snapshot
fix:  check snapshot
2026-08-05 09:20:57 -07:00
Marcos Paulo Diniz 1c291bb893 refactor(client): use raw channel types
Refs #3821
2026-08-05 08:35:56 -03:00
Marcos Paulo Diniz 466d356ad1 refactor(validation): use Zod URL protocols
Refs #3821
2026-08-05 08:34:12 -03:00
Marcos Paulo Diniz 0c184e6d0c fix(notifications): guard incident link
Refs #3821
2026-08-05 08:33:29 -03:00
Alex Holliday 303f47a117 add opt out flag for recent checks 2026-08-04 15:30:34 -07:00
Alex Holliday 5fa74bd883 replace findAll with findAllForScheduling 2026-08-04 15:16:03 -07:00
Alex Holliday 4c8342f379 fix pretty name bug, ordering bug 2026-08-04 14:46:52 -07:00
Alex Holliday dfcb3b4b21 slim check snapshot model/types 2026-08-04 11:27:32 -07:00
Marcos Paulo Diniz 2e19b85a2d docs(notifications): document Rocket.Chat
Refs #3821
2026-08-02 01:59:58 -03:00
Marcos Paulo Diniz 5774cb0e06 feat(client): expose Rocket.Chat channel
Refs #3821
2026-08-02 01:59:34 -03:00
Marcos Paulo Diniz 750fc9e238 feat(notifications): add Rocket.Chat provider
Refs #3821
2026-08-02 01:59:11 -03:00
Alexander Holliday 3646af9c04 Merge pull request #3820 from bluewave-labs/chore/ms-cleanup
chore: ms cleanup
2026-07-31 13:06:35 -07:00
Alex Holliday d4782a97a3 add missing verbose flag 2026-07-31 13:05:10 -07:00
Alex Holliday e0122d3761 hoist props 2026-07-31 13:04:10 -07:00
Alex Holliday e3b0b33b15 replace duration in monitorstatus, remove dead import 2026-07-31 13:00:58 -07:00
Alex Holliday 19b5976f85 extract formatting durations 2026-07-31 12:58:58 -07:00
Alex Holliday 336dbe4de2 use formatMs function to consistently format point times 2026-07-31 12:40:18 -07:00
Alex Holliday 65c6f567b5 add ms formatting util 2026-07-31 11:55:05 -07:00
Alexander Holliday 6e6ac5dbf6 Merge pull request #3819 from bluewave-labs/feat/client-side-presentation
feat: client side presentation
2026-07-31 11:52:54 -07:00
Alex Holliday e81fdb9810 remove unused hook 2026-07-31 11:50:02 -07:00
Alex Holliday 9712579c4d remove unused imports 2026-07-31 11:45:41 -07:00
Alex Holliday 98e0ffc983 update tooltips 2026-07-31 11:41:18 -07:00
Alex Holliday ac251c4d3f update tests 2026-07-31 11:36:20 -07:00
Alex Holliday 08f167c18e cap values at p95 on client 2026-07-31 11:36:10 -07:00
Alex Holliday 8a608961e6 remove server side normalization 2026-07-31 10:31:39 -07:00
Alexander Holliday 955c400731 Merge pull request #3808 from anjupathak03/fix/tag-filtering
test: cover monitor tag filtering
2026-07-31 10:06:59 -07:00
Alexander Holliday 093553f9ba Merge pull request #3818 from bluewave-labs/feat/chart-cleanup
feat: chart cleanup
2026-07-30 12:16:24 -07:00
Alex Holliday e5282b9099 opacity -> 0.2 2026-07-30 12:04:25 -07:00
Alex Holliday 6b4444038c add tests 2026-07-30 11:54:19 -07:00
Alex Holliday 89464e9be4 add legend 2026-07-30 11:54:12 -07:00
Alex Holliday 6856914d41 extract hardcoded strings 2026-07-30 10:56:24 -07:00
Alex Holliday c102cd8d26 openapi schemas 2026-07-30 10:35:01 -07:00
Alexander Holliday ff28e74aca Merge pull request #3797 from LvckyAPI/fix-incorrect-status
Fix monitoring status stuck
2026-07-30 09:44:15 -07:00
Alexander Holliday 872e5f32cb Merge pull request #3807 from bluewave-labs/develop
develop -> master
2026-07-30 09:39:06 -07:00
Alexander Holliday 23b420d072 Merge pull request #3815 from bluewave-labs/feat/stacked-timing-chart
feat: stacked timing chart
2026-07-29 12:42:50 -07:00
Alex Holliday 0172272dc3 update tests 2026-07-29 12:33:56 -07:00
Alex Holliday 3f088c70f8 universal ymax cap 2026-07-29 12:30:36 -07:00
Alex Holliday ed54fa4668 first byte to total repsonse 2026-07-29 12:30:18 -07:00
Alex Holliday 0e0cace676 merge charts 2026-07-29 11:45:49 -07:00
Alex Holliday 58f1ae7fe9 extract xtick 2026-07-29 11:04:11 -07:00
Alex Holliday e3075318b6 denormalize resposne times 2026-07-29 10:57:20 -07:00
Alex Holliday 81aab35961 conditionally render charts 2026-07-28 15:27:19 -07:00
Alex Holliday 4e495eed33 add chart colors to theme 2026-07-28 15:27:04 -07:00
Alex Holliday 061da5e7ef coerce values to 0 if null 2026-07-28 15:26:52 -07:00
Anju a29b7037cf test: cover monitor tag filtering 2026-07-29 03:40:11 +05:30
Alex Holliday c7662727e7 add area charts 2026-07-28 11:24:23 -07:00
Alex Holliday 13c921531c update types, add skeleton for stacked timing phase chart 2026-07-28 11:13:50 -07:00
Alex Holliday 0cf8cfa5dc add avg timing phases to details response 2026-07-28 11:13:35 -07:00
Alex Holliday 4182a964a6 minor version bump 2026-07-28 09:48:31 -07:00
Iven Schlenther 04f7ef252a Fix indentation in statusService.ts for isUnknownStatus assignment. 2026-07-27 15:07:00 +02:00
Iven Schlenther 3201653f19 Fix monitor status handling: replace isKnownStatus with isUnknownStatus for clarity and simplify initialization logic. 2026-07-27 15:03:43 +02:00
Iven Schlenther 0988a61c1d Resolve legacy monitor status issues: handle unknown statuses, fix stuck states from old boolean values, and add regression tests. Adjust BaseChart text color for theme compatibility. 2026-07-27 15:03:41 +02:00
Alexander Holliday 80ea09b864 Merge pull request #3801 from bluewave-labs/chore/cleanup
chore: cleanup
2026-07-24 12:30:08 -07:00
Alex Holliday e0228918fb fix tests 2026-07-24 12:28:45 -07:00
Alex Holliday 8369db5ca2 remove logger 2026-07-24 11:38:08 -07:00
Alex Holliday 49e8d26bfc remove legacy try/catch blocks 2026-07-24 11:34:54 -07:00
Alex Holliday c28be97fb0 update tests 2026-07-24 11:32:18 -07:00
Alex Holliday ed36db172a dedupe hardware stat definitions, fix tests, remove misleading null return types 2026-07-24 11:32:00 -07:00
Alex Holliday c021d04f53 extract swtich 2026-07-24 11:09:53 -07:00
Alex Holliday 86eeda3f0e replace error instances with AppError 2026-07-24 10:49:39 -07:00
Alex Holliday 25d81f4238 remove dead findByMonitorIdAndDateRange method 2026-07-24 10:43:56 -07:00
Alexander Holliday af8ee643e1 Merge pull request #3800 from bluewave-labs/chore/monitor-domain-refactor
extact monitor children deletion to helper
2026-07-23 11:25:12 -07:00
Alex Holliday 61e442493a extact monitor children deletion to helper 2026-07-23 10:49:59 -07:00
Alexander Holliday 9540084425 Merge pull request #3799 from bluewave-labs/chore/domain-refactoring
chore: domain refactoring
2026-07-23 10:30:33 -07:00
Alex Holliday f50ba1bf23 update tests 2026-07-23 10:26:01 -07:00
Alex Holliday b4e42bfd68 add type check script 2026-07-23 10:25:51 -07:00
Alex Holliday 3af00281c4 extract date range calculation for conisistency 2026-07-23 10:25:24 -07:00
Alex Holliday 4a90ab8e78 extract pagination 2026-07-23 09:36:06 -07:00
Alexander Holliday 7598cf3007 Merge pull request #3798 from bluewave-labs/chore/api-cleanup
chore: api cleanup
2026-07-22 11:03:07 -07:00
Alex Holliday e1c89cd093 dedupe normalization 2026-07-22 11:00:59 -07:00
Alex Holliday 80d4e818f1 remove unused static vars 2026-07-22 10:36:16 -07:00
Alex Holliday 4949acac1c remove unused email templates, remove duplicate constants 2026-07-22 10:26:00 -07:00
Alex Holliday 586e79efb2 remove unused getters 2026-07-22 10:13:37 -07:00
Alex Holliday c961f5292d fix signature, add tests 2026-07-22 10:05:36 -07:00
Alex Holliday d53785b768 remove unused utilities, fix delete profile image issue 2026-07-22 10:03:48 -07:00
Alexander Holliday b9cbf199e6 Merge pull request #3796 from LvckyAPI/develop
Fixes UI bug with German translations
2026-07-22 09:44:19 -07:00
Iven Schlenther ac6678a986 Update English and German translations for "temp" label to "CPU temperature" in locales files. 2026-07-22 11:33:06 +02:00
Iven Schlenther ed1ef56184 - Add sx={{ overflowWrap: "anywhere" }} to Typography in BaseChart for improved text wrapping.
- Adjust `alignItems` to `stretch` and add `maxWidth` (260) to Gauge components in Infrastructure Details page.
- Update German translations for memory titles, temperatures, and labels in locales file.
2026-07-22 11:27:38 +02:00
Alexander Holliday ac16126e2e Merge pull request #3795 from bluewave-labs/fix/enums
fix: remove inline enum definitions
2026-07-21 10:53:52 -07:00
Alex Holliday 8e600e9a87 remove inline enum definitions 2026-07-21 10:48:18 -07:00
Alexander Holliday 790d83b7ed Merge pull request #3794 from bluewave-labs/fix/positional-args
fix: positional args
2026-07-21 10:30:29 -07:00
Alex Holliday c567e4be5e Update tests 2026-07-21 10:26:10 -07:00
Alex Holliday 936d9cd9a0 update call site 2026-07-21 10:26:02 -07:00
Alex Holliday 6e047d2cdb convert positional args to config obj 2026-07-21 10:25:50 -07:00
Alex Holliday aad03b787f update tests 2026-07-21 10:07:18 -07:00
Alex Holliday bb374db463 remove stray import 2026-07-21 10:07:09 -07:00
Alex Holliday 57e008c7ba update services config 2026-07-21 10:05:17 -07:00
Alex Holliday 5d3dc03c89 add a provider registry to notificaiton service, replace positional args and switch statements 2026-07-21 10:02:20 -07:00
Alexander Holliday bf255a0178 Merge pull request #3793 from bluewave-labs/chore/refactor-routes
chore: convert router classes to exported functions
2026-07-20 11:30:41 -07:00
Alex Holliday 36434c1160 convert router classes to exported functions 2026-07-20 11:25:39 -07:00
Alexander Holliday 63cecf3447 Merge pull request #3792 from bluewave-labs/chore/purge-team/groups
purge unused groups route, update tests
2026-07-20 10:26:45 -07:00
Alex Holliday 4d9e5821a8 purge unused groups route, update tests 2026-07-20 10:21:43 -07:00
Alexander Holliday 5433a0cf5f Merge pull request #3791 from bluewave-labs/chore/fix-access-modifier
chore: protected -> private as no class inherits
2026-07-20 10:06:08 -07:00
Alex Holliday d485adbb54 narrow mapDocument types 2026-07-20 10:04:55 -07:00
Alex Holliday 3eadb99c5e protected -> private as no class inherits 2026-07-20 10:01:48 -07:00
Alexander Holliday 9c04061a04 Merge pull request #3787 from jpomykala/develop
Add Polish language
2026-07-20 09:34:54 -07:00
Alexander Holliday 897ffd5736 Merge pull request #3788 from Osamaali313/fix/response-time-poor-label
Fix missing "Poor" label on the average response-time gauge
2026-07-20 09:34:16 -07:00
Osamaali313 368595178d Fix missing "Poor" label on the average response-time gauge
`RadialAvgResponse` maps the response-time palette to a qualitative word:

    const palette = getResponseTimeColor(avg);
    const msg: Record<string, string> = {
        success: "Excellent",
        warning: "Average",
        danger: "Poor",
    };
    ...
    {msg[palette]}

But `getResponseTimeColor` only ever returns `"success" | "warning" | "error"`
(MonitorUtils.ts) — never `"danger"`. So for the slow case `palette` is
`"error"`, `msg["error"]` is `undefined`, and no label renders. The same
component uses `theme.palette[palette].main`, which works precisely because
`palette` is the real MUI key `"error"`; every other palette helper in
MonitorUtils.ts also uses `"error"`, and `"danger"` appears nowhere else in
the client. Any monitor whose average response time is >= 300 ms shows an
empty label exactly where the "Poor" signal matters most. Use the `error`
key.
2026-07-18 23:36:43 +03:00
Jakub Pomykała 924c862244 Format pl.json 2026-07-18 22:13:05 +02:00
Jakub Pomykała cf451416b1 Update polish translations after review 2026-07-18 21:33:10 +02:00
Jakub Pomykała 6e2a64fc9f Add Polish to language selector 2026-07-18 20:57:50 +02:00
Jakub Pomykała 0e52cb6231 Add polish 2026-07-18 20:22:00 +02:00
Alexander Holliday 507b96164f Merge pull request #3784 from bluewave-labs/chore/refactor-utils
chore: refactor utils
2026-07-15 10:24:51 -07:00
Alex Holliday 887521c376 Merge branch 'develop' into chore/refactor-utils 2026-07-15 10:24:23 -07:00
Alexander Holliday caee9510eb Merge pull request #3783 from bluewave-labs/chore/controller-refactor
chore: add catchAsync to handle errors, remove all try/catch blocks from con…
2026-07-15 10:23:48 -07:00
Alex Holliday 825a81d326 extract shared mongo mapper utils 2026-07-15 10:20:16 -07:00
Alex Holliday bd13e9d9c8 add catchAsync to handle errors, remove all try/catch blocks from contorllers 2026-07-15 10:02:36 -07:00
Alexander Holliday 92696d3685 Merge pull request #3782 from bluewave-labs/chore/hardware-aggregation-refactor
chore: hardware aggregation refactor
2026-07-15 09:59:03 -07:00
Alex Holliday 5120390923 aggregation->aggregations 2026-07-15 09:23:16 -07:00
Alex Holliday 0810fcbb88 refactor hardware aggregation pipeline 2026-07-15 09:18:35 -07:00
Alexander Holliday 15453c2d8d Merge pull request #3781 from bluewave-labs/chore/monitor-service-refactor
chore: remove duplicated code
2026-07-14 10:48:53 -07:00
Alex Holliday 70f10a73d8 remove duplicated code 2026-07-14 10:46:25 -07:00
Alexander Holliday 930c06f5ff Merge pull request #3780 from bluewave-labs/chore/dedupe
chore: remove toEntityWithChecks
2026-07-14 10:37:14 -07:00
Alex Holliday b088a8c878 remove toEntityWithChecks 2026-07-14 10:17:38 -07:00
Alexander Holliday 067a186765 Merge pull request #3779 from bluewave-labs/fix/cascade-incidents
fix: delete incidents on all monitors deleted
2026-07-14 09:26:13 -07:00
Alex Holliday 107b5cbd96 delete incidents on all monitors deleted 2026-07-14 09:24:14 -07:00
Alexander Holliday 7fdd51c072 Merge pull request #3778 from edobusy/fix/readme-k8s-install-link
fix(docs): correct K8s install link path in translated READMEs
2026-07-14 09:10:10 -07:00
Alexander Holliday 2331035da0 Merge pull request #3773 from bluewave-labs/fix/status-page-brand-color-not-applied
fix(status page): apply brand color via a dedicated brand token
2026-07-14 09:04:06 -07:00
edobusy fd4edb7227 fix(docs): correct K8s install link path in translated READMEs
The translated READMEs in docs/translations/ link to the Kubernetes
installation guide with a path copied from the root README
(./charts/helm/checkmate/INSTALLATION.md). Because these files live two
levels below the repo root, that path resolves to
docs/translations/charts/..., which does not exist, so the link is
broken in all 17 translations.

Fix the path to ../../charts/helm/checkmate/INSTALLATION.md so it
resolves to the repo-root charts/ directory. The root README.md is
correct as-is and is left unchanged.
2026-07-14 13:43:37 +10:00
Alexander Holliday 538b0df7e9 Merge pull request #3777 from bluewave-labs/fix/shield-csp
add img.shields.io to csp directive
2026-07-13 10:11:24 -07:00
Alex Holliday 9f79feec8e add img.shields.io to csp directive 2026-07-13 10:10:28 -07:00
Alexander Holliday fe703199f5 Merge pull request #3776 from bluewave-labs/chore/simplify-deployment
chore: simplify deployment
2026-07-13 09:57:13 -07:00
Alex Holliday 800080fd06 add log warnings about legacy env vars 2026-07-13 09:48:02 -07:00
Alex Holliday c1bcd9b206 add note to helm chart about migration 2026-07-13 09:47:45 -07:00
Alex Holliday 5d55750b7b update docs 2026-07-13 09:38:55 -07:00
Alexander Holliday 4f398e98d3 Merge pull request #3774 from bluewave-labs/feat/italian-translation-and-contrib-docs
feat(i18n): add Italian translation, document translation workflow
2026-07-13 09:05:01 -07:00
gorkem-bwl 242e853bf0 i18n(it): review pass — fix grammar, awkward phrasing, inconsistencies
91 leaves refined after a native-Italian style pass.

## Categories of fixes

- Grammar / gender agreement — e.g. `logs.table.headers.lastSeenAt`
  "Ultima visto il" → "Visto l'ultima volta"; heap gauge subject
  agreement in `logs.diagnostics.gauges.allocatedPercentage`.
- Awkward phrasing / calques — `notFound.subtitle` drops stilted
  "ad esso"; `incidents.fallback.title` "in salute" → "funzionano
  correttamente" (was reading as if for a person, not systems);
  `settings.form.removeMonitors.title` "Reset del sistema" →
  "Reimposta il sistema"; "con successo" → "correttamente" on
  status-page delete confirmations.
- Consistency — paused monitor status unified on "In pausa" across
  `common.labels.paused`, monitor status labels, status-bar keys, and
  the `bulkPause.*_one/other` copies. Button verbs
  "Sospendi"/"Riprendi" kept for brevity; status noun stays "In pausa".
- Mistranslations — `buttons.testNotifications` "Testa" → "Prova";
  `buttons.toggleTheme` "Cambia" → "Alterna"; Twilio "Auth token" now
  translated; Mongoose `readyState` clarified to "Stato di connessione".
- Register — trimmed "Ancora" from several empty-state titles so they
  read like natural Italian empty states.

## Verification

874 leaves in EN and IT, 0 missing / 0 extra, all placeholders and
inline tags preserved, no empty values.
2026-07-13 16:18:24 +03:00
gorkem-bwl 523bf2253c feat(i18n): add Italian translation, document translation workflow
## Changes

- Add `client/src/locales/it.json` — Italian translation of all 874 UI
  keys, structure mirrors `en.json` exactly. Placeholders (`{{value}}`,
  etc.) and inline tag tokens (`<settingsLink>`, `<highlight>`, ...)
  are preserved. Product/brand names, technical abbreviations, and
  placeholder-style examples (e.g. `you@example.com`) are kept in
  English. 116 leaves are intentionally kept verbatim in English
  (brand names, protocol names, sample values, port numbers, regex).
- Register `it: "Italiano"` in `LanguageSelector`'s `languageNames`
  map so the dropdown shows the localised name; the selector already
  picks up any locale present in the i18n resources.
- Fill in the two "How do I..." sections that CONTRIBUTING.md's table
  of contents already links to but did not have bodies:
  - "Improve the documentation?" — short guidance on doc PRs and
    keeping README translations in sync.
  - "Help with translations?" — describes the current per-language
    JSON workflow (source of truth: `en.json`; improve an existing
    language by editing its JSON; add a new one by adding a JSON file
    and registering the display name in `LanguageSelector`). Notes
    that the older PoEditor sync workflow is no longer authoritative.

## Benefits

- Italian speakers can now use Checkmate in Italian.
- Contributors who follow the "Help with translations?" link from
  the CONTRIBUTING TOC land on real, current guidance instead of a
  broken anchor.
- Translation quality can be improved incrementally by anyone
  editing `it.json` directly; no external tooling required.
2026-07-13 16:10:04 +03:00
gorkem-bwl f92dc835b7 fix(status page): apply brand color via a dedicated brand token
The status page appearance form saves a customer-picked brand color
(`statusPage.color`), but `StatusPageThemeProvider` never consumed it —
every rendered surface styled from `tokens.up`, so the picker had no
visible effect on the public page.

Rather than overriding `tokens.up` (the semantic OK/up status color,
which would let a red or orange brand color render the "all
operational" indicator in a red that reads as downtime), this
introduces a separate `brand` / `brandStrong` / `brandSoft` token
triple that is distinct from the up/OK color.

## Changes

- `themes/tokens.ts`: add optional `brand` / `brandStrong` / `brandSoft`
  fields on `StatusPageThemeTokens`. Themes leave them undefined; the
  provider falls back to the theme's `up*` values so nothing regresses
  before individual themes opt in.
- `themes/StatusPageThemeProvider.tsx`: add `brandColor` prop and a
  `resolveBrandTokens` helper. When a valid hex is provided it feeds
  `brand`, `brandStrong = darken(x, 0.2)`, `brandSoft = alpha(x, 0.15)`
  using MUI's shared `alpha`/`darken` (replaces the ad-hoc hex helpers).
  Invalid or empty values fall through cleanly.
- `Status/index.tsx`: pass `brandColor={statusPage.color}` only at the
  public route. The admin preview provider wraps an iframe whose
  document renders itself, so passing the prop there had no effect and
  is dropped.
- `themes/refined/styles.ts`: switch accent surfaces (hero logo, active
  chart-switch button, hardware pill, footer link) from `tokens.up` to
  `tokens.brand`. Uptime bars, status pills, and OK indicators stay on
  `tokens.up`. Other themes keep their current styling until a designer
  decides which surfaces should switch.

## Benefits

- Brand Color picker actually changes the rendered accent surfaces.
- The semantic "OK/up" status color is never repainted by the brand
  color, so operational-state indicators stay green regardless of the
  customer's brand.
- Reuses MUI's `alpha` / `darken` helpers already used across the
  client; no divergent color utilities.
- Small, incremental adoption: themes opt in at their own pace by
  reading `tokens.brand*` where they want the brand accent.
2026-07-13 15:52:12 +03:00
Alexander Holliday 0b0d412187 Merge pull request #3766 from bluewave-labs/fix/network-notice-loopback-detection
fix(infrastructure): trigger network 'no traffic' notice on interface name only
2026-07-10 19:50:02 -07:00
gorkem-bwl 77b817778b address review feedback: rename notice to match new trigger
The notice now triggers on interface names (loopback / container-bridge
only), not on zero traffic. Update the variable, i18n key, and English
copy to match:

- showNoTrafficNotice -> showLoopbackOnlyNotice
- pages.infrastructure.charts.labels.netNoTraffic ->
  pages.infrastructure.charts.labels.loopbackOnlyNotice
- English string rewritten to describe the loopback-only condition
  rather than "zero traffic on every interface"

Other locales still hold the old string under the old key and will fall
back to English until the translation pipeline picks up the new key.
Tracked separately.
2026-07-11 04:29:22 +03:00
Alexander Holliday eddb9a82a8 Merge pull request #3764 from bluewave-labs/fix/increase-json-body-limit
fix(server): raise express.json body limit to 10mb
2026-07-10 18:23:44 -07:00
gorkem-bwl 9459edce00 scope 10mb body limit to /monitors/import/json only
Address review feedback: keep the global express.json() at the default
100kb and mount a route-scoped 10mb parser in front of it for
/api/v1/monitors/import/json. Express matches middleware in registration
order, so the import route uses the larger parser and every other
endpoint keeps the tight default. No change in DoS surface for normal
traffic.
2026-07-11 04:22:42 +03:00
Alexander Holliday eaf1064413 Merge pull request #3763 from bluewave-labs/fix/status-page-custom-css-injection
fix(status-page): inject sanitized customCSS on the public page
2026-07-10 09:51:10 -07:00
Alexander Holliday dae6f9a570 Merge pull request #3769 from edobusy/docs/readme-italian
docs(readme): add Italian translation
2026-07-10 09:20:18 -07:00
edobusy 9fa45c5871 docs(readme): add Italian translation 2026-07-10 23:46:49 +10:00
gorkem-bwl 2810cd3562 fix(infrastructure): trigger network "no traffic" notice on interface name only
The notice at the top of the network charts was firing whenever the aggregated
per-second delta was zero across all interfaces. That is a false positive when
Capture is running on a real host but the polling interval is >= 1 minute and
the aggregation bucket is also 1 minute — first == last in the bucket, delta
is zero, but the interface has plenty of real traffic in its cumulative
counters.

Base the notice on interface names instead: only show it when the ONLY
interfaces Capture reports are loopback / container-bridge names (lo, lo0,
docker0). Real NICs (enp0s6, eth0, wlan0, tunN, vethN, brN, etc.) keep the
notice hidden even when the aggregation currently reports zero throughput.

The underlying zero-throughput bug (single-sample buckets producing zero
deltas) is tracked separately.
2026-07-10 03:16:42 +03:00
gorkem-bwl f4177baed5 fix(server): raise express.json body limit to 10mb
Express defaults to a 100kb JSON body, which is too small for realistic
imports through POST /api/v1/monitors/import/json (backups with many
monitors and their config easily exceed 100kb, returning
"request entity too large").

Raise the parser cap to 10mb. That covers real-world import payloads
while keeping the DoS surface bounded — a larger, streaming file-upload
route for imports remains desirable as a follow-up.
2026-07-10 00:06:31 +03:00
gorkem-bwl 5feb666bdb fix(status-page): inject sanitized customCSS on the public page
The customCSS field on status pages was persisted and validated
end-to-end but never rendered on the public page, so operator-supplied
styles had no effect.

Inject the string as a <style> tag on the public route (which the
admin preview iframe also renders), reusing the existing
cssReferencesExternalResource check so external-resource CSS
(@import, url(http…), //…) is dropped defensively on the client.

The admin preview iframe already points at the public URL, so this
one insertion covers both the live page and the in-app preview.
2026-07-09 21:55:25 +03:00
Alex Holliday ceacaa5499 scope workflows 2026-07-09 11:24:35 -07:00
Alex Holliday 91343f996b hosting docs 2026-07-09 10:55:04 -07:00
Alex Holliday 312961e8c9 use repo owner 2026-07-09 10:27:56 -07:00
Alex Holliday 0475f194e9 temporary branch 2026-07-09 10:24:06 -07:00
Alex Holliday bbb051406f udpate docs 2026-07-09 10:21:09 -07:00
Alex Holliday 40a7599388 update workflows 2026-07-09 10:20:24 -07:00
Alex Holliday dffce2709e clean up docker files 2026-07-09 10:19:38 -07:00
Alex Holliday 62031a0ab1 add a CLIENT_CONFIG to the client, serve from BE with env vars 2026-07-09 09:49:45 -07:00
Alex Holliday ef175950b2 add dockerfile and build-image workflow 2026-07-09 09:29:31 -07:00
Alex Holliday 6d91efecad default client to same-origin /api/v1 and window.location.origin, drop UPTIME_APP_* placeholders 2026-07-08 11:04:47 -07:00
Alex Holliday f7d3bcb696 add root docker-compose 2026-07-08 10:47:03 -07:00
Alex Holliday 4a093e2b97 start health server on primary process true 2026-07-08 10:46:34 -07:00
453 changed files with 20678 additions and 15006 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
**/node_modules
**/dist
**/.env
**/*.env
.git
docker/dev/mongo/data
+103
View File
@@ -0,0 +1,103 @@
name: Build image
on:
push:
branches: ["develop", "master"]
tags: ["v*"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# checkmate-backend-mono is a transitional alias so existing deployments
# keep receiving updates; drop it after one release cycle.
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/checkmate
ghcr.io/${{ github.repository_owner }}/checkmate-backend-mono
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# The hosted staging/prod instances share one host and one compose file; each
# deploy pulls and recreates only its own service.
deploy-staging:
needs: build-and-push
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Deploy staging.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-staging
docker compose up -d checkmate-staging
docker system prune -af
deploy-prod:
needs: build-and-push
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy demo.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-demo
docker compose up -d checkmate-demo
docker system prune -af
+22
View File
@@ -0,0 +1,22 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
@@ -1,148 +0,0 @@
name: Deploy images on release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }}
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:${{ steps.extract_tag.outputs.version }}
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }}
-143
View File
@@ -1,143 +0,0 @@
name: Deploy images
on:
push:
branches: ["master"]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:latest \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:latest
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:latest \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:latest
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:latest \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:latest
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ env.VERSION }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:latest \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:latest
-138
View File
@@ -1,138 +0,0 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
-91
View File
@@ -1,91 +0,0 @@
name: Demo deploy
on:
push:
branches: [ "demo" ]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-demo \
-f ./docker/prod/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-demo
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-demo \
-f ./docker/prod/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-demo
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-demo \
-f ./docker/prod/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-demo
deploy-to-demo:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
-91
View File
@@ -1,91 +0,0 @@
name: Staging deploy
on:
push:
branches: [ "develop" ]
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-staging \
-f ./docker/staging/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-staging
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-staging \
-f ./docker/staging/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-staging
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-staging \
-f ./docker/staging/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-staging
deploy-to-staging:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
-113
View File
@@ -1,113 +0,0 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+3 -3
View File
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
### Docker Development
```bash
cd docker/dev
./build_images.sh
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
docker build -f docker/Dockerfile -t checkmate . # build the mono image (server + built client)
docker compose -f docker/dev/docker-compose.yaml up # local full stack (builds image, runs mongo + worker)
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 # just a dev database
```
## Environment Setup
+30 -25
View File
@@ -88,34 +88,10 @@ cd Checkmate
#### Step 2: Set Up Docker Containers (MongoDB)
Navigate to the Docker dev directory:
```bash
cd docker/dev
```
Build the Docker images:
Linux:
```bash
./build_images.sh
```
Windows:
```bash
./build_images.bat
```
Run MongoDB container:
```bash
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
```
Navigate back to the root directory:
```bash
cd ../..
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0
```
#### Step 3: Set Up the Backend (Server)
@@ -256,6 +232,35 @@ docker rm uptime_database_mongo
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
### Improve the documentation?
Documentation contributions are very welcome — fixing a typo, clarifying a step, or adding a missing example is a valuable PR.
- Small edits (typos, wording, formatting): open a PR directly against `develop`.
- Larger changes (restructuring a section, adding a new guide): open an issue first so we can align on scope, then a PR.
- Screenshots are helpful when documenting UI behaviour.
- README translations live in `docs/translations/`. If you're adding a new language, please also add a flag + link to the language bar in the root `README.md` and every existing translated README so they stay in sync.
### Help with translations?
The application uses `i18next` with one JSON file per language under `client/src/locales/` (`en.json` is the source of truth). English source strings are the definitive copy; other languages are generated as one-time machine translations that anyone is welcome to refine.
**To improve an existing language:**
1. Edit the relevant `client/src/locales/<code>.json` file directly.
2. Keep the JSON key structure identical to `en.json` — only translate the string values.
3. Open a PR against `develop`. No language-selector code changes are needed.
**To add a new language:**
1. Add `client/src/locales/<code>.json` with the same key structure as `en.json`, translated. If you can't translate every key, leave unfamiliar strings in English rather than removing keys — i18next falls back to the key path when a value is missing.
2. Add the display name to `languageNames` in `client/src/Components/inputs/LanguageSelector.tsx` (e.g. `it: "Italiano"`). The selector picks up any language present in `resources` automatically.
3. Open a PR against `develop`.
If translating a whole file is more than you want to take on, open an issue asking us to add the language and we'll generate an initial machine-translated file that you (or others) can refine over time.
**Note:** an older workflow that synced translations with PoEditor is still in the repo but no longer authoritative. Please treat the `.json` files in `client/src/locales/` as the source of truth.
### Submit a pull request?
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
+44 -6
View File
@@ -9,6 +9,7 @@
🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
🇮🇹 <a href="docs/translations/README.it.md">Italiano</a> |
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
@@ -31,12 +32,13 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br>
@@ -79,7 +81,43 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a>
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
```
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
The image is configured entirely through environment variables on the server container:
| Variable | Required | Description |
|---|---|---|
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
| Variable | Description |
|---|---|
| `CLIENT_CONFIG_API_BASE_URL` | Full base URL the client calls the API at, e.g. `https://api.example.com/api/v1`; defaults to same-origin `/api/v1` |
| `CLIENT_CONFIG_CLIENT_HOST` | Origin used when the client builds absolute links (invites, status pages); defaults to the browser's current origin |
| `CLIENT_CONFIG_LOG_LEVEL` | Browser console log level: `error`, `warn`, `info`, or `debug` (default `error`) |
> **Upgrading from an older image?** The `UPTIME_APP_*` variables (`UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST`, `UPTIME_APP_LOG_LEVEL`) are no longer read. In most setups no replacement is needed — the same-origin defaults cover them; if you pointed the client at a different origin, use the `CLIENT_CONFIG_*` equivalents above. The `checkmate-client`, `checkmate-backend`, `checkmate-mongo`, and `checkmate-backend-mono-multiarch` images are no longer updated — switch to `ghcr.io/bluewave-labs/checkmate`, keeping your existing MongoDB service and data volume.
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
@@ -120,7 +158,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
@@ -174,9 +212,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+4
View File
@@ -3,4 +3,8 @@ name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.2.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+6 -4
View File
@@ -1,4 +1,6 @@
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
# Production builds use same-origin defaults: API at /api/v1, client host from
# window.location.origin. The empty values below override any local .env so a
# production build never bakes in a developer's localhost URLs.
VITE_APP_API_BASE_URL=
VITE_APP_CLIENT_HOST=
VITE_APP_LOG_LEVEL=
-1
View File
@@ -28,5 +28,4 @@ dist-ssr
# Scripts
*.sh
!env.sh
.env.development
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
for i in $(env | grep UPTIME_APP_)
do
key=$(echo $i | cut -d '=' -f 1)
value=$(echo $i | cut -d '=' -f 2-)
echo $key=$value
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
done
+1
View File
@@ -12,6 +12,7 @@
/>
<title>Checkmate</title>
<script src="/config.js"></script>
</head>
<body>
+4 -24
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.9.2",
"version": "3.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.9.2",
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
@@ -92,7 +92,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -408,7 +407,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -446,7 +444,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1226,7 +1223,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1331,7 +1327,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2376,7 +2371,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2392,7 +2386,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2483,7 +2476,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2764,7 +2756,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3121,7 +3112,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3565,8 +3555,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3965,7 +3954,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4778,7 +4766,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5590,7 +5577,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6372,7 +6358,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6384,7 +6369,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6588,8 +6572,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6739,7 +6722,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7581,7 +7563,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7719,7 +7700,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+2 -1
View File
@@ -1,9 +1,10 @@
{
"name": "client",
"private": true,
"version": "3.9.2",
"version": "3.11.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
+4
View File
@@ -0,0 +1,4 @@
// Fallback so /config.js always resolves (e.g. under the Vite dev server). In
// the Docker image the server shadows this path and renders the real config
// from its CLIENT_CONFIG_* environment variables.
window.__CHECKMATE_CONFIG__ = {};
@@ -1,7 +1,7 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -8,6 +8,8 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
import { formatMs } from "@/Utils/TimeUtils";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -18,25 +20,25 @@ interface HistogramResponseTimeProps {
}
interface ResponseTimeStats {
max: number | string;
avg: number | string;
max: string | null;
avg: string | null;
}
const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) {
return { max: "-", avg: "-" };
return { max: null, avg: null };
}
const validChecks = checks.filter((check) => check.originalResponseTime != null);
const validChecks = checks.filter((check) => check.responseTime != null);
if (validChecks.length === 0) {
return { max: "-", avg: "-" };
return { max: null, avg: null };
}
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.round(Math.max(...responseTimes));
const avg = Math.round(
const responseTimes = validChecks.map((check) => check.responseTime);
const max = formatMs(Math.max(...responseTimes));
const avg = formatMs(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
@@ -54,7 +56,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
@@ -68,29 +70,28 @@ export const HistogramResponseTime = ({
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats &&
(typeof stats.max === "number" || typeof stats.avg === "number") && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
<Typography
variant="body2"
noWrap
>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return (
<Stack
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const heightPct = `${barHeights[index] ?? 0}%`;
const barColor =
check.status === true
? theme.palette.success.light
@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
@@ -21,16 +22,22 @@ export const BaseChart = ({
width = "100%",
maxWidth = "100%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
@@ -40,6 +47,7 @@ export const BaseChart = ({
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
+14 -4
View File
@@ -1,18 +1,28 @@
import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps {
color?: string;
size?: string;
size?: DotSize;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
width: DOT_SIZE_PX[size],
height: DOT_SIZE_PX[size],
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
@@ -5,13 +5,15 @@ interface IconProps {
size?: number;
strokeWidth?: number;
stroke?: string;
color?: string;
}
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
return (
<Icon
size={size}
strokeWidth={strokeWidth}
color={color}
/>
);
};
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
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";
import { formatDuration } from "@/Utils/TimeUtils";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
Checking every {formatDuration(monitor?.interval, true)}
</Typography>
</>
)}
+11 -1
View File
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
@@ -15,6 +16,9 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string;
required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
};
export const AutoCompleteInput = ({
@@ -22,15 +26,21 @@ export const AutoCompleteInput = ({
required,
renderInput,
renderOptionContent,
placeholder,
error,
helperText,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
placeholder="Type to search"
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
error={error}
helperText={helperText}
/>
);
@@ -13,7 +13,9 @@ export const languageNames: Record<string, string> = {
ca: "Català",
es: "Español",
fr: "Français",
it: "Italiano",
"pt-BR": "Português (BR)",
pl: "Polski",
cs: "Čeština",
fi: "Suomi",
tr: "Türkçe",
+3
View File
@@ -42,6 +42,9 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": {
mr: theme.spacing(6),
},
"&:has(.Mui-focusVisible)": {
backgroundColor: theme.palette.action.hover,
},
}}
/>
);
+15 -4
View File
@@ -11,10 +11,11 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -27,7 +28,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -92,11 +93,21 @@ const SelectInputInner = <T,>(
/>
);
if (fieldLabel) {
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{select}
{helper}
</Stack>
);
}
+21 -12
View File
@@ -3,6 +3,7 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
@@ -10,11 +11,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
error?: boolean;
helperText?: string;
}
export const TimePickerComponent = ({
fieldLabel,
required,
error,
helperText,
...props
}: TimePickerComponentProps) => {
const theme = useTheme();
@@ -33,12 +38,12 @@ export const TimePickerComponent = ({
px: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.divider,
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.divider,
borderColor: error ? theme.palette.error.main : theme.palette.divider,
},
},
},
@@ -48,14 +53,18 @@ export const TimePickerComponent = ({
</LocalizationProvider>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{picker}
{helperText && (
<Typography
variant="caption"
color={error ? "error" : "text.secondary"}
>
{helperText}
</Typography>
)}
</Stack>
);
};
@@ -0,0 +1,52 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
export interface AutocompleteOption {
id: string;
name: string;
}
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
}
export const FormAutocompleteField = <
T extends FieldValues,
O extends AutocompleteOption,
>({
name,
options,
fieldLabel,
placeholder,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -0,0 +1,38 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Checkbox } from "@/Components/inputs";
interface FormCheckboxFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
}
export const FormCheckboxField = <T extends FieldValues>({
name,
label,
}: FormCheckboxFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label={label}
/>
)}
/>
);
};
@@ -0,0 +1,35 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { ColorInput } from "@/Components/inputs";
interface FormColorFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
}
export const FormColorField = <T extends FieldValues>({
name,
fieldLabel,
}: FormColorFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={fieldLabel}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -0,0 +1,81 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import dayjs, { type Dayjs } from "dayjs";
import { DatePicker, TimePicker } from "@/Components/inputs";
export interface FormPickerFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
format?: string;
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
}
interface DayjsPickerProps {
fieldLabel?: string;
value: Dayjs | null;
onChange: (value: Dayjs | null) => void;
error?: boolean;
helperText?: string;
}
interface FormDayjsPickerFieldProps<
T extends FieldValues,
> extends FormPickerFieldProps<T> {
picker: React.ComponentType<DayjsPickerProps>;
format: string;
}
const FormDayjsPickerField = <T extends FieldValues>({
name,
fieldLabel,
format,
onValueChange,
picker: Picker,
}: FormDayjsPickerFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Picker
fieldLabel={fieldLabel}
value={field.value ? dayjs(field.value, format) : null}
onChange={(value) => {
const next = value ? value.format(format) : "";
field.onChange(next);
onValueChange?.(next);
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
);
};
export const FormDatePickerField = <T extends FieldValues>({
format = "YYYY-MM-DD",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={DatePicker}
/>
);
export const FormTimePickerField = <T extends FieldValues>({
format = "HH:mm",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={TimePicker}
/>
);
@@ -0,0 +1,187 @@
import { useMemo } from "react";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
export interface MultiSelectOption {
id: string | number;
name: string;
}
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
description?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
sortable?: boolean;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
placeholder,
description,
renderRow,
renderOptionContent,
sortable = false,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
const optionsById = useMemo(
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
[options]
);
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
const selectedIds: O["id"][] = field.value ?? [];
const selected = selectedIds
.map((id) => optionsById.get(id))
.filter((o): o is O => o !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = selected.map((o) => o.id);
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...reordered, ...unresolved]);
};
const rowContent = (option: O) =>
renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
);
const removeButton = (option: O) => (
<IconButton
size="small"
onClick={() => {
field.onChange(selectedIds.filter((id) => id !== option.id));
}}
aria-label={t("common.buttons.removeItem", { item: option.name })}
>
<Trash2 size={16} />
</IconButton>
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={options}
value={selected}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(_: unknown, newValue: O[]) => {
// The Autocomplete only sees resolved options, so ids missing
// from options must be re-appended or they'd be silently
// dropped (e.g. a role excluded from the selectable list).
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
}}
fieldLabel={fieldLabel}
placeholder={selected.length > 0 ? "" : placeholder}
renderOptionContent={renderOptionContent}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
{description && (
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{description}
</Typography>
)}
{sortable
? selected.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={`${name}-list`}>
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selected.map((option, index) => (
<Draggable
key={option.id}
draggableId={String(option.id)}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
{rowContent(option)}
{removeButton(option)}
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)
: selected.map((option) => (
<Stack
key={option.id}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
width="100%"
>
{rowContent(option)}
{removeButton(option)}
</Stack>
))}
</Stack>
);
}}
/>
);
};
@@ -0,0 +1,46 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText" | "type"
> {
name: FieldPath<T>;
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
}
export const FormNumberField = <T extends FieldValues>({
name,
emptyValue = 0,
...rest
}: FormNumberFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
value={
field.value === undefined || field.value === emptyValue ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? emptyValue : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
{...rest}
/>
)}
/>
);
};
@@ -0,0 +1,54 @@
import { RadioGroup, useTheme } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
description: string;
}
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
>
{options.map((option) => (
<RadioWithDescription
key={option.value}
value={option.value}
label={option.label}
description={option.description}
/>
))}
</RadioGroup>
</FormControl>
)}
/>
);
};
@@ -0,0 +1,61 @@
import {
Controller,
useFormContext,
type FieldValues,
type FieldPath,
} from "react-hook-form";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
export interface FormSelectOption {
value: string | number;
label: string;
}
interface FormSelectProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof Select>,
"name" | "error" | "value" | "onChange"
> {
name: FieldPath<T>;
options: FormSelectOption[];
fallbackValue?: string | number; // display value while the field is undefined
onValueChange?: (value: string | number) => void; // side effects, see Notes
}
export const FormSelectField = <T extends FieldValues>({
name,
options,
fallbackValue,
onValueChange,
...rest
}: FormSelectProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? fallbackValue ?? ""}
onChange={(e) => {
field.onChange(e.target.value);
onValueChange?.(e.target.value as string | number);
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
);
};
@@ -0,0 +1,34 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { SliderWithLabel } from "@/Components/inputs";
interface FormSliderProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof SliderWithLabel>,
"name" | "value" | "onChange"
> {
name: FieldPath<T>;
}
export const FormSliderField = <T extends FieldValues>({
name,
...rest
}: FormSliderProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
{...rest}
/>
)}
/>
);
};
@@ -0,0 +1,59 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SwitchComponent } from "@/Components/inputs";
import { SPACING } from "@/Utils/Theme/constants";
interface FormSwitchProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
labelPlacement?: "start" | "end"; // left | right
}
export const FormSwitchField = <T extends FieldValues>({
name,
label,
labelPlacement = "end",
}: FormSwitchProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
>
{labelPlacement === "start" ? (
<>
<Typography>{label}</Typography>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
</>
) : (
<>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>{label}</Typography>
</>
)}
</Stack>
)}
/>
);
};
@@ -0,0 +1,40 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormTextFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText"
> {
name: FieldPath<T>;
helperText?: string;
}
export const FormTextField = <T extends FieldValues>({
name,
...rest
}: FormTextFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
{...rest}
value={field.value ?? ""}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
fullWidth
/>
)}
/>
);
};
+7 -9
View File
@@ -18,15 +18,13 @@ const RootLayout = () => {
flex={1}
padding={6}
overflow={"hidden"}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(0, 0, 0, 0.01)",
display: "flex",
alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
bgcolor={
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(124, 116, 116, 0.01)"
}
alignItems="center"
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
>
<Stack
maxWidth={1280}
@@ -6,6 +6,7 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
@@ -83,10 +84,6 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
@@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
{formatMs(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
@@ -1,22 +1,24 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
domainExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -31,14 +33,9 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const streakTime = formatDuration(timeSinceLastFailure);
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const lastCheckTime = formatDuration(timeSinceLastCheck);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -67,14 +64,20 @@ export const MonitorStatBoxes = ({
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
/>
{monitor?.type === "http" && (
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
<>
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
)}
</Stack>
);
@@ -0,0 +1,34 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -1,53 +1,39 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
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 { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { formatDateWithTz, formatMs, 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 { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
import { useMemo, useState } from "react";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABEL_KEYS,
} from "@/Components/monitors/charts/ChartUtils";
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -63,17 +49,65 @@ const ResponseTimeToolTip = ({
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
<Typography>
{t("common.charts.histogram.responseTime", {
value: formatMs(responseTime),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: formatMs(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
</Typography>
</Stack>
))}
</BaseBox>
);
};
@@ -82,11 +116,28 @@ export const HistogramDetails = ({
checks,
range,
}: {
checks: GroupedCheck[];
checks: DetailsCheck[];
range: string;
}) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -95,7 +146,15 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title="Response times"
title={
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
>
<ResponsiveContainer
width="100%"
@@ -128,7 +187,34 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
@@ -141,24 +227,99 @@ export const HistogramDetails = ({
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart>
);
};
@@ -8,7 +8,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import {
XAxis,
AreaChart,
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
</Typography>
) : null}
<Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
<Typography variant="body2">{formatMs(avg)}</Typography>
</Stack>
);
};
@@ -7,6 +7,7 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { formatMs } from "@/Utils/TimeUtils";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation();
@@ -20,7 +21,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
error: "Poor",
};
return (
@@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
<Typography
variant="h1"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
>
{formatMs(avg)}
</Typography>
</Stack>
</Stack>
</BaseChart>
@@ -0,0 +1,30 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
+21 -9
View File
@@ -10,10 +10,11 @@ import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useNavigate } from "react-router";
import { MoreVertical, LogOut } from "lucide-react";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps {
collapsed: boolean;
@@ -27,6 +28,7 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation();
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -105,13 +107,14 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography
fontWeight={500}
noWrap
sx={{ color: theme.palette.text.primary }}
color={theme.palette.text.primary}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
noWrap
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
textTransform={"capitalize"}
color={theme.palette.text.secondary}
>
{getRoleText()}
</Typography>
@@ -154,15 +157,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2}
sx={{ pointerEvents: "none" }}
>
<Typography
fontWeight={500}
fontSize={13}
>
<Typography fontWeight={500}>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
fontSize={12}
sx={{ textTransform: "capitalize" }}
variant="body2"
textTransform={"capitalize"}
>
{getRoleText()}
</Typography>
@@ -178,6 +178,18 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name}
</MenuItem>
))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem
onClick={handleLogout}
sx={menuItemSx}
+6 -1
View File
@@ -18,8 +18,8 @@ import {
Lock,
Users,
Tag,
Waypoints,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
@@ -47,6 +47,11 @@ export const getMenu = (t: Function) => {
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
+1
View File
@@ -102,6 +102,7 @@ export const Sidebar = () => {
})}
</List>
<StarPrompt />
<Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter
+12 -3
View File
@@ -1,6 +1,11 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -42,8 +47,10 @@ export const getMonitorDefaults = (
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
@@ -137,8 +144,10 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: "",
matchMethod: DefaultMonitorMatchMethod,
expectedValue: "",
jsonPath: "",
customUpCodes: [],
+8 -80
View File
@@ -8,89 +8,17 @@ interface UseNotificationFormOptions {
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
type: data?.type ?? "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
+2
View File
@@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+39 -103
View File
@@ -1,11 +1,6 @@
import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import { TextField, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User";
@@ -22,6 +17,8 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption {
id: UserRole;
@@ -43,10 +40,11 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
const form = useForm<EditUserFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
useEffect(() => {
if (user) {
@@ -64,13 +62,6 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`),
}));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
@@ -90,42 +81,31 @@ const EditUserPage = () => {
};
return (
<BasePage loading={isLoading}>
<form onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...form}>
<BasePage
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<Stack gap={theme.spacing(8)}>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
<FormTextField
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<Controller
<FormTextField
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<TextField
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
@@ -139,55 +119,11 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")}
rightContent={
<Stack spacing={theme.spacing(4)}>
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
<FormMultiSelectField
name="role"
fieldLabel={t("common.form.role.option.role.label")}
options={roleOptions}
/>
}
/>
<Stack
@@ -217,19 +153,19 @@ const EditUserPage = () => {
</Button>
</Stack>
</Stack>
</form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
</FormProvider>
);
};
+61 -82
View File
@@ -1,13 +1,15 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { TextField, Button } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => {
const { t } = useTranslation();
@@ -15,10 +17,11 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>();
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
const form = useForm<PasswordFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData();
@@ -32,85 +35,61 @@ export const TabPassword = () => {
};
return (
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<Controller
name="currentPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
{t("common.buttons.save")}
</Button>
</Stack>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<FormTextField
name="currentPassword"
type="password"
autoComplete="current-password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="newPassword"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
/>
<FormTextField
name="confirm"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
);
};
+52 -64
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { TextField, Button } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi";
@@ -13,6 +13,8 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => {
const theme = useTheme();
@@ -22,7 +24,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>();
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
const form = useForm<ProfileFormData>({
resolver,
defaultValues: {
firstName: user?.firstName ?? defaults.firstName,
@@ -31,7 +33,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage,
},
});
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage");
@@ -74,66 +76,52 @@ export const TabProfile = () => {
};
return (
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
autoComplete="given-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
autoComplete="family-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
{t("common.buttons.save")}
</Button>
</Stack>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="firstName"
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
);
};
@@ -1,15 +1,16 @@
import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog, TextField, Select } from "@/Components/inputs";
import { Dialog } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { UserRole, User } from "@/Types/User";
import type { User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps {
open: boolean;
@@ -35,17 +36,13 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>();
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
const form = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
values: defaults,
});
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const { handleSubmit, reset, setError } = form;
const handleClose = () => {
reset();
@@ -55,7 +52,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return;
const { confirm, ...userData } = data;
const { confirm: _confirm, ...userData } = data;
const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload);
@@ -72,124 +69,64 @@ export const AddTeamMemberDialog = ({
};
return (
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack>
</Stack>
</Dialog>
</Dialog>
</FormProvider>
);
};
@@ -1,17 +1,18 @@
import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useTheme, Typography } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useForm, FormProvider } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse {
token: string;
@@ -30,11 +31,12 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation();
const { resolver, defaults } = useInviteForm();
const { control, handleSubmit, reset } = useForm<InviteFormData>({
const form = useForm<InviteFormData>({
resolver,
defaultValues: defaults,
values: defaults,
});
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData,
@@ -46,17 +48,11 @@ export const InviteTeamMemberDialog = ({
>();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data);
if (result?.data?.token) {
const token = result.data.token;
const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token;
const link = `${CLIENT_HOST || window.location.origin}/register/${token}`;
setInviteLink(link);
}
};
@@ -75,88 +71,55 @@ export const InviteTeamMemberDialog = ({
};
return (
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<RoleSelectField />
{inviteLink && (
<>
<Select
{...field}
value={Array.isArray(value) ? (value[0] ?? "") : ""}
onChange={(e) => onChange([e.target.value as UserRole])}
fieldLabel={t("common.form.role.option.role.label")}
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
/>
{inviteLink && (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
</Stack>
</Dialog>
</FormProvider>
);
};
@@ -0,0 +1,45 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+38 -49
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login";
@@ -11,6 +11,7 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => {
const { t } = useTranslation();
@@ -31,11 +32,13 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({
const form = useForm<LoginFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => {
@@ -50,53 +53,39 @@ const LoginPage = () => {
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
);
};
+31 -35
View File
@@ -1,12 +1,13 @@
import { Button, TextField } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => {
const { t } = useTranslation();
@@ -14,10 +15,11 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm();
const { control, handleSubmit } = useForm<RecoveryFormData>({
const form = useForm<RecoveryFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => {
if (loading) return;
@@ -30,39 +32,33 @@ const ForgotPasswordPage = () => {
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
);
};
+70 -103
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
@@ -12,6 +12,7 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
@@ -45,10 +46,11 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
const form = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, setError, reset } = form;
useEffect(() => {
if (!token || hasVerified.current) return;
@@ -64,14 +66,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true });
}
});
}, [token]);
}, [token, defaults, navigate, reset, verifyToken]);
if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => {
if (loading) return;
const { confirm, ...userData } = data;
const { confirm: _confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData };
if (token) {
payload.token = token;
@@ -89,105 +91,70 @@ const RegisterPage = () => {
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
</FormProvider>
);
};
+66 -78
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,6 +12,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => {
const theme = useTheme();
@@ -23,11 +24,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm();
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
const form = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, watch } = form;
const password = watch("password");
const confirm = watch("confirm");
@@ -57,81 +58,68 @@ const SetNewPasswordPage = () => {
};
return (
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
);
};
File diff suppressed because it is too large. Load diff
+2 -5
View File
@@ -1,5 +1,5 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import prettyMilliseconds from "pretty-ms";
import { formatDuration } from "@/Utils/TimeUtils";
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
@@ -24,8 +24,5 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
return "-";
}
return prettyMilliseconds(durationMs, {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
});
return formatDuration(durationMs);
};
@@ -63,10 +63,20 @@ const getChartConfigs = (
return configs;
};
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
checks.some((c) =>
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
);
// Loopback / bridge names Capture sees when it can only observe the container's
// own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) return false;
for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
}
return true;
};
export const InfraNetworkCharts = ({
checks,
@@ -82,13 +92,14 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
const showLoopbackOnlyNotice =
chartConfigs.length > 0 && onlyContainerVisibleInterfaces(checks);
return (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{showNoTrafficNotice && (
{showLoopbackOnlyNotice && (
<NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.netNoTraffic")}
{t("pages.infrastructure.charts.labels.loopbackOnlyNotice")}
</NoticeBanner>
)}
<Grid
@@ -25,10 +25,11 @@ export const InfraDetailsGauges = ({
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"center"}
alignItems={"stretch"}
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -37,6 +38,7 @@ export const InfraDetailsGauges = ({
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -49,6 +51,7 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
@@ -11,7 +11,8 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation();
const theme = useTheme();
const latestCheck = monitor?.recentChecks?.[0];
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
// Get data from latest check
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
@@ -21,13 +22,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes =
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
const osPlatform = getOsAndPlatform(latestCheck?.host);
return (
<Stack
@@ -21,10 +21,11 @@ export const TabOverview = ({
}
const checks = stats?.checks || [];
const recentChecks = monitor.recentChecks ?? [];
return (
<Stack gap={theme.spacing(8)}>
<StatusBoxes monitor={monitor} />
<InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} />
<InfraDetailsCharts
checks={checks}
dateRange={dateRange}
@@ -248,7 +248,8 @@ export const InfraMonitorsTable = ({
id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => {
const check = row.recentChecks?.[0];
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />;
},
@@ -257,7 +258,8 @@ export const InfraMonitorsTable = ({
id: "memory",
content: t("pages.infrastructure.table.headers.memory"),
render: (row) => {
const check = row.recentChecks?.[0];
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />;
},
@@ -266,7 +268,8 @@ export const InfraMonitorsTable = ({
id: "disk",
content: t("pages.infrastructure.table.headers.disk"),
render: (row) => {
const check = row.recentChecks?.[0];
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0),
+5 -9
View File
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
interface StatsProps {
diagnostics: Diagnostics | null;
@@ -18,13 +18,9 @@ export const Stats = ({ diagnostics }: StatsProps) => {
const theme = useTheme();
const eventLoopDelay = diagnostics
? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})
: PLACEHOLDER;
const uptime = diagnostics
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
? formatMs(diagnostics.eventLoopDelayMs ?? 0)
: PLACEHOLDER;
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
const usedHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
: PLACEHOLDER;
@@ -37,10 +33,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
return (
<Box
display="grid"
gap={theme.spacing(8)}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" },
}}
>
@@ -1,7 +1,6 @@
import { Table, Pagination } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material";
import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
@@ -84,7 +83,7 @@ export const TableJobs = ({
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography>
),
},
{
@@ -4,7 +4,6 @@ import { Pagination } from "@/Components/design-elements/Table";
import { ActionsMenu } from "@/Components/actions-menu";
import { DialogInput } from "@/Components/inputs/Dialog";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import type { Header } from "@/Components/design-elements/Table";
import type { ActionMenuItem } from "@/Components/actions-menu";
@@ -18,6 +17,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
import dayjs from "dayjs";
import { useState } from "react";
import { useDelete, usePatch } from "@/Hooks/UseApi";
import { formatDuration } from "@/Utils/TimeUtils";
interface MaintenanceWindowTableProps {
maintenanceWindows: MaintenanceWindow[];
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
// Window is in the future
if (start.isAfter(now)) {
return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
return formatDuration(start.diff(now));
}
return "N/A";
@@ -152,9 +152,7 @@ export const MaintenanceWindowTable = ({
id: "repeat",
content: t("pages.maintenanceWindow.table.headers.repeat"),
render: (row) =>
row.repeat === 0
? t("common.labels.na")
: prettyMilliseconds(row.repeat, { verbose: true }),
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat),
},
{
id: "actions",
+116 -257
View File
@@ -1,18 +1,8 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import {
TextField,
Select,
DatePicker,
TimePicker,
Button,
Autocomplete,
} from "@/Components/inputs";
import { Button } from "@/Components/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -25,10 +15,17 @@ 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";
import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Trash2 } from "lucide-react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormDatePickerField,
FormTimePickerField,
} from "@/Components/inputs/forms/FormDateTimePickerField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const CreateMaintenanceWindowPage = () => {
const theme = useTheme();
@@ -57,7 +54,7 @@ const CreateMaintenanceWindowPage = () => {
defaultValues: defaults,
});
const { control, handleSubmit, trigger } = form;
const { handleSubmit, trigger } = form;
useEffect(() => {
if (existingMaintenanceWindow) {
form.reset(defaults);
@@ -114,255 +111,117 @@ const CreateMaintenanceWindowPage = () => {
};
return (
<BasePage
component={"form"}
onSubmit={handleSubmit(onSubmit, onError)}
>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="name"
control={control}
defaultValue={defaults.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="repeat"
control={control}
defaultValue={defaults.repeat}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field, fieldState }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
trigger("startDate");
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startTime"
control={control}
defaultValue={defaults.startTime}
render={({ field }) => (
<TimePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
value={field.value ? dayjs(field.value, "HH:mm") : null}
onChange={(time) => {
field.onChange(time ? time.format("HH:mm") : "");
trigger("startDate");
}}
/>
)}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<Controller
name="duration"
control={control}
defaultValue={defaults.duration}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ width: 120 }}
/>
<FormProvider {...form}>
<BasePage
component={"form"}
onSubmit={handleSubmit(onSubmit, onError)}
>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="name"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
/>
<Controller
name="durationUnit"
control={control}
defaultValue={defaults.durationUnit}
render={({ field }) => (
<Select
fieldLabel=" "
value={field.value}
onChange={field.onChange}
sx={{ minWidth: 120 }}
>
{durationUnitOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
<FormSelectField
name="repeat"
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")}
options={repeatOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
defaultValue={defaults.monitors}
render={({ field, fieldState }) => {
const monitorsList = monitors ?? [];
const selectedMonitors = field.value
.map((id: string) => monitorsList.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitorsList}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<Stack>
{selectedMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter((id: string) => id !== monitor.id)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
))}
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormDatePickerField
name="startDate"
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
onValueChange={() => trigger("startDate")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTimePickerField
name="startTime"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
onValueChange={() => trigger("startDate")}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<FormNumberField
name="duration"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
</Stack>
);
}}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
sx={{ width: 120 }}
/>
<FormSelectField
name="durationUnit"
fieldLabel={" "}
options={durationUnitOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<FormMultiSelectField
name="monitors"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
placeholder={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
+188 -377
View File
@@ -1,14 +1,12 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { TextField, Select, Button } from "@/Components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import { Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { Controller, useForm } from "react-hook-form";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm";
@@ -16,6 +14,8 @@ import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
const NotificationsCreatePage = () => {
const { t } = useTranslation();
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
defaultValues: defaults,
});
const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
useEffect(() => {
reset(defaults);
@@ -93,384 +93,195 @@ const NotificationsCreatePage = () => {
};
return (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<Controller
name="notificationName"
control={control}
defaultValue={defaults.notificationName}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
defaultValue={defaults.type}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.type.optionType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{NotificationChannels.map((type: string) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">{type}</Typography>
</MenuItem>
))}
</Select>
)}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<FormTextField
name="notificationName"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<FormSelectField
name="type"
fieldLabel={t("pages.notifications.form.type.optionType")}
options={NotificationChannels.map((channel) => ({
value: channel,
label: t(`pages.notifications.form.type.value.${channel}`),
}))}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<FormTextField
name="address"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
/>
<FormTextField
name="topic"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
/>
</Stack>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="roomId"
control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.accessToken.optionAccessToken"
)}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
variant="contained"
color="primary"
onClick={handleTest}
loading={isTesting}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accountSid"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
/>
<FormTextField
name="twilioPhoneNumber"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
/>
<FormTextField
name="phone"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="homeserverUrl"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
/>
<FormTextField
name="roomId"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
{t("common.buttons.test")}
</Button>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
<Button
variant="contained"
color="primary"
onClick={handleTest}
loading={isTesting}
>
{t("common.buttons.test")}
</Button>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
+4 -2
View File
@@ -45,6 +45,8 @@ const PageSpeedDetails = () => {
const monitor = monitorData?.monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null;
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
return (
<BasePage
@@ -75,8 +77,8 @@ const PageSpeedDetails = () => {
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(10)}
>
<PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
<PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
<PiePageSpeed latestCheck={latestCheck} />
<PiePageSpeedLegend latestCheck={latestCheck} />
</Stack>
</BasePage>
);
@@ -0,0 +1,103 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
+213
View File
@@ -0,0 +1,213 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Button, FieldLabel } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { ProxyFormData } from "@/Validation/proxy";
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
import { useTranslation } from "react-i18next";
import { useProxiesForm } from "@/Hooks/useProxiesForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { useMemo } from "react";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const ProxiesCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { proxyId } = useParams<{ proxyId: string }>();
const isEditMode = Boolean(proxyId);
const { data: existingProxy } = useGet<ProxyResponse>(
isEditMode ? `/proxies/${proxyId}` : null
);
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
const { schema, defaults } = useProxiesForm({ data: existingProxy });
const form = useForm<ProxyFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { reset, handleSubmit } = form;
const isPasswordSet = existingProxy?.hasPassword ?? false;
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
const handleResetPassword = () => {
form.setValue("password", "");
setPasswordHasBeenReset(true);
};
const isUsernameSet = Boolean(existingProxy?.username);
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
const handleResetUsername = () => {
form.setValue("username", "");
setUsernameHasBeenReset(true);
};
const protocolOptions = useMemo(
() =>
ProxyProtocols.map((protocol) => ({
value: protocol,
label: protocol,
})),
[]
);
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: ProxyFormData) => {
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
{
...data,
};
// Don't send password/username if one is set and the user hasn't clicked reset
if (isPasswordSet && !passwordHasBeenReset) {
delete payload.password;
} else if (passwordHasBeenReset && !payload.password) {
payload.clearPassword = true;
}
if (isUsernameSet && !usernameHasBeenReset) {
delete payload.username;
} else if (usernameHasBeenReset && !payload.username) {
payload.clearUsername = true;
}
const result = isEditMode
? await patch(`/proxies/${proxyId}`, payload)
: await post("/proxies", payload);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
navigate("/proxies");
}
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.proxies.form.name.title")}
subtitle={t("pages.proxies.form.name.description")}
rightContent={
<FormTextField
name="name"
fieldLabel={t("pages.proxies.form.name.optionName")}
placeholder={t("pages.proxies.form.name.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.proxies.form.server.title")}
subtitle={t("pages.proxies.form.server.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSelectField
name="protocol"
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
options={protocolOptions}
/>
<FormTextField
name="host"
fieldLabel={t("pages.proxies.form.server.option.host.label")}
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
/>
<FormNumberField
name="port"
fieldLabel={t("pages.proxies.form.server.option.port.label")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.proxies.form.auth.title")}
subtitle={t("pages.proxies.form.auth.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
{(isUsernameSet === false || usernameHasBeenReset === true) && (
<FormTextField
name="username"
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
/>
)}
{isUsernameSet === true && usernameHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.username.labelSet")}
</FieldLabel>
<Button
onClick={handleResetUsername}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
{(isPasswordSet === false || passwordHasBeenReset === true) && (
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
/>
)}
{isPasswordSet === true && passwordHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.password.labelSet")}
</FieldLabel>
<Button
onClick={handleResetPassword}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
export default ProxiesCreatePage;
+72
View File
@@ -0,0 +1,72 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { ProxyResponse } from "@/Types/Proxy";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const ProxiesPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
const isDialogOpen = Boolean(selectedProxy);
const {
data: proxies,
isLoading,
isValidating,
error,
refetch,
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedProxy) return;
await deleteFn(`/proxies/${selectedProxy.id}`);
setSelectedProxy(null);
refetch();
};
const handleCancel = () => {
setSelectedProxy(null);
};
return (
<BasePageWithStates
headerKey="proxies"
page={t("pages.proxies.fallback.title")}
description={t("pages.proxies.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={proxies?.length ?? 0}
actionButtonText={t("pages.proxies.fallback.actionButton")}
actionLink="/proxies/create"
>
<HeaderCreate
path="/proxies/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<ProxiesTable
proxies={proxies ?? []}
setSelectedProxy={setSelectedProxy}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default ProxiesPage;
+1 -2
View File
@@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
const responseTime = Math.floor(Math.random() * 800) + 20;
checks.push({
id: `dummy-${i}`,
status: isUp,
statusCode: isUp ? 200 : 500,
responseTime: responseTime,
originalResponseTime: responseTime,
message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(),
});
File diff suppressed because it is too large. Load diff
+285 -489
View File
@@ -1,56 +1,81 @@
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import {
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import {
ImageUpload,
SwitchComponent,
Button,
TextField,
Autocomplete,
Checkbox,
Dialog,
ColorInput,
} from "@/Components/inputs";
import { ImageUpload, Button, Dialog } from "@/Components/inputs";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider, useController } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { SelectableMonitorTypes } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { timezoneOptions } from "@/Utils/timezoneOptions";
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";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField";
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
const ThemePickerField = () => {
const { field: themeField } = useController<StatusPageFormData, "theme">({
name: "theme",
});
const { field: modeField } = useController<StatusPageFormData, "themeMode">({
name: "themeMode",
});
return (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
);
};
const LogoUploadField = () => {
const { field } = useController<StatusPageFormData, "logo">({ name: "logo" });
return (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
}}
/>
);
};
// Derived from SelectableMonitorTypes so a newly supported monitor type is
// offered here automatically; the previous hardcoded list silently omitted DNS.
// "hardware" is appended because infrastructure monitors are created on their
// own page and so are absent from the selectable set.
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
[...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type));
return `/monitors/team?${params.toString()}`;
})();
interface TimezoneOption {
_id: string;
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
@@ -67,7 +92,9 @@ const CreateStatusPage = () => {
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
const { post, loading: isSubmittingPost } = usePost();
const { put, loading: isSubmittingPut } = usePut();
@@ -87,7 +114,7 @@ const CreateStatusPage = () => {
defaultValues: defaults,
});
const { control, reset, handleSubmit, trigger } = form;
const { reset, handleSubmit, trigger } = form;
// Reset form when defaults change (from fetched data)
useEffect(() => {
@@ -108,7 +135,7 @@ const CreateStatusPage = () => {
};
const handleBack = () => setCurrentStep((step) => step - 1);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const watchedMonitorIds: string[] = form.watch("monitors");
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
@@ -124,7 +151,7 @@ const CreateStatusPage = () => {
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes]);
}, [computedTypes, form]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
@@ -178,18 +205,8 @@ const CreateStatusPage = () => {
if (data.logo === null) {
// Signal to remove the logo
fd.append("removeLogo", "true");
} else if (data.logo?.data && data.logo.data !== "") {
if (data.logo.data.startsWith("blob:")) {
try {
const imageResult = await axios.get(data.logo.data, {
responseType: "blob",
});
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
} else if (data.logo?.file) {
fd.append("logo", data.logo.file);
}
let result;
@@ -216,470 +233,249 @@ const CreateStatusPage = () => {
return <BasePage>Loading...</BasePage>;
}
return (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && (
<StepProgress
steps={totalSteps}
current={currentStep}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
>
<Controller
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && (
<StepProgress
steps={totalSteps}
current={currentStep}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<FormSwitchField
name="isPublished"
control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
label={t("pages.statusPages.form.access.option.published.name")}
/>
<Typography>
{t("pages.statusPages.form.access.option.published.name")}
</Typography>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="companyName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = Array.from(field.value);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
field.onChange(reordered);
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.statusPages.form.monitors.option.monitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
control={control}
render={({ field }) => (
<Autocomplete
options={timezones}
getOptionLabel={(option: TimezoneOption) => option.name}
value={
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
}
onChange={(_, newValue: TimezoneOption | null) => {
field.onChange(newValue?._id ?? "");
}}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
renderInput={(params) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="companyName"
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
<FormTextField
name="url"
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
/>
<FormTextField
name="customDomain"
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={t(
"pages.statusPages.form.basicInfo.option.customDomain.helper"
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<FormMultiSelectField
sortable
name="monitors"
fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")}
placeholder={t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)}
options={monitors}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
{monitor.tags.map((tagId) => {
const tag = tags.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
renderRow={(monitor) => (
<Typography flexGrow={1}>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
)}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<FormAutocompleteField
name="timezone"
options={timezoneOptions}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<LogoUploadField />
</Stack>
<FormColorField
name="color"
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={<ThemePickerField />}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<FormTextField
name="customCSS"
multiline
rows={8}
fieldLabel={t("pages.statusPages.form.customCSS.option.customCSS.label")}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormCheckboxField
name="showCharts"
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
<FormCheckboxField
name="showInfrastructure"
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
{/*
<FormCheckboxField
name="showUptimePercentage"
label={t(
"pages.statusPages.form.features.option.showUptimePercentage.label"
)}
/>
)}
/>
<Controller
name="showAdminLoginLink"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
<FormCheckboxField
name="showAdminLoginLink"
label={t(
"pages.statusPages.form.features.option.showAdminLoginLink.label"
)}
/>
)}
/> */}
</Stack>
}
/> */}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
)}
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
</BasePage>
</FormProvider>
);
};
+34 -3
View File
@@ -1,6 +1,6 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
@@ -9,7 +9,11 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
isStatusPageRange,
resolveStatusPageTheme,
type StatusPageRange,
} from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPageResponse,
@@ -21,6 +25,7 @@ import {
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { cssReferencesExternalResource } from "@/Utils/customCss";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -79,21 +84,37 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const onRangeChange = (next: StatusPageRange) => {
const updated = new URLSearchParams(searchParams);
if (next === "latest") {
updated.delete("range");
} else {
updated.set("range", next);
}
setSearchParams(updated, { replace: true });
};
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const rawRange = searchParams.get("range");
const range = isStatusPageRange(rawRange) ? rawRange : "latest";
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
range,
});
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
{},
{
refreshInterval: 10000,
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
}
);
@@ -132,17 +153,27 @@ const StatusPageView = () => {
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const customCss =
statusPage.customCSS && !cssReferencesExternalResource(statusPage.customCSS)
? statusPage.customCSS
: "";
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
brandColor={statusPage.color}
paintBody
>
{customCss && <style>{customCss}</style>}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
range={range}
onRangeChange={onRangeChange}
bucketTimezone={data.bucketTimezone ?? "Etc/UTC"}
checkTTLDays={data.checkTTLDays}
/>
</StatusPageThemeProvider>
);
@@ -6,6 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { alpha, darken } from "@mui/material/styles";
import {
DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme,
@@ -36,6 +37,15 @@ interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* Brand color picked in the status-page appearance settings. When
* provided, it feeds `tokens.brand` / `brandStrong` / `brandSoft`,
* which theme styles use for accent surfaces (hero, active tabs,
* links). It does NOT recolor the semantic `up`/OK status color, so
* a red or orange brand cannot make the "all operational" indicator
* signal downtime.
*/
brandColor?: 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,10 +64,32 @@ interface Props {
children: ReactNode;
}
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
// Resolve the brand triple that theme styles read from. A customer's
// picked color (when valid) overrides the theme's default; otherwise
// the theme's own `brand*` values win, and finally we fall back to the
// `up*` triple so unmigrated theme styles have something to consume.
const resolveBrandTokens = (
tokens: StatusPageThemeTokens,
brandColor: string | undefined
): StatusPageThemeTokens => {
const validOverride = brandColor && HEX_COLOR.test(brandColor) ? brandColor : undefined;
const brand = validOverride ?? tokens.brand ?? tokens.up;
const brandStrong = validOverride
? darken(validOverride, 0.2)
: (tokens.brandStrong ?? tokens.upStrong);
const brandSoft = validOverride
? alpha(validOverride, 0.15)
: (tokens.brandSoft ?? tokens.upSoft);
return { ...tokens, brand, brandStrong, brandSoft };
};
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
brandColor,
paintBody = false,
transparent = false,
children,
@@ -109,7 +141,11 @@ export const StatusPageThemeProvider = ({
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const tokens = themeTokens[resolvedTheme][resolvedMode];
const baseTokens = themeTokens[resolvedTheme][resolvedMode];
const tokens = useMemo(
() => resolveBrandTokens(baseTokens, brandColor),
[baseTokens, brandColor]
);
const value = useMemo(
() => ({
@@ -1,11 +1,13 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { mixSeverityColor, 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";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type BoldHeatCell = HeatCellKind;
export type BoldBarKind = BarKind;
export type BoldGaugeFill = "ok" | "warm" | "hot";
export interface BoldStyles {
@@ -32,9 +34,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -55,6 +57,7 @@ export const boldStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -62,12 +65,14 @@ export const boldStyles = (
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`,
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
empty: "none",
};
const barBg: Record<BoldBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -322,9 +327,12 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind) => ({
heatmapCell: (kind, severity = 1) => ({
borderRadius: "3px",
background: heatCellBg[kind],
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -339,8 +347,11 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
@@ -8,8 +8,10 @@ import {
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type EditorialHeatCell = HeatCellKind;
export type EditorialBarKind = BarKind;
export type EditorialGaugeFill = "ok" | "warm" | "hot";
export interface EditorialStyles {
@@ -36,9 +38,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -59,12 +61,14 @@ export const editorialStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<EditorialBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -268,8 +272,11 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind) => ({
background: heatCellBg[kind],
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -281,8 +288,11 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
@@ -1,12 +1,14 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { mixSeverityColor, 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";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type ModernHeatCell = HeatCellKind;
export type ModernBarKind = BarKind;
export type ModernGaugeFill = "ok" | "warm" | "hot";
export interface ModernStyles {
@@ -35,9 +37,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -81,17 +83,20 @@ export const modernStyles = (
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
empty: tokens.border,
};
const barBg: Record<ModernBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -99,7 +104,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
};
return {
@@ -350,13 +355,19 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
heatmapCell: (kind, severity = 1) => {
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
return {
borderRadius: "3px",
background:
kind === "degraded"
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
@@ -366,8 +377,11 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,10 +1,12 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type RefinedHeatCell = HeatCellKind;
export type RefinedBarKind = BarKind;
export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -35,9 +37,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -71,12 +73,14 @@ export const refinedStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<RefinedBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -119,7 +123,7 @@ export const refinedStyles = (
width: 28,
height: 28,
borderRadius: "8px",
background: tokens.up,
background: tokens.brand,
display: "grid",
placeItems: "center",
color: "#fff",
@@ -191,16 +195,16 @@ export const refinedStyles = (
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
background: active ? tokens.brandSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: { xs: "5px 6px", md: "5px 14px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
color: active ? tokens.brand : tokens.textMuted,
borderRadius: "5px",
transition: "background 0.15s ease, color 0.15s ease",
fontWeight: active ? 600 : 500,
"&:hover": { color: active ? tokens.up : tokens.text },
"&:hover": { color: active ? tokens.brand : tokens.text },
}),
monitorList: {
@@ -259,9 +263,9 @@ export const refinedStyles = (
},
pillHardware: {
...pillBase,
color: tokens.up,
color: tokens.brand,
border: `1px solid ${tokens.border}`,
background: tokens.upSoft,
background: tokens.brandSoft,
},
monitorUrl: {
fontSize: 12,
@@ -292,9 +296,12 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind) => ({
heatmapCell: (kind, severity = 1) => ({
borderRadius: "2px",
background: heatCellBg[kind],
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -308,8 +315,11 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
@@ -382,11 +392,11 @@ export const refinedStyles = (
fontSize: 12,
mt: "40px",
"& a": {
color: tokens.up,
color: tokens.brand,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 600,
"&:hover": { color: tokens.upStrong || tokens.up },
"&:hover": { color: tokens.brandStrong },
},
},
};
@@ -1,20 +1,19 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import Typography from "@mui/material/Typography";
import { TriangleAlert } from "lucide-react";
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 type { StatusPage, StatusPageRange } from "@/Types/StatusPage";
import {
getMonitorTypeLabel,
STATUS_PAGE_RANGE_DAYS,
STATUS_PAGE_RANGES,
} from "@/Types/StatusPage";
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 { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import {
ThemedInfrastructure,
type GaugeFillLevel,
@@ -26,10 +25,22 @@ import {
resolveOverallStatus,
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
import {
checksToCells,
dailyBucketsToCells,
type BarKind,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Icon } from "@/Components/design-elements";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
type StatusPageMonitor = Monitor;
export interface BaseStyles {
page: SxProps<Theme>;
@@ -48,9 +59,9 @@ export interface BaseStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -82,9 +93,22 @@ interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
config: ThemeConfig<any>;
range: StatusPageRange;
onRangeChange: (range: StatusPageRange) => void;
bucketTimezone: string;
checkTTLDays?: number;
}
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
export const BaseStatusPage = ({
statusPage,
monitors,
config,
range,
onRangeChange,
bucketTimezone,
checkTTLDays,
}: Props) => {
const theme = useTheme();
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(
@@ -126,33 +150,75 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
/>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Stack>
<Box sx={styles.chartSwitchWrap}>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
sx={styles.chartSwitch}
role="radiogroup"
>
{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
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>
</Box>
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
aria-label={t("pages.statusPages.rangeSelectorAria")}
>
{STATUS_PAGE_RANGES.map((value) => (
<Box
component="button"
type="button"
role="radio"
key={value}
aria-checked={range === value}
onClick={() => onRangeChange(value)}
sx={styles.chartSwitchButton(range === value)}
>
{t(`pages.statusPages.range.${value}`)}
</Box>
))}
</Box>
</Box>
{range !== "latest" &&
checkTTLDays &&
STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && (
<Stack
mb={LAYOUT.XS}
alignSelf="flex-end"
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Icon
icon={TriangleAlert}
color={theme.palette.warning.main}
/>
<Typography>
{t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })}
</Typography>
</Stack>
)}
</Stack>
)}
<Stack
@@ -166,6 +232,16 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
const cells =
range === "latest"
? checksToCells(monitor.recentChecks ?? [])
: dailyBucketsToCells(
monitor.dailyChecks ?? [],
STATUS_PAGE_RANGE_DAYS[range],
bucketTimezone,
t
);
return (
<Box
component="li"
@@ -226,13 +302,13 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
cells={cells}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
cells={cells}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
@@ -0,0 +1,137 @@
import {
ThemedChartTooltip,
ThemedDailyTooltip,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import {
computeBarHeights,
computeDayBarHeights,
MIN_HEIGHT_PCT,
} from "@/Utils/DataUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { enumerateDays, formatMs } from "@/Utils/TimeUtils";
import type { TFunction } from "i18next";
export type BarKind = "up" | "degraded" | "down" | "empty";
export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty";
export interface ChartCell {
key: string;
severity: number;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null;
ariaLabel: string;
}
const DAY_DOWN_THRESHOLD = 0.5;
// Color interpolation
// 0 = perfect
// 1 = at or below the down threshold
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
export const severityFromUpRatio = (upRatio: number): number => {
const failed = 1 - upRatio;
if (failed <= 0) return 0;
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
const clamped = Math.min(1, Math.max(0, scaled));
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
};
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
if (bucket.totalChecks === 0) return "empty";
if (upRatio >= 1) return "up";
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
return "degraded";
};
export const classifyResponseTime = (
status: boolean,
responseTime: number | undefined
): Exclude<HeatCellKind, "empty"> => {
if (!status) return "down";
const rt = responseTime ?? 0;
if (rt > 500) return "slow";
if (rt > 250) return "med";
return "fast";
};
const emptyCell = (key: string): ChartCell => ({
key,
barKind: "empty",
heatKind: "empty",
heightPct: MIN_HEIGHT_PCT,
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
...cells,
...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) =>
emptyCell(`empty-${cells.length + i}`)
),
];
export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
const source = checks.slice(-MAX_RECENT_CHECKS);
const heights = computeBarHeights(source);
const cells = source.map(
(check, i): ChartCell => ({
key: check.id ?? String(i),
barKind: check.status ? "up" : "down",
heatKind: classifyResponseTime(check.status, check.responseTime),
heightPct: heights[i] ?? MIN_HEIGHT_PCT,
responseTime: check.responseTime ?? 0,
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
};
export const dailyBucketsToCells = (
buckets: DailyCheckBucket[],
days: number,
bucketTimezone: string,
t: TFunction
): ChartCell[] => {
const byDate = new Map(buckets.map((b) => [b.date, b]));
const heights = computeDayBarHeights(buckets);
const enumerated = enumerateDays(days, bucketTimezone);
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
return {
key: `day-${date}`,
severity:
barKind === "degraded"
? severityFromUpRatio(upRatio)
: barKind === "down"
? 1
: 0,
barKind,
heatKind:
barKind === "up"
? classifyResponseTime(true, bucket.avgResponseTime ?? 0)
: barKind,
heightPct: heights.get(date) ?? MIN_HEIGHT_PCT,
responseTime: bucket.avgResponseTime ?? 0,
tooltip: <ThemedDailyTooltip bucket={bucket} />,
ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", {
date,
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
}),
};
});
};
@@ -0,0 +1,64 @@
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { useTranslation } from "react-i18next";
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? formatMs(check.responseTime)
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
};
export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => {
const { t } = useTranslation();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{bucket.date}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayUptime", {
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
upChecks: bucket.upChecks.toLocaleString(),
totalChecks: bucket.totalChecks.toLocaleString(),
})}
</Typography>
{bucket.avgResponseTime !== null && (
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayAvgResponse", {
value: formatMs(bucket.avgResponseTime),
})}
</Typography>
)}
</Stack>
);
};
@@ -1,86 +1,49 @@
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
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 type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
import type {
ChartCell,
HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
interface Props {
checks: CheckSnapshot[];
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
}
const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
if (!check.status) return "down";
const rt = check.responseTime ?? 0;
if (rt > 500) return "slow";
if (rt > 250) return "med";
return "fast";
};
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
return (
<Box
sx={containerSx}
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
{padded.map((check, i) => {
if (!check) {
{cells.map((cell) => {
if (cell.heatKind === "empty") {
return (
<Box
key={`empty-${i}`}
key={cell.key}
sx={cellSx("empty")}
/>
);
}
const kind = classify(check);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
key={cell.key}
title={cell.tooltip}
arrow
placement="top"
>
<Box
sx={cellSx(kind)}
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
sx={cellSx(cell.heatKind, cell.severity)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
);
@@ -2,101 +2,67 @@ import { useMemo } from "react";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
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 type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
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";
import { formatMs } from "@/Utils/TimeUtils";
import type {
BarKind,
ChartCell,
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
interface Props {
checks: CheckSnapshot[];
cells: ChartCell[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
check.status ? "up" : "down";
export const ThemedHistogram = ({
checks,
cells,
containerSx,
barSx,
statsSx,
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
const out: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
const { avg, peak } = useMemo(() => {
const valid = cells.filter((cell) => cell.responseTime > 0);
const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0;
const avgRt = valid.length
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length
: 0;
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [checks]);
return { avg: formatMs(avgRt), peak: formatMs(maxRt) };
}, [cells]);
return (
<Stack gap={statsGap}>
<Box sx={containerSx}>
{padded.map((check, i) => {
if (!check) {
<Box
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
>
{cells.map((cell) => {
if (cell.barKind === "empty") {
return (
<Box
key={`empty-${i}`}
sx={barSx("empty", MIN_HEIGHT_PCT)}
key={cell.key}
sx={barSx("empty", cell.heightPct)}
/>
);
}
const height = Math.max(
MIN_HEIGHT_PCT,
Math.round((check.responseTime / max) * 100)
);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
timezone
)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
key={cell.key}
title={cell.tooltip}
arrow
placement="top"
>
<Box
sx={barSx(tone(check), height)}
aria-label={`${check.responseTime} ms`}
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
);
@@ -18,7 +18,7 @@ export interface InfraSx {
}
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
monitor: Monitor;
sxApi: InfraSx;
}
@@ -36,7 +36,8 @@ const heatLevel = (value: number): GaugeFillLevel =>
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation();
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const recentChecks = monitor.recentChecks ?? [];
const latest = recentChecks[recentChecks.length - 1];
const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff. Show more