Compare commits

...
Author SHA1 Message Date
gorkem-bwl 056e40be8f chore(deps): bump rollup (transitive)
Patches CVE-2026-27606 (high — arbitrary file write via path traversal in Rollup 4).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:45:06 -04:00
Gorkem Cetin 7f4282ab1f Update README.md 2026-04-29 17:46:22 -04:00
Gorkem Cetin fcf2557a2f Update README to remove sponsorship and refine features
Removed sponsorship links and consolidated monitoring options.
2026-04-29 17:04:01 -04:00
Alexander Holliday bdf1e13ce8 Merge pull request #3607 from bluewave-labs/feat/smtp-from-name
feat(smtp): add optional sender display name to SMTP configuration
2026-04-29 13:01:08 -07:00
Alex Holliday 4de2ba4b25 add missing tests 2026-04-29 12:59:48 -07:00
Alex Holliday d5bf45e2a9 allow from 2026-04-29 12:57:37 -07:00
gorkem-bwl 6f988cc688 fix(settings): drop from field from transport JSON preview
The transport JSON preview is strictly for verifying what is handed to
nodemailer.createTransport. The From header is built per-message in
sendMail, so showing it inside the transport JSON was misleading. The
separate From: ... pre block below the JSON is the right place for
that preview.
2026-04-29 15:48:12 -04:00
gorkem-bwl 8fbbb1dd4b Revert "refactor(email): set From at transport level instead of per-message"
This reverts commit baa4d693ae.
2026-04-29 15:42:02 -04:00
gorkem-bwl baa4d693ae refactor(email): set From at transport level instead of per-message
The /settings transport preview shows `from` inside the createTransport
JSON, but the implementation was passing it per-message via sendMail.
Switch to passing it as part of the transport config so what the
preview shows matches what is actually built — no behavioural change
for outgoing emails since nodemailer treats transport-level `from` as
the default for every sendMail call.
2026-04-29 15:38:22 -04:00
gorkem-bwl e11553c218 feat(settings): restore From-header preview block under SMTP transport JSON
Brings back the second pre-block I dropped when consolidating the
preview. The transport JSON shows what's handed to nodemailer
createTransport; the From-header block shows the rendered envelope
recipients will see — they answer different questions and both belong.

Conditioned on systemEmailAddress so empty configs don't render an
empty pre.
2026-04-29 15:33:15 -04:00
gorkem-bwl e71892d0bc feat(smtp): add optional sender display name to SMTP configuration
Closes #3600. Previously the From header on test emails and notifications
showed only the raw email address. Adds an optional "Sender name" field
so emails appear from a friendly name like "Checkmate notifications"
<user@example.org>.

## Behaviour
- Field is optional. When empty/null/whitespace, the From header falls
  back to the raw email address — identical to current behaviour, no
  regression for existing installs.
- When set, the email service hands nodemailer { name, address } as the
  message from field. Nodemailer handles RFC-5322 encoding (quotes,
  commas, non-ASCII) so the display name is safe for any input.
- Unsetting works: clearing the input transforms the value to null in
  the Zod parse step, and the settings repository already turns null
  values into a $unset operation.
- Both client and server Zod schemas trim and empty-string-to-null,
  preventing whitespace-only display names from being stored.

## Surfaces
- AppSettings schema, Settings type, EmailTransportConfig pick, server
  Zod validation, client Zod validation, client form defaults, and the
  Settings page form all carry the new systemEmailDisplayName field.
- The transport preview pre-block on /settings now includes a `from`
  key showing exactly what the email service hands nodemailer — the
  string when no display name is set, or { name, address } when it is.
- en.json carries the new label and placeholder. Other locales fall
  back until translated (16 locales pending — separate follow-up).

## Conventions conformance (per docs/frontend-conventions.md)
- The pre-block I touched had three rule #1 violations (sx p,
  borderRadius, backgroundColor); hoisted those to native props using
  theme.shape.borderRadius. Other rule #1 violations elsewhere on this
  page are pre-existing and intentionally left alone.
- No new sx-with-native-equivalents introduced.
- No color="text.X" shorthand introduced.
- No theme module import introduced.
2026-04-29 15:30:16 -04:00
Alexander Holliday 100e569aa8 Merge pull request #3583 from ayshaL/fix/blank-screen-on-timezone
Fix: prevent blank screen crash when clearing timezone search field
2026-04-29 12:13:40 -07:00
Alexander Holliday 343167012e Merge pull request #3605 from bluewave-labs/fix/reveresed-status-page-checks
Fix/reveresed status page checks
2026-04-29 12:06:45 -07:00
Alex Holliday 5330fc3183 bump version 2026-04-29 12:04:44 -07:00
Alex Holliday df7e348366 Merge branch 'develop' into fix/reveresed-status-page-checks 2026-04-29 12:04:22 -07:00
Alex Holliday b2c3e08db3 fix reveresed checks 2026-04-29 12:03:23 -07:00
Alexander Holliday 8ca62db317 Merge pull request #3603 from bluewave-labs/chore/bump-version
bump version
2026-04-29 11:51:00 -07:00
Alex Holliday 3bb767b780 bump version 2026-04-29 11:50:28 -07:00
Alexander Holliday 20d27cb984 Merge pull request #3568 from bluewave-labs/feat/status-page-themes-backend
feat(status-page): add theme and themeMode fields (backend)
2026-04-29 11:45:50 -07:00
Alex Holliday 0322a96090 update settings service tests 2026-04-29 11:41:22 -07:00
Alex Holliday 3dbad1743a resolve conflicts 2026-04-29 11:39:08 -07:00
Alex Holliday f4c3ee16db Merge branch 'develop' into feat/status-page-themes-backend 2026-04-29 11:35:55 -07:00
Alexander Holliday a7d5a84206 Merge pull request #3569 from bluewave-labs/feat/status-page-themes-frontend
feat(status-page): four selectable themes for the public status page
2026-04-29 11:35:23 -07:00
Alex Holliday e86734cee9 resolve conflcits 2026-04-29 11:28:57 -07:00
Alex Holliday cc84c37a2d remove planning docs 2026-04-29 11:22:20 -07:00
Alex Holliday e85e9dc2d4 remove duplicated time util 2026-04-29 11:15:33 -07:00
Alex Holliday 1398ab6721 remove dead code 2026-04-29 11:12:35 -07:00
Alex Holliday d32ea7f8c3 remove dead code 2026-04-29 11:07:34 -07:00
Alex Holliday edeed00be6 fix unnecessary cast, remove dead keys 2026-04-29 10:56:57 -07:00
Gorkem Cetin 4c7e96122f Merge pull request #3599 from bluewave-labs/feat/frontend-conventions-eslint
docs(client): document frontend conventions and enforce via ESLint
2026-04-29 13:56:23 -04:00
Alex Holliday 45c02e0f7f unsafe lookup fix 2026-04-29 10:51:40 -07:00
gorkem-bwl aa7051095a docs(client): document frontend conventions and enforce via ESLint
Capture the five recurring code-review patterns from the recent PR series
(#3591–#3596) as enforceable rules so they don't have to be raised again
in review.

## Conventions
- Prefer MUI native props over `sx` (e.g. color={...}, mt={...}, width={...}).
- Use the full theme path for colors: theme.palette.text.secondary, never
  the "text.secondary" shorthand string. The shorthand is invisible to
  grep, which makes future audits and refactors miss callsites.
- No hardcoded numeric or string literals — use LAYOUT, typographyLevels,
  theme.shape.borderRadius, theme.palette tokens.
- Use useTheme() inside components; don't import { theme } from
  @/Utils/Theme/Theme (factory output, not a live mode-aware instance).
- Pair runtime tuples with derived types so they can't drift.

## Files
- docs/frontend-conventions.md — long-form doc with before/after examples
  drawn from the actual review fixes, plus a pre-PR checklist.
- CLAUDE.md — short summary in the Code Conventions section pointing to
  the doc, so it's loaded into agent context at session start.

## ESLint enforcement
- client/.eslintrc.cjs — four no-restricted-syntax selectors for each rule:
  - color="…" / bgcolor="…" / borderColor="…" with a dotted shorthand value
  - sx={{ key: ... }} where key has a native equivalent, only on hoistable
    components (Box, Stack, Typography, Grid, Container, Paper, Card*,
    Divider, Link, Chip) — Dialog/Drawer/Menu etc. only accept sx.
  - import { theme } from "@/Utils/Theme/Theme"
  - Typography fontSize={13} (the body default — drop the prop).
- TypeScript files are now actually parsed: added @typescript-eslint/parser
  and @typescript-eslint/eslint-plugin and a TS override that extends the
  React plugin configs. Several @typescript-eslint/recommended rules
  disabled to avoid noise on the existing codebase.
- client/package.json — lint script now covers .ts/.tsx; new lint-strict
  script for CI use once the codebase is conformant; --max-warnings 0
  removed from `lint` so existing-code warnings don't block the workflow.

## Surface
Running `npm run lint` after this change reports ~280 warnings against
the existing codebase, almost all from rule #1 (sx-vs-native). These are
intentionally warnings, not errors — they surface the work without
blocking. Each warning links to the rule number in the conventions doc
so the reasoning is one click away.
2026-04-29 13:49:58 -04:00
Alex Holliday fcb4fbf277 fix optional types 2026-04-29 10:35:35 -07:00
Alex Holliday 74a12754b6 fix controller architecture 2026-04-29 10:33:46 -07:00
Alexander Holliday fe8213f8bb Merge pull request #3596 from bluewave-labs/feat/settings-remove-monitors-modal
feat(settings): explanatory body in Remove monitors confirmation modal
2026-04-29 10:03:15 -07:00
Alex Holliday cf5691e250 fix build error 2026-04-29 10:01:43 -07:00
Alex Holliday f3b4b908fd Merge branch 'develop' into feat/settings-remove-monitors-modal 2026-04-29 10:00:01 -07:00
Alexander Holliday f58456961b Merge pull request #3595 from bluewave-labs/feat/infrastructure-network-empty-state
feat(infrastructure): show notice when network charts have zero traffic
2026-04-29 09:57:49 -07:00
Alex Holliday 91bdc662ca Merge branch 'develop' into feat/infrastructure-network-empty-state 2026-04-29 09:56:55 -07:00
Alexander Holliday 53e04e6af2 Merge pull request #3594 from bluewave-labs/feat/incidents-page-polish
feat(incidents): consistent typography, icons, and layout across the page
2026-04-29 09:56:27 -07:00
Alex Holliday 301942d0eb normalize 2026-04-29 09:56:01 -07:00
Alex Holliday 29bb1615f5 Merge branch 'develop' into feat/incidents-page-polish 2026-04-29 09:51:06 -07:00
Alexander Holliday afc3ad237f Merge pull request #3593 from bluewave-labs/feat/account-team-modals
feat(account): side-by-side name and password fields in Add Member modal
2026-04-29 09:50:34 -07:00
Alex Holliday e0ac040063 Merge branch 'develop' into feat/account-team-modals 2026-04-29 09:49:23 -07:00
Alexander Holliday f895125145 Merge pull request #3592 from bluewave-labs/refactor/button-input-styling
refactor(inputs): softer button hover, sx-forwarding TextInput, themed FieldLabel
2026-04-29 09:47:31 -07:00
Alex Holliday 63031e938e normalize 2026-04-29 09:47:11 -07:00
Alex Holliday 4b82129199 Merge branch 'develop' into refactor/button-input-styling 2026-04-29 09:46:54 -07:00
Alexander Holliday ccd176ba0b Merge pull request #3591 from bluewave-labs/refactor/dialog-component
refactor(dialog): standard modal layout, NoticeBanner design-element, softer Cancel
2026-04-29 09:43:12 -07:00
Alex Holliday 2dd9218910 normailze dialog and basepage 2026-04-29 09:42:49 -07:00
Alex Holliday 5792ca624e normalize NoticeBanner 2026-04-29 09:37:37 -07:00
gorkem-bwl 5716b5eea4 feat(settings): explanatory body in Remove monitors confirmation modal
The remove-all-monitors confirmation dialog only had a one-line title
and an empty body, which looked awkward and didn't communicate what was
actually about to be deleted alongside the monitors themselves.

- Adds a paragraph explaining that check history, incidents, scheduled
  maintenance windows, and dependent status pages will also be removed,
  with an inline tip to export monitors first to keep a backup.
- The Confirm button now uses confirmColor='error' to make the
  destructive action visually distinct, with a localized
  'Remove monitors' label.
- Translated to all 16 locales, with terminology aligned to each
  locale's existing usage (e.g. zh-CN/zh-TW use 监控/監控 to match the
  existing button label, vi uses 'trình giám sát', etc.).
2026-04-28 23:58:13 -04:00
gorkem-bwl c89dbebe98 feat(infrastructure): show notice when network charts have zero traffic
When Capture runs inside a container (typical for Docker Desktop), the
only visible interfaces are loopback, so every bytes_sent/bytes_recv
counter is zero and the charts render as flat lines without any
explanation. Users couldn't tell whether Capture was unreachable, the
monitor was broken, or there was just genuinely no traffic.

Detect the case where checks exist (chart configs were built) but every
interface across every check reports zero bytesSentPerSecond and
deltaBytesRecv, and render a NoticeBanner above the chart grid
explaining it and suggesting Capture be run on the host.

Adds pages.infrastructure.charts.labels.netNoTraffic to all 16 locales.
2026-04-28 23:57:14 -04:00
gorkem-bwl 5d5d1dbb23 feat(incidents): consistent typography, icons, and layout across the page
- All uppercase headers (Active Incidents, Latest Incidents, Incident
  Statistics, Resolved Incidents, summary card titles, details card
  sections) now use the existing 'eyebrow' typography variant. That's
  the same style used on the Logs page and lives in Theme.ts so future
  tweaks land in one place.
- Latest Incidents and Incident Statistics rows now share a minHeight
  so both cards line up vertically; Latest Incidents items dropped
  their extra dividers and Box wrappers.
- Body text in the summary cards (and the details dialog) uses
  variant=body1 (13px) consistently — previously a mix of body1/body2
  and unspecified Typography.
- All lucide icons in CardSummary go through the design-system <Icon>
  wrapper so they share the project's strokeWidth=1.5 default rather
  than lucide's default 2.
- ControlsIncidentFilter: replaced the missing
  pages.incidents.filters.clearFilters key (which was rendering as
  raw text) with the existing common.buttons.clearFilters.
- CardDetails: extracted local SectionHeading and Cell helpers; switched
  the cards' bare <Typography> cells to variant=body1 + variant=eyebrow
  for headings; dropped redundant title-dividers (eyebrow style already
  separates visually).
2026-04-28 23:56:10 -04:00
gorkem-bwl 93505f006c feat(account): side-by-side name and password fields in Add Member modal
- AddTeamMemberDialog: First name / Last name now share a row, and
  Password / Confirm password share a row. Each field uses sx={flex:1}
  so the row totals match the width of the email/role rows below.
- InviteTeamMemberDialog: drop the redundant mt={theme.spacing(...)}
  workaround on the form Stack (the new Dialog component provides this
  spacing).
- Constants conformance: spacing literals replaced with LAYOUT.XS.
2026-04-28 23:55:27 -04:00
gorkem-bwl a6204b7bf5 Merge branch 'refactor/button-input-styling' into feat/account-team-modals 2026-04-28 23:54:48 -04:00
gorkem-bwl 9c21d71c44 refactor(inputs): softer button hover, sx-forwarding TextInput, themed FieldLabel
- Button.tsx: contained variant's hover now uses HOVER.DARKEN=0.06
  instead of MUI's default 16%, which was visibly heavy on the green
  primary buttons across the app. Also fixes a subtle bug where the
  earlier hover sx merge order silently dropped the boxShadow:'none'
  reset. Added a HOVER token group to constants.ts so the darken value
  has a single source of truth.
- TextInput.tsx: forward the caller's sx to the wrapper Stack when a
  fieldLabel is present (previously it was applied to the inner
  TextField, so flex:1 etc. didn't propagate to the visible width).
  Inner sx now also explicitly width:100% so wrapped inputs don't
  collapse to content width when their parent is a flex row.
- FieldLabel.tsx: drop the hardcoded fontSize:'14px' and use
  variant=body1 (=13px from theme) to match the rest of the app's
  body typography.
2026-04-28 23:54:39 -04:00
gorkem-bwl fef3f2c294 refactor(dialog): standard modal layout, NoticeBanner design-element, softer Cancel
Dialog component:
- Title bar gets a subtle background tint + bottom border. Title is
  variant=h2 weight=600. Description tucks under the title with
  variant=body1 secondary color (replaces MUI's DialogContentText).
- Action bar gets a matching subtle tint + top border. Cancel is now
  outlined (was contained-error, which made every modal's Cancel button
  look destructive). Confirm keeps its color (primary by default,
  caller can pass error for destructive flows).
- Content area sits between with its own breathing room baked in, so
  callers no longer need mt={theme.spacing(...)} workarounds.
- The cancelColor prop is removed; sites that genuinely need a
  destructive Cancel can use confirmColor='error' on the Confirm button
  instead.

NoticeBanner design-element:
- Extracted from the inline alert pattern in PageSpeedKeyPriorityFallback
  into a reusable component with severity=info|warning|error.
- Refactored PageSpeedKeyPriorityFallback to use it.

DialogResolution.tsx: drop the now-removed cancelColor prop and the
mt-Box wrapper that's no longer needed (Dialog provides the spacing).
2026-04-28 23:53:59 -04:00
Gorkem Cetin a617bd340a Merge pull request #3590 from bluewave-labs/feat/game-monitor-helper-link
feat(create-monitor): explain server-side query setup for game monitors
2026-04-28 18:05:41 -04:00
gorkem-bwl 23061e31d1 i18n(create-monitor): translate game description for the remaining 9 locales
Fills in machine translations for ar, cs, fi, fr, ja, pt-BR, tr, uk, vi
so every locale now ships a native translation of the game-monitor
description (including the new server-query setup sentence and the
<gamedigLink> placeholder).
2026-04-28 18:02:57 -04:00
gorkem-bwl 34b1a9cdc2 feat(create-monitor): explain server-side query setup for game monitors
The game monitor type relies on each game's native query protocol — many
require a config file change on the game server (e.g. Minecraft Java
needs `enable-query=true` in `server.properties`). Without that, the
monitor silently fails to reach the server.

Append a sentence to the game-type general settings description that:

- tells the user some games need server-side queries enabled
- points them to the GameDig supported-games list for protocol details

Implementation:

- Use <Trans> with a `gamedigLink` slot so the link renders inline
- Loosen ConfigBox `subtitle` from `string` to `React.ReactNode` so the
  subtitle can host the <Trans> output
- Update all 16 locale files. Six (de, es, ru, th, zh-CN, zh-TW) get a
  full translation of the new sentence; nine empty ones (ar, cs, fi, fr,
  ja, pt-BR, tr, uk, vi) fall back to the English string per the locale
  gap policy
2026-04-28 17:55:24 -04:00
gorkem-bwl bab65770ce refactor(status-page): simplify renderer lookup, extract route prefix, memoize wrapper style
Four small follow-ups from a code review pass:

1. Drop the redundant `?? THEMED_RENDERERS[DEFAULT_STATUS_PAGE_THEME]`
   fallback in Status/index.tsx — the map is now a total Record, so the
   first lookup cannot return undefined.
2. Extract the literal "/status/public" into PUBLIC_STATUS_PAGE_PREFIX in
   Types/StatusPage.ts and reuse from the three runtime call sites
   (Status/index.tsx, HeaderStatusPageControls.tsx, StatusPagesTable.tsx).
   The Routes/index.tsx route definition keeps its string literal — the
   route table is otherwise all literals and the prefix only matters when
   it's interpolated.
3. Delete two narrating comments above the public/admin branches in
   Status/index.tsx — the JSX is self-describing.
4. Move the inline `style` object construction in StatusPageThemeProvider
   into the existing useMemo so the wrapper div receives a stable style
   reference. The previous spread defeated the surrounding memoization on
   every render.
2026-04-28 16:52:16 -04:00
gorkem-bwl ed3645c3d0 refactor(status-page): drop legacy single-design renderer and CSS overrides
The 4 themed renderers (refined, modern, bold, editorial) cover every
StatusPageTheme value and the model defaults to "refined", so the legacy
fallback path in Status/index.tsx was unreachable in practice.

Removing it lets us delete:

- theme-overrides.css (the only remaining CSS file in StatusPage)
- StatusBar / MonitorsList / InfrastructureMetrics (only the legacy
  fallback consumed them)
- The .sp-* className hooks that wired CSS rules into the components

Pure sx via the per-theme styles.ts recipes is now the single source of
truth for every status page surface. Net diff is -801 / +42.

Status/index.tsx now resolves a renderer from the theme map (falling back
to the default theme) and keeps the public-route and admin-preview
branches it already had.
2026-04-28 16:38:35 -04:00
Alexander Holliday 0654e4adfa Merge pull request #3589 from bluewave-labs/feat/alert-on-initial-down
feat: incident on initializing && down
2026-04-28 10:14:54 -07:00
Alex Holliday 54c0e089a8 incident on initializing && down 2026-04-28 10:09:42 -07:00
Gorkem Cetin aee81a03fa Merge pull request #3588 from bluewave-labs/docs/contributing-redis-formatcheck
docs: align CONTRIBUTING.md and CLAUDE.md with current toolchain
2026-04-28 00:00:25 -04:00
gorkem-bwl 40bcf7f6ac docs: align CONTRIBUTING.md and CLAUDE.md with current toolchain
CONTRIBUTING.md: surface format-check + build in code guidelines
and the PR checklist so contributors run the same verifications
the project enforces locally.

CLAUDE.md: correct stale test framework reference (Mocha+Chai+Sinon
-> Jest) and update the test file glob to server/test/**/*.test.ts.
2026-04-27 23:50:01 -04:00
Gorkem Cetin e908d34b3a Remove sponsors section from README
Removed sponsorship section from README.
2026-04-27 23:39:22 -04:00
Gorkem Cetin a706e68c6a Remove documentation and translation improvement sections
Removed sections about improving documentation and translations from the contributing guidelines.
2026-04-27 23:38:37 -04:00
gorkem-bwl a2786a77ec refactor(status-page): tighten the theme-fields backend after second review
Apply review feedback:
- Extract makeStatusPage from the two test files that duplicated it (test/unit/services/statusPageService.test.ts and test/unit/controllers/statusPageThemeFlag.test.ts) into test/helpers/makeStatusPage.ts. Both files now import the shared helper, matching the existing test/helpers/ convention used for repository fakes and provider contracts
- Add a blank line in statusPageController.getStatusPageByUrl between the normalizedMonitors map and the themesEnabled lookup so the response-shaping section breathes a bit
- Drop the obsolete localStorage references from the plan doc that survived from an earlier draft of the design — the public page never persisted a per-visitor mode choice in localStorage, so the test bullet under 2.11 and the risk row both pointed at functionality that does not exist
2026-04-27 19:33:03 -04:00
gorkem-bwl a5af4e48df Merge remote-tracking branch 'origin/develop' into feat/status-page-themes-backend 2026-04-27 19:29:44 -04:00
gorkem-bwl d80d939bd8 test(status-page): cover theme-flag helpers and document remaining caveats
Address review follow-ups on PR #3568:

- Extract the controller's withoutThemeFields and applyDefaultTheme helpers from a private class field into a standalone module (controllers/statusPageThemeFlag.ts) so they can be tested without a controller harness; the controller is now a one-line consumer
- Add 9 unit tests covering both helpers (test/unit/controllers/statusPageThemeFlag.test.ts): no-mutation guarantees, partial inputs, all-fields-preserved verbatim, override-with-defaults semantics, and "page has no theme set yet" — the flag-off path is now exercised in both directions
- Expand the comment over withoutThemeFields to spell out why the strip is silent rather than a 400: this is an ops kill switch, not API semantics; clients shouldn't need to know whether themes are disabled today

Two caveats called out in review that this commit does NOT change:
- Mongoose `default:` directives only inject during hydration, not on partial updates. Legacy documents updated with `findOneAndUpdate` still won't have `theme` / `themeMode` written to Mongo. They render as defaults on read because the schema injects them — functionally equivalent, but the DB will hold a mix of present and absent fields indefinitely. Acceptable per the original commit intent.
- docs/plans/status-page-themes.md (304 lines) covers both backend and frontend work for the themes feature. It lives in this PR as the design artefact that drove both branches; moving it for purity would break the "where did this come from?" trail. Leaving it in.
2026-04-27 19:26:16 -04:00
gorkem-bwl 5874efff3c refactor(status-page): apply review feedback on theme-fields backend
- StatusPage Mongoose model: import DEFAULT_STATUS_PAGE_THEME / DEFAULT_STATUS_PAGE_THEME_MODE from types/statusPage so the four sources of the default literal stay locked together
- openapi.json: add explicit "default" alongside the enum on StatusPageTheme and StatusPageThemeMode component schemas; documentation already mentioned the defaults but the JSON didn't carry them
- statusPageController: add JSDoc-style comments above withoutThemeFields and applyDefaultTheme spelling out the asymmetric strip-on-write / force-on-read semantics so the next reviewer doesn't have to re-derive the design
2026-04-27 19:02:44 -04:00
gorkem-bwl ab9e722caa refactor(status-page): consolidate font stacks and pill recipes
Second /simplify pass picked up:
- pillHardware in refined and modern hardcoded blue hex codes (#60a5fa, #1d4ed8, #2563eb) while bold and editorial already used the green token system. Switch refined and modern to tokens.up / tokens.upSoft so all four themes are consistent and the public status page no longer leaks blue
- pill and pillHardware in refined and modern duplicated the shared 6 typography fields (fontSize, textTransform, letterSpacing, padding, borderRadius, fontWeight). Extract a pillBase constant per theme and spread, leaving only the colour deltas on each
- The 4 themes each defined their own font-stack constants. Consolidate them in themes/shared/fontStacks.ts (SANS_STACK, BOLD_SANS_STACK, SERIF_STACK, EDITORIAL_SECONDARY_SANS_STACK, MONO_STACK) and import. Drops 6 duplicated literal stacks
- modern dark-mode card hover used the light-mode shadow as a fallback. Add darkShadowHover and pick it up via cardHoverShadow when isDark
- refined and editorial styles factories silence their unused isDark parameter with `void isDark` instead of an underscore prefix; bold and modern still use it actively
2026-04-27 18:52:53 -04:00
gorkem-bwl 4f8f2f1422 refactor(status-page): tighten the bold/modern/editorial conversion
Apply review feedback:
- Editorial chartSwitch: drop the per-button isFirst flag and put the divider on the parent via "& > button + button { borderLeft }"; chartSwitchButton(active) now matches bold/modern's signature
- Drop the empty company: {} entries in bold/modern and the wrapper span around the company name; the parent brand row already styles it
- Editorial styles factory: keep the (tokens, isDark) signature for cross-theme uniformity but acknowledge the unused isDark with a void-statement instead of an underscore prefix
2026-04-27 18:45:45 -04:00
gorkem-bwl c6c50063d8 refactor(status-page): convert bold, modern and editorial themes to MUI sx
Finishes the migration started for refined. Each theme now consumes the same pattern:

- A typed *Styles interface that names every entry the page consumes (no `as Function` casts at the call site)
- A *Styles factory that takes (tokens, isDark) and returns sx recipes derived from existing tokens.ts plus the shared toneColor/toneSoft helpers in shared/overallStatus
- A useMemo around the factory call so MUI's sx processor sees referential equality across renders that don't change tokens
- modern.css's keyframes (pulse + staggered fade-in + reduced-motion guard) ported via @mui/system keyframes inside the modern styles factory
- bold.css's hero glow + conic logo + pseudo-elements ported as inline sx via "&::before" / "& > *" selectors
- editorial.css's serif typography + hairline dividers + flat rectangular badges ported as plain sx with the serif/sans family stacks named at module scope

Drop the four .css files (~1789 lines) along with the className= attributes and the import "./*.css" lines in each StatusPage component.

The shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure) lose their legacy classPrefix branch entirely now that no theme uses it. The dual-API gate is gone; there is one path.

Verified: tsc --noEmit clean, prettier clean.

Note: theme-overrides.css remains for the legacy non-themed status page components (StatusBar, MonitorsList, HeaderStatusPageControls). That's separate scope from the four-theme work the reviewer flagged; can be migrated in a follow-up.
2026-04-27 18:39:42 -04:00
gorkem-bwl b95d8886b9 refactor(status-page): tighten the refined-theme conversion
Apply review feedback:
- Type the styles factory's return as a named RefinedStyles interface so the consumer no longer needs `as Function` / `as any` casts on factory entries
- Lift toneColor / toneSoft to shared/overallStatus alongside the existing OverallTone type — the next three themes will need the same mapping
- Reuse heatLevel() in the legacy classPrefix branch of ThemedInfrastructure so both API paths share one threshold table
- Memoize the styles map in RefinedStatusPage so MUI's sx processor sees referential equality across renders that don't actually change tokens
- Make the dual-API gate in ThemedHeatmap / ThemedHistogram require ALL relevant sx props together so a partial migration can't silently fall back to the legacy class path
2026-04-27 18:30:04 -04:00
gorkem-bwl 163f5ec1ec refactor(status-page): convert refined theme to MUI sx + theme tokens
The reviewer's feedback on this PR is that the themes ship hardcoded class names against per-theme CSS files instead of following the project's MUI sx + theme.spacing()/palette pattern. This commit handles the refined theme as a proof of concept; the other three follow.

What changed
- New src/Pages/StatusPage/Status/themes/refined/styles.ts: a single function (tokens, isDark) -> sx recipes that mirror every rule in the deleted refined.css. All values are derived from the tokens already declared in tokens.ts so the colour/spacing source of truth doesn't change.
- RefinedStatusPage.tsx: drops the className= attributes and the import "./refined.css" in favour of sx={styles.x} drawn from the recipe map. Hardcoded class strings gone.
- refined.css: deleted (was 420 lines).
- Shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure): grew an opt-in sx-based API alongside the existing classPrefix one. The new API is what refined uses; bold / modern / editorial keep working unchanged on the legacy classPrefix path until they are migrated, after which the legacy code path is removed.

Verified
- tsc --noEmit clean
- prettier clean
- Pre-existing vite build error on this branch (flag-icons resolve, unrelated) is untouched

Next
- Convert the bold, modern and editorial themes the same way and remove the legacy classPrefix branch from the shared components.
2026-04-27 18:21:31 -04:00
Gorkem Cetin 959e2ff7b8 Merge pull request #3585 from bluewave-labs/chore/locale-completeness
chore(i18n): close translation gaps across all 16 locales
2026-04-27 11:24:38 -04:00
gorkem-bwl 2f8765cf3e chore(i18n): close translation gaps across all 16 locales
- Seed the 29 keys that were missing in every non-English locale (Pushover/Twilio config, register setup notice, public status-page status-bar messages, "service is down")
- Fill core UI strings (sidebar menu, buttons, table headers, password rules, image upload, auth footer) where they were left as empty values in the original translations
- Bring Spanish to 100% net coverage with a hand pass over remaining empties and same-as-English entries

Coverage net of empty/missing keys:
- 6 locales now at 100% (de, es, ru, th, zh-CN, zh-TW)
- All locales above 94% (worst: ar/fi at 94.8%, was 85.1% / 87.9%)
- Zero missing keys in any locale (was 29 each)

Some keys are intentionally same-as-English (HTTP, gRPC, Pagespeed, Docker etc.). Long-form copy in createMonitor descriptions and a few infrastructure stat labels remain empty in the lower-coverage locales for native review.
2026-04-27 11:21:48 -04:00
Aicha Lassoued 5a72a11ac2 (client): format code change 2026-04-26 18:40:04 +01:00
Aicha Lassoued 66418bf591 fix: prevent blank screen crash when clearing timezone search field 2026-04-26 18:25:53 +01:00
gorkem-bwl 944ee74a1d feat(status-page): four selectable themes for the public status page
Adds four fully-designed visual themes for the public status page
(refined | modern | bold | editorial) with a light/dark mode selector,
admin preview in a mock browser frame, and a shared theme provider
that keeps the component tree unified while letting each theme own
its visual identity.

## Selectable themes
- Refined — clean, flat cards, soft shadows, colored status dot.
- Modern — flat surfaces with a pulsing status dot and staggered
  fade-in cards.
- Bold — conic-gradient logo, 34px hero title with a check disc,
  subtle dual-radial glow on dark mode, accent-green hover border.
- Editorial — serif headings, narrow newspaper column, 'Service
  Report' eyebrow + title, flat rectangular badges and dateline.

## Architecture
- Single `StatusPageThemeProvider` hosts CSS custom-property tokens
  per theme and resolves light/dark based on admin-forced mode,
  falling back to `prefers-color-scheme` with a matchMedia listener.
- Each theme owns only its `*StatusPage.tsx` + scoped CSS file.
  The heatmap, histogram, and infrastructure renderers are shared
  across all four themes (parameterized by a `classPrefix`) to avoid
  the 12-file duplication the initial draft had.
- Renderer lookup table in `Status/index.tsx` replaces a 4-way
  ternary chain; unknown themes fall through to the legacy renderer.
- `resolveOverallStatus`, `statusBadgeKey`, `monoFirstChar`, and
  `monitorBadgeTone` are extracted to `themes/shared/overallStatus.tsx`.

## Admin UX
- `ThemePicker` uses the project's `RadioWithDescription` and
  `Select` components — one radio card per theme plus an Auto/
  Light-only/Dark-only mode dropdown.
- `BrowserFrame` wraps the preview in the admin panel with a mock
  chrome (traffic lights + URL bar) that adopts the status page's
  theme tokens. Edge-to-edge fill with no gray bleed.
- Public route renders the theme directly on the viewport with the
  document body painted to the theme background, reverted on unmount.

## Performance & polish
- Histogram's derived stats (padded array, max, avg, peak) are
  memoized on `checks`; `max` is reused for `peak` instead of
  computing twice.
- Editorial's dateline is wrapped in `useMemo` so the 10s poll
  doesn't rerender "April 23, 2026" as a changing string.
- Provider's `setSystemMode` reconciliation uses the updater form
  to avoid an extra render when the value is unchanged.
- `monoFirstChar` no longer uses a non-null assertion.

## Form plumbing
- `useStatusPageForm` defaults include `theme` + `themeMode`.
- `Validation/statusPage.ts` accepts both as optional zod enums.
- `Pages/StatusPage/Create/index.tsx` appends both to FormData on
  create and update.

## CLAUDE.md
- Dev port note: local client runs on 10001 (5173 is used by a
  different local project); server `CLIENT_HOST` is set to match.
2026-04-23 22:51:40 -04:00
gorkem-bwl ad964446de feat(status-page): add theme and themeMode fields (backend)
## Changes
- Add `theme` (refined | modern | bold | editorial, default "refined") and
  `themeMode` (auto | light | dark, default "auto") fields to the StatusPage
  Mongoose model and the shared `StatusPage` type.
- Extend the create/update Zod validator to accept both fields optionally.
- Map both fields in the repository's `toEntity` so GET responses include them.
- Document both fields in the create and update endpoints in `openapi.json`.
- Add `STATUS_PAGE_THEMES_ENABLED` env var (default `true`) wired through
  `SettingsService` for ops-level rollback. When disabled, the controller
  strips theme fields on write and forces defaults on read.

## Notes
- No migration needed; Mongoose applies defaults for legacy documents.
- Controller short-circuits to zero-cost when the feature flag is on.
- `DEFAULT_STATUS_PAGE_THEME` / `DEFAULT_STATUS_PAGE_THEME_MODE` constants
  exported so the frontend PR can consume the same values.
2026-04-23 22:45:50 -04:00
103 changed files with 6285 additions and 1502 deletions

No files matched your search

+17 -6
View File
@@ -12,20 +12,22 @@ Checkmate is an open-source uptime and infrastructure monitoring application. It
```bash
cd client
npm install
npm run dev # Start dev server at http://localhost:5173
npm run dev -- --port 10001 --strictPort # Local dev port is 10001 (5173 is used by another project on this machine)
npm run build # TypeScript check + production build
npm run lint # ESLint (strict, max-warnings 0)
npm run format # Prettier formatting
npm run format-check # Check formatting
```
Server `.env` on this machine is configured with `CLIENT_HOST="http://localhost:10001"` to match the client dev port. If you change the client port, keep `.env` in sync.
### Server (Node.js/Express)
```bash
cd server
npm install
npm run dev # Start with hot-reload (nodemon + tsx) at http://localhost:52345
npm run build # TypeScript compile + path alias resolution
npm run test # Run Mocha tests with c8 coverage
npm run test # Run Jest tests with coverage
npm run lint # ESLint v9
npm run lint-fix # Auto-fix lint issues
npm run format # Prettier formatting
@@ -157,6 +159,14 @@ When working on anything related to check scheduling, incident lifecycle, or not
## Code Conventions
### Frontend conventions (mandatory for `client/src`)
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
### Internationalization
All user-facing strings must use the translation function:
```javascript
@@ -174,12 +184,13 @@ t('your.key') // Never hardcode UI strings
- Both use ESLint with strict settings
### Testing
Server tests use Mocha + Chai + Sinon:
Server tests use Jest (with `--experimental-vm-modules` for ESM):
```bash
npm test # Run all tests with coverage
npm test -- --grep "pattern" # Run specific tests
npm test # Run all tests with coverage
npm test -- -t "pattern" # Run tests matching name pattern
npm test -- path/to/file.test.ts # Run a specific file
```
Test files: `server/tests/**/*.test.js`
Test files: `server/test/**/*.test.ts`
## Database Models
+2 -16
View File
@@ -255,20 +255,6 @@ 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?
Docs live in [checkmate-documentation](https://github.com/bluewave-labs/checkmate-documentation). You can fix typos, add guides, or explain features better.
### Help with translations?
We use [PoEditor](https://poeditor.com) for translations. You can:
- [Sign up and join your language team](https://poeditor.com/join/project/lRUoGZFCsJ).
- Translate UI strings.
- Ask questions on Discord in the relevant #translations channel.
Make sure all new UI strings in code use `t('key')`.
### Submit a pull request?
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
@@ -283,7 +269,7 @@ Follow the [pull request checklist](#pull-request-checklist). Your PR should:
## Code guidelines
- Use ESLint and Prettier (`npm run lint`).
- Use ESLint and Prettier. Run `npm run lint` and `npm run format-check` in both `client` and `server`. If `format-check` reports issues, fix them with `npm run format` before committing.
- Follow naming conventions: `camelCase` for variables, `PascalCase` for components, `UPPER_CASE` for constants.
- No hard-coded strings — use `t('your.key')` for everything visible.
- Use the shared theme and components. No magic numbers or hardcoded styles.
@@ -304,7 +290,7 @@ Before submitting your pull request, please confirm the following:
- You used the shared theme for any styling — no magic numbers or inline styles.
- The pull request addresses only one issue or topic.
- You added screenshots or a video for any UI-related changes.
- Your code passes linting and has no TypeScript errors.
- Your code passes `npm run lint`, `npm run format-check`, and `npm run build` in both `client` and `server` with no errors.
If one or more of these are missing, we may ask you to update your pull request before we can merge it.
+8 -26
View File
@@ -16,7 +16,10 @@
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
<br>
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
@@ -24,8 +27,6 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
## 📚 Table of contents
- [📦 Demo](#demo)
@@ -38,7 +39,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [🏗️ Tech stack](#tech-stack)
- [🔗 A few links](#a-few-links)
- [🤝 Contributing](#contributing)
- [💰 Our sponsors](#our-sponsors)
## Demo
@@ -89,30 +89,17 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Website monitoring
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
- Docker monitoring
- Ping monitoring
- SSL monitoring
- Port monitoring
- Game server monitoring (3.0)
- Incidents at a glance
- Status pages
- E-mail, Webhooks, Discord and Slack notifications
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
- 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
**Short term roadmap:**
- Plugins that will help Checkmate get any information from a remote service (e.g database, etc)
- Better notifications
- Network monitoring
- ..and a few more features
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
## Monitor Lifecycle
@@ -180,8 +167,3 @@ Here's how you can contribute:
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date)
## Our sponsors
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
+135
View File
@@ -1,3 +1,104 @@
// MUI sx keys that have a native (system) prop equivalent.
// Flagged when found inside an sx={{ ... }} object literal — see frontend-conventions.md rule #1.
const SX_NATIVE_EQUIVALENT_KEYS = [
"color",
"bgcolor",
"backgroundColor",
"borderRadius",
"border",
"borderTop",
"borderBottom",
"borderLeft",
"borderRight",
"width",
"height",
"minHeight",
"maxWidth",
"minWidth",
"maxHeight",
"p",
"pt",
"pb",
"px",
"py",
"pl",
"pr",
"m",
"mt",
"mb",
"mx",
"my",
"ml",
"mr",
"display",
"textAlign",
"fontWeight",
"fontSize",
"lineHeight",
"gap",
];
// MUI components that accept system props as top-level attributes.
// sx={{ color, p, mt, ... }} on these can be hoisted into native props.
// Dialog*, Drawer*, Menu*, Tab*, Toolbar etc. only accept sx — don't flag those.
const SX_HOISTABLE_COMPONENTS = [
"Box",
"Stack",
"Typography",
"Grid",
"Container",
"Paper",
"Card",
"CardContent",
"CardActions",
"CardHeader",
"Divider",
"Link",
"Chip",
];
const sxKeySelector = SX_HOISTABLE_COMPONENTS.flatMap((component) =>
SX_NATIVE_EQUIVALENT_KEYS.map(
(k) =>
`JSXOpeningElement[name.name='${component}'] > JSXAttribute[name.name='sx'] > JSXExpressionContainer > ObjectExpression > Property[key.name='${k}']`
)
).join(", ");
const FRONTEND_CONVENTION_RULES = {
// Rule #2 — color/bgcolor/borderColor as a string shorthand like "text.secondary".
// Catches: <Typography color="text.secondary" /> — should be color={theme.palette.text.secondary}.
"no-restricted-syntax": [
"warn",
{
selector:
"JSXAttribute[name.name=/^(color|bgcolor|borderColor)$/] > Literal[value=/\\./]",
message:
'Use the full theme path instead of a string shorthand. e.g. color={theme.palette.text.secondary} not color="text.secondary". See docs/frontend-conventions.md rule #2.',
},
{
// Rule #1 — sx={{ key: ... }} where key has a native prop equivalent.
selector: sxKeySelector,
message:
"Hoist this key out of `sx` onto the component as a native MUI prop (e.g. color={...}, mt={...}, width={...}). See docs/frontend-conventions.md rule #1.",
},
{
// Rule #4 — importing the singleton theme from the Theme module.
// Catches: import { theme } from "@/Utils/Theme/Theme" — should be useTheme() inside the component.
selector:
"ImportDeclaration[source.value='@/Utils/Theme/Theme'] > ImportSpecifier[imported.name='theme']",
message:
"Don't import { theme } from @/Utils/Theme/Theme — use the useTheme() hook inside the component. See docs/frontend-conventions.md rule #4.",
},
{
// Rule #3 (subset) — Typography fontSize={13} is the body default; drop it.
selector:
"JSXOpeningElement[name.name='Typography'] JSXAttribute[name.name='fontSize'] > JSXExpressionContainer > Literal[value=13]",
message:
"fontSize={13} is the Typography body default — drop the prop. For other sizes use typographyLevels tokens. See docs/frontend-conventions.md rule #3.",
},
],
};
module.exports = {
root: true,
env: { browser: true, es2020: true },
@@ -15,9 +116,43 @@ module.exports = {
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
...FRONTEND_CONVENTION_RULES,
},
globals: {
__APP_VERSION__: "readonly",
process: "readonly",
},
overrides: [
{
files: ["**/*.ts", "**/*.tsx"],
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
],
plugins: ["@typescript-eslint", "react-refresh"],
rules: {
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// TS handles these; eslint duplicates create noise on existing files.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-non-null-assertion": "off",
...FRONTEND_CONVENTION_RULES,
},
},
],
};
+446 -105
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.6.1",
"version": "3.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.6.1",
"version": "3.7.1",
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
@@ -49,6 +49,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
@@ -1633,9 +1635,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@@ -1646,9 +1648,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -1659,9 +1661,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -1672,9 +1674,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -1685,9 +1687,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@@ -1698,9 +1700,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -1711,9 +1713,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -1724,9 +1726,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@@ -1737,9 +1739,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -1750,9 +1752,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -1763,9 +1765,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
@@ -1776,9 +1778,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@@ -1789,9 +1791,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
@@ -1802,9 +1804,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@@ -1815,9 +1817,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@@ -1828,9 +1830,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@@ -1841,9 +1843,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -1854,9 +1856,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -1867,9 +1869,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -1880,9 +1882,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
@@ -1893,9 +1895,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -1906,9 +1908,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -1919,9 +1921,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@@ -1932,9 +1934,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -1945,9 +1947,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@@ -2350,6 +2352,238 @@
"version": "0.0.6",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
"integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/type-utils": "7.18.0",
"@typescript-eslint/utils": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/typescript-estree": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
"integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
"integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.18.0",
"@typescript-eslint/utils": "7.18.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
"integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
"integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/visitor-keys": "7.18.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
"integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
"@typescript-eslint/typescript-estree": "7.18.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
"integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.18.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"dev": true,
@@ -2557,6 +2791,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"dev": true,
@@ -3272,6 +3516,19 @@
"node": ">=6"
}
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"dev": true,
@@ -3814,6 +4071,36 @@
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"dev": true,
@@ -4170,6 +4457,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -5137,6 +5445,16 @@
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -6101,9 +6419,9 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6116,31 +6434,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@@ -6393,6 +6711,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -6793,6 +7121,19 @@
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
"integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"license": "0BSD"
+5 -2
View File
@@ -1,13 +1,14 @@
{
"name": "client",
"private": true,
"version": "3.6.1",
"version": "3.7.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives",
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check ."
@@ -57,6 +58,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
@@ -9,13 +9,14 @@ import {
EmptyMonitorFallback,
} from "@/Components/design-elements/Fallback";
import { EmptyState } from "@/Components/design-elements/EmptyState";
import { NoticeBanner } from "@/Components/design-elements/NoticeBanner";
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
import { PageHeader } from "@/Components/design-elements/PageHeader";
import CircularProgress from "@mui/material/CircularProgress";
import { LanguageSelector, SwitchTheme } from "@/Components/inputs";
import type { StackProps } from "@mui/material/Stack";
import { useTheme, alpha } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { Typography } from "@mui/material";
@@ -23,60 +24,30 @@ import { Typography } from "@mui/material";
export const PageSpeedKeyPriorityFallback = () => {
const theme = useTheme();
const { t } = useTranslation();
const alertBg = alpha(theme.palette.warning.main, 0.08);
const alertBorder = alpha(theme.palette.warning.main, 0.45);
const alertIcon = theme.palette.warning.main;
return (
<EmptyState
fullscreen
title={t("pages.pageSpeed.fallback.title")}
description={t("pages.pageSpeed.fallback.description")}
alert={
<Stack
direction="row"
alignItems="flex-start"
gap={theme.spacing(LAYOUT.SM)}
sx={{
width: "100%",
p: theme.spacing(LAYOUT.MD),
borderRadius: 1,
border: `1px solid ${alertBorder}`,
backgroundColor: alertBg,
textAlign: "left",
}}
>
<Box
component="span"
sx={{
color: alertIcon,
fontSize: 18,
lineHeight: 1,
mt: "2px",
<NoticeBanner severity="warning">
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
color={theme.palette.primary.main}
fontWeight={600}
sx={{
textDecoration: "underline",
}}
/>
),
}}
>
⚠
</Box>
<Typography
sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}
>
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
sx={{
color: theme.palette.primary.main,
fontWeight: 600,
textDecoration: "underline",
}}
/>
),
}}
/>
</Typography>
</Stack>
/>
</NoticeBanner>
}
/>
);
@@ -17,7 +17,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
return (
<Stack>
<Typography
fontSize={typographyLevels.xl}
fontSize={typographyLevels.xxl}
fontWeight={500}
overflow={"hidden"}
textOverflow={"ellipsis"}
@@ -0,0 +1,55 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme, alpha } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
export const Severities = ["info", "warning", "error"] as const;
export type Severity = (typeof Severities)[number];
const SEVERITY_GLYPH: Record<Severity, string> = {
info: "ⓘ",
warning: "⚠",
error: "✕",
};
interface NoticeBannerProps {
severity?: Severity;
children: React.ReactNode;
}
export const NoticeBanner = ({ severity = "info", children }: NoticeBannerProps) => {
const theme = useTheme();
const tone = theme.palette[severity].main;
return (
<Stack
direction="row"
alignItems="flex-start"
gap={theme.spacing(LAYOUT.SM)}
width={"100%"}
p={theme.spacing(LAYOUT.MD)}
borderRadius={theme.shape.borderRadius}
border={`1px solid ${alpha(tone, 0.45)}`}
bgcolor={alpha(tone, 0.08)}
textAlign={"left"}
>
<Box
component="span"
color={tone}
fontSize={typographyLevels.xl}
lineHeight={1}
mt={LAYOUT.XXS}
aria-hidden
>
{SEVERITY_GLYPH[severity]}
</Box>
<Typography
color={theme.palette.text.primary}
lineHeight={1.55}
>
{children}
</Typography>
</Stack>
);
};
@@ -53,7 +53,7 @@ export const ConfigBox = ({
rightContent,
}: {
title: string;
subtitle: string;
subtitle: React.ReactNode;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
@@ -22,5 +22,6 @@ export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
+31 -9
View File
@@ -1,19 +1,43 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme } from "@mui/material/styles";
import { useTheme, darken } from "@mui/material/styles";
import { HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
"secondary",
"error",
"warning",
"info",
"success",
] as const;
type PaletteColor = (typeof PALETTE_COLORS)[number];
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
const theme = useTheme();
const outlinedSx =
const hoverOverrides: Record<string, unknown> = { boxShadow: "none" };
if (props.variant === "outlined") {
hoverOverrides.borderColor = theme.palette.text.secondary;
}
if (props.variant === "contained") {
const colorKey = (props.color ?? "primary") as PaletteColor;
const palette = theme.palette[colorKey];
if (palette && typeof palette === "object" && "main" in palette) {
hoverOverrides.backgroundColor = darken(palette.main, HOVER.DARKEN);
}
}
const variantSx =
props.variant === "outlined"
? {
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
"&:hover": {
borderColor: theme.palette.text.secondary,
},
}
: {};
return (
<Button
{...props}
@@ -26,10 +50,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
"&:hover": {
boxShadow: "none",
},
...outlinedSx,
"&:hover": hoverOverrides,
...variantSx,
...sx,
}}
/>
+45 -12
View File
@@ -2,10 +2,12 @@ import Dialog from "@mui/material/Dialog";
import type { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { Button } from "@/Components/inputs";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
@@ -16,7 +18,6 @@ export const DialogInput = ({
onConfirm,
onCancel,
confirmColor = "primary",
cancelColor = "error",
loading = false,
cancelText,
confirmText,
@@ -31,7 +32,6 @@ export const DialogInput = ({
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
@@ -41,6 +41,7 @@ export const DialogInput = ({
additionalButtons?: ReactNode;
}) => {
const { t } = useTranslation();
const theme = useTheme();
return (
<Dialog
open={open}
@@ -50,16 +51,48 @@ export const DialogInput = ({
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
}}
>
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
<DialogContent>
{content && <DialogContentText>{content}</DialogContentText>}
{children}
</DialogContent>
<DialogActions>
{title && (
<DialogTitle
pb={theme.spacing(LAYOUT.XS)}
bgcolor={theme.palette.action.hover}
borderBottom={`1px solid ${theme.palette.divider}`}
>
<Typography
component="span"
variant="h2"
fontWeight={600}
display={"block"}
>
{title}
</Typography>
{content && (
<Typography
variant="body1"
color={theme.palette.text.secondary}
mt={theme.spacing(LAYOUT.XS)}
display={"block"}
>
{content}
</Typography>
)}
</DialogTitle>
)}
{children && (
<DialogContent sx={{ pt: theme.spacing(LAYOUT.MD) }}>
<Box pt={theme.spacing(LAYOUT.XS)}>{children}</Box>
</DialogContent>
)}
<DialogActions
sx={{
p: theme.spacing(LAYOUT.MD),
pt: theme.spacing(LAYOUT.SM),
backgroundColor: theme.palette.action.hover,
borderTop: `1px solid ${theme.palette.divider}`,
}}
>
<Button
loading={loading}
variant="contained"
color={cancelColor}
variant="outlined"
onClick={onCancel}
>
{cancelText ?? t("common.buttons.cancel")}
+1 -1
View File
@@ -15,9 +15,9 @@ export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelPr
return (
<Typography
component="label"
variant="body1"
htmlFor={htmlFor}
sx={{
fontSize: "14px",
fontWeight: 500,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(2),
+35 -28
View File
@@ -5,6 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
@@ -12,47 +13,53 @@ interface TextInputProps extends Omit<TextFieldProps, "label"> {
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ fieldLabel, required, ...props },
{ fieldLabel, required, sx, ...props },
ref
) {
const theme = useTheme();
const innerSx = {
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
},
};
const input = (
<TextField
{...props}
inputRef={ref}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
},
}}
sx={fieldLabel ? innerSx : { ...innerSx, ...sx }}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<Stack
spacing={theme.spacing(LAYOUT.XXS)}
sx={sx}
>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{input}
</Stack>
+3 -1
View File
@@ -89,7 +89,9 @@ const uiSlice = createSlice({
},
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
state.timezone = action.payload.timezone;
if (action.payload.timezone) {
state.timezone = action.payload.timezone;
}
},
setLanguage: (state, action: PayloadAction<string>) => {
state.language = action.payload;
+1
View File
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailHost: data?.systemEmailHost || "",
systemEmailUser: data?.systemEmailUser || "",
systemEmailAddress: data?.systemEmailAddress || "",
systemEmailDisplayName: data?.systemEmailDisplayName || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
+6
View File
@@ -1,6 +1,10 @@
import { useMemo } from "react";
import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
import type { StatusPage } from "@/Types/StatusPage";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
interface UseStatusPageFormOptions {
@@ -36,6 +40,8 @@ export const useStatusPageForm = ({
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME,
themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE,
logo: transformLogo(data?.logo),
};
@@ -9,6 +9,7 @@ import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
interface AddTeamMemberDialogProps {
open: boolean;
@@ -82,38 +83,40 @@ export const AddTeamMemberDialog = ({
maxWidth="sm"
fullWidth
>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<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 ?? ""}
fullWidth
/>
)}
/>
<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 ?? ""}
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}
@@ -151,36 +154,41 @@ export const AddTeamMemberDialog = ({
</Select>
)}
/>
<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 ?? ""}
fullWidth
/>
)}
/>
<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 ?? ""}
fullWidth
/>
)}
/>
<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 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
);
@@ -9,6 +9,7 @@ import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
@@ -95,10 +96,7 @@ export const InviteTeamMemberDialog = ({
</Button>
}
>
<Stack
gap={theme.spacing(4)}
mt={theme.spacing(4)}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Controller
name="email"
control={control}
+14 -1
View File
@@ -359,7 +359,20 @@ const CreateMonitorPage = () => {
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
subtitle={
<Trans
i18nKey={`pages.createMonitor.form.general.description.${watchedType}`}
components={{
gamedigLink: (
<Link
href="https://github.com/gamedig/node-gamedig/blob/master/GAMES_LIST.md"
target="_blank"
rel="noopener noreferrer"
/>
),
}}
/>
}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
{/* URL/Host/Container field - not shown for hardware */}
@@ -20,6 +20,24 @@ interface CardDetailsProps {
sx?: object;
}
const SectionHeading = ({ children }: { children: React.ReactNode }) => {
const theme = useTheme();
return (
<Typography
component="h2"
variant="eyebrow"
color={theme.palette.text.secondary}
>
{children}
</Typography>
);
};
const Cell = ({ children }: { children: React.ReactNode }) => (
<Typography variant="body1">{children}</Typography>
);
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -33,22 +51,18 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
gap={theme.spacing(LAYOUT.MD)}
sx={sx}
>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")}
</Typography>
<SectionHeading>{t("pages.incidents.dialog.details.title")}</SectionHeading>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")}
</Typography>
<Divider />
<SectionHeading>{t("pages.incidents.dialog.details.overview")}</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.status")}</Cell>
</Grid>
<Grid size={10}>
<ValueLabel
value={incident.status ? "negative" : "positive"}
@@ -61,15 +75,17 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Grid>
{monitor && (
<>
<Grid size={2}>{t("pages.incidents.dialog.details.monitor")}</Grid>
<Grid size={2}>
<Cell>{t("pages.incidents.dialog.details.monitor")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{monitor.name ?? "N/A"}</Typography>
<Cell>{monitor.name ?? "N/A"}</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.url")}</Typography>
<Cell>{t("pages.incidents.dialog.details.url")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{monitor.url ?? "N/A"}</Typography>
<Cell>{monitor.url ?? "N/A"}</Cell>
</Grid>
</>
)}
@@ -78,51 +94,48 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</BaseBox>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")}
</Typography>
<Divider />
<SectionHeading>{t("pages.incidents.dialog.details.analysis")}</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
<Cell>{t("pages.incidents.dialog.details.timeline")}</Cell>
</Grid>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.detailsLabel")}</Typography>
<Cell>{t("pages.incidents.dialog.details.detailsLabel")}</Cell>
</Grid>
<Grid size={6}>
<Divider></Divider>
<Divider />
</Grid>
<Grid size={6}>
<Divider></Divider>
<Divider />
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.startedAt")}</Typography>
<Cell>{t("pages.incidents.dialog.details.startedAt")}</Cell>
</Grid>
<Grid size={4}>
<Typography>
<Cell>
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
</Typography>
</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.statusCode")}</Typography>
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{incident.statusCode ?? "N/A"}</Typography>
<Cell>{incident.statusCode ?? "N/A"}</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.downtime")}</Typography>
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{getIncidentsDuration(incident)}</Typography>
<Cell>{getIncidentsDuration(incident)}</Cell>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.message")}</Typography>
<Cell>{t("pages.incidents.dialog.details.message")}</Cell>
</Grid>
<Grid size={4}>
<Typography>{incident.message ?? "N/A"}</Typography>
<Cell>{incident.message ?? "N/A"}</Cell>
</Grid>
</Grid>
</Stack>
@@ -130,60 +143,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
{!incident.status && (
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Typography textTransform={"uppercase"}>
<SectionHeading>
{t("pages.incidents.dialog.details.resolutionDetails")}
</Typography>
<Divider />
</SectionHeading>
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.resolvedAt")}</Typography>
<Cell>{t("pages.incidents.dialog.details.resolvedAt")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
<Cell>
{incident.endTime
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
: "N/A"}
</Typography>
</Cell>
</Grid>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolutionType")}
</Typography>
<Cell>{t("pages.incidents.dialog.details.resolutionType")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
<Cell>
{incident.resolutionType
? t(
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
)
: "N/A"}
</Typography>
</Cell>
</Grid>
{incident.resolvedBy && (
<>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolvedBy")}
</Typography>
<Cell>{t("pages.incidents.dialog.details.resolvedBy")}</Cell>
</Grid>
<Grid size={10}>
<Typography>
{incident.resolvedByEmail ?? incident.resolvedBy}
</Typography>
<Cell>{incident.resolvedByEmail ?? incident.resolvedBy}</Cell>
</Grid>
</>
)}
{incident.comment && (
<>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.comment")}</Typography>
<Cell>{t("pages.incidents.dialog.details.comment")}</Cell>
</Grid>
<Grid size={10}>
<Typography>{incident.comment}</Typography>
<Cell>{incident.comment}</Cell>
</Grid>
</>
)}
@@ -1,8 +1,7 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { BaseBox, Icon, ValueLabel } from "@/Components/design-elements";
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
import Box from "@mui/material/Box";
@@ -25,6 +24,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(2)}
minHeight={32}
>
<Stack
direction="row"
@@ -32,10 +32,10 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
gap={theme.spacing(2)}
>
{icon}
<Typography variant="body2">{label}</Typography>
<Typography variant="body1">{label}</Typography>
</Stack>
<Typography
variant="body2"
variant="body1"
fontWeight={600}
>
{value}
@@ -67,15 +67,11 @@ export const SummaryCard = ({
>
<Typography
component="h2"
sx={{
textTransform: "uppercase",
fontWeight: 500,
fontSize: 13,
}}
variant="eyebrow"
color={theme.palette.text.secondary}
>
{title}
</Typography>
<Divider />
{children}
</Stack>
</BaseBox>
@@ -97,10 +93,13 @@ export const SummaryCardActiveIncidents = ({
const activeCount = summary.totalActive;
const hasActive = activeCount > 0;
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
const icon = hasActive ? (
<TriangleAlert color={color} />
) : (
<CircleCheck color={color} />
const icon = (
<Box sx={{ color, display: "inline-flex" }}>
<Icon
icon={hasActive ? TriangleAlert : CircleCheck}
size={32}
/>
</Box>
);
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
count: activeCount,
@@ -133,10 +132,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
container
alignItems="center"
spacing={2}
sx={{
width: "100%",
py: theme.spacing(0.5),
}}
sx={{ width: "100%", minHeight: 32 }}
>
<Grid
size={{ xs: 12, lg: 5 }}
@@ -147,7 +143,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
<Globe />
<Icon icon={Globe} />
<Typography
variant="body1"
fontWeight={500}
@@ -159,9 +155,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
<Grid
size={{ xs: 12, md: 6, lg: 3 }}
sx={{
display: "flex",
}}
sx={{ display: "flex" }}
>
<ValueLabel
value={incident.status ? "negative" : "positive"}
@@ -173,7 +167,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
size={{ xs: 12, md: 6, lg: 4 }}
sx={{
textAlign: { xs: "left", md: "right" },
fontWeight: 500,
fontWeight: 600,
}}
>
<Typography variant="body1">{duration}</Typography>
@@ -190,22 +184,17 @@ export const SummaryCardLatestIncidents = ({
summary,
}: SummaryCardLatestIncidentsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const latestIncidents = summary?.latestIncidents ?? [];
return (
<SummaryCard title={t("pages.incidents.summaryCard.latestIncidents.title")}>
<Stack gap={theme.spacing(4)}>
{latestIncidents.slice(0, 3).map((incident, index) => (
<Box key={incident.id}>
<SummaryIncidentItem incident={incident} />
{index < latestIncidents.length - 1 && (
<Divider sx={{ mt: theme.spacing(2) }} />
)}
</Box>
))}
</Stack>
{latestIncidents.slice(0, 3).map((incident) => (
<SummaryIncidentItem
key={incident.id}
incident={incident}
/>
))}
</SummaryCard>
);
};
@@ -224,17 +213,17 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
return (
<SummaryCard title={t("pages.incidents.summaryCard.incidentStats.title")}>
<SummaryItem
icon={<Bell size={18} />}
icon={<Icon icon={Bell} />}
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
value={summary?.total || 0}
/>
<SummaryItem
icon={<TriangleAlert size={18} />}
icon={<Icon icon={TriangleAlert} />}
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
value={mostAffected}
/>
<SummaryItem
icon={<Wrench size={18} />}
icon={<Icon icon={Wrench} />}
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
value={
summary.total > 0
@@ -75,7 +75,7 @@ export const ControlsIncidentFilter = ({
variant="contained"
onClick={onClearFilters}
>
{t("pages.incidents.filters.clearFilters")}
{t("common.buttons.clearFilters")}
</Button>
)}
</Stack>
@@ -1,9 +1,7 @@
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import { Dialog, TextField } from "@/Components/inputs";
import { usePut } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface DialogResolutionProps {
open: boolean;
@@ -19,7 +17,6 @@ export const DialogResolution = ({
onResolved,
}: DialogResolutionProps) => {
const { t } = useTranslation();
const theme = useTheme();
const [comment, setComment] = useState("");
const { put: resolveIncident, loading: isResolving } = usePut();
@@ -50,22 +47,19 @@ export const DialogResolution = ({
onCancel={handleCancel}
onConfirm={handleConfirm}
confirmColor="error"
cancelColor="primary"
loading={isResolving}
maxWidth="sm"
fullWidth
>
<Box sx={{ mt: theme.spacing(4) }}>
<TextField
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
placeholder={t(
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
)}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
/>
</Box>
<TextField
fieldLabel={t("pages.incidents.dialog.resolveIncident.option.comment.label")}
placeholder={t(
"pages.incidents.dialog.resolveIncident.option.comment.placeholder"
)}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
/>
</Dialog>
);
};
+7 -4
View File
@@ -15,6 +15,7 @@ import { HeaderTimeRange } from "@/Components/common";
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
@@ -186,8 +187,9 @@ const IncidentsPage = () => {
{activeIncidentsCount > 0 && (
<>
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
variant="eyebrow"
color={theme.palette.text.secondary}
mb={theme.spacing(LAYOUT.XS)}
>
{t("pages.incidents.table.activeIncidents")}
</Typography>
@@ -208,8 +210,9 @@ const IncidentsPage = () => {
)}
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
variant="eyebrow"
color={theme.palette.text.secondary}
mb={theme.spacing(LAYOUT.XS)}
>
{t("pages.incidents.table.resolvedIncidents")}
</Typography>
@@ -1,4 +1,6 @@
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { NoticeBanner } from "@/Components/design-elements";
import { HistogramInfrastructure } from "@/Components/monitors";
import { useTranslation } from "react-i18next";
@@ -6,6 +8,7 @@ import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
@@ -60,6 +63,11 @@ const getChartConfigs = (
return configs;
};
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
checks.some((c) =>
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
);
export const InfraNetworkCharts = ({
checks,
dateRange,
@@ -74,35 +82,43 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
return (
<Grid
container
spacing={theme.spacing(8)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
checks={checks}
xKey="bucketDate"
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={formatBytesToMB}
/>
</Grid>
);
})}
</Grid>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{showNoTrafficNotice && (
<NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.netNoTraffic")}
</NoticeBanner>
)}
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
>
{chartConfigs.map((config) => {
return (
<Grid
size={isSmall ? 12 : 6}
key={`${config.type}-${config.interfaceName ?? config.idx ?? ""}`}
>
<HistogramInfrastructure
dateRange={dateRange}
title={config.title}
type={config.type}
idx={config.idx}
checks={checks}
xKey="bucketDate"
dataKeys={config.dataKeys}
gradient={true}
gradientStartColor={config.gradientStartColor}
gradientEndColor="#ffffff"
strokeColor={config.strokeColor}
yAxisFormatter={formatBytesToMB}
/>
</Grid>
);
})}
</Grid>
</Stack>
);
};
+89 -41
View File
@@ -128,8 +128,8 @@ export const SettingsPage = () => {
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
const handleTimezoneChange = (newValue: Timezone | null) => {
const newId = newValue?.id ?? "";
dispatch(setTimezone({ timezone: newId }));
if (!newValue?.id) return;
dispatch(setTimezone({ timezone: newValue.id }));
};
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
@@ -182,6 +182,9 @@ export const SettingsPage = () => {
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
...(formValues.systemEmailDisplayName && {
systemEmailDisplayName: formValues.systemEmailDisplayName,
}),
...(formValues.systemEmailTLSServername && {
systemEmailTLSServername: formValues.systemEmailTLSServername,
}),
@@ -577,44 +580,65 @@ export const SettingsPage = () => {
href="https://nodemailer.com/smtp/"
target="_blank"
/>
<Box
component="pre"
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
p: 2,
borderRadius: 1,
overflow: "auto",
backgroundColor: theme.palette.action.hover,
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user:
form.watch("systemEmailUser") ||
form.watch("systemEmailAddress") ||
"",
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{(() => {
const address = form.watch("systemEmailAddress") || "";
const displayName = form.watch("systemEmailDisplayName")?.trim();
return (
<>
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: form.watch("systemEmailHost") || "",
port: form.watch("systemEmailPort") || "",
secure: form.watch("systemEmailSecure") ?? false,
auth: {
user: form.watch("systemEmailUser") || address,
pass: "<your_password>",
},
name: form.watch("systemEmailConnectionHost") || "localhost",
pool: form.watch("systemEmailPool") ?? false,
tls: {
rejectUnauthorized:
form.watch("systemEmailRejectUnauthorized") ?? true,
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
servername: form.watch("systemEmailTLSServername") || "",
},
},
null,
2
)}
</code>
</Box>
{address && (
<Box
component="pre"
p={2}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "auto",
}}
>
<code>
{`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
</code>
</Box>
)}
</>
);
})()}
</Stack>
}
rightContent={
@@ -680,6 +704,24 @@ export const SettingsPage = () => {
)}
/>
{/* Email Display Name (Optional) */}
<Controller
name="systemEmailDisplayName"
control={form.control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t("pages.settings.form.email.option.displayName.label")}
placeholder={t(
"pages.settings.form.email.option.displayName.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{/* Email User (Optional) */}
<Controller
name="systemEmailUser"
@@ -974,7 +1016,13 @@ export const SettingsPage = () => {
setIsDemoMonitorsDialogOpen(false);
}}
loading={isDeletingAllMonitors}
/>
confirmColor="error"
confirmText={t("common.buttons.removeMonitors")}
>
<Typography variant="body1">
{t("pages.settings.form.removeMonitors.dialog.paragraph")}
</Typography>
</Dialog>
<Stack
direction="row"
@@ -0,0 +1,66 @@
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import RadioGroup from "@mui/material/RadioGroup";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material/styles";
import { RadioWithDescription, Select } from "@/Components/inputs";
import {
STATUS_PAGE_THEMES,
STATUS_PAGE_THEME_MODES,
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
interface Props {
theme: StatusPageTheme;
themeMode: StatusPageThemeMode;
onThemeChange: (value: StatusPageTheme) => void;
onThemeModeChange: (value: StatusPageThemeMode) => void;
}
export const ThemePicker = ({
theme,
themeMode,
onThemeChange,
onThemeModeChange,
}: Props) => {
const { t } = useTranslation();
const muiTheme = useTheme();
return (
<Stack spacing={muiTheme.spacing(6)}>
<RadioGroup
value={theme}
onChange={(_, value) => onThemeChange(value as StatusPageTheme)}
>
<Stack spacing={muiTheme.spacing(4)}>
{STATUS_PAGE_THEMES.map((option) => (
<RadioWithDescription
key={option}
value={option}
label={t(`pages.statusPages.form.theme.options.${option}.name`)}
description={t(
`pages.statusPages.form.theme.options.${option}.description`
)}
/>
))}
</Stack>
</RadioGroup>
<Select
fieldLabel={t("pages.statusPages.form.themeMode.label")}
value={themeMode}
onChange={(e) => onThemeModeChange(e.target.value as StatusPageThemeMode)}
>
{STATUS_PAGE_THEME_MODES.map((mode) => (
<MenuItem
key={mode}
value={mode}
>
{t(`pages.statusPages.form.themeMode.${mode}`)}
</MenuItem>
))}
</Select>
</Stack>
);
};
@@ -34,6 +34,7 @@ import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
const monitorsUrl = (() => {
const params = new URLSearchParams();
@@ -139,6 +140,8 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -438,6 +441,30 @@ const CreateStatusPage = () => {
</Stack>
}
/>
<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}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
@@ -8,7 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import type { StatusPage } from "@/Types/StatusPage";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
`/status/public/${statusPage.url}`,
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
@@ -1,246 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import type { Monitor } from "@/Types/Monitor";
import Grid from "@mui/material/Grid";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
const PERCENTAGE_MULTIPLIER = 100;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricDetail {
label: string;
value: string;
}
interface MetricConfig {
key: string;
label: string;
hasData: boolean;
progress: number;
details: MetricDetail[];
}
const MetricDetailRow = ({ label, value }: MetricDetail) => {
const theme = useTheme();
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Stack>
);
};
interface MetricItemProps {
label: string;
progress: number;
details?: MetricDetail[];
}
const MetricItem = ({ label, progress, details }: MetricItemProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Grid
size={isSmall ? 12 : 4}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: theme.spacing(SPACING.LG),
padding: theme.spacing(LAYOUT.XS),
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
/>
<Typography variant="body2">{label}</Typography>
</Box>
{details && details.length > 0 && (
<Box
width="100%"
paddingX={theme.spacing(LAYOUT.LG)}
>
{details.map((detail) => (
<MetricDetailRow
key={detail.label}
label={detail.label}
value={detail.value}
/>
))}
</Box>
)}
</Grid>
);
};
type LatestCheck = NonNullable<Monitor["recentChecks"]>[number];
const buildCpuMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
return null;
}
const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
return {
key: "cpu",
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
hasData: true,
progress: usagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.usage"),
value: `${usagePercent.toFixed(2)}%`,
},
],
};
};
const buildMemoryMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (
!check.memory ||
typeof check.memory.usage_percent !== "number" ||
typeof check.memory.used_bytes !== "number" ||
typeof check.memory.total_bytes !== "number"
) {
return null;
}
return {
key: "memory",
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
hasData: true,
progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(check.memory.used_bytes ?? 0),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(check.memory.total_bytes ?? 0),
},
],
};
};
const buildDiskMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.disk || check.disk.length === 0) {
return null;
}
const disks = check.disk;
const avgUsagePercent =
(disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
PERCENTAGE_MULTIPLIER;
const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
return {
key: "disk",
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
hasData: true,
progress: avgUsagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(totalUsedBytes),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(totalTotalBytes),
},
],
};
};
export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics: MetricConfig[] = [
buildCpuMetric(latestCheck, t),
buildMemoryMetric(latestCheck, t),
buildDiskMetric(latestCheck, t),
].filter((m): m is MetricConfig => m !== null);
if (metrics.length === 0) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
>
{metrics.map(({ key, label, progress, details }) => (
<MetricItem
key={key}
label={label}
progress={progress}
details={details}
/>
))}
</Grid>
);
};
@@ -1,171 +0,0 @@
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useState } from "react";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MonitorsListProps {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
borderRadius: theme.shape.borderRadius,
};
};
const MonitorHeader = ({
monitor,
showURL,
}: {
monitor: StatusPageMonitor;
showURL: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
mb={theme.spacing(LAYOUT.XS)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.LG)}
mb={theme.spacing(SPACING.SM)}
>
<Typography
variant="h6"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
</Typography>
</Stack>
{showURL && monitor.url && (
<Typography
variant="body2"
color={theme.palette.text.secondary}
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
);
};
const MonitorContent = ({
monitor,
statusPage,
chartType,
}: {
monitor: StatusPageMonitor;
statusPage: StatusPage;
chartType: string;
}) => {
const theme = useTheme();
if (monitor.type === "hardware") {
if (statusPage.showInfrastructure === false) return null;
return <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
return (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
checks={checks}
/>
) : (
<HeatmapResponseTime checks={checks} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme();
const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL);
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{statusPage.showCharts && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent
dualOption
value={chartType}
checked={chartType === "histogram"}
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
/>
<Typography>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Typography>
</Stack>
)}
{monitors.map((monitor) => (
<BaseBox
key={monitor.id}
padding={theme.spacing(LAYOUT.MD)}
>
<MonitorHeader
monitor={monitor}
showURL={showURL}
/>
<MonitorContent
monitor={monitor}
statusPage={statusPage}
chartType={chartType}
/>
</BaseBox>
))}
</Stack>
);
};
@@ -1,138 +0,0 @@
import {
AlertTriangle,
CircleCheck,
CircleX,
Loader,
PauseCircle,
ShieldAlert,
Wrench,
} from "lucide-react";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import type { Theme } from "@mui/material";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
interface StatusDisplay {
icon: JSX.Element;
msg?: string;
color?: string;
}
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const monitorsStatus: StatusDisplay = {
icon: <AlertTriangle size={24} />,
};
// Handle empty monitors array
if (monitors.length === 0) {
monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <CircleX size={24} />;
return monitorsStatus;
}
const allOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => statuses.includes(m.status));
const someOf = (...statuses: MonitorStatus[]) =>
monitors.some((m) => statuses.includes(m.status));
const noneOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => !statuses.includes(m.status));
// All monitors in a single state
if (allOf("up")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = <CircleCheck size={24} />;
} else if (allOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (allOf("maintenance")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (allOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <CircleX size={24} />;
} else if (allOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
} else if (allOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Loader size={24} />;
// Breached takes highest priority in mixed states
} else if (someOf("breached") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (someOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
// Maintenance combinations
} else if (someOf("maintenance") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (someOf("maintenance") && noneOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
// Degraded (some down, no maintenance/breached)
} else if (someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <AlertTriangle size={24} />;
// Some Paused
} else if (someOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
// Initializing
} else if (someOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
monitorsStatus.color = theme.palette.info.main;
monitorsStatus.icon = <Loader size={24} />;
} else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main;
}
return monitorsStatus;
};
interface StatusBarProps {
monitors: Monitor[];
}
export const StatusBar = ({ monitors }: StatusBarProps) => {
const theme = useTheme();
const { t } = useTranslation();
const monitorsStatus = getMonitorStatus(monitors, theme, t);
return (
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
height={theme.spacing(30)}
bgcolor={monitorsStatus.color}
borderRadius={theme.shape.borderRadius}
>
{monitorsStatus.icon}
<Typography>{monitorsStatus.msg}</Typography>
</Stack>
);
};
+61 -37
View File
@@ -1,18 +1,43 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useMediaQuery, useTheme } from "@mui/material";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { StatusPageResponse } from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPage,
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
import { BrowserFrame } from "./themes/BrowserFrame";
type ThemedRendererProps = {
statusPage: StatusPage;
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
const THEMED_RENDERERS: Record<
StatusPageTheme,
React.ComponentType<ThemedRendererProps>
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
bold: BoldStatusPage,
editorial: EditorialStatusPage,
};
const StatusPageView = () => {
const theme = useTheme();
@@ -20,8 +45,8 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -65,48 +90,47 @@ const StatusPageView = () => {
);
}
let sx: React.CSSProperties = {};
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<ThemedRenderer
statusPage={statusPage}
monitors={monitors}
/>
);
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
sx.paddingTop = theme.spacing(20);
sx.paddingLeft = isSmall ? "5vw" : "20vw";
sx.paddingRight = isSmall ? "5vw" : "20vw";
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
paintBody
>
{themedRenderer}
</StatusPageThemeProvider>
);
}
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
return (
<BasePage
loading={isLoading}
error={error}
sx={sx}
breadcrumbOverride={isPublic ? [] : undefined}
breadcrumbOverride={undefined}
sx={{ flex: 1, minHeight: 0 }}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={isPublic}
/>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alignSelf={"flex-start"}
alt={statusPage.companyName}
sx={{
maxHeight: 120,
maxWidth: "100%",
objectFit: "contain",
mb: 2,
}}
/>
)}
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
monitors={monitors}
isPublic={false}
/>
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
};
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import { useStatusPageTheme } from "./StatusPageThemeProvider";
interface Props {
url: string;
children: ReactNode;
}
export const BrowserFrame = ({ url, children }: Props) => {
const { tokens } = useStatusPageTheme();
return (
<Box
sx={{
width: "100%",
minHeight: "calc(100vh - 180px)",
display: "flex",
flexDirection: "column",
borderRadius: "12px",
overflow: "hidden",
border: `1px solid ${tokens.border}`,
background: tokens.surface,
color: tokens.text,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.25,
padding: "10px 14px",
background: tokens.bg,
borderBottom: `1px solid ${tokens.border}`,
}}
>
<Box sx={{ display: "flex", gap: 0.75 }}>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#ff5f57" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#febc2e" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#28c840" }}
/>
</Box>
<Box
sx={{
flex: 1,
padding: "4px 12px",
marginLeft: 2,
borderRadius: "6px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
fontFamily: "ui-monospace, Menlo, monospace",
fontSize: 12,
color: tokens.textMuted,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
</Box>
);
};
@@ -0,0 +1,142 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme,
resolveStatusPageThemeMode,
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
interface StatusPageThemeContextValue {
theme: StatusPageTheme;
mode: ResolvedMode;
tokens: StatusPageThemeTokens;
}
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
const resolveSystemMode = (): ResolvedMode => {
if (typeof window === "undefined" || !window.matchMedia) return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
* where no admin shell sits behind). Defaults to false for admin
* previews that already have an app background.
*/
paintBody?: boolean;
/**
* When true, the provider's wrapper div does NOT paint its own
* background or set a min-height. Use this when the wrapper is
* inside a clipped container (e.g. BrowserFrame) that already sets
* the visible background, so the wrapper's bg doesn't bleed past the
* container's rounded corners.
*/
transparent?: boolean;
children: ReactNode;
}
export const StatusPageThemeProvider = ({
theme,
themeMode,
paintBody = false,
transparent = false,
children,
}: Props) => {
const resolvedTheme = resolveStatusPageTheme(theme);
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
useEffect(() => {
if (resolvedThemeMode !== "auto") return;
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
// Reconcile on first paint whenever we (re-)enter auto mode — only
// when it disagrees with the stored value, so we don't trigger an
// extra render on the common hot path.
const next: ResolvedMode = mq.matches ? "dark" : "light";
setSystemMode((prev) => (prev === next ? prev : next));
const handler = (e: MediaQueryListEvent) =>
setSystemMode(e.matches ? "dark" : "light");
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [resolvedThemeMode]);
useEffect(() => {
if (!paintBody || typeof document === "undefined") return;
const html = document.documentElement;
const body = document.body;
const prev = {
htmlBg: html.style.background,
bodyBg: body.style.background,
bodyMargin: body.style.margin,
htmlColorScheme: html.style.colorScheme,
};
const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const bg = themeTokens[resolvedTheme][resolvedMode].bg;
html.style.background = bg;
body.style.background = bg;
body.style.margin = "0";
html.style.colorScheme = resolvedMode;
return () => {
html.style.background = prev.htmlBg;
body.style.background = prev.bodyBg;
body.style.margin = prev.bodyMargin;
html.style.colorScheme = prev.htmlColorScheme;
};
}, [paintBody, resolvedTheme, resolvedThemeMode, systemMode]);
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
[resolvedTheme, resolvedMode, tokens]
);
return (
<StatusPageThemeContext.Provider value={value}>
<div
style={
transparent
? undefined
: {
background: tokens.bg,
minHeight: "100vh",
}
}
>
{children}
</div>
</StatusPageThemeContext.Provider>
);
};
export const useStatusPageTheme = (): StatusPageThemeContextValue => {
const ctx = useContext(StatusPageThemeContext);
if (!ctx) {
return {
theme: DEFAULT_STATUS_PAGE_THEME,
mode: resolveSystemMode(),
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
};
}
return ctx;
};
@@ -0,0 +1,207 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { boldStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -0,0 +1,404 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
export type BoldGaugeFill = "ok" | "warm" | "hot";
export interface BoldStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brand: SxProps<Theme>;
logoConic: SxProps<Theme>;
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroCheck: (tone: OverallTone) => SxProps<Theme>;
heroSub: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export const boldStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): BoldStyles => {
const heatCellBg: Record<BoldHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
slow: tokens.warn,
down: tokens.down,
empty: tokens.border,
};
const heatCellShadow: Record<BoldHeatCell, string> = {
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)`,
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
empty: "none",
};
const barBg: Record<BoldBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
const gaugeFillBg: Record<BoldGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`,
warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`,
hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`,
};
const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`;
const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`;
const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`;
const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`;
return {
page: {
flex: "1 0 auto",
maxWidth: 1040,
width: "100%",
mx: "auto",
p: "56px 24px 96px",
fontFamily: BOLD_SANS_STACK,
fontSize: 14,
lineHeight: 1.55,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: "36px",
},
brand: {
display: "flex",
alignItems: "center",
gap: "14px",
fontWeight: 700,
fontSize: 16,
letterSpacing: "-0.01em",
color: tokens.text,
},
logoConic: {
width: 36,
height: 36,
borderRadius: "12px",
background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`,
display: "grid",
placeItems: "center",
color: "#05070a",
fontWeight: 900,
fontSize: 14,
boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`,
},
logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" },
hero: {
position: "relative",
borderRadius: "22px",
padding: "36px 36px 32px",
mb: "28px",
background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`,
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
inset: 0,
background: isDark ? heroGlowDark : heroGlowLight,
pointerEvents: "none",
opacity: 0.8,
zIndex: 0,
...(isDark ? { mixBlendMode: "screen" } : {}),
},
"& > *": { position: "relative", zIndex: 1 },
},
heroTitle: {
m: 0,
fontSize: 34,
fontWeight: 800,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: tokens.text,
display: "flex",
alignItems: "center",
gap: "10px",
},
heroCheck: (tone) => ({
display: "inline-grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
background: toneColor(tone, tokens),
color: tone === "down" ? "#fff" : "#05070a",
flexShrink: 0,
boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`,
}),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
p: "3px",
gap: "2px",
},
chartSwitchButton: (active) => ({
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "8px 18px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.12em",
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
gap: "14px",
},
card: {
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: "18px",
overflow: "hidden",
transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s",
"&:hover": {
transform: "translateY(-3px)",
borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`,
},
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "24px",
p: "22px 28px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontWeight: 700,
fontSize: 17,
letterSpacing: "-0.01em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gap: "10px",
alignItems: "center",
mt: "6px",
flexWrap: "wrap",
},
pill: {
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.1em",
color: tokens.textMuted,
background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
border: `1px solid ${tokens.border}`,
padding: "3px 10px",
borderRadius: "999px",
fontWeight: 700,
},
pillHardware: {
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.1em",
padding: "3px 10px",
borderRadius: "999px",
fontWeight: 700,
color: tokens.up,
borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`,
border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`,
background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 300,
},
badge: (tone) => ({
fontSize: 11,
fontWeight: 800,
padding: "6px 14px",
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
gap: "7px",
textTransform: "uppercase",
letterSpacing: "0.08em",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
"&::before": {
content: '""',
width: 6,
height: 6,
borderRadius: "50%",
background: "currentColor",
boxShadow: "0 0 8px currentColor",
},
}),
heatmap: {
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 48,
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: "0 28px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
height: `${heightPct}%`,
}),
chartStats: {
padding: "0 28px 22px",
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
fontWeight: 700,
letterSpacing: "0.05em",
},
infra: {
padding: "18px 28px 26px",
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "16px",
},
infraEmpty: {
padding: "18px 28px 26px",
color: tokens.textMuted,
fontSize: 13,
},
gauge: {
border: `1px solid ${tokens.border}`,
borderRadius: "16px",
p: "16px 18px",
background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
},
gaugeLabel: {
fontSize: 10,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
},
gaugeValue: {
fontSize: 30,
fontWeight: 800,
letterSpacing: "-0.02em",
mt: "6px",
fontVariantNumeric: "tabular-nums",
color: tokens.text,
},
gaugeBar: {
height: 6,
background: tokens.border,
borderRadius: "3px",
overflow: "hidden",
mt: "12px",
},
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
borderRadius: "3px",
transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "10px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 12,
mt: "52px",
"& a": {
color: tokens.up,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 800,
},
},
};
};
@@ -0,0 +1,224 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { editorialStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => editorialStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
const overall = resolveOverallStatus(monitors, t, {
allUpKey: "pages.statusPages.editorial.allUp",
});
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
</Box>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -0,0 +1,337 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
MONO_STACK,
SERIF_STACK,
} from "../shared/fontStacks";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
export type EditorialGaugeFill = "ok" | "warm" | "hot";
export interface EditorialStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brandWrap: SxProps<Theme>;
logoImg: SxProps<Theme>;
brandEyebrow: SxProps<Theme>;
brandTitle: SxProps<Theme>;
statusLine: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
dateline: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
const heatCellBg: Record<EditorialHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
slow: tokens.warn,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<EditorialBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
const gaugeFillBg: Record<EditorialGaugeFill, string> = {
ok: tokens.up,
warm: tokens.warn,
hot: tokens.down,
};
return {
page: {
flex: "1 0 auto",
maxWidth: 760,
width: "100%",
mx: "auto",
p: "64px 24px 96px",
fontFamily: SERIF_STACK,
fontSize: 15,
lineHeight: 1.6,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
mb: "48px",
borderBottom: `2px solid ${tokens.text}`,
pb: "18px",
},
brandWrap: { display: "flex", flexDirection: "column" },
logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" },
brandEyebrow: {
m: 0,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.3em",
fontWeight: 700,
color: tokens.textMuted,
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
brandTitle: {
mt: "6px",
mb: 0,
fontSize: 40,
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1,
color: tokens.text,
},
statusLine: {
fontSize: 22,
fontWeight: 400,
m: 0,
mb: "8px",
letterSpacing: "-0.01em",
lineHeight: 1.35,
color: tokens.text,
fontFamily: SERIF_STACK,
},
statusDot: (tone) => ({
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: toneColor(tone, tokens),
mr: "10px",
verticalAlign: "middle",
}),
dateline: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.12em",
color: tokens.textMuted,
m: 0,
mb: "56px",
},
chartSwitchWrap: {
display: "flex",
justifyContent: "flex-end",
mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
},
chartSwitchButton: (active) => ({
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "6px 14px",
border: 0,
background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text,
cursor: "pointer",
fontWeight: 700,
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
},
card: {
background: "transparent",
border: 0,
borderBottom: `1px solid ${tokens.border}`,
mb: 0,
py: "20px",
"&:last-of-type": { borderBottom: 0 },
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "baseline",
gap: "20px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.015em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
alignItems: "center",
},
pill: {
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
color: tokens.textMuted,
},
pillHardware: {
textTransform: "uppercase",
letterSpacing: "0.12em",
fontWeight: 700,
color: tokens.up,
},
monitorUrl: {
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 280,
fontSize: 12,
},
badge: (tone) => ({
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
padding: "4px 10px",
borderRadius: 0,
textTransform: "uppercase",
letterSpacing: "0.15em",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
}),
heatmap: {
mt: "16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
height: 40,
},
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
histogram: {
mt: "16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
}),
chartStats: {
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
textTransform: "uppercase",
letterSpacing: "0.1em",
},
infra: {
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "24px",
mt: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 },
gauge: {},
gaugeLabel: {
fontSize: 10,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.18em",
fontWeight: 700,
},
gaugeValue: {
fontSize: 28,
fontWeight: 700,
letterSpacing: "-0.02em",
mt: "4px",
fontVariantNumeric: "tabular-nums",
fontFamily: SERIF_STACK,
color: tokens.text,
},
gaugeBar: { height: 2, background: tokens.border, mt: "10px" },
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
transition: "width 0.8s",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "8px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 11,
mt: "56px",
textTransform: "uppercase",
letterSpacing: "0.2em",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
"& a": {
color: tokens.text,
textDecoration: "none",
borderBottom: `1px solid ${tokens.text}`,
pb: "2px",
fontWeight: 700,
},
},
};
};
@@ -0,0 +1,205 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { modernStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -0,0 +1,428 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
export type ModernGaugeFill = "ok" | "warm" | "hot";
export interface ModernStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brand: SxProps<Theme>;
logoGrad: SxProps<Theme>;
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
pulse: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
const lightShadow =
"0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)";
const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)";
const lightShadowHover =
"0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)";
const darkShadowHover = "0 4px 10px rgba(0, 0, 0, 0.55), 0 24px 48px rgba(0, 0, 0, 0.45)";
const pillBase = {
fontSize: 10,
textTransform: "uppercase" as const,
letterSpacing: "0.08em",
padding: "3px 9px",
borderRadius: "999px",
fontWeight: 600,
};
const pulseKeyframes = keyframes`
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(2.4); opacity: 0; }
`;
const fadeInKeyframes = keyframes`
to { opacity: 1; transform: translateY(0); }
`;
export const modernStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
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%))`,
empty: tokens.border,
};
const barBg: Record<ModernBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
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%))`,
};
return {
page: {
flex: "1 0 auto",
maxWidth: 980,
width: "100%",
mx: "auto",
p: "56px 20px 80px",
fontFamily: SANS_STACK,
fontSize: 14,
lineHeight: 1.5,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: "32px",
},
brand: {
display: "flex",
alignItems: "center",
gap: "12px",
fontWeight: 700,
fontSize: 15,
letterSpacing: "-0.01em",
color: tokens.text,
},
logoGrad: {
width: 32,
height: 32,
borderRadius: "10px",
background: `linear-gradient(135deg, ${tokens.up}, ${tokens.upStrong})`,
display: "grid",
placeItems: "center",
color: "#fff",
fontWeight: 700,
fontSize: 14,
boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`,
},
logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
hero: {
position: "relative",
borderRadius: "20px",
padding: "28px 32px",
mb: "24px",
display: "flex",
alignItems: "center",
gap: "20px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
boxShadow: cardShadow,
},
pulse: (tone) => {
const c = toneColor(tone, tokens);
return {
position: "relative",
width: 14,
height: 14,
borderRadius: "50%",
background: c,
color: c,
flexShrink: 0,
"&::before, &::after": {
content: '""',
position: "absolute",
inset: 0,
borderRadius: "50%",
background: "currentColor",
opacity: 0.5,
animation: `${pulseKeyframes} 2s ease-out infinite`,
},
"&::after": { animationDelay: "1s" },
"@media (prefers-reduced-motion: reduce)": {
"&::before, &::after": { animation: "none" },
},
};
},
statusCopy: { flex: 1, minWidth: 0 },
heroTitle: {
m: 0,
mb: "4px",
fontSize: 22,
fontWeight: 700,
letterSpacing: "-0.02em",
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
p: "3px",
gap: "2px",
},
chartSwitchButton: (active) => ({
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "6px 16px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
gap: "14px",
},
card: {
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: "16px",
boxShadow: cardShadow,
overflow: "hidden",
transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.2s",
opacity: 0,
transform: "translateY(8px)",
animation: `${fadeInKeyframes} 0.5s forwards`,
"&:nth-of-type(1)": { animationDelay: "0.05s" },
"&:nth-of-type(2)": { animationDelay: "0.1s" },
"&:nth-of-type(3)": { animationDelay: "0.15s" },
"&:nth-of-type(4)": { animationDelay: "0.2s" },
"&:nth-of-type(5)": { animationDelay: "0.25s" },
"&:nth-of-type(n+6)": { animationDelay: "0.3s" },
"&:hover": { transform: "translateY(-2px)", boxShadow: cardHoverShadow },
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
opacity: 1,
transform: "none",
},
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "20px",
p: "18px 24px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontWeight: 600,
fontSize: 15,
letterSpacing: "-0.005em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gap: "10px",
alignItems: "center",
mt: "6px",
flexWrap: "wrap",
},
pill: {
...pillBase,
color: tokens.textMuted,
background: tokens.bg,
},
pillHardware: {
...pillBase,
color: tokens.up,
background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 280,
},
badge: (tone) => ({
fontSize: 11,
fontWeight: 700,
padding: "5px 12px",
borderRadius: "999px",
display: "inline-flex",
alignItems: "center",
gap: "6px",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
"&::before": {
content: '""',
width: 6,
height: 6,
borderRadius: "50%",
background: "currentColor",
},
}),
heatmap: {
padding: "0 24px 20px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "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)" },
}),
histogram: {
padding: "0 24px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
height: `${heightPct}%`,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.08)" },
}),
chartStats: {
padding: "0 24px 20px",
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
},
infra: {
padding: "16px 24px 22px",
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "14px",
},
infraEmpty: {
padding: "16px 24px 22px",
color: tokens.textMuted,
fontSize: 13,
},
gauge: {
border: `1px solid ${tokens.border}`,
borderRadius: "14px",
p: "14px 16px",
background: `linear-gradient(135deg, ${tokens.bg}, ${tokens.surface})`,
},
gaugeLabel: {
fontSize: 10,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.1em",
fontWeight: 700,
},
gaugeValue: {
fontSize: 26,
fontWeight: 700,
letterSpacing: "-0.02em",
mt: "4px",
fontVariantNumeric: "tabular-nums",
color: tokens.text,
},
gaugeBar: {
height: 6,
background: tokens.border,
borderRadius: "3px",
overflow: "hidden",
mt: "10px",
},
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
borderRadius: "3px",
transition: "width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "8px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 12,
mt: "48px",
"& a": {
color: tokens.up,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 700,
"&:hover": { color: tokens.upStrong },
},
},
};
};
@@ -0,0 +1,210 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { refinedStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => refinedStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -0,0 +1,364 @@
import type { SxProps, Theme } from "@mui/material/styles";
import 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";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
export interface RefinedStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
brand: SxProps<Theme>;
logoMono: SxProps<Theme>;
logoImg: SxProps<Theme>;
company: SxProps<Theme>;
hero: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)";
const cardShadowHover =
"0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)";
const pillBase = {
fontSize: 10,
textTransform: "uppercase" as const,
letterSpacing: "0.08em",
padding: "2px 8px",
borderRadius: "999px",
fontWeight: 600,
};
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
const heatCellBg: Record<RefinedHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
slow: tokens.warn,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<RefinedBarKind, string> = {
up: tokens.up,
down: tokens.down,
empty: tokens.border,
};
const gaugeFillBg: Record<RefinedGaugeFill, string> = {
ok: tokens.up,
warm: tokens.warn,
hot: tokens.down,
};
return {
page: {
flex: "1 0 auto",
maxWidth: 960,
width: "100%",
mx: "auto",
p: "48px 20px 80px",
fontFamily: SANS_STACK,
fontSize: 14,
lineHeight: 1.5,
color: tokens.text,
WebkitFontSmoothing: "antialiased",
},
top: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: "28px",
},
brand: {
display: "flex",
alignItems: "center",
gap: "10px",
fontWeight: 600,
letterSpacing: "-0.01em",
color: tokens.text,
},
logoMono: {
width: 28,
height: 28,
borderRadius: "8px",
background: tokens.up,
display: "grid",
placeItems: "center",
color: "#fff",
fontWeight: 700,
fontSize: 13,
},
logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
company: { fontSize: 14 },
hero: {
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius,
padding: "22px 24px",
display: "flex",
alignItems: "center",
gap: "16px",
boxShadow: cardShadow,
mb: "20px",
},
statusDot: (tone) => ({
width: 10,
height: 10,
borderRadius: "50%",
background: toneColor(tone, tokens),
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
flexShrink: 0,
}),
statusCopy: { flex: 1, minWidth: 0 },
heroTitle: {
m: 0,
mb: "2px",
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.border}`,
borderRadius: "8px",
background: tokens.surface,
p: "3px",
gap: "2px",
},
chartSwitchButton: (active) => ({
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "5px 14px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
borderRadius: "5px",
transition: "background 0.15s ease, color 0.15s ease",
fontWeight: active ? 600 : 500,
"&:hover": { color: active ? tokens.up : tokens.text },
}),
monitorList: {
listStyle: "none",
m: 0,
p: 0,
display: "flex",
flexDirection: "column",
gap: "12px",
},
card: {
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius,
boxShadow: cardShadow,
overflow: "hidden",
position: "relative",
transition: "transform 0.15s, box-shadow 0.15s",
"&:hover": { transform: "translateY(-1px)", boxShadow: cardShadowHover },
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "16px",
p: "16px 20px",
},
cardLeft: { minWidth: 0 },
monitorName: {
fontWeight: 600,
fontSize: 14,
letterSpacing: "-0.005em",
color: tokens.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gap: "10px",
alignItems: "center",
mt: "4px",
flexWrap: "wrap",
},
pill: {
...pillBase,
color: tokens.textMuted,
border: `1px solid ${tokens.border}`,
},
pillHardware: {
...pillBase,
color: tokens.up,
border: `1px solid ${tokens.border}`,
background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 280,
},
badge: (tone) => ({
fontSize: 11,
fontWeight: 600,
padding: "4px 10px",
borderRadius: "999px",
whiteSpace: "nowrap",
background: toneSoft(tone, tokens),
color: toneColor(tone, tokens),
}),
heatmap: {
padding: "0 20px 16px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 42,
},
heatmapCell: (kind) => ({
borderRadius: "2px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
}),
histogram: {
padding: "0 20px",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
height: `${heightPct}%`,
}),
chartStats: {
padding: "0 20px 16px",
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
},
infra: {
padding: "14px 20px 18px",
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "12px",
},
infraEmpty: {
padding: "14px 20px 18px",
color: tokens.textMuted,
fontSize: 13,
},
gauge: {
border: `1px solid ${tokens.border}`,
borderRadius: "10px",
p: "12px 14px",
background: tokens.bg,
},
gaugeLabel: {
fontSize: 11,
color: tokens.textMuted,
textTransform: "uppercase",
letterSpacing: "0.08em",
fontWeight: 600,
},
gaugeValue: {
fontSize: 20,
fontWeight: 600,
letterSpacing: "-0.01em",
mt: "2px",
fontVariantNumeric: "tabular-nums",
color: tokens.text,
},
gaugeBar: {
height: 4,
background: tokens.border,
borderRadius: "2px",
overflow: "hidden",
mt: "8px",
},
gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
borderRadius: "2px",
transition: "width 0.6s",
width: `${Math.max(0, Math.min(100, widthPct))}%`,
}),
gaugeSub: {
fontSize: 11,
color: tokens.textMuted,
mt: "6px",
fontVariantNumeric: "tabular-nums",
},
footer: {
textAlign: "center",
color: tokens.textMuted,
fontSize: 12,
mt: "40px",
"& a": {
color: tokens.up,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 600,
"&:hover": { color: tokens.upStrong || tokens.up },
},
},
};
};
@@ -0,0 +1,94 @@
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 { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
const CELLS = 25;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
interface Props {
checks: CheckSnapshot[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind) => 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) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
return (
<Box
sx={containerSx}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
{padded.map((check, i) => {
if (!check) {
return (
<Box
key={`empty-${i}`}
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",
uiTimezone
)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
arrow
placement="top"
>
<Box
sx={cellSx(kind)}
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
/>
</Tooltip>
);
})}
</Box>
);
};
@@ -0,0 +1,116 @@
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 { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
const CELLS = 25;
const MIN_HEIGHT_PCT = 6;
export type BarKind = "up" | "down" | "empty";
interface Props {
checks: CheckSnapshot[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
check.status ? "up" : "down";
export const ThemedHistogram = ({
checks,
containerSx,
barSx,
statsSx,
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
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 avgRt = valid.length
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
: 0;
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [checks]);
return (
<Stack gap={statsGap}>
<Box sx={containerSx}>
{padded.map((check, i) => {
if (!check) {
return (
<Box
key={`empty-${i}`}
sx={barSx("empty", MIN_HEIGHT_PCT)}
/>
);
}
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",
uiTimezone
)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
arrow
placement="top"
>
<Box
sx={barSx(tone(check), height)}
aria-label={`${check.responseTime} ms`}
/>
</Tooltip>
);
})}
</Box>
<Stack
direction="row"
justifyContent="space-between"
sx={statsSx}
>
<span>{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}</span>
<span>{t("pages.statusPages.monitorsList.chart.max", { value: peak })}</span>
</Stack>
</Stack>
);
};
@@ -0,0 +1,105 @@
import Box from "@mui/material/Box";
import prettyBytes from "pretty-bytes";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
export type GaugeFillLevel = "ok" | "warm" | "hot";
export interface InfraSx {
containerSx: SxProps<Theme>;
emptySx: SxProps<Theme>;
gaugeSx: SxProps<Theme>;
gaugeLabelSx: SxProps<Theme>;
gaugeValueSx: SxProps<Theme>;
gaugeBarSx: SxProps<Theme>;
gaugeFillSx: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
gaugeSubSx: SxProps<Theme>;
}
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
sxApi: InfraSx;
}
interface Gauge {
key: string;
label: string;
value: number;
sub: string;
}
const PCT = 100;
const heatLevel = (value: number): GaugeFillLevel =>
value > 85 ? "hot" : value > 70 ? "warm" : "ok";
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation();
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
);
if (!latest) return renderEmpty();
const gauges: Gauge[] = [];
if (typeof latest.cpu?.usage_percent === "number") {
const pct = latest.cpu.usage_percent * PCT;
gauges.push({
key: "cpu",
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
value: pct,
sub: `${pct.toFixed(0)}%`,
});
}
if (
typeof latest.memory?.usage_percent === "number" &&
typeof latest.memory?.used_bytes === "number" &&
typeof latest.memory?.total_bytes === "number"
) {
gauges.push({
key: "memory",
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
value: latest.memory.usage_percent * PCT,
sub: `${prettyBytes(latest.memory.used_bytes)} / ${prettyBytes(latest.memory.total_bytes)}`,
});
}
if (latest.disk && latest.disk.length > 0) {
const disks = latest.disk;
const avg =
(disks.reduce((acc, d) => acc + (d?.usage_percent ?? 0), 0) / disks.length) * PCT;
const used = disks.reduce((acc, d) => acc + (d?.used_bytes ?? 0), 0);
const total = disks.reduce((acc, d) => acc + (d?.total_bytes ?? 0), 0);
gauges.push({
key: "disk",
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
value: avg,
sub: `${prettyBytes(used)} / ${prettyBytes(total)}`,
});
}
if (gauges.length === 0) return renderEmpty();
return (
<Box sx={sxApi.containerSx}>
{gauges.map((g) => (
<Box
key={g.key}
sx={sxApi.gaugeSx}
>
<Box sx={sxApi.gaugeLabelSx}>{g.label}</Box>
<Box sx={sxApi.gaugeValueSx}>{g.value.toFixed(0)}%</Box>
<Box sx={sxApi.gaugeBarSx}>
<Box sx={sxApi.gaugeFillSx(heatLevel(g.value), Math.min(100, g.value))} />
</Box>
<Box sx={sxApi.gaugeSubSx}>{g.sub}</Box>
</Box>
))}
</Box>
);
};
@@ -0,0 +1,11 @@
export const SANS_STACK =
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
export const SERIF_STACK = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif';
export const EDITORIAL_SECONDARY_SANS_STACK =
'-apple-system, "Helvetica Neue", Arial, sans-serif';
export const MONO_STACK = "ui-monospace, Menlo, monospace";
@@ -0,0 +1,167 @@
import type { ReactNode } from "react";
import {
AlertTriangle,
CircleCheck,
CircleX,
Loader,
PauseCircle,
ShieldAlert,
Wrench,
} from "lucide-react";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
import type { StatusPageThemeTokens } from "../tokens";
export type OverallTone = "up" | "warn" | "down";
export const toneColor = (tone: OverallTone, t: StatusPageThemeTokens): string =>
tone === "down" ? t.down : tone === "warn" ? t.warn : t.up;
export const toneSoft = (tone: OverallTone, t: StatusPageThemeTokens): string =>
tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft;
export interface OverallStatus {
tone: OverallTone;
message: string;
icon: ReactNode;
}
type StatusPageMonitor = Pick<Monitor, "status">;
interface Options {
iconSize?: number;
// Optional override for the `allUp` message key (editorial uses a more
// formal sentence).
allUpKey?: string;
}
export const resolveOverallStatus = (
monitors: StatusPageMonitor[],
t: (key: string) => string,
options: Options = {}
): OverallStatus => {
const size = options.iconSize ?? 18;
const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp");
if (monitors.length === 0) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.noMonitors"),
icon: <CircleX size={size} />,
};
}
const allOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => statuses.includes(m.status));
const someOf = (...statuses: MonitorStatus[]) =>
monitors.some((m) => statuses.includes(m.status));
const noneOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => !statuses.includes(m.status));
if (allOf("up")) {
return { tone: "up", message: allUpMessage, icon: <CircleCheck size={size} /> };
}
if (allOf("breached")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.allBreached"),
icon: <ShieldAlert size={size} />,
};
}
if (allOf("maintenance")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allMaintenance"),
icon: <Wrench size={size} />,
};
}
if (allOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.allDown"),
icon: <CircleX size={size} />,
};
}
if (allOf("paused")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allPaused"),
icon: <PauseCircle size={size} />,
};
}
if (allOf("initializing")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allInitializing"),
icon: <Loader size={size} />,
};
}
if (someOf("breached") && someOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.breachedAndDown"),
icon: <ShieldAlert size={size} />,
};
}
if (someOf("breached")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.breached"),
icon: <ShieldAlert size={size} />,
};
}
if (someOf("maintenance") && someOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.maintenanceAndDown"),
icon: <Wrench size={size} />,
};
}
if (someOf("maintenance") && noneOf("down")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.maintenance"),
icon: <Wrench size={size} />,
};
}
if (someOf("down")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.degraded"),
icon: <AlertTriangle size={size} />,
};
}
if (someOf("paused")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.partiallyPaused"),
icon: <PauseCircle size={size} />,
};
}
if (someOf("initializing")) {
return {
tone: "up",
message: t("pages.statusPages.statusBar.initializing"),
icon: <Loader size={size} />,
};
}
return {
tone: "warn",
message: t("pages.statusPages.statusBar.unknown"),
icon: <AlertTriangle size={size} />,
};
};
export const statusBadgeKey: Record<MonitorStatus, string> = {
up: "pages.statusPages.monitorsList.status.up",
down: "pages.statusPages.monitorsList.status.down",
breached: "pages.statusPages.monitorsList.status.breached",
maintenance: "pages.statusPages.monitorsList.status.maintenance",
paused: "pages.statusPages.monitorsList.status.paused",
initializing: "pages.statusPages.monitorsList.status.initializing",
};
export const monoFirstChar = (s?: string): string =>
(s?.trim().charAt(0) || "?").toUpperCase();
export const monitorBadgeTone = (status: MonitorStatus): OverallTone =>
status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn";
@@ -0,0 +1,199 @@
import type { StatusPageTheme } from "@/Types/StatusPage";
export interface StatusPageThemeTokens {
bg: string;
surface: string;
border: string;
text: string;
textMuted: string;
up: string;
upStrong: string;
upSoft: string;
degraded: string;
degradedSoft: string;
down: string;
downSoft: string;
warn: string;
warnSoft: string;
radius: string;
fontFamily?: string;
headingFontFamily?: string;
headingWeight?: number;
headingLetterSpacing?: string;
chartBarRadius?: string;
pulseStatusDot?: boolean;
staggeredCardFadeIn?: boolean;
conicLogo?: boolean;
heroSize?: "default" | "large";
cardStyle?: "card" | "hairline";
}
type ThemeVariants = { light: StatusPageThemeTokens; dark: StatusPageThemeTokens };
const refined: ThemeVariants = {
light: {
bg: "#f4f6fa",
surface: "#ffffff",
border: "#dce1ea",
text: "#0a1020",
textMuted: "#4b5768",
up: "#0f8a6d",
upStrong: "#0c7359",
upSoft: "#d6f1e6",
degraded: "#c2630a",
degradedSoft: "#fde7bf",
down: "#d11f2f",
downSoft: "#fdd9dc",
warn: "#e08a0b",
warnSoft: "#fdebc5",
radius: "12px",
},
dark: {
bg: "#070b11",
surface: "#131a24",
border: "#253142",
text: "#f1f5fb",
textMuted: "#9aa7bd",
up: "#2fd7a2",
upStrong: "#1fb487",
upSoft: "rgba(47,215,162,0.15)",
degraded: "#e69138",
degradedSoft: "rgba(230,145,56,0.18)",
down: "#ff5b6b",
downSoft: "rgba(255,91,107,0.18)",
warn: "#f0a837",
warnSoft: "rgba(240,168,55,0.18)",
radius: "12px",
},
};
const modern: ThemeVariants = {
light: {
bg: "#fafbfc",
surface: "#ffffff",
border: "#eceef2",
text: "#0b1220",
textMuted: "#6b7280",
up: "#10a37f",
upStrong: "#0b8a6a",
upSoft: "#d7f3e9",
degraded: "#d97706",
degradedSoft: "#fef3c7",
down: "#dc2626",
downSoft: "#fee2e2",
warn: "#f59e0b",
warnSoft: "#fef3c7",
radius: "16px",
pulseStatusDot: true,
staggeredCardFadeIn: true,
},
dark: {
bg: "#0a0d12",
surface: "#10151c",
border: "#1b2430",
text: "#e8edf5",
textMuted: "#8a95a8",
up: "#10a37f",
upStrong: "#0b8a6a",
upSoft: "rgba(16,163,127,0.18)",
degraded: "#d97706",
degradedSoft: "rgba(217,119,6,0.22)",
down: "#dc2626",
downSoft: "rgba(220,38,38,0.22)",
warn: "#f59e0b",
warnSoft: "rgba(245,158,11,0.2)",
radius: "16px",
pulseStatusDot: true,
staggeredCardFadeIn: true,
},
};
const bold: ThemeVariants = {
light: {
bg: "#f5f6f9",
surface: "#ffffff",
border: "#e6e8ee",
text: "#0b1220",
textMuted: "#6b7280",
up: "#10a37f",
upStrong: "#0b8a6a",
upSoft: "rgba(16,163,127,0.12)",
degraded: "#f59e0b",
degradedSoft: "rgba(245,158,11,0.14)",
down: "#f43f5e",
downSoft: "rgba(244,63,94,0.12)",
warn: "#f59e0b",
warnSoft: "rgba(245,158,11,0.14)",
radius: "18px",
heroSize: "large",
conicLogo: true,
},
dark: {
bg: "#05070a",
surface: "#0c1117",
border: "#1a2330",
text: "#eef2f7",
textMuted: "#7a8699",
up: "#22d3a6",
upStrong: "#10a37f",
upSoft: "rgba(34,211,166,0.14)",
degraded: "#f59e0b",
degradedSoft: "rgba(245,158,11,0.14)",
down: "#f43f5e",
downSoft: "rgba(244,63,94,0.16)",
warn: "#f59e0b",
warnSoft: "rgba(245,158,11,0.14)",
radius: "18px",
heroSize: "large",
conicLogo: true,
},
};
const editorial: ThemeVariants = {
light: {
bg: "#fbfaf7",
surface: "#ffffff",
// Darkened from mockup's #e9e4d8 (~1.3:1 on #fbfaf7) to pass WCAG AA 3:1 for UI borders.
border: "#d4cdb8",
text: "#1a1a1a",
textMuted: "#6b675e",
up: "#2c6a4f",
upStrong: "#205239",
upSoft: "#dfeee4",
degraded: "#a46200",
degradedSoft: "#f7ecd4",
down: "#9a2a2a",
downSoft: "#f5dede",
warn: "#a46200",
warnSoft: "#f7ecd4",
radius: "6px",
headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif',
cardStyle: "hairline",
},
dark: {
bg: "#141310",
surface: "#1c1a15",
border: "#3a352b",
text: "#f0ece3",
textMuted: "#9e988a",
up: "#6fbf96",
upStrong: "#4fa37a",
upSoft: "rgba(44,106,79,0.22)",
degraded: "#d79a3b",
degradedSoft: "rgba(164,98,0,0.22)",
down: "#d87a7a",
downSoft: "rgba(154,42,42,0.22)",
warn: "#d79a3b",
warnSoft: "rgba(164,98,0,0.22)",
radius: "6px",
headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif',
cardStyle: "hairline",
},
};
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
refined,
modern,
bold,
editorial,
};
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import type { StatusPage } from "@/Types/StatusPage";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -53,7 +53,7 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
const url = `/status/public/${row.url}`;
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
};
+1
View File
@@ -12,6 +12,7 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
systemEmailTLSServername?: string;
+21
View File
@@ -21,6 +21,25 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type;
};
export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
export const resolveStatusPageTheme = (
value: string | null | undefined
): StatusPageTheme =>
STATUS_PAGE_THEMES.find((t) => t === value) ?? DEFAULT_STATUS_PAGE_THEME;
export const resolveStatusPageThemeMode = (
value: string | null | undefined
): StatusPageThemeMode =>
STATUS_PAGE_THEME_MODES.find((m) => m === value) ?? DEFAULT_STATUS_PAGE_THEME_MODE;
export const PUBLIC_STATUS_PAGE_PREFIX = "/status/public";
export interface StatusPage {
id: string;
userId: string;
@@ -43,6 +62,8 @@ export interface StatusPage {
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
theme: StatusPageTheme;
themeMode: StatusPageThemeMode;
createdAt: string;
updatedAt: string;
}
+7 -6
View File
@@ -1,12 +1,13 @@
const typographyBase = 13;
export const typographyLevels = {
base: typographyBase,
xs: `${(typographyBase - 4) / 16}rem`,
s: `${(typographyBase - 2) / 16}rem`,
m: `${typographyBase / 16}rem`,
l: `${(typographyBase + 2) / 16}rem`,
xl: `${(typographyBase + 10) / 16}rem`,
base: typographyBase, // 13px
xs: `${(typographyBase - 4) / 16}rem`, // 9px
s: `${(typographyBase - 2) / 16}rem`, // 11px
m: `${typographyBase / 16}rem`, // 13px
l: `${(typographyBase + 2) / 16}rem`, // 15px
xl: `${(typographyBase + 5) / 16}rem`, // 18px
xxl: `${(typographyBase + 10) / 16}rem`, // 23px
};
export const colors = {
+1 -1
View File
@@ -58,7 +58,7 @@ export const theme = (mode: string, palette: any) =>
fontFamilyMonospace,
fontSize: typographyLevels.base,
h1: {
fontSize: typographyLevels.xl,
fontSize: typographyLevels.xxl,
fontWeight: 500,
},
h2: {
+5
View File
@@ -8,6 +8,7 @@ export const SPACING = {
} as const;
export const LAYOUT = {
XXS: 2,
XS: 4,
SM: 6,
MD: 8,
@@ -15,3 +16,7 @@ export const LAYOUT = {
XL: 12,
XXL: 16,
} as const;
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
} as const;
+5 -2
View File
@@ -19,8 +19,11 @@ export const formatDateWithTz = (timestamp: string, format: string, timezone: st
if (!timestamp) {
return "Unknown time";
}
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
return formattedDate;
try {
return dayjs(timestamp).tz(timezone).format(format);
} catch {
return dayjs(timestamp).utc().format(format);
}
};
export const tickDateFormatLookup = (range: string) => {
+5
View File
@@ -32,6 +32,11 @@ export const settingsSchema = z.object({
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
+3
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
export const statusPageSchema = z.object({
companyName: z
@@ -25,6 +26,8 @@ export const statusPageSchema = z.object({
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
theme: z.enum(STATUS_PAGE_THEMES).optional(),
themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(),
logo: z
.object({
data: z.string(),
+82 -47
View File
@@ -13,7 +13,7 @@
"content": "تحذير: لم تقم بإضافة مفتاح Google PageSpeed API بعد. قم بزيارة <settingsLink>الإعدادات</settingsLink> لإضافته. بدونه، لن تعمل مراقبة PageSpeed."
}
},
"appName": "",
"appName": "Checkmate",
"breadcrumbs": {
"details": "التفاصيل",
"home": "الرئيسية"
@@ -35,11 +35,11 @@
"sendInvite": "إرسال دعوة",
"test": "اختبار",
"testNotifications": "اختبار الإشعارات",
"toggleTheme": "",
"toggleTheme": "تبديل الوضع الفاتح والداكن",
"flushQueue": "تفريغ قائمة الانتظار",
"notFound": "الانتقال إلى لوحة التحكم الرئيسية",
"resetPassword": "",
"reset": "",
"resetPassword": "إعادة تعيين كلمة المرور",
"reset": "إعادة تعيين",
"clear": "مسح",
"addDemo": "إضافة مراقبات تجريبية",
"removeMonitors": "إزالة المراقبات",
@@ -53,8 +53,8 @@
"labels": {
"averageResponseTime": "متوسط وقت الاستجابة",
"downtime": "وقت التوقف",
"high": "",
"low": "",
"high": "مرتفع",
"low": "منخفض",
"uptime": "وقت التشغيل"
},
"histogram": {
@@ -86,12 +86,12 @@
"name": {
"option": {
"firstName": {
"label": "",
"placeholder": ""
"label": "الاسم الأول",
"placeholder": "أدخل اسمك الأول"
},
"lastName": {
"label": "",
"placeholder": ""
"label": "اسم العائلة",
"placeholder": "أدخل اسم عائلتك"
}
}
},
@@ -107,14 +107,14 @@
"table": {
"empty": "لا يوجد شيء هنا",
"headers": {
"actions": "",
"actions": "إجراءات",
"dateTime": "التاريخ والوقت",
"message": "الرسالة",
"monitor": "المراقب",
"monitorId": "معرّف المراقب",
"name": "الاسم",
"status": "الحالة",
"type": "",
"type": "النوع",
"url": "الرابط",
"interval": "الفاصل الزمني",
"active": "نشط",
@@ -125,17 +125,17 @@
"components": {
"imageUpload": {
"clickToUpload": "انقر للرفع",
"dragAndDrop": "",
"supportedFormats": "",
"maxSize": "",
"dragAndDrop": "أو اسحب وأسقط",
"supportedFormats": "الصيغ المدعومة",
"maxSize": "الحجم الأقصى",
"orDragAndDrop": "أو اسحب وأفلت",
"errors": {
"invalidFileSize": "",
"invalidFileFormat": ""
"invalidFileSize": "حجم الملف كبير جدًا!",
"invalidFileFormat": "تنسيق الملف غير مدعوم!"
}
},
"headerStatusPageControls": {
"publicLink": ""
"publicLink": "رابط عام"
},
"headerTimeRange": {
"labels": {
@@ -155,26 +155,26 @@
"serverUnreachable": "تعذر الوصول إلى الخادم",
"retry": "إعادة المحاولة",
"retrying": "جارٍ إعادة المحاولة...",
"reconnected": ""
"reconnected": "تمت استعادة الاتصال"
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"notifications": "",
"uptime": "وقت التشغيل",
"pagespeed": "Pagespeed",
"infrastructure": "البنية التحتية",
"notifications": "الإشعارات",
"checks": "الفحوصات",
"incidents": "",
"statusPages": "",
"maintenance": "",
"logs": "",
"settings": ""
"incidents": "الحوادث",
"statusPages": "صفحات الحالة",
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات"
},
"bottomMenu": {
"support": "",
"discussions": "",
"docs": "",
"changelog": ""
"support": "الدعم",
"discussions": "المناقشات",
"docs": "الوثائق",
"changelog": "سجل التغييرات"
},
"accountMenu": {
"profile": "الملف الشخصي",
@@ -186,13 +186,13 @@
"description": "اطلع على أحدث الإصدارات وساعد في تنمية المجتمع على GitHub"
},
"authFooter": {
"navControls": "",
"logOut": "",
"navControls": "عناصر التحكم",
"logOut": "تسجيل الخروج",
"roles": {
"superAdmin": "مسؤول أعلى",
"admin": "مسؤول",
"user": "مستخدم",
"demoUser": ""
"demoUser": "مستخدم تجريبي"
}
}
},
@@ -259,15 +259,15 @@
"team": {
"filter": {
"placeholder": "تصفية حسب الدور",
"all": "",
"all": "كل الأدوار",
"admin": "مسؤول",
"member": ""
"member": "عضو"
},
"table": {
"headers": {
"email": "البريد الإلكتروني",
"role": "الدور",
"created": ""
"created": "تم الإنشاء في"
}
},
"addMember": {
@@ -364,7 +364,8 @@
"register": {
"title": "مرحباً بعودتك إلى Checkmate!",
"subtitle": "سجّل للبدء",
"submit": "تسجيل"
"submit": "تسجيل",
"setupNotice": "هذه الصفحة متاحة فقط أثناء الإعداد الأولي. بمجرد تسجيل المسؤول الأعلى، يمكن دعوة مستخدمين إضافيين من إعدادات الفريق."
},
"forgotPassword": {
"title": "نسيت كلمة المرور؟",
@@ -426,7 +427,8 @@
"activeFor": "نشط منذ",
"certificateExpiry": "انتهاء الشهادة",
"lastCheck": "آخر فحص",
"lastResponseTime": "آخر وقت استجابة"
"lastResponseTime": "آخر وقت استجابة",
"serviceIsDown": "الخدمة معطلة"
},
"status": {
"down": "متوقف",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على <gamedigLink>قائمة الألعاب المدعومة</gamedigLink> لمعرفة تفاصيل البروتوكول.",
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
@@ -767,6 +769,7 @@
"memory": "استخدام الذاكرة",
"netBytesRecv": "{{name}} - البايتات المستلمة",
"netBytesSent": "{{name}} - البايتات المرسلة",
"netNoTraffic": "Capture يبلغ عن عدم وجود حركة بيانات على أي واجهة. هذا أمر طبيعي عندما يعمل Capture داخل حاوية لا ترى سوى واجهات الاسترجاع — قم بتشغيل Capture مباشرة على المضيف لرؤية نشاط الشبكة الحقيقي.",
"temp": "الحرارة"
}
},
@@ -825,9 +828,9 @@
"description": "ستظهر هنا نشاط الخادم والتحذيرات والأخطاء أثناء تشغيل Checkmate."
},
"tabs": {
"diagnostics": "",
"logs": "",
"queue": ""
"diagnostics": "التشخيص",
"logs": "السجلات",
"queue": "قائمة المهام"
},
"logLevelSelect": {
"label": "مستوى السجل"
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "معرّف المحادثة",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "إعدادات Pushover",
"description": "أدخل رمز التطبيق ومفتاح المستخدم في Pushover.",
"optionAppToken": "رمز التطبيق",
"placeholderAppToken": "أدخل رمز التطبيق",
"optionUserKey": "مفتاح المستخدم",
"placeholderUserKey": "أدخل مفتاح المستخدم"
},
"twilio": {
"title": "إعدادات Twilio",
"description": "أدخل بيانات اعتماد حساب Twilio لإرسال الرسائل النصية.",
"optionAccountSid": "معرف الحساب (SID)",
"placeholderAccountSid": "أدخل معرف الحساب",
"optionAuthToken": "رمز المصادقة",
"placeholderAuthToken": "أدخل رمز المصادقة",
"optionFromNumber": "من الرقم",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "إلى الرقم",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "إضافة مراقبات نموذجية لأغراض العرض التوضيحي."
},
"removeMonitors": {
"title": "إعادة تعيين النظام",
"title": "إعادة ضبط النظام",
"description": "إزالة جميع المراقبات من نظامك.",
"dialog": {
"title": "إزالة جميع المراقبات؟",
"description": "لا يمكن التراجع عن هذا."
"description": "لا يمكن التراجع عن هذا الإجراء.",
"paragraph": "سيتم أيضًا إزالة جميع سجلات الفحص والحوادث ونوافذ الصيانة المجدولة وأي صفحات حالة كانت تعتمد على هذه المراقبات. صدّر المراقبات أولًا للاحتفاظ بنسخة احتياطية من بياناتها."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "جميع الأنظمة متوقفة",
"allUp": "جميع الأنظمة تعمل بشكل طبيعي",
"degraded": "بعض الأنظمة تواجه مشكلات",
"unknown": "تعذر تحديد حالة النظام"
"unknown": "تعذر تحديد حالة النظام",
"allBreached": "تجاوزت جميع الأنظمة الحدود",
"allMaintenance": "جميع الأنظمة قيد الصيانة",
"allPaused": "جميع الأنظمة متوقفة مؤقتًا",
"allInitializing": "جميع الأنظمة قيد التهيئة",
"breachedAndDown": "تجاوزت بعض الأنظمة الحدود وهي معطلة",
"breached": "تجاوزت بعض الأنظمة الحدود",
"maintenanceAndDown": "بعض الأنظمة معطلة أثناء الصيانة",
"maintenance": "بعض الأنظمة قيد الصيانة",
"partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا",
"initializing": "بعض الأنظمة قيد التهيئة",
"noMonitors": "لم يتم تكوين أي شاشة"
},
"table": {
"headers": {
+75 -40
View File
@@ -39,7 +39,7 @@
"flushQueue": "Vyprázdnit frontu",
"notFound": "Přejít na hlavní přehled",
"resetPassword": "Obnovit heslo",
"reset": "",
"reset": "Resetovat",
"clear": "Vymazat",
"addDemo": "Přidat ukázkové monitory",
"removeMonitors": "Odebrat monitory",
@@ -53,8 +53,8 @@
"labels": {
"averageResponseTime": "Průměrná doba odezvy",
"downtime": "Výpadek",
"high": "",
"low": "",
"high": "Vysoké",
"low": "Nízké",
"uptime": "Dostupnost"
},
"histogram": {
@@ -107,14 +107,14 @@
"table": {
"empty": "Zde nic není",
"headers": {
"actions": "",
"actions": "Akce",
"dateTime": "Datum a čas",
"message": "Zpráva",
"monitor": "Monitor",
"monitorId": "ID monitoru",
"name": "Název",
"status": "Stav",
"type": "",
"type": "Typ",
"url": "URL",
"interval": "Interval",
"active": "Aktivní",
@@ -125,17 +125,17 @@
"components": {
"imageUpload": {
"clickToUpload": "Klikněte pro nahrání",
"dragAndDrop": "",
"supportedFormats": "",
"maxSize": "",
"dragAndDrop": "nebo přetáhněte",
"supportedFormats": "Podporované formáty",
"maxSize": "Max. velikost",
"orDragAndDrop": "nebo přetáhněte soubor",
"errors": {
"invalidFileSize": "",
"invalidFileFormat": ""
"invalidFileSize": "Soubor je příliš velký!",
"invalidFileFormat": "Nepodporovaný formát souboru!"
}
},
"headerStatusPageControls": {
"publicLink": ""
"publicLink": "Veřejný odkaz"
},
"headerTimeRange": {
"labels": {
@@ -159,22 +159,22 @@
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"notifications": "",
"uptime": "Dostupnost",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastruktura",
"notifications": "Oznámení",
"checks": "Kontroly",
"incidents": "",
"statusPages": "",
"maintenance": "",
"logs": "",
"settings": ""
"incidents": "Incidenty",
"statusPages": "Stavové stránky",
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení"
},
"bottomMenu": {
"support": "",
"discussions": "",
"docs": "",
"changelog": ""
"support": "Podpora",
"discussions": "Diskuze",
"docs": "Dokumentace",
"changelog": "Záznam změn"
},
"accountMenu": {
"profile": "Profil",
@@ -186,13 +186,13 @@
"description": "Sledujte nejnovější vydání a pomozte rozšířit komunitu na GitHubu"
},
"authFooter": {
"navControls": "",
"logOut": "",
"navControls": "Ovládací prvky",
"logOut": "Odhlásit se",
"roles": {
"superAdmin": "Superadministrátor",
"admin": "Administrátor",
"user": "Uživatel",
"demoUser": ""
"demoUser": "Demo uživatel"
}
}
},
@@ -259,15 +259,15 @@
"team": {
"filter": {
"placeholder": "Filtrovat podle role",
"all": "",
"all": "Všechny role",
"admin": "Administrátor",
"member": ""
"member": "Člen"
},
"table": {
"headers": {
"email": "Email",
"role": "Role",
"created": ""
"created": "Vytvořeno"
}
},
"addMember": {
@@ -364,7 +364,8 @@
"register": {
"title": "Vítejte v Checkmate!",
"subtitle": "Zaregistrujte se a začněte",
"submit": "Registrovat"
"submit": "Registrovat",
"setupNotice": "Tato stránka je k dispozici pouze během počátečního nastavení. Po zaregistrování super administrátora lze pozvat další uživatele z nastavení týmu."
},
"forgotPassword": {
"title": "Zapomněli jste heslo?",
@@ -426,7 +427,8 @@
"activeFor": "Aktivní po dobu",
"certificateExpiry": "Expirace certifikátu",
"lastCheck": "Poslední kontrola",
"lastResponseTime": "Poslední doba odezvy"
"lastResponseTime": "Poslední doba odezvy",
"serviceIsDown": "Služba je nedostupná"
},
"status": {
"down": "nedostupný",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "Zadejte IP adresu nebo název hostitele a číslo portu pro ping (např. 192.168.1.100 nebo example.com) a vyberte typ hry. Některé hry vyžadují povolení dotazů na server v jejich konfiguračním souboru — viz dokumentace vašeho herního serveru. Podrobnosti o protokolu najdete v <gamedigLink>seznamu podporovaných her</gamedigLink>.",
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
@@ -767,6 +769,7 @@
"memory": "Využití paměti",
"netBytesRecv": "{{name}} - přijaté bajty",
"netBytesSent": "{{name}} - odeslané bajty",
"netNoTraffic": "Capture hlásí nulový provoz na všech rozhraních. To je normální, když Capture běží uvnitř kontejneru, který vidí pouze loopback rozhraní — spusťte Capture přímo na hostiteli, abyste viděli skutečnou síťovou aktivitu.",
"temp": "Teplota"
}
},
@@ -825,9 +828,9 @@
"description": "Aktivita serveru, varování a chyby se zobrazí zde, jak Checkmate běží."
},
"tabs": {
"diagnostics": "",
"logs": "",
"queue": ""
"diagnostics": "Diagnostika",
"logs": "Záznamy",
"queue": "Fronta úloh"
},
"logLevelSelect": {
"label": "Úroveň logování"
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID chatu",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Konfigurace Pushover",
"description": "Zadejte token aplikace a uživatelský klíč Pushover.",
"optionAppToken": "Token aplikace",
"placeholderAppToken": "Zadejte token aplikace",
"optionUserKey": "Uživatelský klíč",
"placeholderUserKey": "Zadejte uživatelský klíč"
},
"twilio": {
"title": "Konfigurace Twilio",
"description": "Zadejte přihlašovací údaje účtu Twilio pro odesílání SMS.",
"optionAccountSid": "SID účtu",
"placeholderAccountSid": "Zadejte SID účtu",
"optionAuthToken": "Autentizační token",
"placeholderAuthToken": "Zadejte autentizační token",
"optionFromNumber": "Z čísla",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Na číslo",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "Přidejte ukázkové monitory pro demonstrační účely."
},
"removeMonitors": {
"title": "Obnovení systému",
"description": "Odeberte všechny monitory z vašeho systému.",
"title": "Reset systému",
"description": "Odstranit všechny monitory ze systému.",
"dialog": {
"title": "Odebrat všechny monitory?",
"description": "Tuto akci nelze vrátit zpět."
"description": "Tuto akci nelze vrátit zpět.",
"paragraph": "Smaže se také veškerá historie kontrol, incidenty, naplánovaná údržbová okna a stavové stránky, které tyto monitory používaly. Pokud si chcete data zachovat, nejprve monitory exportujte."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Všechny systémy jsou nedostupné",
"allUp": "Všechny systémy fungují",
"degraded": "Některé systémy mají problémy",
"unknown": "Nelze určit stav systému"
"unknown": "Nelze určit stav systému",
"allBreached": "Všechny systémy překročily prahové hodnoty",
"allMaintenance": "Všechny systémy jsou v údržbě",
"allPaused": "Všechny systémy jsou pozastavené",
"allInitializing": "Všechny systémy se inicializují",
"breachedAndDown": "Některé systémy překročily prahové hodnoty a jsou nedostupné",
"breached": "Některé systémy překročily prahové hodnoty",
"maintenanceAndDown": "Některé systémy jsou nedostupné během údržby",
"maintenance": "Některé systémy jsou v údržbě",
"partiallyPaused": "Některé systémy jsou pozastavené",
"initializing": "Některé systémy se inicializují",
"noMonitors": "Nebyly nakonfigurovány žádné monitory"
},
"table": {
"headers": {
+41 -6
View File
@@ -364,7 +364,8 @@
"register": {
"title": "Willkommen bei Checkmate!",
"subtitle": "Registrieren Sie sich, um loszulegen",
"submit": "Registrieren"
"submit": "Registrieren",
"setupNotice": "Diese Seite ist nur während der Ersteinrichtung verfügbar. Sobald ein Super-Admin registriert ist, können weitere Benutzer in den Team-Einstellungen eingeladen werden."
},
"forgotPassword": {
"title": "Passwort vergessen?",
@@ -426,7 +427,8 @@
"activeFor": "Aktiv seit",
"certificateExpiry": "Zertifikatablauf",
"lastCheck": "Letzter Check",
"lastResponseTime": "Letzte Antwortzeit"
"lastResponseTime": "Letzte Antwortzeit",
"serviceIsDown": "Dienst ist nicht erreichbar"
},
"status": {
"down": "ausgefallen",
@@ -540,7 +542,7 @@
"ping": "Geben Sie die IP-Adresse oder den Hostnamen ein, der angepingt werden soll (z. B. 192.168.1.100 oder example.com), und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard angezeigt wird.",
"port": "Geben Sie die URL oder IP des Servers, die Portnummer und einen eindeutigen Anzeigenamen ein, der auf dem Dashboard angezeigt wird.",
"docker": "Geben Sie die Docker-ID Ihres Containers ein. Docker-IDs müssen die vollständige 64-Zeichen-ID sein. Sie können docker inspect <short_id> ausführen, um die vollständige Container-ID abzurufen.",
"game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen.",
"game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen. Einige Spiele erfordern das Aktivieren von Server-Abfragen in ihrer Konfigurationsdatei — sieh in der Dokumentation deines Spielservers nach. In der <gamedigLink>Liste unterstützter Spiele</gamedigLink> findest du Protokolldetails.",
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
@@ -767,6 +769,7 @@
"memory": "Arbeitsspeicherauslastung",
"netBytesRecv": "{{name}} - Empfangene Bytes",
"netBytesSent": "{{name}} - Gesendete Bytes",
"netNoTraffic": "Capture meldet null Datenverkehr auf allen Schnittstellen. Das ist normal, wenn Capture in einem Container läuft, der nur Loopback-Schnittstellen sieht — führe Capture direkt auf dem Host aus, um echten Netzwerkverkehr zu sehen.",
"temp": "Temp."
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat-ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover-Konfiguration",
"description": "Geben Sie Ihr Anwendungstoken und Ihren Benutzerschlüssel von Pushover ein.",
"optionAppToken": "Anwendungstoken",
"placeholderAppToken": "Anwendungstoken eingeben",
"optionUserKey": "Benutzerschlüssel",
"placeholderUserKey": "Benutzerschlüssel eingeben"
},
"twilio": {
"title": "Twilio-Konfiguration",
"description": "Geben Sie Ihre Twilio-Kontoanmeldeinformationen ein, um SMS zu senden.",
"optionAccountSid": "Account-SID",
"placeholderAccountSid": "Account-SID eingeben",
"optionAuthToken": "Auth-Token",
"placeholderAuthToken": "Auth-Token eingeben",
"optionFromNumber": "Absendernummer",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Empfängernummer",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "System zurücksetzen",
"description": "Alle Monitore aus Ihrem System entfernen.",
"description": "Alle Monitore aus dem System entfernen.",
"dialog": {
"title": "Alle Monitore entfernen?",
"description": "Dies kann nicht rückgängig gemacht werden."
"description": "Diese Aktion kann nicht rückgängig gemacht werden.",
"paragraph": "Dabei werden auch der gesamte Prüfverlauf, alle Vorfälle, geplante Wartungsfenster sowie Statusseiten entfernt, die diese Monitore verwenden. Exportiere die Monitore zuerst, wenn du eine Sicherung der Daten behalten möchtest."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Alle Systeme sind ausgefallen",
"allUp": "Alle Systeme funktionsfähig",
"degraded": "Einige Systeme haben Probleme",
"unknown": "Systemstatus kann nicht ermittelt werden"
"unknown": "Systemstatus kann nicht ermittelt werden",
"allBreached": "Alle Systeme haben Schwellenwerte überschritten",
"allMaintenance": "Alle Systeme befinden sich in Wartung",
"allPaused": "Alle Systeme sind pausiert",
"allInitializing": "Alle Systeme werden initialisiert",
"breachedAndDown": "Einige Systeme haben Schwellenwerte überschritten und sind ausgefallen",
"breached": "Einige Systeme haben Schwellenwerte überschritten",
"maintenanceAndDown": "Einige Systeme sind während der Wartung ausgefallen",
"maintenance": "Einige Systeme befinden sich in Wartung",
"partiallyPaused": "Einige Systeme sind pausiert",
"initializing": "Einige Systeme werden initialisiert",
"noMonitors": "Es wurden keine Monitore konfiguriert"
},
"table": {
"headers": {
+60 -2
View File
@@ -542,7 +542,7 @@
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
@@ -769,6 +769,7 @@
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"netNoTraffic": "Capture is reporting zero traffic on every interface. This is normal when Capture runs inside a container that can only see loopback interfaces — run Capture directly on the host to see real network activity.",
"temp": "Temp"
}
},
@@ -1197,6 +1198,10 @@
"label": "Email address - Used for authentication",
"placeholder": "you@example.com"
},
"displayName": {
"label": "Sender name (optional) - Friendly name shown in the From field",
"placeholder": "Checkmate notifications"
},
"user": {
"label": "Email user - Username for authentication, overrides email address if specified",
"placeholder": "Leave empty if not required"
@@ -1240,7 +1245,8 @@
"description": "Remove all monitors from your system.",
"dialog": {
"title": "Remove all monitors?",
"description": "This cannot be undone."
"description": "This cannot be undone.",
"paragraph": "All check history, incidents, scheduled maintenance windows, and any status pages that referenced these monitors will be removed too. Export your monitors first to keep a backup of their data."
}
},
"exportMonitors": {
@@ -1274,6 +1280,20 @@
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram",
"noData": "No data available",
"status": {
"up": "Operational",
"down": "Down",
"breached": "Threshold exceeded",
"maintenance": "Maintenance",
"paused": "Paused",
"initializing": "Initializing"
},
"chart": {
"avg": "avg {{value}}ms",
"max": "max {{value}}ms",
"downTooltip": "Down",
"heatmapAria": "Response time heatmap"
},
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
@@ -1290,6 +1310,8 @@
}
},
"statusBar": {
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
"allUp": "All systems operational",
"allBreached": "All systems have exceeded thresholds",
"allMaintenance": "All systems are under maintenance",
@@ -1321,6 +1343,13 @@
"addMonitor": "Add a monitor to get started"
}
},
"footer": {
"poweredBy": "Powered by"
},
"editorial": {
"reportTitle": "Service Report",
"allUp": "All systems are operating normally."
},
"form": {
"access": {
"title": "Access",
@@ -1378,6 +1407,35 @@
}
}
},
"theme": {
"title": "Page theme",
"description": "Choose the visual style for your public status page.",
"label": "Theme",
"options": {
"refined": {
"name": "Refined",
"description": "Clean, professional, close to the classic Checkmate look."
},
"modern": {
"name": "Modern",
"description": "SaaS-grade with a pulsing status dot and softly fading cards."
},
"bold": {
"name": "Bold",
"description": "Large type and confident spacing. Great for brand-forward pages."
},
"editorial": {
"name": "Editorial",
"description": "Serif headings and hairline dividers. Distinctive and calm."
}
}
},
"themeMode": {
"label": "Theme mode",
"auto": "Auto (follow visitor's device)",
"light": "Light only",
"dark": "Dark only"
},
"features": {
"title": "Features",
"description": "Configure what information is displayed on your status page.",
+134 -85
View File
@@ -13,7 +13,7 @@
"content": "Advertencia: Aún no ha agregado una clave de API de Google PageSpeed. Visite <settingsLink>Configuración</settingsLink> para agregar una. Sin ella, el monitor de PageSpeed no funcionará."
}
},
"appName": "",
"appName": "Checkmate",
"breadcrumbs": {
"details": "Detalles",
"home": "Inicio"
@@ -35,11 +35,11 @@
"sendInvite": "Enviar invitación",
"test": "Probar",
"testNotifications": "Probar notificaciones",
"toggleTheme": "",
"toggleTheme": "Cambiar tema claro / oscuro",
"flushQueue": "Vaciar cola",
"notFound": "Ir al panel principal",
"resetPassword": "",
"reset": "",
"resetPassword": "Restablecer contraseña",
"reset": "Restablecer",
"clear": "Limpiar",
"addDemo": "Agregar monitores de demostración",
"removeMonitors": "Eliminar monitores",
@@ -47,7 +47,14 @@
"exportToJSON": "Exportar a JSON",
"importFromJSON": "Importar desde JSON",
"clearFilters": "Limpiar filtros",
"removeUser": "Eliminar usuario"
"removeUser": "Eliminar usuario",
"testNotification": "Probar notificación",
"invite": "Invitar",
"copyLink": "Copiar enlace",
"regenerateToken": "Regenerar token",
"copyToken": "Copiar token",
"exportLogs": "Exportar registros",
"refresh": "Actualizar"
},
"charts": {
"labels": {
@@ -86,12 +93,12 @@
"name": {
"option": {
"firstName": {
"label": "",
"placeholder": ""
"label": "Nombre",
"placeholder": "Introduce tu nombre"
},
"lastName": {
"label": "",
"placeholder": ""
"label": "Apellidos",
"placeholder": "Introduce tus apellidos"
}
}
},
@@ -99,7 +106,7 @@
"option": {
"email": {
"label": "Correo electrónico",
"placeholder": "you@example.com"
"placeholder": "tu@ejemplo.com"
}
}
}
@@ -107,14 +114,14 @@
"table": {
"empty": "No hay nada aquí",
"headers": {
"actions": "",
"actions": "Acciones",
"dateTime": "Fecha y hora",
"message": "Mensaje",
"monitor": "Monitor",
"monitorId": "ID de monitor",
"name": "Nombre",
"status": "Estado",
"type": "",
"type": "Tipo",
"url": "URL",
"interval": "Intervalo",
"active": "Activo",
@@ -125,17 +132,17 @@
"components": {
"imageUpload": {
"clickToUpload": "Haga clic para subir",
"dragAndDrop": "",
"supportedFormats": "",
"maxSize": "",
"dragAndDrop": "o arrastra y suelta",
"supportedFormats": "Formatos admitidos",
"maxSize": "Tamaño máximo",
"orDragAndDrop": "o arrastre y suelte",
"errors": {
"invalidFileSize": "",
"invalidFileFormat": ""
"invalidFileSize": "¡El archivo es demasiado grande!",
"invalidFileFormat": "¡Formato de archivo no admitido!"
}
},
"headerStatusPageControls": {
"publicLink": ""
"publicLink": "Enlace público"
},
"headerTimeRange": {
"labels": {
@@ -155,26 +162,26 @@
"serverUnreachable": "No se puede conectar al servidor",
"retry": "Reintentar",
"retrying": "Reintentando...",
"reconnected": ""
"reconnected": "Conexión restablecida"
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"notifications": "",
"uptime": "Disponibilidad",
"pagespeed": "Pagespeed",
"infrastructure": "Infraestructura",
"notifications": "Notificaciones",
"checks": "Verificaciones",
"incidents": "",
"statusPages": "",
"maintenance": "",
"logs": "",
"settings": ""
"incidents": "Incidentes",
"statusPages": "Páginas de estado",
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración"
},
"bottomMenu": {
"support": "",
"discussions": "",
"docs": "",
"changelog": ""
"support": "Soporte",
"discussions": "Discusiones",
"docs": "Documentación",
"changelog": "Registro de cambios"
},
"accountMenu": {
"profile": "Perfil",
@@ -186,13 +193,13 @@
"description": "Vea las últimas versiones y ayude a crecer la comunidad en GitHub"
},
"authFooter": {
"navControls": "",
"logOut": "",
"navControls": "Controles",
"logOut": "Cerrar sesión",
"roles": {
"superAdmin": "Superadministrador",
"admin": "Administrador",
"user": "Usuario",
"demoUser": ""
"demoUser": "Usuario demo"
}
}
},
@@ -259,15 +266,15 @@
"team": {
"filter": {
"placeholder": "Filtrar por rol",
"all": "",
"all": "Todos los roles",
"admin": "Administrador",
"member": ""
"member": "Miembro"
},
"table": {
"headers": {
"email": "Correo electrónico",
"role": "Rol",
"created": ""
"created": "Creado el"
}
},
"addMember": {
@@ -306,35 +313,35 @@
"common": {
"passwordRules": {
"length": {
"beginning": "",
"highlighted": ""
"beginning": "Debe tener al menos",
"highlighted": "8 caracteres"
},
"lowercase": {
"beginning": "",
"highlighted": ""
"beginning": "Debe contener al menos",
"highlighted": "una letra minúscula"
},
"match": {
"beginning": "",
"highlighted": ""
"beginning": "Las contraseñas",
"highlighted": "deben coincidir"
},
"number": {
"beginning": "",
"highlighted": ""
"beginning": "Debe contener al menos",
"highlighted": "un número"
},
"special": {
"beginning": "",
"highlighted": ""
"beginning": "Debe contener al menos",
"highlighted": "un carácter especial (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)"
},
"uppercase": {
"beginning": "",
"highlighted": ""
"beginning": "Debe contener al menos",
"highlighted": "una letra mayúscula"
}
},
"form": {
"option": {
"email": {
"label": "Correo electrónico",
"placeholder": "me@example.com"
"placeholder": "yo@ejemplo.com"
},
"password": {
"label": "Contraseña",
@@ -364,7 +371,8 @@
"register": {
"title": "¡Bienvenido de nuevo a Checkmate!",
"subtitle": "Regístrese para comenzar",
"submit": "Registrarse"
"submit": "Registrarse",
"setupNotice": "Esta página solo está disponible durante la configuración inicial. Una vez que se haya registrado un super administrador, se podrá invitar a usuarios adicionales desde la configuración del equipo."
},
"forgotPassword": {
"title": "¿Olvidó su contraseña?",
@@ -426,7 +434,8 @@
"activeFor": "Activo desde hace",
"certificateExpiry": "Vencimiento del certificado",
"lastCheck": "Última verificación",
"lastResponseTime": "Último tiempo de respuesta"
"lastResponseTime": "Último tiempo de respuesta",
"serviceIsDown": "El servicio está caído"
},
"status": {
"down": "caído",
@@ -467,9 +476,9 @@
},
"matchMethod": {
"label": "Método de coincidencia",
"equal": "",
"include": "",
"regex": ""
"equal": "Igual",
"include": "Contiene",
"regex": "Regex"
}
},
"title": "Configuración avanzada"
@@ -481,15 +490,15 @@
"label": "Frecuencia de verificación",
"value": {
"fifteenMinutes": "15 minutos",
"fifteenSeconds": "",
"fifteenSeconds": "15 segundos",
"fiveMinutes": "5 minutos",
"fourMinutes": "",
"oneMinute": "",
"fourMinutes": "4 minutos",
"oneMinute": "1 minuto",
"tenMinutes": "10 minutos",
"thirtyMinutes": "30 minutos",
"thirtySeconds": "",
"threeMinutes": "",
"twoMinutes": ""
"thirtySeconds": "30 segundos",
"threeMinutes": "3 minutos",
"twoMinutes": "2 minutos"
}
}
},
@@ -518,8 +527,8 @@
"placeholder": "https://www.google.com"
},
"game": {
"label": "",
"placeholder": ""
"label": "Elige el juego",
"placeholder": "Selecciona un juego"
},
"port": {
"label": "Puerto a monitorear",
@@ -537,10 +546,10 @@
"title": "Configuración general",
"description": {
"http": "Ingrese la URL o IP a monitorear (p. ej., https://example.com/ o 192.168.1.100) y agregue un nombre para mostrar que aparecerá en el panel.",
"ping": "",
"port": "",
"docker": "",
"game": "",
"ping": "Introduce la dirección IP o el nombre de host para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel.",
"port": "Introduce la URL o IP del servidor, el número de puerto y un nombre descriptivo que aparecerá en el panel.",
"docker": "Introduce el nombre o ID del contenedor de Docker. Puedes usar el nombre del contenedor (p. ej., my-app) o su ID.",
"game": "Introduce la dirección IP o el nombre de host y el número de puerto para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel. Algunos juegos requieren habilitar consultas del servidor en su archivo de configuración — consulta la documentación de tu servidor. Consulta la <gamedigLink>lista de juegos compatibles</gamedigLink> para ver los detalles del protocolo.",
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
@@ -745,7 +754,7 @@
"headers": {
"endTime": "Hora de fin",
"resolutionType": "Tipo de resolución",
"startTime": "",
"startTime": "Hora de inicio",
"statusCode": "Código de estado"
},
"resolvedIncidents": "Incidentes resueltos",
@@ -767,6 +776,7 @@
"memory": "Uso de memoria",
"netBytesRecv": "{{name}} - Bytes recibidos",
"netBytesSent": "{{name}} - Bytes enviados",
"netNoTraffic": "Capture no informa tráfico en ninguna interfaz. Esto es normal cuando Capture se ejecuta dentro de un contenedor que solo ve interfaces de loopback — ejecuta Capture directamente en el host para ver actividad de red real.",
"temp": "Temp"
}
},
@@ -788,10 +798,10 @@
}
},
"statBoxes": {
"avgCpuTemperature": "",
"cpuFrequency": "",
"cpuLogical": "",
"cpuPhysical": "",
"avgCpuTemperature": "Temperatura media de la CPU",
"cpuFrequency": "Frecuencia de la CPU",
"cpuLogical": "CPU (lógicas)",
"cpuPhysical": "CPU (físicas)",
"disk": "Disco",
"memory": "Memoria",
"os": "SO"
@@ -805,7 +815,7 @@
},
"tabs": {
"labels": {
"network": "",
"network": "Red",
"overview": "Resumen"
}
},
@@ -825,9 +835,9 @@
"description": "La actividad del servidor, advertencias y errores aparecerán aquí mientras Checkmate se ejecuta."
},
"tabs": {
"diagnostics": "",
"logs": "",
"queue": ""
"diagnostics": "Diagnóstico",
"logs": "Registros",
"queue": "Cola de trabajos"
},
"logLevelSelect": {
"label": "Nivel de registro"
@@ -865,7 +875,7 @@
},
"sections": {
"runtimeStats": {
"title": "Estadísticas de ejecución",
"title": "Estadísticas de tiempo de ejecución",
"description": "Instantánea del proceso Node.js y la máquina anfitriona. Se actualiza cada 5 segundos."
},
"memoryCpu": {
@@ -908,8 +918,8 @@
},
"table": {
"headers": {
"nextWindow": "",
"repeat": ""
"nextWindow": "Próxima ventana",
"repeat": "Repetición"
}
},
"form": {
@@ -1016,6 +1026,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID de chat",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Configuración de Pushover",
"description": "Introduce tu token de aplicación y la clave de usuario de Pushover.",
"optionAppToken": "Token de aplicación",
"placeholderAppToken": "Introduce el token de aplicación",
"optionUserKey": "Clave de usuario",
"placeholderUserKey": "Introduce la clave de usuario"
},
"twilio": {
"title": "Configuración de Twilio",
"description": "Introduce las credenciales de tu cuenta de Twilio para enviar SMS.",
"optionAccountSid": "SID de la cuenta",
"placeholderAccountSid": "Introduce el SID de la cuenta",
"optionAuthToken": "Token de autenticación",
"placeholderAuthToken": "Introduce el token de autenticación",
"optionFromNumber": "Número de origen",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Número de destino",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1160,7 +1190,7 @@
"email": {
"title": "Configuración de correo electrónico",
"description": "Configure los ajustes de correo electrónico de su sistema. Se usa para enviar notificaciones y alertas.",
"descriptionTransport": "",
"descriptionTransport": "Esto crea un transporte SMTP para NodeMailer",
"descriptionTransportLink": "Ver especificaciones aquí",
"option": {
"host": {
@@ -1214,11 +1244,12 @@
"description": "Agregue monitores de ejemplo con fines demostrativos."
},
"removeMonitors": {
"title": "Reinicio del sistema",
"description": "Elimine todos los monitores de su sistema.",
"title": "Reiniciar sistema",
"description": "Eliminar todos los monitores del sistema.",
"dialog": {
"title": "¿Eliminar todos los monitores?",
"description": "Esto no se puede deshacer."
"description": "Esta acción no se puede deshacer.",
"paragraph": "También se eliminarán todo el historial de comprobaciones, los incidentes, las ventanas de mantenimiento programadas y las páginas de estado que usaban estos monitores. Exporta los monitores primero si quieres conservar una copia de seguridad de sus datos."
}
},
"exportMonitors": {
@@ -1271,7 +1302,18 @@
"allDown": "Todos los sistemas están caídos",
"allUp": "Todos los sistemas operativos",
"degraded": "Algunos sistemas están experimentando problemas",
"unknown": "No se puede determinar el estado del sistema"
"unknown": "No se puede determinar el estado del sistema",
"allBreached": "Todos los sistemas han superado los umbrales",
"allMaintenance": "Todos los sistemas están en mantenimiento",
"allPaused": "Todos los sistemas están en pausa",
"allInitializing": "Todos los sistemas se están inicializando",
"breachedAndDown": "Algunos sistemas han superado los umbrales y están caídos",
"breached": "Algunos sistemas han superado los umbrales",
"maintenanceAndDown": "Algunos sistemas están caídos durante el mantenimiento",
"maintenance": "Algunos sistemas están en mantenimiento",
"partiallyPaused": "Algunos sistemas están en pausa",
"initializing": "Algunos sistemas se están inicializando",
"noMonitors": "No se ha configurado ningún monitor"
},
"table": {
"headers": {
@@ -1367,6 +1409,13 @@
"header": {
"title": "Páginas de estado",
"description": "Publica páginas públicas que muestren a tus clientes y partes interesadas la disponibilidad en tiempo real y el historial de incidentes."
},
"publicPage": {
"maintenance": {
"upcoming": "Mantenimiento programado",
"inProgress": "Mantenimiento en curso",
"completed": "Mantenimiento completado"
}
}
},
"uptime": {
+62 -27
View File
@@ -13,7 +13,7 @@
"content": "Varoitus: Et ole vielä lisännyt Google PageSpeed API -avainta. Siirry <settingsLink>Asetuksiin</settingsLink> lisätäksesi sellainen. Ilman sitä PageSpeed-monitorointi ei toimi."
}
},
"appName": "",
"appName": "Checkmate",
"breadcrumbs": {
"details": "Tiedot",
"home": "Etusivu"
@@ -35,10 +35,10 @@
"sendInvite": "Lähetä kutsu",
"test": "Testaa",
"testNotifications": "Testaa ilmoitukset",
"toggleTheme": "",
"toggleTheme": "Vaihda vaalea / tumma",
"flushQueue": "Tyhjennä jono",
"notFound": "Siirry pääsivulle",
"resetPassword": "",
"resetPassword": "Nollaa salasana",
"reset": "Palauta",
"clear": "Tyhjennä",
"addDemo": "Lisää demomonitorit",
@@ -53,8 +53,8 @@
"labels": {
"averageResponseTime": "Keskimääräinen vasteaika",
"downtime": "Käyttökatko",
"high": "",
"low": "",
"high": "Korkea",
"low": "Matala",
"uptime": "Käytettävyys"
},
"histogram": {
@@ -86,12 +86,12 @@
"name": {
"option": {
"firstName": {
"label": "",
"placeholder": ""
"label": "Etunimi",
"placeholder": "Anna etunimesi"
},
"lastName": {
"label": "",
"placeholder": ""
"label": "Sukunimi",
"placeholder": "Anna sukunimesi"
}
}
},
@@ -107,14 +107,14 @@
"table": {
"empty": "Täällä ei ole mitään",
"headers": {
"actions": "",
"actions": "Toiminnot",
"dateTime": "Päivämäärä ja aika",
"message": "Viesti",
"monitor": "Monitori",
"monitorId": "Monitorin tunnus",
"name": "Nimi",
"status": "Tila",
"type": "",
"type": "Tyyppi",
"url": "Osoite",
"interval": "Aikaväli",
"active": "Aktiivinen",
@@ -131,7 +131,7 @@
"orDragAndDrop": "tai vedä ja pudota",
"errors": {
"invalidFileSize": "Tiedoston koko on liian suuri!",
"invalidFileFormat": ""
"invalidFileFormat": "Tiedostomuotoa ei tueta!"
}
},
"headerStatusPageControls": {
@@ -155,19 +155,19 @@
"serverUnreachable": "Palvelimeen ei saada yhteyttä",
"retry": "Yritä uudelleen",
"retrying": "Yritetään uudelleen...",
"reconnected": ""
"reconnected": "Yhteys palautettu"
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"uptime": "Käytettävyys",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastruktuuri",
"notifications": "",
"notifications": "Ilmoitukset",
"checks": "Tarkistukset",
"incidents": "",
"incidents": "Häiriöt",
"statusPages": "Tilasivut",
"maintenance": "Huolto",
"logs": "",
"logs": "Lokit",
"settings": "Asetukset"
},
"bottomMenu": {
@@ -364,7 +364,8 @@
"register": {
"title": "Tervetuloa takaisin Checkmateen!",
"subtitle": "Rekisteröidy aloittaaksesi",
"submit": "Rekisteröidy"
"submit": "Rekisteröidy",
"setupNotice": "Tämä sivu on käytettävissä vain alkuasennuksen aikana. Kun super-admin on rekisteröity, lisäkäyttäjiä voi kutsua tiimiasetuksista."
},
"forgotPassword": {
"title": "Unohditko salasanasi?",
@@ -426,7 +427,8 @@
"activeFor": "Aktiivinen",
"certificateExpiry": "Varmenteen vanheneminen",
"lastCheck": "Viimeisin tarkistus",
"lastResponseTime": "Viimeisin vasteaika"
"lastResponseTime": "Viimeisin vasteaika",
"serviceIsDown": "Palvelu ei ole käytettävissä"
},
"status": {
"down": "alhaalla",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "Anna IP-osoite tai isäntänimi ja portin numero pingausta varten (esim. 192.168.1.100 tai example.com) ja valitse pelityyppi. Jotkin pelit vaativat palvelinkyselyiden käyttöönoton asetustiedostossaan — tarkista pelipalvelimesi dokumentaatio. Katso protokollatiedot <gamedigLink>tuettujen pelien luettelosta</gamedigLink>.",
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
@@ -767,6 +769,7 @@
"memory": "Muistin käyttö",
"netBytesRecv": "{{name}} - tavua vastaanotettu",
"netBytesSent": "{{name}} - tavua lähetetty",
"netNoTraffic": "Capture ilmoittaa nollaliikennettä kaikilla rajapinnoilla. Tämä on normaalia, kun Capture käynnistetään säiliössä, joka näkee vain loopback-rajapinnat — suorita Capture suoraan isännällä nähdäksesi todellisen verkkoaktiviteetin.",
"temp": "Lämpö"
}
},
@@ -825,9 +828,9 @@
"description": "Palvelimen toiminta, varoitukset ja virheet näkyvät täällä Checkmaten toimiessa."
},
"tabs": {
"diagnostics": "",
"logs": "",
"queue": ""
"diagnostics": "Diagnostiikka",
"logs": "Lokit",
"queue": "Työjono"
},
"logLevelSelect": {
"label": "Lokitaso"
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat-tunnus",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover-määritys",
"description": "Anna Pushover-sovellustunnus ja käyttäjäavain.",
"optionAppToken": "Sovellustunnus",
"placeholderAppToken": "Anna sovellustunnus",
"optionUserKey": "Käyttäjäavain",
"placeholderUserKey": "Anna käyttäjäavain"
},
"twilio": {
"title": "Twilio-määritys",
"description": "Anna Twilio-tilin tunnistetiedot tekstiviestien lähettämistä varten.",
"optionAccountSid": "Tilin SID",
"placeholderAccountSid": "Anna tilin SID",
"optionAuthToken": "Todennustunnus",
"placeholderAuthToken": "Anna todennustunnus",
"optionFromNumber": "Lähettäjän numero",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Vastaanottajan numero",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "Lisää esimerkkimonitoreja esittelytarkoituksiin."
},
"removeMonitors": {
"title": "Järjestelmän nollaus",
"title": "Järjestelmän palautus",
"description": "Poista kaikki monitorit järjestelmästäsi.",
"dialog": {
"title": "Poistetaanko kaikki monitorit?",
"description": "Tätä ei voi kumota."
"description": "Tätä toimintoa ei voi kumota.",
"paragraph": "Mukana poistuvat myös kaikki tarkistushistoria, häiriöt, ajoitetut huoltoikkunat sekä tilasivut, jotka käyttivät näitä monitoreja. Vie monitorit ensin, jos haluat säilyttää varmuuskopion niiden tiedoista."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Kaikki järjestelmät ovat alhaalla",
"allUp": "Kaikki järjestelmät toiminnassa",
"degraded": "Joissakin järjestelmissä on ongelmia",
"unknown": "Järjestelmän tilaa ei voida määrittää"
"unknown": "Järjestelmän tilaa ei voida määrittää",
"allBreached": "Kaikki järjestelmät ovat ylittäneet kynnysarvot",
"allMaintenance": "Kaikki järjestelmät ovat huollossa",
"allPaused": "Kaikki järjestelmät on keskeytetty",
"allInitializing": "Kaikki järjestelmät alustuvat",
"breachedAndDown": "Jotkin järjestelmät ovat ylittäneet kynnysarvot ja ovat alhaalla",
"breached": "Jotkin järjestelmät ovat ylittäneet kynnysarvot",
"maintenanceAndDown": "Jotkin järjestelmät ovat alhaalla huollon aikana",
"maintenance": "Jotkin järjestelmät ovat huollossa",
"partiallyPaused": "Jotkin järjestelmät on keskeytetty",
"initializing": "Jotkin järjestelmät alustuvat",
"noMonitors": "Yhtään monitoria ei ole määritetty"
},
"table": {
"headers": {
+42 -7
View File
@@ -364,7 +364,8 @@
"register": {
"title": "Bienvenue sur Checkmate !",
"subtitle": "Inscrivez-vous pour commencer",
"submit": "S'inscrire"
"submit": "S'inscrire",
"setupNotice": "Cette page n'est disponible que lors de la configuration initiale. Une fois qu'un super administrateur est enregistré, des utilisateurs supplémentaires peuvent être invités depuis les paramètres de l'équipe."
},
"forgotPassword": {
"title": "Mot de passe oublié ?",
@@ -426,7 +427,8 @@
"activeFor": "Actif depuis",
"certificateExpiry": "Expiration du certificat",
"lastCheck": "Dernière vérification",
"lastResponseTime": "Dernier temps de réponse"
"lastResponseTime": "Dernier temps de réponse",
"serviceIsDown": "Le service est indisponible"
},
"status": {
"down": "hors ligne",
@@ -540,7 +542,7 @@
"ping": "Entrez l'adresse IP ou le nom d'hôte à tester (par exemple, 192.168.1.100 ou exemple.fr) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.",
"port": "Entrez l'URL ou l'adresse IP du serveur, le numéro de port et un nom d'affichage familier qui apparaîtra sur le tableau de bord.",
"docker": "Entrer l'ID Docker du container. Les identifiants Docker doivent être les 64 caractères de l'ID Docker. Vous pouvez utiliser la commande docker inspect <short_id> pour avoir l'ID complet.",
"game": "",
"game": "Saisissez l'adresse IP ou le nom d'hôte et le numéro de port à interroger (par ex. 192.168.1.100 ou example.com) et choisissez le type de jeu. Certains jeux nécessitent l'activation des requêtes serveur dans leur fichier de configuration — consultez la documentation de votre serveur de jeu. Voir la <gamedigLink>liste des jeux pris en charge</gamedigLink> pour les détails du protocole.",
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
@@ -767,6 +769,7 @@
"memory": "Utilisation mémoire",
"netBytesRecv": "{{name}} - Octets reçus",
"netBytesSent": "{{name}} - Octets envoyés",
"netNoTraffic": "Capture ne signale aucun trafic sur toutes les interfaces. C'est normal lorsque Capture s'exécute dans un conteneur qui ne voit que les interfaces de loopback — exécutez Capture directement sur l'hôte pour voir l'activité réseau réelle.",
"temp": "Temp."
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID du chat",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Configuration Pushover",
"description": "Entrez votre jeton d'application et votre clé utilisateur Pushover.",
"optionAppToken": "Jeton d'application",
"placeholderAppToken": "Entrez le jeton d'application",
"optionUserKey": "Clé utilisateur",
"placeholderUserKey": "Entrez la clé utilisateur"
},
"twilio": {
"title": "Configuration Twilio",
"description": "Entrez les identifiants de votre compte Twilio pour envoyer des SMS.",
"optionAccountSid": "SID du compte",
"placeholderAccountSid": "Entrez le SID du compte",
"optionAuthToken": "Jeton d'authentification",
"placeholderAuthToken": "Entrez le jeton d'authentification",
"optionFromNumber": "Numéro d'origine",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Numéro de destination",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "Ajoutez des moniteurs d'exemple à des fins de démonstration."
},
"removeMonitors": {
"title": "Réinitialisation du système",
"description": "Supprimez tous les moniteurs de votre système.",
"title": "Réinitialiser le système",
"description": "Supprimer tous les moniteurs de votre système.",
"dialog": {
"title": "Supprimer tous les moniteurs ?",
"description": "Cette action est irréversible."
"description": "Cette action est irréversible.",
"paragraph": "L'historique des vérifications, les incidents, les fenêtres de maintenance planifiées et les pages de statut qui utilisaient ces moniteurs seront également supprimés. Exportez d'abord les moniteurs si vous souhaitez conserver une sauvegarde de leurs données."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Tous les systèmes sont hors service",
"allUp": "Tous les systèmes sont opérationnels",
"degraded": "Certains systèmes rencontrent des problèmes",
"unknown": "Impossible de déterminer le statut du système"
"unknown": "Impossible de déterminer le statut du système",
"allBreached": "Tous les systèmes ont dépassé les seuils",
"allMaintenance": "Tous les systèmes sont en maintenance",
"allPaused": "Tous les systèmes sont en pause",
"allInitializing": "Tous les systèmes sont en cours d'initialisation",
"breachedAndDown": "Certains systèmes ont dépassé les seuils et sont hors ligne",
"breached": "Certains systèmes ont dépassé les seuils",
"maintenanceAndDown": "Certains systèmes sont hors ligne pendant la maintenance",
"maintenance": "Certains systèmes sont en maintenance",
"partiallyPaused": "Certains systèmes sont en pause",
"initializing": "Certains systèmes sont en cours d'initialisation",
"noMonitors": "Aucun moniteur n'a été configuré"
},
"table": {
"headers": {
+40 -5
View File
@@ -364,7 +364,8 @@
"register": {
"title": "Checkmateへようこそ!",
"subtitle": "サインアップして始めましょう",
"submit": "登録"
"submit": "登録",
"setupNotice": "このページは初期セットアップ中のみ利用できます。スーパー管理者が登録されると、追加のユーザーはチーム設定から招待できます。"
},
"forgotPassword": {
"title": "パスワードをお忘れですか?",
@@ -426,7 +427,8 @@
"activeFor": "稼働期間",
"certificateExpiry": "証明書の有効期限",
"lastCheck": "最終チェック",
"lastResponseTime": "最終応答時間"
"lastResponseTime": "最終応答時間",
"serviceIsDown": "サービスがダウンしています"
},
"status": {
"down": "ダウン",
@@ -540,7 +542,7 @@
"ping": "pingするIPアドレスまたはホスト名を入力(例: 192.168.1.100 または example.com)し、ダッシュボードに表示される明確な表示名を追加。",
"port": "サーバーのURLまたはIP、ポート番号、ダッシュボードに表示される明確な表示名を入力。",
"docker": "コンテナのDocker IDを入力。Docker IDは64文字のフルDocker IDである必要があります。docker inspect <short_id>を実行してフルコンテナIDを取得できます。",
"game": "",
"game": "Ping を実行する IP アドレスまたはホスト名とポート番号(例: 192.168.1.100 や example.com)を入力し、ゲームタイプを選択してください。一部のゲームでは設定ファイルでサーバークエリを有効にする必要があります — ゲームサーバーのドキュメントを確認してください。プロトコルの詳細は<gamedigLink>サポートされているゲームの一覧</gamedigLink>をご覧ください。",
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
@@ -767,6 +769,7 @@
"memory": "メモリ使用率",
"netBytesRecv": "{{name}} - 受信バイト数",
"netBytesSent": "{{name}} - 送信バイト数",
"netNoTraffic": "Capture はすべてのインターフェイスでゼロのトラフィックを報告しています。これは Capture がループバックインターフェイスのみを認識できるコンテナ内で実行されている場合に正常です — 実際のネットワークアクティビティを表示するには、ホスト上で Capture を直接実行してください。",
"temp": "温度"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "チャットID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover の設定",
"description": "Pushover のアプリケーショントークンとユーザーキーを入力してください。",
"optionAppToken": "アプリトークン",
"placeholderAppToken": "アプリトークンを入力",
"optionUserKey": "ユーザーキー",
"placeholderUserKey": "ユーザーキーを入力"
},
"twilio": {
"title": "Twilio の設定",
"description": "SMS を送信するための Twilio アカウント認証情報を入力してください。",
"optionAccountSid": "アカウント SID",
"placeholderAccountSid": "アカウント SID を入力",
"optionAuthToken": "認証トークン",
"placeholderAuthToken": "認証トークンを入力",
"optionFromNumber": "送信元番号",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "送信先番号",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1218,7 +1241,8 @@
"description": "システムからすべてのモニターを削除します。",
"dialog": {
"title": "すべてのモニターを削除しますか?",
"description": "この操作は元に戻せません。"
"description": "この操作は元に戻せません。",
"paragraph": "これらのモニターに関連するチェック履歴、インシデント、予定されたメンテナンスウィンドウ、ステータスページもすべて削除されます。データのバックアップを残したい場合は、先にモニターをエクスポートしてください。"
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "すべてのシステムがダウンしています",
"allUp": "すべてのシステムが正常に稼働しています",
"degraded": "一部のシステムに問題が発生しています",
"unknown": "システムステータスを判定できません"
"unknown": "システムステータスを判定できません",
"allBreached": "すべてのシステムがしきい値を超えました",
"allMaintenance": "すべてのシステムがメンテナンス中です",
"allPaused": "すべてのシステムが一時停止中です",
"allInitializing": "すべてのシステムが初期化中です",
"breachedAndDown": "一部のシステムがしきい値を超えてダウンしています",
"breached": "一部のシステムがしきい値を超えています",
"maintenanceAndDown": "一部のシステムがメンテナンス中にダウンしています",
"maintenance": "一部のシステムがメンテナンス中です",
"partiallyPaused": "一部のシステムが一時停止中です",
"initializing": "一部のシステムが初期化中です",
"noMonitors": "モニターが設定されていません"
},
"table": {
"headers": {
+42 -7
View File
@@ -364,7 +364,8 @@
"register": {
"title": "Bem-vindo ao Checkmate!",
"subtitle": "Cadastre-se para começar",
"submit": "Cadastrar"
"submit": "Cadastrar",
"setupNotice": "Esta página está disponível apenas durante a configuração inicial. Após o registro de um super administrador, usuários adicionais podem ser convidados a partir das configurações da equipe."
},
"forgotPassword": {
"title": "Esqueceu sua senha?",
@@ -426,7 +427,8 @@
"activeFor": "Ativo há",
"certificateExpiry": "Validade do certificado",
"lastCheck": "Última verificação",
"lastResponseTime": "Último tempo de resposta"
"lastResponseTime": "Último tempo de resposta",
"serviceIsDown": "O serviço está fora do ar"
},
"status": {
"down": "fora do ar",
@@ -540,7 +542,7 @@
"ping": "Insira o endereço IP ou nome de domínio para ping (ex. 192.168.1.100 ou exemplo.com.br) e adicione uma descrição que aparecerá na dashboard.",
"port": "Insira a URL ou o IP do servidor, aporta e uma descrição que aparecerá na dashboard.",
"docker": "Insira o Docker Id do seu container. Docker Ids devem ser todos os 64 caracteres. Você pode executar docker inspect <short_id> para descobrir o Id completo.",
"game": "",
"game": "Digite o endereço IP ou nome de host e o número da porta para pingar (por exemplo, 192.168.1.100 ou example.com) e escolha o tipo de jogo. Alguns jogos exigem habilitar consultas ao servidor no arquivo de configuração — consulte a documentação do seu servidor de jogo. Veja a <gamedigLink>lista de jogos suportados</gamedigLink> para detalhes do protocolo.",
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
@@ -767,6 +769,7 @@
"memory": "Uso de memória",
"netBytesRecv": "{{name}} - Bytes recebidos",
"netBytesSent": "{{name}} - Bytes enviados",
"netNoTraffic": "Capture está reportando zero tráfego em todas as interfaces. Isso é normal quando Capture é executado dentro de um container que só consegue ver interfaces de loopback — execute Capture diretamente no host para ver a atividade real da rede.",
"temp": "Temp"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID do chat",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Configuração do Pushover",
"description": "Insira o token de aplicação e a chave de usuário do Pushover.",
"optionAppToken": "Token de aplicação",
"placeholderAppToken": "Insira o token de aplicação",
"optionUserKey": "Chave de usuário",
"placeholderUserKey": "Insira a chave de usuário"
},
"twilio": {
"title": "Configuração do Twilio",
"description": "Insira as credenciais da conta Twilio para enviar SMS.",
"optionAccountSid": "SID da conta",
"placeholderAccountSid": "Insira o SID da conta",
"optionAuthToken": "Token de autenticação",
"placeholderAuthToken": "Insira o token de autenticação",
"optionFromNumber": "Número de origem",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Número de destino",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "Adicione monitores de exemplo para fins de demonstração."
},
"removeMonitors": {
"title": "Redefinição do sistema",
"description": "Remova todos os monitores do seu sistema.",
"title": "Redefinir sistema",
"description": "Remover todos os monitores do seu sistema.",
"dialog": {
"title": "Remover todos os monitores?",
"description": "Isso não pode ser desfeito."
"description": "Esta ação não pode ser desfeita.",
"paragraph": "Todo o histórico de verificações, incidentes, janelas de manutenção agendadas e páginas de status que usavam esses monitores também serão removidos. Exporte os monitores antes se quiser manter uma cópia de segurança dos dados."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Todos os sistemas estão fora do ar",
"allUp": "Todos os sistemas operacionais",
"degraded": "Alguns sistemas estão com problemas",
"unknown": "Não foi possível determinar o status do sistema"
"unknown": "Não foi possível determinar o status do sistema",
"allBreached": "Todos os sistemas excederam os limites",
"allMaintenance": "Todos os sistemas estão em manutenção",
"allPaused": "Todos os sistemas estão pausados",
"allInitializing": "Todos os sistemas estão inicializando",
"breachedAndDown": "Alguns sistemas excederam os limites e estão fora do ar",
"breached": "Alguns sistemas excederam os limites",
"maintenanceAndDown": "Alguns sistemas estão fora do ar durante a manutenção",
"maintenance": "Alguns sistemas estão em manutenção",
"partiallyPaused": "Alguns sistemas estão pausados",
"initializing": "Alguns sistemas estão inicializando",
"noMonitors": "Nenhum monitor foi configurado"
},
"table": {
"headers": {
+41 -6
View File
@@ -364,7 +364,8 @@
"register": {
"title": "С возвращением в Checkmate!",
"subtitle": "Зарегистрируйтесь, чтобы начать",
"submit": "Регистрация"
"submit": "Регистрация",
"setupNotice": "Эта страница доступна только во время первоначальной настройки. После регистрации суперадминистратора дополнительных пользователей можно пригласить из настроек команды."
},
"forgotPassword": {
"title": "Забыли пароль?",
@@ -426,7 +427,8 @@
"activeFor": "Активен",
"certificateExpiry": "Истечение сертификата",
"lastCheck": "Последняя проверка",
"lastResponseTime": "Последнее время ответа"
"lastResponseTime": "Последнее время ответа",
"serviceIsDown": "Сервис недоступен"
},
"status": {
"down": "недоступен",
@@ -540,7 +542,7 @@
"ping": "Ведите IP-адрес или имя хоста для пинга (например, 192.168.1.100 или example.com) и добавьте понятное имя, которое будет отображаться на панели инструментов.",
"port": "Введите URL-адрес или IP-адрес сервера, номер порта и понятное имя, которое будет отображаться на панели инструментов.",
"docker": "Введите Docker ID вашего контейнера. Docker ID должен быть полным 64-символьным Docker ID. Вы можете запустить docker inspect <short_id>, чтобы получить полный ID контейнера.",
"game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры.",
"game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры. Некоторым играм требуется включить серверные запросы в файле конфигурации — проверьте документацию вашего игрового сервера. См. <gamedigLink>список поддерживаемых игр</gamedigLink> для деталей протокола.",
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
@@ -767,6 +769,7 @@
"memory": "Использование памяти",
"netBytesRecv": "{{name}} - байт получено",
"netBytesSent": "{{name}} - байт отправлено",
"netNoTraffic": "Capture сообщает о нулевом трафике на всех интерфейсах. Это нормально, когда Capture работает внутри контейнера, который видит только loopback-интерфейсы — запустите Capture непосредственно на хосте, чтобы увидеть реальную сетевую активность.",
"temp": "Температура"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID чата",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Настройка Pushover",
"description": "Введите токен приложения и ключ пользователя Pushover.",
"optionAppToken": "Токен приложения",
"placeholderAppToken": "Введите токен приложения",
"optionUserKey": "Ключ пользователя",
"placeholderUserKey": "Введите ключ пользователя"
},
"twilio": {
"title": "Настройка Twilio",
"description": "Введите учётные данные аккаунта Twilio для отправки SMS.",
"optionAccountSid": "SID аккаунта",
"placeholderAccountSid": "Введите SID аккаунта",
"optionAuthToken": "Токен аутентификации",
"placeholderAuthToken": "Введите токен аутентификации",
"optionFromNumber": "Номер отправителя",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Номер получателя",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "Сброс системы",
"description": "Удалить все мониторы из Вашей системы.",
"description": "Удалить все мониторы из вашей системы.",
"dialog": {
"title": "Удалить все мониторы?",
"description": "Это действие нельзя отменить."
"description": "Это действие нельзя отменить.",
"paragraph": "Также будут удалены вся история проверок, инциденты, запланированные окна обслуживания и страницы состояния, использовавшие эти мониторы. Сначала экспортируйте мониторы, если хотите сохранить резервную копию их данных."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Все системы недоступны",
"allUp": "Все системы работают",
"degraded": "Некоторые системы испытывают проблемы",
"unknown": "Не удаётся определить статус системы"
"unknown": "Не удаётся определить статус системы",
"allBreached": "Все системы превысили пороговые значения",
"allMaintenance": "Все системы на техническом обслуживании",
"allPaused": "Все системы приостановлены",
"allInitializing": "Все системы инициализируются",
"breachedAndDown": "Некоторые системы превысили пороги и недоступны",
"breached": "Некоторые системы превысили пороги",
"maintenanceAndDown": "Некоторые системы недоступны во время техобслуживания",
"maintenance": "Некоторые системы на техобслуживании",
"partiallyPaused": "Некоторые системы приостановлены",
"initializing": "Некоторые системы инициализируются",
"noMonitors": "Мониторы не настроены"
},
"table": {
"headers": {
+41 -6
View File
@@ -364,7 +364,8 @@
"register": {
"title": "ยินดีต้อนรับกลับสู่ Checkmate!",
"subtitle": "สมัครสมาชิกเพื่อเริ่มต้น",
"submit": "ลงทะเบียน"
"submit": "ลงทะเบียน",
"setupNotice": "หน้านี้พร้อมใช้งานเฉพาะระหว่างการตั้งค่าเริ่มต้นเท่านั้น เมื่อลงทะเบียนผู้ดูแลระบบสูงสุดแล้ว สามารถเชิญผู้ใช้เพิ่มเติมได้จากการตั้งค่าทีม"
},
"forgotPassword": {
"title": "ลืมรหัสผ่านใช่ไหม?",
@@ -426,7 +427,8 @@
"activeFor": "ทำงานมาแล้ว",
"certificateExpiry": "วันหมดอายุใบรับรอง",
"lastCheck": "ตรวจสอบล่าสุด",
"lastResponseTime": "เวลาตอบสนองล่าสุด"
"lastResponseTime": "เวลาตอบสนองล่าสุด",
"serviceIsDown": "บริการขัดข้อง"
},
"status": {
"down": "ล่ม",
@@ -540,7 +542,7 @@
"ping": "กรอกที่อยู่ IP หรือชื่อโฮสต์ที่ต้องการ ping (เช่น 192.168.1.100 หรือ example.com) และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด",
"port": "กรอก URL หรือ IP ของเซิร์ฟเวอร์ หมายเลขพอร์ต และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด",
"docker": "กรอกชื่อหรือ ID ของคอนเทนเนอร์ Docker คุณสามารถใช้ชื่อคอนเทนเนอร์ (เช่น my-app) หรือ ID ของคอนเทนเนอร์ (ID เต็ม 64 ตัวอักษร หรือ ID สั้น)",
"game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม",
"game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม เกมบางเกมต้องเปิดใช้งานการสอบถามเซิร์ฟเวอร์ในไฟล์การตั้งค่า — โปรดดูเอกสารของเซิร์ฟเวอร์เกมของคุณ ดู<gamedigLink>รายการเกมที่รองรับ</gamedigLink>สำหรับรายละเอียดของโปรโตคอล",
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
@@ -767,6 +769,7 @@
"memory": "การใช้งานหน่วยความจำ",
"netBytesRecv": "{{name}} - ไบต์ที่รับ",
"netBytesSent": "{{name}} - ไบต์ที่ส่ง",
"netNoTraffic": "Capture รายงานว่าไม่มีการรับส่งข้อมูลบนอินเทอร์เฟซใดๆ เลย ซึ่งเป็นเรื่องปกติเมื่อ Capture ทำงานอยู่ในคอนเทนเนอร์ที่มองเห็นเพียงอินเทอร์เฟซ loopback เท่านั้น — รัน Capture บนโฮสต์โดยตรงเพื่อดูกิจกรรมเครือข่ายจริง",
"temp": "อุณหภูมิ"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "การตั้งค่า Pushover",
"description": "ป้อนโทเค็นแอปและคีย์ผู้ใช้ Pushover ของคุณ",
"optionAppToken": "โทเค็นแอป",
"placeholderAppToken": "ป้อนโทเค็นแอป",
"optionUserKey": "คีย์ผู้ใช้",
"placeholderUserKey": "ป้อนคีย์ผู้ใช้"
},
"twilio": {
"title": "การตั้งค่า Twilio",
"description": "ป้อนข้อมูลรับรองบัญชี Twilio เพื่อส่ง SMS",
"optionAccountSid": "SID บัญชี",
"placeholderAccountSid": "ป้อน SID บัญชี",
"optionAuthToken": "โทเค็นการรับรอง",
"placeholderAuthToken": "ป้อนโทเค็นการรับรอง",
"optionFromNumber": "หมายเลขผู้ส่ง",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "หมายเลขผู้รับ",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "รีเซ็ตระบบ",
"description": "ลบมอนิเตอร์ทั้งหมดออกจากระบบ",
"description": "ลบมอนิเตอร์ทั้งหมดจากระบบของคุณ",
"dialog": {
"title": "ลบมอนิเตอร์ทั้งหมด?",
"description": "ไม่สามารถย้อนกลับได้"
"description": "การกระทำนี้ไม่สามารถย้อนกลับได้",
"paragraph": "ประวัติการตรวจสอบทั้งหมด เหตุการณ์ ช่วงเวลาบำรุงรักษาที่กำหนดไว้ และหน้าสถานะที่ใช้มอนิเตอร์เหล่านี้จะถูกลบไปด้วย ส่งออกมอนิเตอร์ก่อนหากต้องการเก็บสำเนาข้อมูลสำรองไว้"
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "ระบบทั้งหมดล่ม",
"allUp": "ระบบทั้งหมดทำงานปกติ",
"degraded": "บางระบบกำลังประสบปัญหา",
"unknown": "ไม่สามารถระบุสถานะระบบได้"
"unknown": "ไม่สามารถระบุสถานะระบบได้",
"allBreached": "ทุกระบบเกินขีดจำกัด",
"allMaintenance": "ทุกระบบอยู่ระหว่างการบำรุงรักษา",
"allPaused": "ทุกระบบถูกหยุดชั่วคราว",
"allInitializing": "ทุกระบบกำลังเริ่มต้น",
"breachedAndDown": "บางระบบเกินขีดจำกัดและขัดข้อง",
"breached": "บางระบบเกินขีดจำกัด",
"maintenanceAndDown": "บางระบบขัดข้องระหว่างการบำรุงรักษา",
"maintenance": "บางระบบอยู่ระหว่างการบำรุงรักษา",
"partiallyPaused": "บางระบบถูกหยุดชั่วคราว",
"initializing": "บางระบบกำลังเริ่มต้น",
"noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์"
},
"table": {
"headers": {
+47 -12
View File
@@ -35,7 +35,7 @@
"sendInvite": "Davet gönder",
"test": "Test",
"testNotifications": "Bildirimleri test et",
"toggleTheme": "",
"toggleTheme": "Açık / koyu temayı değiştir",
"flushQueue": "Kuyruğu temizle",
"notFound": "Ana panele git",
"resetPassword": "Parolayı sıfırla",
@@ -86,7 +86,7 @@
"name": {
"option": {
"firstName": {
"label": "",
"label": "Ad",
"placeholder": "Jordan"
},
"lastName": {
@@ -155,13 +155,13 @@
"serverUnreachable": "Sunucuya ulaşılamıyor",
"retry": "Tekrar dene",
"retrying": "Yeniden deneniyor...",
"reconnected": ""
"reconnected": "Bağlantı yeniden kuruldu"
},
"sidebar": {
"menu": {
"uptime": "",
"uptime": "Çalışma süresi",
"pagespeed": "Sayfa hızı",
"infrastructure": "",
"infrastructure": "Altyapı",
"notifications": "Bildirimler",
"checks": "Kontroller",
"incidents": "Olaylar",
@@ -364,7 +364,8 @@
"register": {
"title": "Checkmate'e tekrar hoş geldiniz!",
"subtitle": "Başlamak için kaydolun",
"submit": "Kaydol"
"submit": "Kaydol",
"setupNotice": "Bu sayfa yalnızca ilk kurulum sırasında kullanılabilir. Süper yönetici kaydedildikten sonra, ek kullanıcılar takım ayarlarından davet edilebilir."
},
"forgotPassword": {
"title": "Parolanızı mı unuttunuz?",
@@ -426,7 +427,8 @@
"activeFor": "Aktif süresi",
"certificateExpiry": "Sertifika bitiş tarihi",
"lastCheck": "Son kontrol",
"lastResponseTime": "Son yanıt süresi"
"lastResponseTime": "Son yanıt süresi",
"serviceIsDown": "Servis çalışmıyor"
},
"status": {
"down": "çevrimdışı",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "Ping atılacak IP adresini veya ana bilgisayar adını ve port numarasını girin (ör. 192.168.1.100 veya example.com) ve oyun türünü seçin. Bazı oyunlar yapılandırma dosyasında sunucu sorgularının etkinleştirilmesini gerektirir — oyun sunucunuzun belgelerine bakın. Protokol ayrıntıları için <gamedigLink>desteklenen oyunlar listesine</gamedigLink> bakın.",
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
@@ -767,6 +769,7 @@
"memory": "Bellek kullanımı",
"netBytesRecv": "{{name}} - Alınan bayt",
"netBytesSent": "{{name}} - Gönderilen bayt",
"netNoTraffic": "Capture tüm arabirimlerde sıfır trafik raporluyor. Bu, Capture yalnızca loopback arabirimlerini görebilen bir konteyner içinde çalışırken normaldir — gerçek ağ etkinliğini görmek için Capture'ı doğrudan host üzerinde çalıştırın.",
"temp": "Sıcaklık"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Sohbet ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover yapılandırması",
"description": "Pushover uygulama belirtecinizi ve kullanıcı anahtarınızı girin.",
"optionAppToken": "Uygulama belirteci",
"placeholderAppToken": "Uygulama belirtecini girin",
"optionUserKey": "Kullanıcı anahtarı",
"placeholderUserKey": "Kullanıcı anahtarını girin"
},
"twilio": {
"title": "Twilio yapılandırması",
"description": "SMS göndermek için Twilio hesap kimlik bilgilerinizi girin.",
"optionAccountSid": "Hesap SID",
"placeholderAccountSid": "Hesap SID girin",
"optionAuthToken": "Kimlik doğrulama belirteci",
"placeholderAuthToken": "Kimlik doğrulama belirtecini girin",
"optionFromNumber": "Gönderen numara",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Alıcı numara",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "Gösterim amaçlı örnek monitörler ekleyin."
},
"removeMonitors": {
"title": "Sistem sıfırlama",
"description": "Sisteminizdeki tüm monitörleri kaldırın.",
"title": "Sistemi sıfırla",
"description": "Sistemdeki tüm monitörleri kaldır.",
"dialog": {
"title": "Tüm monitörler kaldırılsın mı?",
"description": "Bu işlem geri alınamaz."
"description": "Bu işlem geri alınamaz.",
"paragraph": "Tüm kontrol geçmişi, olaylar, planlanmış bakım pencereleri ve bu monitörleri kullanan durum sayfaları da kaldırılacaktır. Verilerin yedeğini saklamak istiyorsanız, önce monitörleri dışa aktarın."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Tüm sistemler çevrimdışı",
"allUp": "Tüm sistemler çalışıyor",
"degraded": "Bazı sistemlerde sorun yaşanıyor",
"unknown": "Sistem durumu belirlenemiyor"
"unknown": "Sistem durumu belirlenemiyor",
"allBreached": "Tüm sistemler eşik değerlerini aştı",
"allMaintenance": "Tüm sistemler bakımda",
"allPaused": "Tüm sistemler duraklatıldı",
"allInitializing": "Tüm sistemler başlatılıyor",
"breachedAndDown": "Bazı sistemler eşik değerlerini aştı ve çalışmıyor",
"breached": "Bazı sistemler eşik değerlerini aştı",
"maintenanceAndDown": "Bazı sistemler bakım sırasında çalışmıyor",
"maintenance": "Bazı sistemler bakımda",
"partiallyPaused": "Bazı sistemler duraklatıldı",
"initializing": "Bazı sistemler başlatılıyor",
"noMonitors": "Hiç monitör yapılandırılmadı"
},
"table": {
"headers": {
+83 -48
View File
@@ -13,7 +13,7 @@
"content": "Увага: Ви ще не додали ключ API Google PageSpeed. Перейдіть у <settingsLink>Налаштування</settingsLink>, щоб додати його. Без нього моніторинг PageSpeed не працюватиме."
}
},
"appName": "",
"appName": "Checkmate",
"breadcrumbs": {
"details": "Деталі",
"home": "Головна"
@@ -31,15 +31,15 @@
"inviteMember": "Запросити учасника",
"pause": "Призупинити",
"resume": "Відновити",
"save": "",
"save": "Зберегти",
"sendInvite": "Надіслати запрошення",
"test": "Тест",
"testNotifications": "Тестові сповіщення",
"toggleTheme": "",
"toggleTheme": "Перемкнути світлу / темну",
"flushQueue": "Очистити чергу",
"notFound": "Перейти на головну панель",
"resetPassword": "",
"reset": "",
"resetPassword": "Скинути пароль",
"reset": "Скинути",
"clear": "Очистити",
"addDemo": "Додати демо-монітори",
"removeMonitors": "Видалити монітори",
@@ -53,8 +53,8 @@
"labels": {
"averageResponseTime": "Середній час відповіді",
"downtime": "Час простою",
"high": "",
"low": "",
"high": "Високий",
"low": "Низький",
"uptime": "Час роботи"
},
"histogram": {
@@ -86,12 +86,12 @@
"name": {
"option": {
"firstName": {
"label": "",
"placeholder": ""
"label": "Ім'я",
"placeholder": "Введіть ім'я"
},
"lastName": {
"label": "",
"placeholder": ""
"label": "Прізвище",
"placeholder": "Введіть прізвище"
}
}
},
@@ -107,14 +107,14 @@
"table": {
"empty": "Тут нічого немає",
"headers": {
"actions": "",
"actions": "Дії",
"dateTime": "Дата і час",
"message": "Повідомлення",
"monitor": "Монітор",
"monitorId": "ID монітора",
"name": "Ім'я",
"status": "Статус",
"type": "",
"type": "Тип",
"url": "URL",
"interval": "Інтервал",
"active": "Активний",
@@ -125,17 +125,17 @@
"components": {
"imageUpload": {
"clickToUpload": "Натисніть, щоб завантажити",
"dragAndDrop": "",
"supportedFormats": "",
"maxSize": "",
"dragAndDrop": "або перетягніть",
"supportedFormats": "Підтримувані формати",
"maxSize": "Макс. розмір",
"orDragAndDrop": "або перетягніть файл",
"errors": {
"invalidFileSize": "",
"invalidFileFormat": ""
"invalidFileSize": "Розмір файлу занадто великий!",
"invalidFileFormat": "Непідтримуваний формат файлу!"
}
},
"headerStatusPageControls": {
"publicLink": ""
"publicLink": "Публічне посилання"
},
"headerTimeRange": {
"labels": {
@@ -155,26 +155,26 @@
"serverUnreachable": "Не вдається з'єднатися з сервером",
"retry": "Повторити",
"retrying": "Повторна спроба...",
"reconnected": ""
"reconnected": "З'єднання відновлено"
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"notifications": "",
"uptime": "Доступність",
"pagespeed": "Pagespeed",
"infrastructure": "Інфраструктура",
"notifications": "Сповіщення",
"checks": "Перевірки",
"incidents": "",
"statusPages": "",
"maintenance": "",
"logs": "",
"settings": ""
"incidents": "Інциденти",
"statusPages": "Сторінки статусу",
"maintenance": "Обслуговування",
"logs": "Журнали",
"settings": "Налаштування"
},
"bottomMenu": {
"support": "",
"discussions": "",
"docs": "",
"changelog": ""
"support": "Підтримка",
"discussions": "Обговорення",
"docs": "Документація",
"changelog": "Журнал змін"
},
"accountMenu": {
"profile": "Профіль",
@@ -186,13 +186,13 @@
"description": "Переглядайте останні оновлення та допомагайте розвивати спільноту на GitHub"
},
"authFooter": {
"navControls": "",
"logOut": "",
"navControls": "Керування",
"logOut": "Вийти",
"roles": {
"superAdmin": "Суперадміністратор",
"admin": "Адміністратор",
"user": "Користувач",
"demoUser": ""
"demoUser": "Демо-користувач"
}
}
},
@@ -259,15 +259,15 @@
"team": {
"filter": {
"placeholder": "Фільтрувати за роллю",
"all": "",
"all": "Усі ролі",
"admin": "Адміністратор",
"member": ""
"member": "Учасник"
},
"table": {
"headers": {
"email": "Електронна пошта",
"role": "",
"created": ""
"role": "Роль",
"created": "Створено"
}
},
"addMember": {
@@ -364,7 +364,8 @@
"register": {
"title": "Ласкаво просимо до Checkmate!",
"subtitle": "Зареєструйтесь, щоб розпочати",
"submit": "Зареєструватися"
"submit": "Зареєструватися",
"setupNotice": "Ця сторінка доступна лише під час початкового налаштування. Після реєстрації суперадміністратора додаткових користувачів можна запросити з налаштувань команди."
},
"forgotPassword": {
"title": "Забули пароль?",
@@ -426,7 +427,8 @@
"activeFor": "Активний протягом",
"certificateExpiry": "Закінчення сертифіката",
"lastCheck": "Остання перевірка",
"lastResponseTime": "Останній час відповіді"
"lastResponseTime": "Останній час відповіді",
"serviceIsDown": "Сервіс недоступний"
},
"status": {
"down": "недоступний",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "Введіть IP-адресу або ім'я хоста та номер порту для пінгу (наприклад, 192.168.1.100 або example.com) і виберіть тип гри. Деяким іграм потрібно ввімкнути серверні запити у файлі конфігурації — перевірте документацію вашого ігрового сервера. Деталі протоколу див. у <gamedigLink>списку підтримуваних ігор</gamedigLink>.",
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
@@ -767,6 +769,7 @@
"memory": "Використання пам'яті",
"netBytesRecv": "{{name}} - Отримано байтів",
"netBytesSent": "{{name}} - Надіслано байтів",
"netNoTraffic": "Capture повідомляє про нульовий трафік на всіх інтерфейсах. Це нормально, коли Capture працює всередині контейнера, який бачить лише loopback-інтерфейси — запустіть Capture безпосередньо на хості, щоб побачити реальну мережеву активність.",
"temp": "Темп."
}
},
@@ -826,8 +829,8 @@
},
"tabs": {
"diagnostics": "Діагностика",
"logs": "",
"queue": ""
"logs": "Журнали",
"queue": "Черга завдань"
},
"logLevelSelect": {
"label": "Рівень логів"
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID чату",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Налаштування Pushover",
"description": "Введіть токен застосунку та ключ користувача Pushover.",
"optionAppToken": "Токен застосунку",
"placeholderAppToken": "Введіть токен застосунку",
"optionUserKey": "Ключ користувача",
"placeholderUserKey": "Введіть ключ користувача"
},
"twilio": {
"title": "Налаштування Twilio",
"description": "Введіть облікові дані Twilio для надсилання SMS.",
"optionAccountSid": "SID акаунта",
"placeholderAccountSid": "Введіть SID акаунта",
"optionAuthToken": "Токен автентифікації",
"placeholderAuthToken": "Введіть токен автентифікації",
"optionFromNumber": "Номер відправника",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Номер отримувача",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "Скидання системи",
"description": "Видалити всі монітори з Вашої системи.",
"description": "Видалити всі монітори з вашої системи.",
"dialog": {
"title": "Видалити всі монітори?",
"description": "Це не можна скасувати."
"description": "Цю дію не можна скасувати.",
"paragraph": "Також будуть видалені вся історія перевірок, інциденти, заплановані вікна обслуговування та сторінки статусу, які використовували ці монітори. Спочатку експортуйте монітори, якщо хочете зберегти резервну копію їхніх даних."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Усі системи недоступні",
"allUp": "Усі системи працюють",
"degraded": "Деякі системи мають проблеми",
"unknown": "Неможливо визначити стан системи"
"unknown": "Неможливо визначити стан системи",
"allBreached": "Усі системи перевищили порогові значення",
"allMaintenance": "Усі системи на технічному обслуговуванні",
"allPaused": "Усі системи призупинено",
"allInitializing": "Усі системи ініціалізуються",
"breachedAndDown": "Деякі системи перевищили пороги та недоступні",
"breached": "Деякі системи перевищили пороги",
"maintenanceAndDown": "Деякі системи недоступні під час техобслуговування",
"maintenance": "Деякі системи на техобслуговуванні",
"partiallyPaused": "Деякі системи призупинено",
"initializing": "Деякі системи ініціалізуються",
"noMonitors": "Не налаштовано жодного монітора"
},
"table": {
"headers": {
+83 -48
View File
@@ -13,7 +13,7 @@
"content": "Cảnh báo: Bạn chưa thêm khóa API Google PageSpeed. Truy cập <settingsLink>Cài đặt</settingsLink> để thêm. Nếu không có khóa này, trình giám sát PageSpeed sẽ không hoạt động."
}
},
"appName": "",
"appName": "Checkmate",
"breadcrumbs": {
"details": "Chi tiết",
"home": "Trang chủ"
@@ -31,15 +31,15 @@
"inviteMember": "Mời thành viên",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"save": "",
"save": "Lưu",
"sendInvite": "Gửi lời mời",
"test": "Kiểm tra",
"testNotifications": "Kiểm tra thông báo",
"toggleTheme": "",
"toggleTheme": "Chuyển sáng / tối",
"flushQueue": "Xóa hàng đợi",
"notFound": "Về bảng điều khiển chính",
"resetPassword": "",
"reset": "",
"resetPassword": "Đặt lại mật khẩu",
"reset": "Đặt lại",
"clear": "Xóa",
"addDemo": "Thêm trình giám sát mẫu",
"removeMonitors": "Xóa trình giám sát",
@@ -53,8 +53,8 @@
"labels": {
"averageResponseTime": "Thời gian phản hồi trung bình",
"downtime": "Thời gian ngừng hoạt động",
"high": "",
"low": "",
"high": "Cao",
"low": "Thấp",
"uptime": "Thời gian hoạt động"
},
"histogram": {
@@ -86,12 +86,12 @@
"name": {
"option": {
"firstName": {
"label": "",
"placeholder": ""
"label": "Tên",
"placeholder": "Nhập tên của bạn"
},
"lastName": {
"label": "",
"placeholder": ""
"label": "Họ",
"placeholder": "Nhập họ của bạn"
}
}
},
@@ -107,14 +107,14 @@
"table": {
"empty": "Không có dữ liệu",
"headers": {
"actions": "",
"actions": "Hành động",
"dateTime": "Ngày & giờ",
"message": "Tin nhắn",
"monitor": "Trình giám sát",
"monitorId": "ID trình giám sát",
"name": "Tên",
"status": "Trạng thái",
"type": "",
"type": "Loại",
"url": "Url",
"interval": "Khoảng thời gian",
"active": "Đang hoạt động",
@@ -125,17 +125,17 @@
"components": {
"imageUpload": {
"clickToUpload": "Nhấn để tải lên",
"dragAndDrop": "",
"supportedFormats": "",
"maxSize": "",
"dragAndDrop": "hoặc kéo và thả",
"supportedFormats": "Định dạng hỗ trợ",
"maxSize": "Kích thước tối đa",
"orDragAndDrop": "hoặc kéo và thả",
"errors": {
"invalidFileSize": "",
"invalidFileFormat": ""
"invalidFileSize": "Tệp quá lớn!",
"invalidFileFormat": "Định dạng tệp không được hỗ trợ!"
}
},
"headerStatusPageControls": {
"publicLink": ""
"publicLink": "Liên kết công khai"
},
"headerTimeRange": {
"labels": {
@@ -155,26 +155,26 @@
"serverUnreachable": "Không thể kết nối đến máy chủ",
"retry": "Thử lại",
"retrying": "Đang thử lại...",
"reconnected": ""
"reconnected": "Đã khôi phục kết nối"
},
"sidebar": {
"menu": {
"uptime": "",
"pagespeed": "",
"infrastructure": "",
"notifications": "",
"uptime": "Thời gian hoạt động",
"pagespeed": "Pagespeed",
"infrastructure": "Hạ tầng",
"notifications": "Thông báo",
"checks": "Kiểm tra",
"incidents": "",
"statusPages": "",
"maintenance": "",
"logs": "",
"settings": ""
"incidents": "Sự cố",
"statusPages": "Trang trạng thái",
"maintenance": "Bảo trì",
"logs": "Nhật ký",
"settings": "Cài đặt"
},
"bottomMenu": {
"support": "",
"discussions": "",
"docs": "",
"changelog": ""
"support": "Hỗ trợ",
"discussions": "Thảo luận",
"docs": "Tài liệu",
"changelog": "Nhật ký thay đổi"
},
"accountMenu": {
"profile": "Hồ sơ",
@@ -186,13 +186,13 @@
"description": "Xem các bản phát hành mới nhất và giúp phát triển cộng đồng trên GitHub"
},
"authFooter": {
"navControls": "",
"logOut": "",
"navControls": "Điều khiển",
"logOut": "Đăng xuất",
"roles": {
"superAdmin": "Quản trị viên cấp cao",
"admin": "Quản trị viên",
"user": "Người dùng",
"demoUser": ""
"demoUser": "Người dùng demo"
}
}
},
@@ -259,15 +259,15 @@
"team": {
"filter": {
"placeholder": "Lọc theo vai trò",
"all": "",
"all": "Tất cả vai trò",
"admin": "Quản trị viên",
"member": ""
"member": "Thành viên"
},
"table": {
"headers": {
"email": "Email",
"role": "",
"created": ""
"role": "Vai trò",
"created": "Đã tạo"
}
},
"addMember": {
@@ -364,7 +364,8 @@
"register": {
"title": "Chào mừng bạn trở lại Checkmate!",
"subtitle": "Đăng ký để bắt đầu",
"submit": "Đăng ký"
"submit": "Đăng ký",
"setupNotice": "Trang này chỉ khả dụng trong quá trình thiết lập ban đầu. Sau khi siêu quản trị viên được đăng ký, có thể mời thêm người dùng từ cài đặt nhóm."
},
"forgotPassword": {
"title": "Quên mật khẩu?",
@@ -426,7 +427,8 @@
"activeFor": "Hoạt động từ",
"certificateExpiry": "Hạn chứng chỉ",
"lastCheck": "Lần kiểm tra cuối",
"lastResponseTime": "Thời gian phản hồi cuối"
"lastResponseTime": "Thời gian phản hồi cuối",
"serviceIsDown": "Dịch vụ đang ngừng hoạt động"
},
"status": {
"down": "ngừng hoạt động",
@@ -540,7 +542,7 @@
"ping": "",
"port": "",
"docker": "",
"game": "",
"game": "Nhập địa chỉ IP hoặc tên máy chủ và số cổng để ping (ví dụ: 192.168.1.100 hoặc example.com) và chọn loại trò chơi. Một số trò chơi yêu cầu bật truy vấn máy chủ trong tệp cấu hình của chúng — hãy xem tài liệu của máy chủ trò chơi. Xem <gamedigLink>danh sách các trò chơi được hỗ trợ</gamedigLink> để biết chi tiết giao thức.",
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
@@ -767,6 +769,7 @@
"memory": "Sử dụng bộ nhớ",
"netBytesRecv": "{{name}} - Byte nhận",
"netBytesSent": "{{name}} - Byte gửi",
"netNoTraffic": "Capture đang báo cáo lưu lượng bằng không trên mọi giao diện. Điều này là bình thường khi Capture chạy bên trong một container chỉ có thể thấy các giao diện loopback — hãy chạy Capture trực tiếp trên máy chủ để xem hoạt động mạng thực tế.",
"temp": "Nhiệt độ"
}
},
@@ -826,8 +829,8 @@
},
"tabs": {
"diagnostics": "Chẩn đoán",
"logs": "",
"queue": ""
"logs": "Nhật ký",
"queue": "Hàng đợi tác vụ"
},
"logLevelSelect": {
"label": "Mức nhật ký"
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID cuộc trò chuyện",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Cấu hình Pushover",
"description": "Nhập mã thông báo ứng dụng và khóa người dùng Pushover của bạn.",
"optionAppToken": "Mã thông báo ứng dụng",
"placeholderAppToken": "Nhập mã thông báo ứng dụng",
"optionUserKey": "Khóa người dùng",
"placeholderUserKey": "Nhập khóa người dùng"
},
"twilio": {
"title": "Cấu hình Twilio",
"description": "Nhập thông tin xác thực tài khoản Twilio để gửi SMS.",
"optionAccountSid": "SID tài khoản",
"placeholderAccountSid": "Nhập SID tài khoản",
"optionAuthToken": "Mã thông báo xác thực",
"placeholderAuthToken": "Nhập mã thông báo xác thực",
"optionFromNumber": "Số gửi",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "Số nhận",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "Đặt lại hệ thống",
"description": "Xóa tất cả trình giám sát khỏi hệ thống.",
"description": "Xóa tất cả các monitor khỏi hệ thống của bạn.",
"dialog": {
"title": "Xóa tất cả trình giám sát?",
"description": "Không thể hoàn tác."
"description": "Hành động này không thể hoàn tác.",
"paragraph": "Toàn bộ lịch sử kiểm tra, sự cố, cửa sổ bảo trì đã lên lịch và các trang trạng thái sử dụng những trình giám sát này cũng sẽ bị xóa. Hãy xuất trình giám sát trước nếu bạn muốn giữ một bản sao lưu dữ liệu."
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "Tất cả hệ thống đều ngừng hoạt động",
"allUp": "Tất cả hệ thống đang hoạt động",
"degraded": "Một số hệ thống đang gặp sự cố",
"unknown": "Không thể xác định trạng thái hệ thống"
"unknown": "Không thể xác định trạng thái hệ thống",
"allBreached": "Tất cả hệ thống đã vượt ngưỡng",
"allMaintenance": "Tất cả hệ thống đang bảo trì",
"allPaused": "Tất cả hệ thống đã tạm dừng",
"allInitializing": "Tất cả hệ thống đang khởi tạo",
"breachedAndDown": "Một số hệ thống đã vượt ngưỡng và đang ngừng hoạt động",
"breached": "Một số hệ thống đã vượt ngưỡng",
"maintenanceAndDown": "Một số hệ thống ngừng hoạt động trong quá trình bảo trì",
"maintenance": "Một số hệ thống đang bảo trì",
"partiallyPaused": "Một số hệ thống đã tạm dừng",
"initializing": "Một số hệ thống đang khởi tạo",
"noMonitors": "Chưa cấu hình màn hình giám sát nào"
},
"table": {
"headers": {
+41 -6
View File
@@ -364,7 +364,8 @@
"register": {
"title": "欢迎回到 Checkmate!",
"subtitle": "注册以开始使用",
"submit": "注册"
"submit": "注册",
"setupNotice": "此页面仅在初始设置期间可用。注册超级管理员后,可以从团队设置中邀请更多用户。"
},
"forgotPassword": {
"title": "忘记密码了?",
@@ -426,7 +427,8 @@
"activeFor": "已活跃",
"certificateExpiry": "证书到期",
"lastCheck": "上次检查",
"lastResponseTime": "上次响应时间"
"lastResponseTime": "上次响应时间",
"serviceIsDown": "服务已停止"
},
"status": {
"down": "宕机",
@@ -540,7 +542,7 @@
"ping": "输入要 ping 的 IP 地址或主机名(例如.. 192.168.1.100 或 example.com),并添加显示在仪表板上的清晰显示名称。",
"port": "输入服务器的 URL 或 IP、端口号和仪表板上显示的清晰显示名称。",
"docker": "码输入 Docker 容器名称或 ID。您可以使用容器名称(例如.. my-app)或容器 ID(完整的 64 字符 ID 或短 ID)。",
"game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。",
"game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。某些游戏需要在配置文件中启用服务器查询 — 请查阅你的游戏服务器文档。详见<gamedigLink>支持的游戏列表</gamedigLink>了解协议细节。",
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
@@ -767,6 +769,7 @@
"memory": "内存使用率",
"netBytesRecv": "{{name}} - 接收字节",
"netBytesSent": "{{name}} - 发送字节",
"netNoTraffic": "Capture 报告所有接口的流量均为零。当 Capture 在只能看到回环接口的容器内运行时,这属于正常情况——请直接在主机上运行 Capture 才能看到真实的网络活动。",
"temp": "温度"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "聊天 ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover 配置",
"description": "输入您的 Pushover 应用令牌和用户密钥。",
"optionAppToken": "应用令牌",
"placeholderAppToken": "输入应用令牌",
"optionUserKey": "用户密钥",
"placeholderUserKey": "输入用户密钥"
},
"twilio": {
"title": "Twilio 配置",
"description": "输入 Twilio 帐户凭据以发送短信。",
"optionAccountSid": "帐户 SID",
"placeholderAccountSid": "输入帐户 SID",
"optionAuthToken": "认证令牌",
"placeholderAuthToken": "输入认证令牌",
"optionFromNumber": "发送号码",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "接收号码",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1215,10 +1238,11 @@
},
"removeMonitors": {
"title": "系统重置",
"description": "从系统中移除所有监控。",
"description": "从系统中移除所有监视器。",
"dialog": {
"title": "移除所有监控?",
"description": "此操作无法撤销。"
"description": "此操作无法撤销。",
"paragraph": "所有检查历史、事件、计划维护窗口以及使用这些监控的状态页面也将一并被移除。如需保留数据备份,请先导出监控。"
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "所有系统已宕机",
"allUp": "所有系统运行正常",
"degraded": "部分系统正在出现问题",
"unknown": "无法确定系统状态"
"unknown": "无法确定系统状态",
"allBreached": "所有系统已超过阈值",
"allMaintenance": "所有系统正在维护中",
"allPaused": "所有系统已暂停",
"allInitializing": "所有系统正在初始化",
"breachedAndDown": "部分系统超过阈值且已停止",
"breached": "部分系统超过阈值",
"maintenanceAndDown": "部分系统在维护期间停止",
"maintenance": "部分系统正在维护",
"partiallyPaused": "部分系统已暂停",
"initializing": "部分系统正在初始化",
"noMonitors": "尚未配置任何监控器"
},
"table": {
"headers": {
+42 -7
View File
@@ -364,7 +364,8 @@
"register": {
"title": "歡迎回到 Checkmate!",
"subtitle": "註冊以開始使用",
"submit": "註冊"
"submit": "註冊",
"setupNotice": "此頁面僅在初始設定期間可用。註冊超級管理員後,可以從團隊設定中邀請更多使用者。"
},
"forgotPassword": {
"title": "忘記密碼了嗎?",
@@ -426,7 +427,8 @@
"activeFor": "已啟用",
"certificateExpiry": "憑證到期",
"lastCheck": "最後檢查",
"lastResponseTime": "最後回應時間"
"lastResponseTime": "最後回應時間",
"serviceIsDown": "服務已停止"
},
"status": {
"down": "離線",
@@ -540,7 +542,7 @@
"ping": "輸入要 Ping 的 IP 位址或主機名稱(例如 192.168.1.100 或 example.com),並新增一個顯示在儀表板上的清晰顯示名稱。",
"port": "輸入伺服器的 URL 或 IP、連接埠號碼,以及顯示在儀表板上的清晰顯示名稱。",
"docker": "輸入 Docker 容器名稱或 ID。您可以使用容器名稱(例如 my-app)或容器 ID(完整的 64 字元 ID 或短 ID)。",
"game": "輸入要 Ping 的 IP 位址或主機名稱及連接埠號碼(例如 192.168.1.100 或 example.com),並選擇遊戲類型。",
"game": "輸入要 Ping 的 IP 位址或主機名稱及連接埠號碼(例如 192.168.1.100 或 example.com),並選擇遊戲類型。部分遊戲需要在設定檔中啟用伺服器查詢 — 請參閱你的遊戲伺服器文件。詳見<gamedigLink>支援的遊戲清單</gamedigLink>了解協定細節。",
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
@@ -767,6 +769,7 @@
"memory": "記憶體使用率",
"netBytesRecv": "{{name}} - 接收位元組",
"netBytesSent": "{{name}} - 發送位元組",
"netNoTraffic": "Capture 回報所有介面的流量皆為零。當 Capture 在只能看到回環介面的容器中執行時,這是正常情況——請直接在主機上執行 Capture 才能看到實際的網路活動。",
"temp": "溫度"
}
},
@@ -1016,6 +1019,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "聊天室 ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover 設定",
"description": "輸入您的 Pushover 應用程式權杖和使用者金鑰。",
"optionAppToken": "應用程式權杖",
"placeholderAppToken": "輸入應用程式權杖",
"optionUserKey": "使用者金鑰",
"placeholderUserKey": "輸入使用者金鑰"
},
"twilio": {
"title": "Twilio 設定",
"description": "輸入 Twilio 帳戶憑證以傳送簡訊。",
"optionAccountSid": "帳戶 SID",
"placeholderAccountSid": "輸入帳戶 SID",
"optionAuthToken": "驗證權杖",
"placeholderAuthToken": "輸入驗證權杖",
"optionFromNumber": "發送號碼",
"placeholderFromNumber": "+1234567890",
"optionToNumber": "接收號碼",
"placeholderToNumber": "+1234567890"
}
},
"table": {
@@ -1214,11 +1237,12 @@
"description": "新增範例監控以供示範用途。"
},
"removeMonitors": {
"title": "系統重設",
"description": "從系統中移除所有監控。",
"title": "系統重置",
"description": "從系統中移除所有監視器。",
"dialog": {
"title": "移除所有監控?",
"description": "此操作無法復原。"
"description": "此操作無法復原。",
"paragraph": "所有檢查歷史、事件、排定的維護時段以及使用這些監控的狀態頁面也將一併被移除。如需保留資料備份,請先匯出監控。"
}
},
"exportMonitors": {
@@ -1271,7 +1295,18 @@
"allDown": "所有系統已停機",
"allUp": "所有系統運作正常",
"degraded": "部分系統正在發生問題",
"unknown": "無法判斷系統狀態"
"unknown": "無法判斷系統狀態",
"allBreached": "所有系統已超過閾值",
"allMaintenance": "所有系統正在維護中",
"allPaused": "所有系統已暫停",
"allInitializing": "所有系統正在初始化",
"breachedAndDown": "部分系統超過閾值且已停止",
"breached": "部分系統超過閾值",
"maintenanceAndDown": "部分系統在維護期間停止",
"maintenance": "部分系統正在維護",
"partiallyPaused": "部分系統已暫停",
"initializing": "部分系統正在初始化",
"noMonitors": "尚未設定任何監視器"
},
"table": {
"headers": {
+211
View File
@@ -0,0 +1,211 @@
# Frontend conventions
Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme.
> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases.
---
## 1. Prefer MUI native (system) props over `sx`
MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
### Canonical mappings
| `sx` key | Native prop |
|---|---|
| `color` | `color` |
| `backgroundColor` | `bgcolor` |
| `borderRadius` | `borderRadius` |
| `border`, `borderTop`, … | `border`, `borderTop`, … |
| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
| `p`, `pt`, `pb`, `px`, `py` | same names as native |
| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
| `display` | `display` |
| `textAlign` | `textAlign` |
| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
| `gap` (on Stack) | `gap` |
### Examples
```tsx
// BAD — everything inside sx
<Box
sx={{
width: "100%",
p: theme.spacing(LAYOUT.MD),
borderRadius: 1,
border: `1px solid ${alpha(tone, 0.45)}`,
backgroundColor: alpha(tone, 0.08),
textAlign: "left",
}}
/>
// GOOD — every key that has a native prop is hoisted
<Box
width={"100%"}
p={theme.spacing(LAYOUT.MD)}
borderRadius={theme.shape.borderRadius}
border={`1px solid ${alpha(tone, 0.45)}`}
bgcolor={alpha(tone, 0.08)}
textAlign={"left"}
/>
```
```tsx
// BAD
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
<Typography
color={theme.palette.text.primary}
lineHeight={1.55}
>
```
### When `sx` is still correct
Keep `sx` for things that don't have a native prop:
```tsx
<Link
component={RouterLink}
to="/settings"
color={theme.palette.primary.main}
fontWeight={600}
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
/>
```
Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
---
## 2. Use the full theme path for colors, never the shorthand string
Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
**How to apply:** always destructure or reference the theme value explicitly.
```tsx
// BAD
<Typography color="text.secondary">…</Typography>
<Typography color="primary.main">…</Typography>
// GOOD
const theme = useTheme();
<Typography color={theme.palette.text.secondary}>…</Typography>
<Typography color={theme.palette.primary.main}>…</Typography>
```
This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
---
## 3. No hardcoded numeric or string literals — use tokens
The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
### Token sources
- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
- `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
- `SPACING` for `theme.spacing(...)` multipliers
- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
- `xs / s / m / l / xl / xxl` (rem values)
- **Hover / interaction** — `HOVER.DARKEN` etc.
- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
- **Colors** — `theme.palette.*` (rule 2 above)
### Examples
```tsx
// BAD
<Box sx={{ mt: "2px" }} />
<Stack spacing={theme.spacing(2)} />
<Box sx={{ borderRadius: 1 }} />
<Typography fontSize={18} />
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
// GOOD
<Box mt={LAYOUT.XXS} />
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
<Box borderRadius={theme.shape.borderRadius} />
<Typography fontSize={typographyLevels.xl} />
<Typography /> // inherits 13px from theme
```
### Token naming gotcha
`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
---
## 4. Use `useTheme()` inside components, don't import the theme module
The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
**How to apply:** inside any component or component-helper that needs theme values:
```tsx
// BAD
import { theme } from "@/Utils/Theme/Theme";
const SectionHeading = ({ children }) => (
<Typography color={theme.palette.text.secondary}>{children}</Typography>
);
// GOOD
import { useTheme } from "@mui/material/styles";
const SectionHeading = ({ children }) => {
const theme = useTheme();
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
};
```
Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
---
## 5. Discriminated unions: pair the runtime list with the type
When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
```tsx
// BAD
type Severity = "info" | "warning" | "error";
// GOOD
export const Severities = ["info", "warning", "error"] as const;
export type Severity = (typeof Severities)[number];
```
Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
---
## Pre-PR checklist
Before you open a PR, scan your diff for these:
- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1)
- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2)
- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3)
- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4)
- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5)
- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions)
A clean PR on these axes is a PR that ships without a normalize follow-up.
+6
View File
@@ -24,3 +24,9 @@ CLIENT_HOST=http://localhost:5173
# Optional Configuration
# ----------------------
ORIGIN=localhost
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
# When disabled, all status pages render with the default "refined" theme.
STATUS_PAGE_THEMES_ENABLED=true
+24
View File
@@ -2878,6 +2878,12 @@
"url": {
"type": "string",
"description": "Custom URL for the status page"
},
"theme": {
"$ref": "#/components/schemas/StatusPageTheme"
},
"themeMode": {
"$ref": "#/components/schemas/StatusPageThemeMode"
}
}
}
@@ -2930,6 +2936,12 @@
"url": {
"type": "string",
"description": "Custom URL for the status page"
},
"theme": {
"$ref": "#/components/schemas/StatusPageTheme"
},
"themeMode": {
"$ref": "#/components/schemas/StatusPageThemeMode"
}
}
}
@@ -3410,6 +3422,18 @@
}
},
"schemas": {
"StatusPageTheme": {
"type": "string",
"enum": ["refined", "modern", "bold", "editorial"],
"default": "refined",
"description": "Visual theme for the public status page. Defaults to 'refined'."
},
"StatusPageThemeMode": {
"type": "string",
"enum": ["auto", "light", "dark"],
"default": "auto",
"description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'."
},
"SuccessResponse": {
"type": "object",
"required": ["success", "msg"],
+1 -1
View File
@@ -348,7 +348,7 @@ export const initializeServices = async ({
incidentsRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository);
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const services = {
settingsService,
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailUser,
systemEmailConnectionHost,
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
@@ -66,6 +66,7 @@ class StatusPageController implements IStatusPageController {
if (req.file) {
imageValidation.parse(req.file);
}
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
@@ -127,7 +128,10 @@ class StatusPageController implements IStatusPageController {
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: { statusPage, monitors: normalizedMonitors },
data: {
statusPage,
monitors: normalizedMonitors,
},
});
} catch (error) {
next(error);
@@ -0,0 +1,33 @@
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
/**
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
* theme fields before persisting so an admin client can't accidentally store
* a value the public page won't honour. Whatever a caller sends here for
* `theme` / `themeMode` is silently ignored — the rest of the body is
* preserved verbatim.
*
* The strip is silent (not a 400) because the kill switch is an ops-side
* concern: API clients should not have to know whether the operator has
* disabled themes today, only that their saved value won't take effect.
* If we later decide we'd rather fail loudly, change this to throw a
* 400 AppError; downstream consumers already handle it.
*/
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
const rest = { ...body };
delete rest.theme;
delete rest.themeMode;
return rest;
};
/**
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
* should see the canonical default theme / mode regardless of what's stored
* in Mongo. Whatever the user previously chose stays in the DB so flipping
* the flag back on resurfaces their selection.
*/
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
+1
View File
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
systemEmailDisplayName: { type: String },
systemEmailPassword: { type: String },
systemEmailUser: { type: String },
systemEmailConnectionHost: { type: String, default: "localhost" },
+17 -1
View File
@@ -1,6 +1,12 @@
import { Schema, model, type Types } from "mongoose";
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes } from "@/types/statusPage.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
StatusPageThemeModes,
StatusPageThemes,
StatusPageTypes,
} from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
@@ -108,6 +114,16 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: String,
default: "",
},
theme: {
type: String,
enum: StatusPageThemes,
default: DEFAULT_STATUS_PAGE_THEME,
},
themeMode: {
type: String,
enum: StatusPageThemeModes,
default: DEFAULT_STATUS_PAGE_THEME_MODE,
},
},
{ timestamps: true }
);
@@ -59,6 +59,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
theme: doc.theme,
themeMode: doc.themeMode,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
@@ -1,5 +1,7 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
import { StatusPage } from "@/types/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js";
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
getStatusPageByUrl(url: string): Promise<StatusPage>;
@@ -11,29 +13,52 @@ export interface IStatusPageService {
export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository;
constructor(statusPagesRepository: IStatusPagesRepository) {
private settingsService: ISettingsService;
constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) {
this.statusPagesRepository = statusPagesRepository;
this.settingsService = settingsService;
}
private withoutThemeFields = (data: Partial<StatusPage>): Partial<StatusPage> => {
const { theme: _theme, themeMode: _themeMode, ...rest } = data;
return rest;
};
private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
private normalizeTheme = (statusPage: StatusPage): StatusPage =>
this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage);
private normalizeInput = (data: Partial<StatusPage>): Partial<StatusPage> =>
this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data);
createStatusPage = async (
userId: string,
teamId: string,
image: Express.Multer.File | undefined,
data: Partial<StatusPage>
): Promise<StatusPage> => {
return await this.statusPagesRepository.create(userId, teamId, image, data);
const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(created);
};
getStatusPageByUrl = async (url: string): Promise<StatusPage> => {
return await this.statusPagesRepository.findByUrl(url);
const statusPage = await this.statusPagesRepository.findByUrl(url);
return this.normalizeTheme(statusPage);
};
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
return await this.statusPagesRepository.findByTeamId(teamId);
const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
return statusPages.map((sp) => this.normalizeTheme(sp));
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
return await this.statusPagesRepository.updateById(id, teamId, image, data);
const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(updated);
};
deleteStatusPage = async (statusPageId: string, teamId: string): Promise<StatusPage> => {
@@ -118,6 +118,7 @@ export class EmailService implements IEmailService {
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
@@ -158,10 +159,13 @@ export class EmailService implements IEmailService {
return false;
}
const trimmedDisplayName = systemEmailDisplayName?.trim();
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
try {
const info = await this.transporter.sendMail({
to: to,
from: systemEmailAddress,
from: from,
subject: subject,
html: html,
});
@@ -232,10 +232,17 @@ export class StatusService implements IStatusService {
// Build the status patch — computed against the projected window
const patch: Partial<Monitor> = {};
// Not enough data points yet
if (projectedWindow.length < monitor.statusWindowSize) {
patch.status = status === true ? "up" : "down";
// Resolve "initializing" up-front, incidents should be created on initialization && down
let newStatus: MonitorStatus = monitor.status;
let statusChanged = false;
if (monitor.status === "initializing") {
newStatus = status === true ? "up" : "down";
patch.status = newStatus;
statusChanged = newStatus === "down";
}
// Not enough data points yet — record the check and return
if (projectedWindow.length < monitor.statusWindowSize) {
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
monitor.id,
monitor.teamId,
@@ -248,24 +255,13 @@ export class StatusService implements IStatusService {
return {
monitor: updated,
statusChanged: false,
statusChanged,
prevStatus,
code,
timestamp: Date.now(),
};
}
// With a full window, a single raw check must not change UNLESS we are initializing.
// Otherwise, only the sliding-window threshold can trigger a transition.
let newStatus: MonitorStatus;
if (monitor.status === "initializing") {
newStatus = status === true ? "up" : "down";
} else {
newStatus = monitor.status;
}
let statusChanged = false;
// First evaluate reachability-based status changes, which apply to all monitor types
// and take precedence over hardware breaches.
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
@@ -13,12 +13,14 @@ export type EnvConfig = {
clientHost: string;
dbConnectionString: string;
dbType: DbType;
statusPageThemesEnabled: boolean;
};
export interface ISettingsService {
readonly serviceName: string;
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
}
@@ -37,6 +39,7 @@ export class SettingsService implements ISettingsService {
clientHost: env.CLIENT_HOST,
dbConnectionString: env.DB_CONNECTION_STRING,
dbType: env.DB_TYPE,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
};
}
@@ -56,6 +59,10 @@ export class SettingsService implements ISettingsService {
return this.settings;
}
areStatusPageThemesEnabled() {
return this.settings.statusPageThemesEnabled;
}
private getRepository(): ISettingsRepository {
if (!this.settingsRepository) {
throw new AppError({
+1
View File
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
| "systemEmailHost"
| "systemEmailPort"
| "systemEmailAddress"
| "systemEmailDisplayName"
| "systemEmailPassword"
| "systemEmailUser"
| "systemEmailConnectionHost"
+1
View File
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailPassword?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
+10
View File
@@ -1,6 +1,14 @@
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const;
export type StatusPageTheme = (typeof StatusPageThemes)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
export const StatusPageThemeModes = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
export interface StatusPageLogo {
data: string;
contentType: string;
@@ -30,6 +38,8 @@ export interface StatusPage {
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
theme: StatusPageTheme;
themeMode: StatusPageThemeMode;
createdAt: string;
updatedAt: string;
}
+4
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { DbTypes } from "@/types/settings.js";
import { booleanCoercion } from "./shared.js";
const envSchema = z.object({
// Server Configuration
@@ -20,6 +21,9 @@ const envSchema = z.object({
// Optional
ORIGIN: z.string().optional(),
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
});
export type ValidatedEnv = z.infer<typeof envSchema>;
@@ -113,6 +113,7 @@ export const sendTestEmailBodyValidation = z.object({
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
systemEmailAddress: z.string().optional(),
systemEmailDisplayName: z.string().optional(),
systemEmailPassword: z.string().optional(),
systemEmailUser: z.string().optional(),
systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
@@ -14,6 +14,12 @@ export const updateAppSettingsBodyValidation = z
timezone: z.string().optional(),
systemEmailHost: z.string().nullable().optional(),
systemEmailAddress: z.string().nullable().optional(),
systemEmailDisplayName: z
.string()
.max(100)
.transform((val) => (val.trim() === "" ? null : val.trim()))
.nullable()
.optional(),
systemEmailPassword: z.string().nullable().optional(),
systemEmailUser: z.string().nullable().optional(),
systemEmailConnectionHost: z.string().nullable().optional(),
@@ -1,6 +1,6 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { StatusPageTypes } from "@/types/statusPage.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js";
//****************************************
// Status Page Validations
@@ -33,6 +33,8 @@ export const createStatusPageBodyValidation = z
showAdminLoginLink: booleanCoercion.optional(),
showInfrastructure: booleanCoercion.optional(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
theme: z.enum(StatusPageThemes).optional(),
themeMode: z.enum(StatusPageThemeModes).optional(),
})
.strip();
+14
View File
@@ -0,0 +1,14 @@
import type { StatusPage } from "../../src/types/index.ts";
export const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
({
id: "sp-1",
teamId: "team-1",
userId: "user-1",
url: "my-status-page",
companyName: "Test Co",
monitors: ["mon-1"],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as StatusPage;
@@ -0,0 +1,91 @@
import { describe, expect, it } from "@jest/globals";
import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts";
import { makeStatusPage } from "../../helpers/makeStatusPage.ts";
describe("statusPageThemeFlag", () => {
describe("withoutThemeFields", () => {
it("removes theme and themeMode from a body that has them", () => {
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co" });
});
it("is a no-op when neither field is present", () => {
const body = { companyName: "Co", url: "page" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co", url: "page" });
});
it("removes only the theme fields when one is present", () => {
const body = { companyName: "Co", theme: "bold" };
const result = withoutThemeFields(body);
expect(result).toEqual({ companyName: "Co" });
});
it("does not mutate the input body", () => {
const body = { companyName: "Co", theme: "modern", themeMode: "dark" };
withoutThemeFields(body);
expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" });
});
it("preserves all other fields verbatim", () => {
const body = {
companyName: "Co",
monitors: ["m-1", "m-2"],
isPublished: true,
showCharts: false,
color: "#13715B",
theme: "editorial",
themeMode: "light",
};
const result = withoutThemeFields(body);
expect(result).toEqual({
companyName: "Co",
monitors: ["m-1", "m-2"],
isPublished: true,
showCharts: false,
color: "#13715B",
});
});
});
describe("applyDefaultTheme", () => {
it("overwrites theme and themeMode with the canonical defaults", () => {
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
const result = applyDefaultTheme(sp);
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
});
it("leaves all other fields untouched", () => {
const sp = makeStatusPage({
companyName: "Test Co",
url: "my-page",
monitors: ["mon-1", "mon-2"],
isPublished: true,
color: "#13715B",
});
const result = applyDefaultTheme(sp);
expect(result.companyName).toBe("Test Co");
expect(result.url).toBe("my-page");
expect(result.monitors).toEqual(["mon-1", "mon-2"]);
expect(result.isPublished).toBe(true);
expect(result.color).toBe("#13715B");
});
it("does not mutate the input status page", () => {
const sp = makeStatusPage({ theme: "modern", themeMode: "dark" });
applyDefaultTheme(sp);
expect(sp.theme).toBe("modern");
expect(sp.themeMode).toBe("dark");
});
it("works on a status page that has no theme set yet", () => {
const sp = makeStatusPage({ theme: undefined, themeMode: undefined });
const result = applyDefaultTheme(sp);
expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
});
});
});
@@ -396,5 +396,80 @@ describe("EmailService", () => {
})
);
});
// ── from-header formatting ───────────────────────────────────────
describe("from header formatting", () => {
const sendAndGetFrom = async (overrides: Partial<EmailTransportConfig>) => {
const transporter = createMockTransporter();
const nodemailer = createMockNodemailer(transporter);
const { service } = createService({ nodemailer });
await service.sendEmail("to@example.com", "Subject", "<p>body</p>", makeTransportConfig(overrides));
const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined;
return call?.from;
};
it("uses bare address when systemEmailDisplayName is undefined", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: undefined,
});
expect(from).toBe("noreply@example.com");
});
it("uses bare address when systemEmailDisplayName is empty string", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: "",
});
expect(from).toBe("noreply@example.com");
});
it("uses bare address when systemEmailDisplayName is whitespace-only", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: " ",
});
expect(from).toBe("noreply@example.com");
});
it("uses {name, address} when display name and address are both set", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: "Checkmate notifications",
});
expect(from).toEqual({
name: "Checkmate notifications",
address: "noreply@example.com",
});
});
it("trims surrounding whitespace from display name", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "noreply@example.com",
systemEmailDisplayName: " Checkmate ",
});
expect(from).toEqual({
name: "Checkmate",
address: "noreply@example.com",
});
});
it("falls back to bare address when display name is set but address is empty", async () => {
const from = await sendAndGetFrom({
systemEmailAddress: "",
systemEmailDisplayName: "Checkmate",
});
expect(from).toBe("");
});
});
});
});
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more