Compare commits

..
Author SHA1 Message Date
gorkem-bwl 2507f26480 Merge origin/develop into feat/main-dashboard
Brings the branch up to date with 91 commits on develop, including the
merged theme pass. 20 conflicts, all resolved by keeping both sides.

Locales (19 files): develop added `components.sidebar.menu.groups.*` for
the new grouped sidebar; this branch added `components.sidebar.menu.
dashboard`. Both are wanted, so both are kept. Every file re-validated as
JSON, and all 19 confirmed to carry menu.dashboard, menu.groups and
pages.dashboard.

Menu.tsx: develop restructured the sidebar from a flat list into
`{group, items[]}` groups. That structure is preserved as-is - the
renderer calls `group.items.map` unconditionally, so a flat entry would
throw. Dashboard is now the first item in the Monitoring group, which
suits a page that summarises every monitor type at once. All four groups
(Monitoring, Alerting, Reporting, Configuration) intact.

Gates after merge: tsc clean, build passes, format-check clean, lint
12 errors - the same pre-existing 12 as develop, none in dashboard files.

Left for a follow-up: develop renamed CONTROL_HEIGHT to
INPUT_BASE_HEIGHT, which is now importable here. DashboardCard's
HEADER_MIN_HEIGHT = 44 could derive from it, but the header pads with
theme.spacing(LAYOUT.SM) and the theme's spacing base is 2, so the
padding is 12px a side, not 6 - the derivation would be 56 and would
make every card header taller. That is a visual change, not a merge
resolution, so 44 stands.
2026-09-07 06:56:52 -04:00
gorkem-bwl 34663ec5f9 fix(client): dashboard review pass - correctness, redundancy, comments
Code review of the dashboard branch. Four correctness bugs, one shared
helper, and the comment density brought down.

Correctness:

- CurrentlyDownCard: downSince() broke its backwards walk only on
  `status === true`, so a check with an undefined status kept the run
  going and reported a downtime reaching back to the start of the
  retained window. Break on `status !== false` instead, matching what
  the doc comment already claimed.

- RankingCards: the lowest-uptime card barred the uptime percentage
  itself, so the worst monitor drew the shortest bar - a chart that read
  as "best uptime" directly against its own title and sort order. Bar
  the shortfall instead.

- useCardTransitions: the FLIP rAF and its nested 260ms timeout were
  never cancelled. Navigating away mid-animation wrote styles to
  unmounted nodes, and a second move while the first was settling had
  its inversion wiped by the earlier timeout, so the card jumped rather
  than travelled. Handles are now tracked and cancelled on unmount and
  at the start of each move.

- BreakdownCard: the bar scale took buckets[0].total, silently assuming
  the caller sorted descending. Compute the max instead - both callers
  do sort today, but nothing in the signature said they had to.

Redundancy:

- "newest check is the last element of recentChecks" was implemented
  twice with its own copy of the explanation. Now one `latestCheck`
  helper in checks.ts. RankingCards averages over all checks, which is
  a different operation, and is left alone.

- DEFAULT_CARD_IDS is `readonly` and copied into state rather than
  assigned by reference.

Comments and conventions:

- 283 comment lines down to ~220. Design rationale ("two lines rather
  than one wide row", "the header controls stay invisible until...")
  is gone; comments recording non-obvious API facts - recentChecks is
  oldest-first, snake_case here but camelCase there, credential fields
  must not reach the DOM - are kept and shortened.

- DashboardCard: display/flexDirection/height hoisted out of sx to
  native props (frontend rule 1). A stale comment claiming a "34px
  control height" is corrected; the codebase settled on 32.

Gates: tsc clean, build passes, format-check clean, lint unchanged from
develop (12 errors, all pre-existing in untouched files).
2026-09-07 06:44:16 -04:00
gorkem-bwl 6a54fcd82b fix(client): shorten the dashboard description to one line
At 136 characters the description was the longest on the site by 23, and
the page header caps descriptions at maxWidth 640 — roughly 91 characters
at the header's 14px size — so it wrapped to two lines.

Now 66 characters: 'See every monitor type in one place and spot what
needs attention.' It keeps both halves of what the old wording said, the
breadth and the payoff, without the clause that pushed it over.

Shortened in all 18 other locales too, each kept under the same budget;
the longest translation is now 88 characters.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 11:40:57 -04:00
gorkem-bwl dad32bc475 fix(client): translate the dashboard, drop redundant font sizes, reword the header
Three fixes from an audit of the dashboard against docs/coding-conventions.md.

Translations. The dashboard shipped English-only: all 18 other locales
had an empty pages.dashboard block, so every non-English user saw raw key
paths like pages.dashboard.cards.monitorStatus.title. All 18 are now
seeded, reusing each locale's own existing vocabulary for uptime,
incidents, checks and so on so the dashboard reads consistently with the
rest of that translation. Verified: all 91 keys the code uses resolve in
all 19 locales, with no interpolation placeholder mismatches.

Rule 3. Thirty-one fontSize={typographyLevels.m} props removed. The
conventions doc is explicit that 13px is the body default and the prop
should be dropped entirely; setting it to the token was my error while
fixing an earlier font-size complaint. Only the two xxl figures keep an
explicit size, which is correct. Nine now-dead typographyLevels imports
went with them.

Header description. "What is wrong right now, across every monitor type."
was a negative sentence fragment; every other page uses a full sentence
describing what the page gives you and why it helps. Reworded to match.

Rules 1, 2, 4, 5 and the i18n rule already passed and needed no change.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 11:37:10 -04:00
gorkem-bwl e3a565d976 fix(client): Escape closes the card picker, and lay it out in two columns
Escape did not just fail to close the dialog — it reset the dashboard.
The shared Dialog routes an escapeKeyDown close to onCancel, and this
picker puts Reset to defaults in the cancel slot, so pressing Escape
wiped the user's card selection. Dialog gains an optional onEscape so a
dialog whose cancel slot carries another action can dismiss properly;
without it the behaviour is unchanged for every other caller.

The card list is now two columns. Seventeen cards in a single column
made the dialog scroll, hiding most of the catalogue behind a scrollbar.
Groups flow down the columns and are kept whole with break-inside, so a
group is never split across the gap. Single column below the sm
breakpoint.

Also hardens the selection against silent loss: the persisting effect no
longer writes on mount. That write was a no-op in the normal case, since
the value had just been read from storage — but if the read ever fell
back to the defaults (unparseable value, storage throwing), the effect
immediately overwrote the real selection and made the loss permanent.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:51:07 -04:00
gorkem-bwl a0d91abcd0 feat(client): animate cards when they are reordered
Moving a card was an instant jump, with nothing to show what had changed
or where the card went.

Cards now travel between positions using FLIP — measure the old rect,
re-render in the new order, offset each card back to where it was, then
release it. This animates the real layout rather than faking movement, so
a card that shifts because *another* card moved animates too, and the
effect stays correct however the grid reflows. 260ms on a decelerating
curve: quick to leave, gentle to settle.

The card the user acted on is lifted above its neighbours for the
duration, so a swap reads as one card moving rather than two sliding
through each other.

No new dependency — @hello-pangea/dnd is drag-and-drop, not a layout
animation library, and the house style is plain CSS transitions.

Two things this deliberately avoids:

- Animating on anything but a reorder. The effect runs on every render,
  so it is gated behind an explicit capture taken at click time; the 30s
  SWR refresh, isStale toggles and picker changes all leave cards still.
- Leaving inline styles behind. Transition and z-index are cleared once
  settled, or a later unrelated reflow would animate the card again.

prefers-reduced-motion skips the animation entirely.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:46:36 -04:00
gorkem-bwl 729c2cb049 fix(client): right-align the card header action
The reorder controls sit in the header beside the action, and while
hidden they were still reserving their own width plus a gap. That pushed
"2 open" and every other header action left of the card's right edge, out
of line with the values in the rows beneath.

The controls are now taken out of flow and overlaid on the right, so the
action sits flush right whenever they are not showing. Because they then
occupy the same space, the action fades out as the controls fade in
rather than the two rendering on top of each other.

The card body needed no change: CardRow's negative margin, which lets the
hover highlight bleed to the card edges, is already cancelled by its own
inner padding, so row values were correctly at 16px from the edge all
along. The header slot was the only cause.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:43:24 -04:00
gorkem-bwl 8398a0300d fix(client): one bar thickness, and every dashboard card half-width
Bars came in two weights — 4px for the plain proportional bar and 8px for
the segmented one — so the lines in Monitors by type, Slowest monitors
and Lowest uptime were visibly thinner than the one in Monitor status.
They sit side by side across cards, so the difference read as an
inconsistency rather than as emphasis. There is now a single BAR_HEIGHT
of 8 for both.

Monitor status and Team members drop to half-width, which makes every
card half-width. The grid is now uniform in both dimensions.

That also removes the last reason for the stretch rule. A trailing card
on an odd-numbered dashboard keeps its declared width instead of being
promoted to full: widening one card to fill its row makes it look more
important than it is, and an odd count is a property of the selection,
not something the layout should paper over. layoutCards is now a straight
pass-through, and HALF_WIDTH is gone with the logic that needed it.

Verified across the full catalogue, the non-admin subset, the defaults
and a single card: no card renders full-width in any configuration.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:36:51 -04:00
gorkem-bwl 74d859b877 feat(client): redesign the Monitor status and Incident stats cards
Both cards were laying data out rather than saying anything with it.

Monitor status showed six equal-weight tiles, so "11 up" and "1 down"
carried identical visual weight and four of the six were usually zero. It
now leads with how much of the fleet is healthy — the count against the
total — over a segmented bar showing the whole fleet's composition at a
glance. Below it, only states that actually have monitors appear, worst
first. A fully healthy fleet collapses to one line and a solid green bar
instead of a row of empty counters.

Incident stats showed one figure and two label lines, and ignored three
fields the endpoint already returns. Open incidents still leads but is
now paired with the all-time total, because two open reads very
differently against 12 incidents than against 183. Beneath that, the
automatic-versus-manual resolution split gets a bar of its own: it was
computed server-side and said nothing on the card before, and a high
manual share is worth seeing — it means people are doing work the system
could be doing.

Adds CardSegmentedBar to the shared primitives for both. Zero-value
segments are dropped so they cannot render as hairlines, and each segment
carries a tooltip since the bar has no room for labels. Verified the
proportions across a healthy fleet, an all-down fleet, an empty one, and
a fully-automatic resolution history.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:33:05 -04:00
gorkem-bwl e4718b814f refactor(client): uniform card widths, shared primitives, clearer titles
Four widths (4/6/8/12) put four different card sizes on one page, and the
4-vs-6 difference read as inconsistency rather than intent. There are now
two: every card is half-width and pairs down the page, and only Monitor
status is full-width because it lays six counts across a single row.

Currently down is half-width too. Seven columns would crush the monitor
name there, so its rows are now two lines — name and downtime lead, with
type, status code and the sparkline in a quieter line beneath. No
information is lost.

This collapses the layout logic: two halves fill a row exactly and a full
card fills its own, so nothing is ever stretched to close a gap. The only
remaining decision is a lone trailing half, promoted to full so the page
does not end ragged. Verified across the catalogue, the non-admin subset,
the defaults and single-card cases: no mid-page gaps anywhere.

Componentization — there was real duplication:

- StatLine was defined twice and MetricLine was a third copy of the same
  thing. All three are now CardStatLine.
- A label + value + bar row appeared five times across the breakdown and
  ranking cards. It is now CardMeterRow.
- Monitors by type and by group were the same card over different keys,
  so they share a BreakdownCard shell. Between them they drop from 232
  lines to 83.

Titles, checked against what each card actually renders:

- Checks on schedule -> Check queue. It reports queue jobs and workers,
  not the checks themselves.
- Maintenance -> Maintenance windows, Team -> Team members. Bare nouns
  read as navigation rather than as what the card reports.
- The picker no longer borrows the sidebar's group labels. The Logs page
  hosts logs, queue and diagnostics tabs, so "Logs" misdescribed the
  three cards under it; that group is now "System". Checkmate server
  keeps its name — it does report on the server process.

Picker buttons: Reset to defaults moves to the left, away from Done,
since a destructive action should not sit next to the one people reach
for by reflex. Done becomes the primary contained button. Dialog gains an
optional cancelSx to allow that placement.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 10:03:59 -04:00
gorkem-bwl c46ead515b fix(client): dashboard hover controls, card widths and font sizes
Reorder controls stayed visible after a click. The reveal used
`:focus-within`, and clicking a button leaves it focused, so that card's
controls stayed pinned open long after the pointer left — Failed checks
being wherever focus happened to land. Now `:has(:focus-visible)`, which
matches keyboard focus only, so a mouse click no longer pins them.

Widths: Currently down was full-width and Recent incidents 8-wide, both
with more whitespace than content. Currently down carries six columns per
row so it is the densest card, but it does not need all twelve; it is now
8. Recent incidents carries four and is now 4, as are Recent errors.

Declaring a narrower width is not enough on its own: a card left alone in
a row gets stretched to fill it, so Currently down at 8 still rendered at
12 while it was the only card in its row. Registry and default order are
now sequenced so widths pair into full rows — 8 beside 4, 6 beside 6 —
which is what actually makes the narrow cards render narrow. Verified
across the full catalogue, the non-admin subset and the defaults: every
mid-page row fills exactly 12 with no gaps.

Fonts: every secondary line used typographyLevels.s, which is 11px —
body2, a de-emphasis size. The theme's body1 is 13px, so all 24 uses are
now typographyLevels.m. Nothing in a card renders below body size any
more; only the large figures differ, at xxl.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 09:53:42 -04:00
gorkem-bwl 49af07009d feat(client): reorder cards on hover, and tidy dashboard styling
Adds move-up / move-down controls to each card, revealed on hover or
when something inside the card takes focus, so a settled dashboard stays
quiet but the controls are still reachable by keyboard.

The controls reach the card header through context rather than a prop,
so none of the 17 card components has to know its own position — the
grid owns ordering. Positions resolve against the *visible* order, not
the stored array: a non-admin never sees the admin cards, so moving a
card past one of them would otherwise appear to do nothing. Hidden cards
keep their stored positions.

Styling fixes:

- 'Reset to defaults' no longer renders as the prominent green contained
  button. It is a destructive secondary action, so it moves to the quiet
  outlined slot beside the close action. The dialog now has no confirm
  button at all, since every change applies immediately and there is
  nothing to confirm.
- Icons go through the shared Icon component at the codebase's usual 16px
  instead of ad-hoc 14px with a one-off stroke width, and the header
  spinner matches at 16px.
- Named the remaining magic numbers — header height, bar thickness,
  sparkline and column widths, status tile width, control sizes — so they
  read as decisions rather than arbitrary pixels. There is no size-token
  scale in the theme (LAYOUT is spacing only) and peer components
  hardcode their own dimensions, so module constants match the house
  pattern rather than inventing a scale.
- Centralised the two poll rates instead of repeating 30000 and 60000
  across cards, and hoisted a stray sx flexShrink onto the component.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 09:43:02 -04:00
gorkem-bwl f86ead0806 feat(client): add the card picker and the remaining catalogue cards
Completes the catalogue at 17 cards and adds the picker that chooses
between them.

New cards: Slowest monitors, Lowest uptime, Monitors by group, Busiest
servers, Incident stats, Notification channels, Maintenance, Status
pages, Checks on schedule, Recent errors, Team.

Data caveats honoured from the specs:

- Rankings exclude paused monitors. A paused ping monitor reads 0% uptime
  and would otherwise rank beside a genuinely down one; neither is a live
  problem. Rankings also hide below two entries — a ranking of one is not
  a ranking.
- Busiest servers reads cpu/memory/disk from the newest recentChecks
  entry (snake_case, 0-1 fractions) and renders a dash for a down server
  rather than 0%.
- Maintenance compares start/end client-side: `active` is a user on/off
  flag, not 'running now', and repeat > 0 means end may sit in the past
  for a live window.
- Notification channels renders only the type. The response also carries
  accessToken and accountSid; those must never reach the DOM.
- Recent errors is worded 'recent' because /logs is a 1000-entry
  in-memory ring buffer that rotates fast and is wiped on restart.

Also fixes a layout gap the six default cards never exposed: a 6-wide
card followed by an 8-wide one left a lone card stretched to 8 in a
middle row, with 4 empty columns beside it. Mid-page rows now always fill
to 12; only the final row may end short, and the cap applies only there,
so a lone trailing 4-wide card does not become a banner.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 09:37:34 -04:00
gorkem-bwl 23b00401ca fix(client): correct recentChecks ordering on the dashboard
recentChecks is oldest-first, not newest-first. The server appends each
snapshot with `$push … $slice: -N` (monitor.repository.mongo.ts:218) and
returns it unreversed, and every existing consumer reads
recentChecks[length - 1] as the latest. I had it backwards, which broke
three things:

- downSince() walked from index 0 and broke on the first element. For any
  monitor that was up before it failed — the normal case — the very first
  check is an "up", so the loop exited immediately and the downtime
  duration never rendered at all.
- The sparkline reversed already-ascending data, drawing every trend line
  mirrored: climbing latency appeared to be improving.
- The status code read index 0, the oldest retained check, so a monitor
  that served 200s before failing showed a green 200 beside a red dot.

Also from review:

- Drop `initializing` from the "Currently down" ranking. On a fresh
  install every new monitor starts there, so the card opened red claiming
  a fleet-wide outage. It means "not checked yet", not "broken".
- Add the maintenance count to Monitor status. The summary reports it and
  the server counts it; omitting it left the tiles summing to less than
  totalMonitors with nothing explaining the gap.
- Link the status tiles to plain /uptime. The monitors list never reads
  query params, so ?status= was silently dropped and the link lied.
- Render the status code as plain text instead of StatusCodeLabel, whose
  Tooltip put a focusable element inside the row's link where no keyboard
  user could reach it.
- Stop sending `limit`: findByTeamIdWithStats derives its page size from
  rowsPerPage alone and never reads it, so it only implied a truncation
  that was not in effect.
- Persist a deliberately emptied dashboard instead of resetting it to the
  defaults on reload.
- Pass isStale on every card. The prop and its dimming were implemented
  but never wired, so background refetches gave no feedback.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 09:30:12 -04:00
gorkem-bwl c58087c787 feat(client): add the main dashboard page
Adds /dashboard, a cross-type overview answering "is anything wrong, and
where" — the question no single-type page can answer today. /uptime shows
uptime monitors, /infrastructure shows hardware, /incidents shows history;
nothing joined them.

Layout is a 12-column grid. Each card type declares a minimum width (4, 6,
8 or 12) in the registry; the grid stretches the last card of a short row
to fill the remainder, capped at 8 so one card never balloons across a
near-empty row. Declared width is never a user setting. grid-auto-flow:
dense was tried and does not help — no card is small enough to backfill
the gaps that occur in practice.

Six cards ship as the default set:

  Monitor status    the five fleet counts, server-computed
  Currently down    the centrepiece: everything wrong now, worst first
  Failed checks     failure rate over the last day
  Recent incidents  latest five, with the open count
  Monitors by type  composition, flagging types with something down
  Checkmate server  the only card that works with zero monitors (admin)

Cards 1-3 and 5 ride a single /monitors/team/with-checks call shared
through context, so adding a monitor-derived card costs no extra request.

Notes on the data:

- Ordering in "Currently down" is down -> breached -> initializing, tied
  on longest downtime. That is ordering derived from existing fields, not
  a new severity scale — Checkmate has none, and inventing one would be a
  product decision dressed as a design choice.
- The healthy state is deliberately quiet: one line, not a wall of green.
- Failed checks ships the day range only. dateRange=week returns figures
  identical to day, so a range selector would silently lie.
- Admin-only cards are hidden rather than disabled, and are filtered out
  of the selection entirely for non-admins.

Card selection persists to localStorage, validated on read so a card
removed in a later release cannot break the page. The picker and the
remaining catalogue cards are not built yet.

The Containers card from the spec is deliberately absent: it needs
containerSummary on the check snapshot, which arrives with the Docker
monitoring work. cards.ts records where it slots in.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-29 09:10:46 -04:00
312 changed files with 11409 additions and 12029 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Install client dependencies
working-directory: client
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Install server dependencies
working-directory: server
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Install client dependencies
working-directory: client
@@ -30,7 +30,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend
- `/server` - Node.js 24+ + Express + TypeScript backend
- `/server` - Node.js 20+ + Express + TypeScript backend
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers
+8 -126
View File
@@ -92,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
@@ -104,15 +104,7 @@ The image is configured entirely through environment variables on the server con
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
| `PORT` | No | Port the API and web client are served on (default `52345`) |
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
@@ -126,122 +118,12 @@ The web client needs no configuration by default: it calls the API on the same o
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
### Docker monitors
A Docker monitor reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
The **Docker host** field accepts three forms:
| Host | Example | Notes |
|---|---|---|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a>
@@ -251,7 +133,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
@@ -269,16 +151,16 @@ 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)
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
- 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
- Incidents at a glance
- Status pages with 5 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
- 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
## Monitor Lifecycle
@@ -331,7 +213,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+24 -4
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.12.0",
"version": "3.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.12.0",
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
@@ -93,6 +93,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -408,6 +409,7 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -445,6 +447,7 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1233,6 +1236,7 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1337,6 +1341,7 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2381,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2396,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2486,6 +2493,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2766,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3122,6 +3131,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3565,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3964,6 +3975,7 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4776,6 +4788,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5587,6 +5600,7 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6368,6 +6382,7 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6379,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6582,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6732,6 +6749,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7573,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7710,6 +7729,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+1 -5
View File
@@ -1,11 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.12.0",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"version": "3.11.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
@@ -8,11 +8,8 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const { t, i18n } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) {
@@ -55,12 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
{t("pages.common.monitors.statBoxes.checkingEvery", {
interval: formatDuration(monitor.interval, {
long: true,
locale: i18n.resolvedLanguage,
}),
})}
Checking every {formatDuration(monitor?.interval, true)}
</Typography>
</>
)}
@@ -81,21 +81,6 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -117,10 +102,7 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -274,8 +256,7 @@ export function DataTable<
}),
...(getRowSx?.(row) as object),
}}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
onClick={() => {
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
+18 -1
View File
@@ -9,6 +9,7 @@ import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material/styles";
import type { ReactNode } from "react";
export const DialogInput = ({
@@ -25,6 +26,8 @@ export const DialogInput = ({
maxWidth,
fullWidth = false,
additionalButtons,
cancelSx,
onEscape,
}: {
open: boolean;
title?: string;
@@ -39,6 +42,14 @@ export const DialogInput = ({
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
additionalButtons?: ReactNode;
/** Styles the cancel button — e.g. an auto margin to push it left. */
cancelSx?: SxProps;
/**
* Called when Escape is pressed. Defaults to `onCancel`, which is right when
* cancel simply dismisses — but not when it does something else, so a dialog
* whose cancel slot carries another action passes its own dismiss here.
*/
onEscape?(): void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -48,7 +59,12 @@ export const DialogInput = ({
maxWidth={maxWidth}
fullWidth={fullWidth}
onClose={(_event, reason) => {
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
if (reason === "backdropClick") return;
if (onEscape) {
onEscape();
return;
}
if (onCancel) onCancel(undefined);
}}
>
{title && (
@@ -94,6 +110,7 @@ export const DialogInput = ({
loading={loading}
variant="outlined"
onClick={onCancel}
sx={cancelSx}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
@@ -5,10 +5,9 @@ import {
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
export interface AutocompleteOption {
id: string | number;
id: string;
name: string;
}
@@ -17,9 +16,6 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
options: O[];
fieldLabel?: string;
placeholder?: string;
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
disableClearable?: boolean;
}
export const FormAutocompleteField = <
@@ -30,9 +26,6 @@ export const FormAutocompleteField = <
options,
fieldLabel,
placeholder,
filterOptions,
getOptionDisabled,
disableClearable,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
@@ -46,14 +39,10 @@ export const FormAutocompleteField = <
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
if (newValue === null && disableClearable) return;
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
filterOptions={filterOptions}
getOptionDisabled={getOptionDisabled}
disableClearable={disableClearable}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
@@ -60,22 +60,20 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
<Button
variant="contained"
color="secondary"
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t, i18n } = useTranslation();
const { t } = useTranslation();
if (!monitorStats || !monitor) {
return null;
}
@@ -29,16 +29,13 @@ export const MonitorStatBoxes = ({
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure);
const lastCheckTime = timeOfLastCheck
? formatDuration(Date.now() - timeOfLastCheck, {
long: true,
relative: true,
locale: i18n.resolvedLanguage,
})
: t("common.labels.na");
const lastCheckTime = formatDuration(timeSinceLastCheck);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -1,58 +1,15 @@
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
@@ -60,9 +17,6 @@ interface HistogramDockerContainerProps {
dataKey: string;
strokeColor: string;
gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
}
export const HistogramDockerContainer = ({
@@ -72,14 +26,11 @@ export const HistogramDockerContainer = ({
dataKey,
strokeColor,
gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon="null"
@@ -105,19 +56,6 @@ export const HistogramDockerContainer = ({
endColor: "transparent",
direction: "vertical",
})}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area
dataKey={dataKey}
type="monotone"
+6 -6
View File
@@ -1,6 +1,7 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -20,18 +21,17 @@ import {
Tag,
Waypoints,
Container,
// LayoutDashboard,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
// {
// name: t("components.sidebar.menu.dashboard"),
// path: "dashboard",
// icon: <Icon icon={LayoutDashboard} />,
// },
{
name: t("components.sidebar.menu.dashboard"),
path: "dashboard",
icon: <Icon icon={LayoutDashboard} />,
},
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
-35
View File
@@ -1,16 +1,8 @@
import {
dashboardCardKeys,
type DashboardCardKey,
type DashboardSortOrder,
} from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "dashboard"
| "monitors"
| "team"
| "maintenance"
@@ -21,8 +13,6 @@ type TableName =
interface TableState {
rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
}
interface SidebarState {
@@ -30,7 +20,6 @@ interface SidebarState {
}
interface UIState {
dashboard: TableState;
monitors: TableState;
pagespeed: TableState;
team: TableState;
@@ -46,8 +35,6 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[];
dashboardUptimeSortOrder: DashboardSortOrder;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -56,9 +43,6 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
: "light";
const initialState: UIState = {
dashboard: {
rowsPerPage: 100,
},
monitors: {
rowsPerPage: 10,
},
@@ -90,8 +74,6 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys],
dashboardUptimeSortOrder: "ascending",
};
const uiSlice = createSlice({
@@ -108,14 +90,6 @@ const uiSlice = createSlice({
const { table, value } = action.payload;
state[table].rowsPerPage = value;
},
setTableSort: (
state,
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
) => {
const { table, field, order } = action.payload;
state[table].sortField = field;
state[table].sortOrder = order;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
@@ -143,12 +117,6 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload;
},
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
state.dashboardUptimeSortOrder = action.payload;
},
},
});
@@ -156,7 +124,6 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer;
export const {
setRowsPerPage,
setTableSort,
toggleSidebar,
setCollapsed,
setMode,
@@ -166,6 +133,4 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardVisibleCards,
setDashboardUptimeSortOrder,
} = uiSlice.actions;
-9
View File
@@ -77,16 +77,7 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
secret: data?.secret || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
};
break;
case "game":
+8 -13
View File
@@ -1,4 +1,4 @@
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
@@ -10,7 +10,6 @@ import {
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
@@ -25,12 +24,10 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const tableState = useSelector(
(state: RootState) => state.ui?.[config.rowsPerPageTable]
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -39,6 +36,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -136,11 +135,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0);
};
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
@@ -195,8 +189,9 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSort,
setSortOrder,
// Delete
selectedMonitor,
+26 -169
View File
@@ -38,7 +38,6 @@ import {
PageSpeedStrategies,
DnsRecordTypes,
MonitorIntervalOptions,
MIN_MONITOR_INTERVAL_MS,
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
@@ -51,8 +50,6 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isCaptureDockerUrl,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -60,62 +57,15 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormAutocompleteField,
type AutocompleteOption,
} from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
import { formatDuration } from "@/Utils/TimeUtils";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
label: method,
}));
const PEM_FIELD_ROWS = 6;
// Hides the pasted key while the field is not focused. Ignored by browsers
// without -webkit-text-security, which then show the key as plain text.
const maskedInputSx = {
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
};
interface IntervalOption extends AutocompleteOption {
id: number;
}
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
const toIntervalOption = (
intervalMs: number,
locale: string | undefined
): IntervalOption => ({
id: intervalMs,
name: formatDuration(intervalMs, { long: true, locale }),
});
const filterIntervalOptions = (
options: IntervalOption[],
inputValue: string,
locale: string | undefined
): IntervalOption[] => {
const input = inputValue.trim();
if (input === "" || options.some((option) => option.name === input)) {
return options;
}
const amount = Number(input);
if (!Number.isFinite(amount) || amount <= 0) {
return [];
}
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
.filter(Number.isSafeInteger)
.map((ms) => toIntervalOption(ms, locale));
};
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -293,7 +243,7 @@ const getGeneralSettingsConfig = (
const CreateMonitorPage = () => {
const theme = useTheme();
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { monitorId } = useParams();
const location = useLocation();
const navigate = useNavigate();
@@ -350,13 +300,10 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
const watchedInterval = watch("interval");
// Steps without an advanced section drop it, so the last step is the form's.
const totalSteps = monitorStepCount(watchedType);
@@ -382,20 +329,11 @@ const CreateMonitorPage = () => {
}
}, [watchedType, showTypeSelector, reset, clearErrors]);
// The logs switch is hidden for Capture hosts, so a value left over from a socket host must not be submitted.
useEffect(() => {
if (isCaptureDocker) {
setValue("dockerLogsEnabled", false);
}
}, [isCaptureDocker, setValue]);
const generalSettingsConfig = useMemo(
() => getGeneralSettingsConfig(watchedType, t),
[watchedType, t]
);
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
@@ -521,18 +459,16 @@ const CreateMonitorPage = () => {
[t]
);
const intervalOptions = useMemo(() => {
const options = MonitorIntervalOptions.map(({ value }) =>
toIntervalOption(value, i18n.resolvedLanguage)
);
if (
typeof watchedInterval === "number" &&
!options.some((option) => option.id === watchedInterval)
) {
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
}
return options;
}, [watchedInterval, i18n.resolvedLanguage]);
const intervalOptions = useMemo(
() =>
MonitorIntervalOptions.map((option) => ({
value: option.value,
label: t(
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
),
})),
[t]
);
const matchMethodOptions = useMemo(
() =>
@@ -617,6 +553,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
@@ -717,8 +654,8 @@ const CreateMonitorPage = () => {
/>
)}
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */}
{(generalSettingsConfig.showSecret || isCaptureDocker) && (
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
<FormTextField
name="secret"
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
@@ -727,12 +664,6 @@ const CreateMonitorPage = () => {
)}
/>
)}
{isCaptureDocker && (
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
/>
)}
{generalSettingsConfig.showDnsServer && (
<FormTextField
name="dnsServer"
@@ -757,83 +688,23 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={
<FormAutocompleteField
<FormSelectField
name="interval"
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
options={intervalOptions}
filterOptions={(options, { inputValue }) =>
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
}
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
disableClearable
/>
}
/>
)}
{/* Alert Thresholds - only for hardware type */}
{watchedType === "hardware" && showStep(1) && (
<ConfigBox
@@ -889,6 +760,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.incidents.title")}
@@ -915,6 +787,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
@@ -927,6 +800,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.tags.title")}
@@ -953,7 +827,8 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
{showStep(1) && watchedType === "docker" && (
<ConfigBox
title={t("pages.createMonitor.form.dockerLogs.title")}
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
@@ -965,28 +840,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && watchedType === "docker" && (
<ConfigBox
title={t("pages.createMonitor.form.dockerAlerts.title")}
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSwitchField
name="dockerAlertOnStopped"
label={t(
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
)}
/>
<FormSwitchField
name="dockerAlertOnUnhealthy"
label={t(
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
)}
/>
</Stack>
}
/>
)}
{showStep(2) &&
(watchedType === "http" ||
watchedType === "grpc" ||
@@ -1002,6 +856,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(2) && watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.advanced.title")}
@@ -1101,6 +956,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(2) && supportsGeoCheck(watchedType) && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
@@ -1133,6 +989,7 @@ const CreateMonitorPage = () => {
}
/>
)}
<Stack
direction="row"
justifyContent={!isEditMode ? "space-between" : "flex-end"}
@@ -0,0 +1,78 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { layoutCards } from "../layout";
import { useCardTransitions } from "../useCardTransitions";
import { CardReorderControls } from "./CardReorderControls";
import { CardSlotContext } from "./CardSlotContext";
export const CardGrid = ({
cards,
onMove,
}: {
cards: CardDefinition[];
onMove: (id: CardId, delta: -1 | 1) => void;
}) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
const { registerCard, capturePositions } = useCardTransitions();
// Positions are measured before the state change so the cards can be
// animated from where they were to where they land.
const handleMove = (id: CardId, delta: -1 | 1) => {
capturePositions(id);
onMove(id, delta);
};
return (
<Box
display="grid"
gap={theme.spacing(LAYOUT.MD)}
sx={{
gridTemplateColumns: {
xs: "1fr",
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
alignItems: "stretch",
}}
>
{placed.map(({ card, renderedWidth }, index) => {
const Component = card.component;
return (
<Box
key={card.id}
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
minWidth={0}
// Positioned so the moving card can be lifted above its
// neighbours with a z-index while it travels.
position="relative"
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
// Hints the compositor before a transform is applied, so the
// first frame of a move does not stutter.
sx={{ willChange: "transform" }}
>
{/*
* The controls go through context rather than a prop so the 17
* card components stay unaware of their own position.
*/}
<CardSlotContext.Provider
value={
<CardReorderControls
canMoveUp={index > 0}
canMoveDown={index < placed.length - 1}
onMoveUp={() => handleMove(card.id, -1)}
onMoveDown={() => handleMove(card.id, 1)}
/>
}
>
<Component />
</CardSlotContext.Provider>
</Box>
);
})}
</Box>
);
};
@@ -0,0 +1,127 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Dialog, Checkbox } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
interface CardPickerProps {
open: boolean;
available: CardDefinition[];
selected: CardId[];
onAdd: (id: CardId) => void;
onRemove: (id: CardId) => void;
onReset: () => void;
onClose: () => void;
}
/**
* Cards are grouped by sidebar section, not by load cost: you look for a card
* where you look for the feature it belongs to. Admin-only cards never reach
* `available` for a non-admin, so nobody is offered a card they cannot load.
*/
export const CardPicker = ({
open,
available,
selected,
onAdd,
onRemove,
onReset,
onClose,
}: CardPickerProps) => {
const theme = useTheme();
const { t } = useTranslation();
const grouped = useMemo(() => {
return CardGroups.map((group: CardGroup) => ({
group,
cards: available.filter((card) => card.group === group),
})).filter((entry) => entry.cards.length > 0);
}, [available]);
const chosen = new Set(selected);
return (
<Dialog
open={open}
maxWidth="sm"
fullWidth
title={t("pages.dashboard.picker.title")}
// The dialog lays its actions out as cancel → additional → confirm, all
// right-aligned. Reset takes the cancel slot with an auto right margin
// so it sits alone on the left, away from Done — a destructive action
// should not be adjacent to the one people reach for by reflex.
onCancel={onReset}
cancelText={t("pages.dashboard.picker.reset")}
cancelSx={{ marginRight: "auto" }}
// Escape must dismiss, not reset. The dialog sends Escape to onCancel by
// default, and this one's cancel slot resets the selection — so without
// this, Escape would wipe the user's cards instead of closing.
onEscape={onClose}
// Changes apply immediately, so Done only dismisses; it is the primary
// action here and carries the usual contained styling.
onConfirm={onClose}
confirmText={t("pages.dashboard.picker.done")}
>
{/*
* Two columns: seventeen cards in one list makes the dialog scroll,
* which hides most of the catalogue behind a scrollbar. Groups flow
* down the columns and are kept whole, so a group is never split
* across the gap.
*/}
<Box
pt={theme.spacing(LAYOUT.SM)}
sx={{
columnCount: { xs: 1, sm: 2 },
columnGap: theme.spacing(LAYOUT.XL),
}}
>
{grouped.map(({ group, cards }) => (
<Stack
key={group}
gap={theme.spacing(LAYOUT.XXS)}
pb={theme.spacing(LAYOUT.MD)}
sx={{ breakInside: "avoid" }}
>
<Typography
variant="eyebrow"
color={theme.palette.text.disabled}
>
{/*
* The picker has its own group labels rather than reusing the
* sidebar's: the Logs page hosts logs, queue and diagnostics
* tabs, so "Logs" would misdescribe the three cards under it.
*/}
{t(`pages.dashboard.picker.groups.${group}`, {
defaultValue: group,
})}
</Typography>
{cards.map((card) => (
<FormControlLabel
key={card.id}
label={
<Typography color={theme.palette.text.primary}>
{t(`pages.dashboard.cards.${card.key}.title`)}
</Typography>
}
control={
<Checkbox
checked={chosen.has(card.id)}
onChange={() =>
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
}
/>
}
/>
))}
</Stack>
))}
</Box>
</Dialog>
);
};
@@ -0,0 +1,275 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { Tooltip } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import type { ReactNode } from "react";
/**
* One thickness for every bar on the dashboard. Plain and segmented bars sit
* side by side across cards, so differing weights read as an inconsistency
* rather than as emphasis.
*/
const BAR_HEIGHT = 8;
export interface BarSegment {
key: string;
value: number;
color: string;
/** Tooltip text, since the segments themselves carry no labels. */
label: string;
}
/**
* A proportional bar. `value` and `max` share whatever unit the caller uses;
* percentages come in as 0-100, not as the API's 0-1 fractions.
*/
export const CardBar = ({
value,
max = 100,
color,
}: {
value: number;
max?: number;
color: string;
}) => {
const theme = useTheme();
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
return (
<Box
width="100%"
height={BAR_HEIGHT}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{ overflow: "hidden" }}
>
<Box
width={`${ratio * 100}%`}
height="100%"
bgcolor={color}
sx={{ transition: "width 0.3s ease" }}
/>
</Box>
);
};
/**
* One bar divided into proportional segments — the shape of a whole split into
* parts, where the relative sizes are the point rather than any single value.
*
* Zero-value segments are dropped so they cannot appear as hairlines, and each
* segment carries a tooltip because the bar itself has no room for labels.
*/
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
const theme = useTheme();
const visible = segments.filter((segment) => segment.value > 0);
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
return (
<Stack
direction="row"
width="100%"
height={BAR_HEIGHT}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
gap="1px"
sx={{ overflow: "hidden" }}
>
{total > 0 &&
visible.map((segment) => (
<Tooltip
key={segment.key}
title={segment.label}
>
<Box
width={`${(segment.value / total) * 100}%`}
height="100%"
bgcolor={segment.color}
sx={{ transition: "width 0.3s ease" }}
/>
</Tooltip>
))}
</Stack>
);
};
/** A clickable list row. Rows link to the entity they describe. */
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
const theme = useTheme();
return (
<Stack
{...(to ? { component: RouterLink, to } : {})}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
px={theme.spacing(LAYOUT.XS)}
py={theme.spacing(LAYOUT.XS)}
mx={`-${theme.spacing(LAYOUT.XS)}`}
borderRadius={theme.shape.borderRadius}
color="inherit"
sx={{
textDecoration: "none",
...(to && {
"&:hover": { backgroundColor: theme.palette.action.hover },
}),
}}
>
{children}
</Stack>
);
};
/** Name plus optional secondary line, truncating rather than wrapping. */
export const CardRowLabel = ({
primary,
secondary,
}: {
primary: string;
secondary?: string | null;
}) => {
const theme = useTheme();
return (
<Stack
minWidth={0}
flex={1}
>
<Typography
color={theme.palette.text.primary}
noWrap
>
{primary}
</Typography>
{secondary && (
<Typography
color={theme.palette.text.secondary}
noWrap
>
{secondary}
</Typography>
)}
</Stack>
);
};
/** A single large figure with a caption — the shape of the stat cards. */
export const CardFigure = ({
value,
caption,
color,
}: {
value: string;
caption?: string;
color?: string;
}) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={300}
color={color ?? theme.palette.text.primary}
lineHeight={1.1}
>
{value}
</Typography>
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
</Stack>
);
};
/**
* A label with its value on the right — the shape almost every stat card
* repeats. `color` highlights the value alone, leaving the label quiet.
*/
export const CardStatLine = ({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography color={theme.palette.text.secondary}>{label}</Typography>
<Typography
color={color ?? theme.palette.text.primary}
noWrap
>
{value}
</Typography>
</Stack>
);
};
/**
* A CardStatLine with a proportional bar beneath it — the ranking and
* breakdown row. `max` defaults to a percentage scale; pass the largest value
* in the set to make bars relative to each other instead.
*
* `label` may be a node so callers can put a "N down" flag beside the name.
*/
export const CardMeterRow = ({
label,
value,
max,
color,
trailing,
}: {
label: ReactNode;
value: number;
max?: number;
color: string;
trailing?: ReactNode;
}) => {
const theme = useTheme();
return (
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
{typeof label === "string" ? (
<Typography
color={theme.palette.text.primary}
noWrap
>
{label}
</Typography>
) : (
label
)}
{trailing}
</Stack>
<CardBar
value={value}
max={max}
color={color}
/>
</Stack>
);
};
@@ -0,0 +1,90 @@
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Icon, Tooltip } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
// Snug around the 16px icon without inflating the card header.
const CONTROL_SIZE = 24;
const ICON_SIZE = 16;
/**
* Move-up / move-down controls, revealed on hover over the card they belong to.
* Kept out of DashboardCard so the 17 card components stay unaware of their own
* position — the grid owns ordering.
*/
export const CardReorderControls = ({
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
}: {
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const buttonSx = {
width: CONTROL_SIZE,
height: CONTROL_SIZE,
borderRadius: theme.shape.borderRadius,
color: theme.palette.text.secondary,
"&:hover": {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
},
"&.Mui-disabled": { color: theme.palette.text.disabled },
};
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XXS)}
p={theme.spacing(LAYOUT.XXS)}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.background.paper}
border={`1px solid ${theme.palette.divider}`}
>
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
{/* span keeps the tooltip working while the button is disabled */}
<span>
<IconButton
size="small"
disabled={!canMoveUp}
onClick={onMoveUp}
aria-label={t("pages.dashboard.reorder.moveUp")}
sx={buttonSx}
>
<Icon
icon={ChevronUp}
size={ICON_SIZE}
/>
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
<span>
<IconButton
size="small"
disabled={!canMoveDown}
onClick={onMoveDown}
aria-label={t("pages.dashboard.reorder.moveDown")}
sx={buttonSx}
>
<Icon
icon={ChevronDown}
size={ICON_SIZE}
/>
</IconButton>
</span>
</Tooltip>
</Stack>
);
};
@@ -0,0 +1,7 @@
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
export const CardSlotContext = createContext<ReactNode>(null);
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
@@ -0,0 +1,219 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
import type { QueueMetrics } from "@/Types/Queue";
import type { Log } from "@/Types/Log";
import type { User } from "@/Types/User";
const MAX_ERROR_ROWS = 5;
/** Reads as a single reassurance line while nothing is failing. */
export const ChecksOnScheduleCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
"/queue/metrics",
{},
{ refreshInterval: REFRESH_INTERVAL_MS }
);
const healthy = data?.failingJobs === 0;
return (
<DashboardCard
title={t("pages.dashboard.cards.checksOnSchedule.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{healthy ? (
<CardMessage
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
jobs: data.jobs,
workers: data.workers.length,
})}
/>
) : (
<>
<CardFigure
value={String(data.failingJobs)}
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
color={theme.palette.error.main}
/>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
<CardRow key={String(job.monitorId)}>
<CardRowLabel
primary={job.monitorUrl ?? String(job.monitorId)}
secondary={job.failReason}
/>
<Typography
color={theme.palette.error.main}
flexShrink={0}
>
{job.failCount}
</Typography>
</CardRow>
))}
</Stack>
</>
)}
<CardStatLine
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
value={String(data.jobs)}
/>
<CardStatLine
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
value={String(data.activeJobs)}
/>
</Stack>
)}
</DashboardCard>
);
};
/**
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
* worded "recent" rather than "all".
*/
export const RecentErrorsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<Log[]>(
"/logs",
{},
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
);
const { errors, warnings, newest } = useMemo(() => {
const relevant = (data ?? []).filter(
(entry) => entry.level === "error" || entry.level === "warn"
);
const sorted = [...relevant].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return {
errors: relevant.filter((entry) => entry.level === "error").length,
warnings: relevant.filter((entry) => entry.level === "warn").length,
newest: sorted.slice(0, MAX_ERROR_ROWS),
};
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.recentErrors.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
errors > 0 ? (
<Typography color={theme.palette.error.main}>
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
</Typography>
) : null
}
>
{newest.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardStatLine
label={t("pages.dashboard.cards.recentErrors.warnings")}
value={String(warnings)}
/>
<Stack gap={theme.spacing(LAYOUT.XS)}>
{newest.map((entry, index) => (
<CardRow key={`${entry.timestamp}-${index}`}>
<CardRowLabel
primary={entry.message}
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
/>
<Typography
color={
entry.level === "error"
? theme.palette.error.main
: theme.palette.warning.main
}
flexShrink={0}
>
{formatTimestamp(entry.timestamp)}
</Typography>
</CardRow>
))}
</Stack>
</Stack>
)}
</DashboardCard>
);
};
/** Never empty — at minimum the current user exists. */
export const TeamCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
const { total, byRole, unverified } = useMemo(() => {
const users = data ?? [];
const roles = new Map<string, number>();
for (const user of users) {
for (const role of user.role ?? []) {
roles.set(role, (roles.get(role) ?? 0) + 1);
}
}
return {
total: users.length,
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
unverified: users.filter((user) => !user.isVerified).length,
};
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.team.title")}
to="/account/team"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={String(total)}
caption={t("pages.dashboard.cards.team.members", { count: total })}
/>
{byRole.map(([role, count]) => (
<CardStatLine
key={role}
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
value={String(count)}
/>
))}
{unverified > 0 && (
<CardStatLine
label={t("pages.dashboard.cards.team.unverified")}
value={String(unverified)}
color={theme.palette.warning.main}
/>
)}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,97 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { LAYOUT } from "@/Utils/Theme/constants";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardMeterRow, CardRow } from "../CardPrimitives";
export interface BreakdownBucket {
/** Stable key and displayed label. */
key: string;
label: string;
total: number;
down: number;
/** Where the row links to, if anywhere. */
to?: string;
}
/**
* "How the fleet splits by X" — one proportional row per bucket, flagging any
* bucket with something down. Monitors by type and Monitors by group are the
* same card over different keys, so they share this shell.
*/
export const BreakdownCard = ({
titleKey,
emptyKey,
to,
buckets,
isLoading,
isStale,
error,
}: {
titleKey: string;
emptyKey: string;
to?: string;
buckets: BreakdownBucket[];
isLoading?: boolean;
isStale?: boolean;
error?: unknown;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
return (
<DashboardCard
title={t(titleKey)}
to={to}
isLoading={isLoading}
error={error}
isStale={isStale}
>
{buckets.length === 0 ? (
<CardMessage text={t(emptyKey)} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{buckets.map((bucket) => (
<CardRow
key={bucket.key}
to={bucket.to}
>
<CardMeterRow
label={bucket.label}
value={bucket.total}
max={max}
color={
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
}
trailing={
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
flexShrink={0}
>
{bucket.down > 0 && (
<Typography color={theme.palette.error.main}>
{t("pages.dashboard.cards.monitorsByType.down", {
count: bucket.down,
})}
</Typography>
)}
<Typography color={theme.palette.text.secondary}>
{bucket.total}
</Typography>
</Stack>
}
/>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,143 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
import { useMonitors } from "../../useDashboardData";
import { latestCheck } from "../../checks";
import type { Monitor } from "@/Types/Monitor";
// <60% fine, 60-85% warm, above that hot.
const WARM = 60;
const HOT = 85;
interface ServerUsage {
monitor: Monitor;
cpu: number | null;
memory: number | null;
disk: number | null;
peak: number;
}
const Metric = ({ label, value }: { label: string; value: number | null }) => {
const theme = useTheme();
const color =
value === null
? theme.palette.text.secondary
: value > HOT
? theme.palette.error.main
: value > WARM
? theme.palette.warning.main
: theme.palette.primary.main;
return (
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
>
<Typography color={theme.palette.text.secondary}>{label}</Typography>
<Typography color={color}>
{/* A down server reports nothing — show a dash, never 0%. */}
{value === null ? "—" : `${value.toFixed(1)}%`}
</Typography>
</Stack>
<CardBar
value={value ?? 0}
color={color}
/>
</Stack>
);
};
export const BusiestServersCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const servers = useMemo<ServerUsage[]>(() => {
return (data?.monitors ?? [])
.filter((monitor) => monitor.type === "hardware")
.map((monitor) => {
const latest = latestCheck(monitor);
// snake_case here, unlike camelCase in HardwareCheckStats, and
// these are 0-1 fractions, so scale to percent.
const toPercent = (value: number | undefined) =>
typeof value === "number" ? value * 100 : null;
const cpu = toPercent(latest?.cpu?.usage_percent);
const memory = toPercent(latest?.memory?.usage_percent);
const disks = (latest?.disk ?? [])
.map((entry) => entry.usage_percent)
.filter((value): value is number => typeof value === "number");
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
return {
monitor,
cpu,
memory,
disk,
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
};
})
.sort((a, b) => b.peak - a.peak);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.busiestServers.title")}
to="/infrastructure"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{servers.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{servers.map(({ monitor, cpu, memory, disk }) => (
<CardRow
key={monitor.id}
to={`/infrastructure/${monitor.id}`}
>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XS)}
>
<CardRowLabel primary={monitor.name} />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.MD)}
>
<Metric
label={t("pages.dashboard.cards.busiestServers.cpu")}
value={cpu}
/>
<Metric
label={t("pages.dashboard.cards.busiestServers.memory")}
value={memory}
/>
<Metric
label={t("pages.dashboard.cards.busiestServers.disk")}
value={disk}
/>
</Stack>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,93 @@
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
import { DashboardCard } from "../DashboardCard";
import { CardBar, CardStatLine } from "../CardPrimitives";
import { REFRESH_INTERVAL_MS } from "../../cards";
import type { Diagnostics } from "@/Types/Diagnostics";
const BYTES_PER_MB = 1024 * 1024;
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
// heap warm past 80% of the V8 limit.
const EVENT_LOOP_WARM_MS = 50;
const EVENT_LOOP_HOT_MS = 200;
const HEAP_WARM_RATIO = 0.8;
/**
* The one card that is never empty — it reports on Checkmate itself, so it
* works on a fresh install with zero monitors. Admin only.
*/
export const CheckmateServerCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
"/diagnostic/system",
{},
{ refreshInterval: REFRESH_INTERVAL_MS }
);
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
const heapColor =
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
const eventLoopColor =
eventLoopMs > EVENT_LOOP_HOT_MS
? theme.palette.error.main
: eventLoopMs > EVENT_LOOP_WARM_MS
? theme.palette.warning.main
: theme.palette.text.primary;
return (
<DashboardCard
title={t("pages.dashboard.cards.checkmateServer.title")}
to="/logs"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.memory")}
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
heapLimit / BYTES_PER_MB
)} MB`}
color={heapColor}
/>
<CardBar
value={heapRatio * 100}
color={heapColor}
/>
</Stack>
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
value={`${eventLoopMs.toFixed(1)} ms`}
color={eventLoopColor}
/>
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.uptime")}
value={formatDuration(data.uptimeMs)}
/>
<CardStatLine
label={t("pages.dashboard.cards.checkmateServer.database")}
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
reads: data.mongoStats.readsPerSecond.toFixed(1),
writes: data.mongoStats.writesPerSecond.toFixed(1),
})}
/>
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,157 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
import { formatStatusCode } from "@/Utils/statusCode";
import { formatDuration } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardRow } from "../CardPrimitives";
import { Sparkline } from "../Sparkline";
import { useMonitors } from "../../useDashboardData";
import { latestCheck } from "../../checks";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
const SPARKLINE_WIDTH = 80;
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
down: 1,
breached: 2,
};
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
/**
* How long the monitor has been down, from the oldest consecutive failing
* check. recentChecks is oldest-first, so the run is walked back from the end.
*/
const downSince = (monitor: Monitor): string | null => {
const checks = monitor.recentChecks ?? [];
let oldestFailing: string | null = null;
for (let index = checks.length - 1; index >= 0; index -= 1) {
if (checks[index].status !== false) {
break;
}
oldestFailing = checks[index].createdAt;
}
return oldestFailing;
};
export const CurrentlyDownCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const problems = useMemo(() => {
const monitors = data?.monitors ?? [];
return monitors
.filter(isProblem)
.map((monitor) => ({ monitor, since: downSince(monitor) }))
.sort((a, b) => {
const rank =
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
if (rank !== 0) {
return rank;
}
// Tie-break on longest downtime: the older timestamp ranks first.
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
});
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
problems.length > 0 ? (
<Typography color={theme.palette.error.main}>
{t("pages.dashboard.cards.currentlyDown.count", {
count: problems.length,
})}
</Typography>
) : null
}
>
{problems.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{problems.map(({ monitor, since }) => (
<CardRow
key={monitor.id}
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
>
<Dot
color={getStatusColor(monitor.status, theme)}
size="md"
/>
<Stack
flex={1}
minWidth={0}
gap={theme.spacing(LAYOUT.XXS)}
>
<Stack
direction="row"
alignItems="baseline"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography
color={theme.palette.text.primary}
noWrap
>
{monitor.name}
</Typography>
{since && (
<Typography
color={theme.palette.error.main}
flexShrink={0}
>
{formatDuration(Date.now() - new Date(since).getTime())}
</Typography>
)}
</Stack>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
focusable element inside the row's link is unreachable
by keyboard. */}
<Typography
color={theme.palette.text.secondary}
noWrap
>
{monitor.type} ·{" "}
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
</Typography>
<Box
width={SPARKLINE_WIDTH}
flexShrink={0}
>
<Sparkline
checks={monitor.recentChecks ?? []}
color={theme.palette.error.main}
/>
</Box>
</Stack>
</Stack>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardBar, CardFigure } from "../CardPrimitives";
import type { ChecksSummary } from "@/Types/Check";
// Day only. `dateRange=week` returns figures identical to `day`, so a range
// selector here would silently lie.
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
export const FailedChecksCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
const total = data?.totalChecks ?? 0;
const failed = data?.downChecks ?? 0;
const percentage = total > 0 ? (failed / total) * 100 : 0;
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
return (
<DashboardCard
title={t("pages.dashboard.cards.failedChecks.title")}
to="/checks"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{total === 0 ? (
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={`${percentage.toFixed(1)}%`}
caption={t("pages.dashboard.cards.failedChecks.ratio", {
failed: failed.toLocaleString(),
total: total.toLocaleString(),
})}
color={color}
/>
<CardBar
value={percentage}
color={color}
/>
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,138 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { getStatusColor } from "@/Utils/MonitorUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
import { useMonitors } from "../../useDashboardData";
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
// Every state the summary reports, ordered worst first so the bar reads
// problems-to-healthy left to right and the chips below list what is wrong
// before what is merely idle. `up` is handled separately as the headline.
const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
{ key: "down", status: "down", field: "downMonitors" },
{ key: "breached", status: "breached", field: "breachedMonitors" },
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
{ key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
{ key: "paused", status: "paused", field: "pausedMonitors" },
];
export const MonitorStatusCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const summary = data?.summary ?? null;
// Only states that actually have monitors are shown. Padding the card with
// zeros says nothing, and a healthy fleet should read as calm rather than as
// a row of empty counters.
const present = summary
? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
(state) => state.count > 0
)
: [];
const segments: BarSegment[] = summary
? [
{
key: "up",
value: summary.upMonitors,
color: getStatusColor("up", theme),
label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
},
...present.map((state) => ({
key: state.key,
value: state.count,
color: getStatusColor(state.status, theme),
label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
})),
]
: [];
return (
<DashboardCard
title={t("pages.dashboard.cards.monitorStatus.title")}
to="/uptime"
isLoading={isLoading && !summary}
error={error}
isStale={isValidating && Boolean(data)}
>
{!summary || summary.totalMonitors === 0 ? (
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(LAYOUT.XS)}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={300}
color={theme.palette.text.primary}
lineHeight={1.1}
>
{summary.upMonitors}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.dashboard.cards.monitorStatus.upOfTotal", {
total: summary.totalMonitors,
})}
</Typography>
</Stack>
<CardSegmentedBar segments={segments} />
</Stack>
{present.length > 0 && (
<Stack
direction="row"
flexWrap="wrap"
gap={theme.spacing(LAYOUT.MD)}
>
{present.map((state) => (
<Stack
key={state.key}
component={RouterLink}
// Plain /uptime: the monitors list does not read query
// params, so a ?status= filter would be silently dropped
// and the link would lie about where it goes.
to="/uptime"
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
px={theme.spacing(LAYOUT.XS)}
py={theme.spacing(LAYOUT.XXS)}
mx={`-${theme.spacing(LAYOUT.XS)}`}
borderRadius={theme.shape.borderRadius}
sx={{
textDecoration: "none",
"&:hover": { backgroundColor: theme.palette.action.hover },
}}
>
<Dot
color={getStatusColor(state.status, theme)}
size="md"
/>
<Typography color={theme.palette.text.secondary}>
{t(`pages.common.monitors.status.${state.key}`)}
</Typography>
<Typography color={theme.palette.text.primary}>
{state.count}
</Typography>
</Stack>
))}
</Stack>
)}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
import { useMonitors } from "../../useDashboardData";
export const MonitorsByGroupCard = () => {
const { data, isLoading, isValidating, error } = useMonitors();
// `group` is a plain string | null on the monitor — there is no groups
// endpoint, so the set is whatever monitors actually declare.
const buckets = useMemo<BreakdownBucket[]>(() => {
const byGroup = new Map<string, BreakdownBucket>();
for (const monitor of data?.monitors ?? []) {
if (!monitor.group) {
continue;
}
const bucket = byGroup.get(monitor.group) ?? {
key: monitor.group,
label: monitor.group,
total: 0,
down: 0,
};
bucket.total += 1;
if (monitor.status === "down" || monitor.status === "breached") {
bucket.down += 1;
}
byGroup.set(monitor.group, bucket);
}
return [...byGroup.values()].sort((a, b) => b.total - a.total);
}, [data]);
return (
<BreakdownCard
titleKey="pages.dashboard.cards.monitorsByGroup.title"
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
to="/uptime"
buckets={buckets}
isLoading={isLoading && !data}
isStale={isValidating && Boolean(data)}
error={error}
/>
);
};
@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { getMonitorPath } from "@/Utils/MonitorUtils";
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
import { useMonitors } from "../../useDashboardData";
import type { MonitorType } from "@/Types/Monitor";
export const MonitorsByTypeCard = () => {
const { data, isLoading, isValidating, error } = useMonitors();
const buckets = useMemo<BreakdownBucket[]>(() => {
const byType = new Map<MonitorType, BreakdownBucket>();
for (const monitor of data?.monitors ?? []) {
const bucket = byType.get(monitor.type) ?? {
key: monitor.type,
label: monitor.type,
total: 0,
down: 0,
to: `/${getMonitorPath(monitor.type)}`,
};
bucket.total += 1;
if (monitor.status === "down" || monitor.status === "breached") {
bucket.down += 1;
}
byType.set(monitor.type, bucket);
}
return [...byType.values()].sort((a, b) => b.total - a.total);
}, [data]);
return (
<BreakdownCard
titleKey="pages.dashboard.cards.monitorsByType.title"
emptyKey="pages.dashboard.cards.monitorsByType.empty"
to="/uptime"
buckets={buckets}
isLoading={isLoading && !data}
isStale={isValidating && Boolean(data)}
error={error}
/>
);
};
@@ -0,0 +1,175 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
import { useMonitors } from "../../useDashboardData";
import type { Monitor } from "@/Types/Monitor";
const TOP_N = 5;
// A ranking of one is not a ranking.
const MIN_RANKABLE = 2;
const averageResponseTime = (monitor: Monitor): number | null => {
const times = (monitor.recentChecks ?? [])
.map((check) => check.responseTime)
.filter((value): value is number => typeof value === "number");
if (times.length === 0) {
return null;
}
return times.reduce((sum, value) => sum + value, 0) / times.length;
};
/** Header reads "top 5 of 12" so a truncated ranking says so. */
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Typography color={theme.palette.text.secondary}>
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
</Typography>
);
};
export const SlowestMonitorsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const ranked = useMemo(() => {
return (data?.monitors ?? [])
.filter((monitor) => monitor.status !== "paused")
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
.filter(
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
)
.sort((a, b) => b.average - a.average);
}, [data]);
const slowest = ranked[0]?.average ?? 0;
return (
<DashboardCard
title={t("pages.dashboard.cards.slowestMonitors.title")}
to="/uptime"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
ranked.length >= MIN_RANKABLE ? (
<RankingCount
shown={Math.min(TOP_N, ranked.length)}
total={ranked.length}
/>
) : null
}
>
{ranked.length < MIN_RANKABLE ? (
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
<CardRow
key={monitor.id}
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
>
<CardMeterRow
label={<CardRowLabel primary={monitor.name} />}
value={average}
max={slowest}
color={theme.palette.primary.main}
trailing={
<Typography
color={theme.palette.text.secondary}
flexShrink={0}
>
{Math.round(average)} ms
</Typography>
}
/>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
export const LowestUptimeCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useMonitors();
const ranked = useMemo(() => {
return (
(data?.monitors ?? [])
// Paused monitors keep whatever uptime they had, or zero — a paused
// ping monitor reads 0% and would rank beside a genuinely down one.
// Neither is a live problem, so neither belongs in a worst-uptime list.
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.lowestUptime.title")}
to="/uptime"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
ranked.length >= MIN_RANKABLE ? (
<RankingCount
shown={Math.min(TOP_N, ranked.length)}
total={ranked.length}
/>
) : null
}
>
{ranked.length < MIN_RANKABLE ? (
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.slice(0, TOP_N).map((monitor) => {
// uptimePercentage is a 0-1 fraction, not 0-100.
const percentage = monitor.uptimePercentage * 100;
const palette = getUptimePercentageColor(monitor.uptimePercentage);
return (
<CardRow
key={monitor.id}
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
>
<CardMeterRow
label={<CardRowLabel primary={monitor.name} />}
// The card ranks worst uptime first, so the bar shows the
// shortfall - the worst monitor gets the longest bar.
value={100 - percentage}
color={theme.palette[palette].main}
trailing={
<Typography
color={theme.palette[palette].main}
flexShrink={0}
>
{percentage.toFixed(1)}%
</Typography>
}
/>
</CardRow>
);
})}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,99 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import { CardRow, CardRowLabel } from "../CardPrimitives";
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
const MAX_ROWS = 5;
/** Open incidents have no end time; closed ones report how long they ran. */
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
if (!incident.endTime) {
return null;
}
const ms =
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
return ms > 0 ? formatDuration(ms) : null;
};
export const RecentIncidentsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
"/incidents/team/summary"
);
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
return (
<DashboardCard
title={t("pages.dashboard.cards.recentIncidents.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
action={
data && data.totalActive > 0 ? (
<Typography color={theme.palette.error.main}>
{t("pages.dashboard.cards.recentIncidents.active", {
count: data.totalActive,
})}
</Typography>
) : null
}
>
{incidents.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{incidents.map((incident) => {
const isOpen = incident.status === true;
const duration = incidentDuration(incident);
return (
<CardRow
key={incident.id}
to="/incidents"
>
<Dot
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
size="md"
/>
<CardRowLabel
primary={
incident.monitorName ??
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
}
secondary={formatTimestamp(incident.startTime)}
/>
<Typography
color={theme.palette.text.secondary}
sx={{ flexShrink: 0 }}
>
{isOpen
? t("pages.dashboard.cards.recentIncidents.ongoing")
: (duration ?? "—")}
</Typography>
{incident.resolutionType === "manual" && (
<Typography
color={theme.palette.warning.main}
sx={{ flexShrink: 0 }}
>
{t("pages.dashboard.cards.recentIncidents.manual")}
</Typography>
)}
</CardRow>
);
})}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,258 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useGet } from "@/Hooks/UseApi";
import { Dot } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { DashboardCard, CardMessage } from "../DashboardCard";
import {
CardFigure,
CardRow,
CardRowLabel,
CardSegmentedBar,
CardStatLine,
} from "../CardPrimitives";
import type { IncidentSummary } from "@/Types/Incident";
import type { Notification } from "@/Types/Notification";
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
import type { StatusPage } from "@/Types/StatusPage";
/** Free when Recent incidents is present — same summary call, shared by SWR. */
export const IncidentStatsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
"/incidents/team/summary"
);
return (
<DashboardCard
title={t("pages.dashboard.cards.incidentStats.title")}
to="/incidents"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{data && (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{/* Open now leads, paired with the all-time total for context: two
open means something different against 12 incidents than 183. */}
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XXL)}
>
<CardFigure
value={String(data.totalActive)}
caption={t("pages.dashboard.cards.incidentStats.openNow")}
color={
data.totalActive > 0
? theme.palette.error.main
: theme.palette.success.main
}
/>
<CardFigure
value={data.total.toLocaleString()}
caption={t("pages.dashboard.cards.incidentStats.total")}
/>
</Stack>
{/* How much of the fleet heals itself — the automatic/manual split
is already computed server-side and said nothing on the card
before. A high manual share means people are doing the work. */}
{data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
<Stack gap={theme.spacing(LAYOUT.XS)}>
<CardSegmentedBar
segments={[
{
key: "automatic",
value: data.totalAutomaticResolutions,
color: theme.palette.success.main,
label: `${t("pages.dashboard.cards.incidentStats.automatic")} — ${data.totalAutomaticResolutions}`,
},
{
key: "manual",
value: data.totalManualResolutions,
color: theme.palette.warning.main,
label: `${t("pages.dashboard.cards.incidentStats.manual")} — ${data.totalManualResolutions}`,
},
]}
/>
<CardStatLine
label={t("pages.dashboard.cards.incidentStats.resolved")}
value={t("pages.dashboard.cards.incidentStats.autoManual", {
automatic: data.totalAutomaticResolutions,
manual: data.totalManualResolutions,
})}
/>
</Stack>
)}
<Stack gap={theme.spacing(LAYOUT.XS)}>
<CardStatLine
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
value={t("pages.dashboard.cards.incidentStats.hours", {
hours: data.avgResolutionTimeHours.toFixed(1),
})}
/>
{data.topMonitor?.monitorName && (
<CardStatLine
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
/>
)}
</Stack>
</Stack>
)}
</DashboardCard>
);
};
export const NotificationChannelsCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } =
useGet<Notification[]>("/notifications/team");
// The response also carries accessToken and accountSid. Only `type` is read
// here — credential fields must never reach the DOM.
const byType = useMemo(() => {
const counts = new Map<string, number>();
for (const notification of data ?? []) {
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.notificationChannels.title")}
to="/notifications"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{byType.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{byType.map(([type, count]) => (
<CardStatLine
key={type}
label={type}
value={String(count)}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
export const MaintenanceCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } = useGet<{
maintenanceWindows: MaintenanceWindow[];
maintenanceWindowCount: number;
}>("/maintenance-window/team?page=0&rowsPerPage=0");
// `active` is a user on/off flag, not "running now" — compare the window
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
// a window that is currently live.
const { running, next } = useMemo(() => {
const now = Date.now();
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
const live = windows.filter((entry) => {
const start = new Date(entry.start).getTime();
const end = new Date(entry.end).getTime();
return start <= now && (now <= end || entry.repeat > 0);
});
const upcoming = windows
.filter((entry) => new Date(entry.start).getTime() > now)
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
return { running: live.length, next: upcoming[0] ?? null };
}, [data]);
return (
<DashboardCard
title={t("pages.dashboard.cards.maintenance.title")}
to="/maintenance"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{!data || data.maintenanceWindows.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
<CardFigure
value={String(running)}
caption={t("pages.dashboard.cards.maintenance.runningNow")}
/>
{next && (
<CardStatLine
label={t("pages.dashboard.cards.maintenance.next")}
value={formatTimestamp(next.start)}
/>
)}
</Stack>
)}
</DashboardCard>
);
};
export const StatusPagesCard = () => {
const theme = useTheme();
const { t } = useTranslation();
const { data, isLoading, isValidating, error } =
useGet<StatusPage[]>("/status-page/team");
return (
<DashboardCard
title={t("pages.dashboard.cards.statusPages.title")}
to="/status"
isLoading={isLoading && !data}
error={error}
isStale={isValidating && Boolean(data)}
>
{!data || data.length === 0 ? (
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
) : (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{data.map((page) => (
<CardRow
key={page.id}
to={`/status/${page.url}`}
>
<Dot
color={
page.isPublished
? theme.palette.success.light
: theme.palette.text.secondary
}
size="md"
/>
<CardRowLabel
primary={page.companyName}
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
count: page.monitors.length,
})}
/>
<Typography
color={theme.palette.text.secondary}
flexShrink={0}
>
{page.isPublished
? t("pages.dashboard.cards.statusPages.published")
: t("pages.dashboard.cards.statusPages.draft")}
</Typography>
</CardRow>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,173 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Skeleton from "@mui/material/Skeleton";
import { useTheme } from "@mui/material/styles";
import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { BaseBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useCardSlot } from "./CardSlotContext";
import type { ReactNode } from "react";
// Control height plus the header's vertical padding, so a card with no action
// still lines up with one that has a button in its header.
const HEADER_MIN_HEIGHT = 44;
const SKELETON_ROW_HEIGHT = 20;
interface DashboardCardProps {
title: string;
/** Where the title links to — the page this card belongs to. */
to?: string;
/** Rendered at the right of the header: a count, an average, a filter. */
action?: ReactNode;
isLoading?: boolean;
error?: unknown;
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
isStale?: boolean;
children: ReactNode;
}
export const DashboardCard = ({
title,
to,
action,
isLoading,
error,
isStale,
children,
}: DashboardCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const slot = useCardSlot();
return (
<BaseBox
display="flex"
flexDirection="column"
height="100%"
sx={{
overflow: "hidden",
"&:hover .dashboard-card-slot, &:has(:focus-visible) .dashboard-card-slot": {
opacity: 1,
pointerEvents: "auto",
},
"&:hover .dashboard-card-action, &:has(:focus-visible) .dashboard-card-action": {
opacity: 0,
},
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
px={theme.spacing(LAYOUT.MD)}
py={theme.spacing(LAYOUT.SM)}
borderBottom={`1px solid ${theme.palette.divider}`}
minHeight={HEADER_MIN_HEIGHT}
>
<Typography
{...(to ? { component: RouterLink, to } : {})}
fontWeight={500}
color={theme.palette.text.primary}
sx={{
textDecoration: "none",
...(to && {
"&:hover": { color: theme.palette.primary.main },
}),
}}
>
{title}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
flexShrink={0}
position="relative"
>
{isStale && (
<CircularProgress
size={16}
color="primary"
/>
)}
{action && (
<Box
className="dashboard-card-action"
sx={{ transition: "opacity 0.15s ease" }}
>
{action}
</Box>
)}
{slot && (
<Box
className="dashboard-card-slot"
position="absolute"
right={0}
sx={{
opacity: 0,
pointerEvents: "none",
transition: "opacity 0.15s ease",
}}
>
{slot}
</Box>
)}
</Stack>
</Stack>
<Box
flex={1}
p={theme.spacing(LAYOUT.MD)}
sx={{
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
}}
>
{isLoading ? (
<CardSkeleton />
) : error ? (
<CardMessage text={t("pages.dashboard.card.error")} />
) : (
children
)}
</Box>
</BaseBox>
);
};
/** Placeholder rows, sized to the row list most cards render. */
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton
key={index}
variant="rounded"
height={SKELETON_ROW_HEIGHT}
width={index === rows - 1 ? "60%" : "100%"}
/>
))}
</Stack>
);
};
/** The quiet one-line state a healthy card collapses to. */
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography color={theme.palette.text.secondary}>{text}</Typography>
{action}
</Stack>
);
};
@@ -0,0 +1,72 @@
import Box from "@mui/material/Box";
import { useMemo } from "react";
import type { CheckSnapshot } from "@/Types/Check";
const VIEWBOX_WIDTH = 100;
// The svg renders at its viewBox height, so one constant serves both.
const VIEWBOX_HEIGHT = 24;
// Matches the 1.5 stroke the shared Icon component uses.
const STROKE_WIDTH = 1.5;
/**
* Response times as a bare polyline. Deliberately axis-free and label-free —
* at this size the shape is the only readable signal.
*
* `checks` arrives oldest-first, which is already left-to-right reading order.
*/
export const Sparkline = ({
checks,
color,
}: {
checks: CheckSnapshot[];
color: string;
}) => {
const points = useMemo(() => {
const values = checks
.map((check) => check.responseTime)
.filter((value): value is number => typeof value === "number");
if (values.length < 2) {
return null;
}
const max = Math.max(...values);
const min = Math.min(...values);
const span = max - min || 1;
return values
.map((value, index) => {
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}, [checks]);
if (!points) {
return null;
}
return (
<Box
component="svg"
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
preserveAspectRatio="none"
aria-hidden="true"
width="100%"
height={VIEWBOX_HEIGHT}
display="block"
>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={STROKE_WIDTH}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</Box>
);
};
+84
View File
@@ -0,0 +1,84 @@
import type { ComponentType } from "react";
// The 12-column grid the dashboard lays cards out on.
//
// Every card is currently half-width, so they pair up down the page and the
// grid reads as one system. Full width stays in the type for a card whose
// content genuinely needs it. Width is declared per card type in code and is
// never a user setting.
export const CardWidths = [6, 12] as const;
export type CardWidth = (typeof CardWidths)[number];
export const GRID_COLUMNS = 12;
// Picker groups mirror the sidebar sections: you look for a card where you
// look for the feature it belongs to.
export const CardGroups = [
"uptime",
"infrastructure",
"incidents",
"notifications",
"maintenance",
"statusPages",
"checks",
"logs",
"settings",
] as const;
export type CardGroup = (typeof CardGroups)[number];
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
// `containerSummary` on the check snapshot, which arrives with the Docker
// monitoring work. Add it here once that lands on develop.
export const CardIds = [
"monitorStatus",
"currentlyDown",
"slowestMonitors",
"lowestUptime",
"monitorsByType",
"monitorsByGroup",
"recentIncidents",
"incidentStats",
"notificationChannels",
"maintenance",
"statusPages",
"failedChecks",
"busiestServers",
"checkmateServer",
"checksOnSchedule",
"recentErrors",
"team",
] as const;
export type CardId = (typeof CardIds)[number];
export interface CardDefinition {
id: CardId;
/** i18n key fragment under pages.dashboard.cards.<key>. */
key: string;
group: CardGroup;
width: CardWidth;
/** Hidden from the page and absent from the picker for non-admins. */
adminOnly?: boolean;
component: ComponentType;
}
// The cards a fresh dashboard starts with. Chosen so a brand new install still
// shows something real: Checkmate server works with zero monitors.
// Ordered so the declared widths pair into full rows: a card left alone in a
// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
export const DEFAULT_CARD_IDS: readonly CardId[] = [
"monitorStatus",
"currentlyDown",
"failedChecks",
"monitorsByType",
"recentIncidents",
"checkmateServer",
];
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
/**
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
*/
export const REFRESH_INTERVAL_MS = 30000;
export const SLOW_REFRESH_INTERVAL_MS = 60000;
+8
View File
@@ -0,0 +1,8 @@
import type { Monitor } from "@/Types/Monitor";
/**
* `recentChecks` is oldest-first — the server appends each snapshot with
* `$push … $slice: -N` — so the newest retained check is the last element.
*/
export const latestCheck = (monitor: Monitor) =>
monitor.recentChecks?.[monitor.recentChecks.length - 1];
@@ -1,54 +0,0 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface DashboardCardProps {
title: string;
topRight?: React.ReactNode;
children: React.ReactNode;
}
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
const theme = useTheme();
return (
<Box
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={`${theme.shape.borderRadius}px`}
padding={theme.spacing(LAYOUT.MD)}
display="flex"
flexDirection="column"
gap={theme.spacing(LAYOUT.SM)}
height="100%"
boxSizing="border-box"
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography
fontSize={typographyLevels.m}
fontWeight={500}
color={theme.palette.text.primary}
>
{title}
</Typography>
{topRight && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{topRight}
</Typography>
)}
</Stack>
{children}
</Box>
);
};
@@ -1,89 +0,0 @@
import { useMemo, useState } from "react";
import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps {
open: boolean;
visibleCards: Set<DashboardCardKey>;
onClose: () => void;
onSave: (visibleCards: Set<DashboardCardKey>) => void;
}
export const EditCardsModal = ({
open,
visibleCards,
onClose,
onSave,
}: EditCardsModalProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
new Set(visibleCards)
);
const handleClose = () => {
setCardsSelection(new Set(visibleCards));
onClose();
};
const handleToggle = (key: DashboardCardKey) => {
setCardsSelection((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const sortedCardKeys = useMemo(
() =>
[...dashboardCardKeys].sort((a, b) =>
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
),
[t]
);
return (
<Dialog
open={open}
title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose}
onConfirm={() => onSave(cardsSelection)}
confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
variant="eyebrow"
color={theme.palette.text.disabled}
>
{t("pages.dashboard.editCardsModal.groups.uptime")}
</Typography>
{sortedCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
control={
<Checkbox
checked={cardsSelection.has(key)}
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
onChange={() => handleToggle(key)}
/>
}
/>
))}
</Stack>
</Dialog>
);
};
@@ -1,28 +0,0 @@
import { LAYOUT } from "@/Utils/Theme/constants";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
interface ProgressBarProps {
fillRatio: number;
}
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
const theme = useTheme();
const clamped = Math.min(1, Math.max(0, fillRatio));
return (
<Box
height={theme.spacing(LAYOUT.XS)}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.disabledBackground}
overflow="hidden"
>
<Box
height="100%"
width={`${clamped * 100}%`}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.primary.main}
/>
</Box>
);
};
@@ -1,42 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
interface BarRowProps {
label: string;
value: string;
fillRatio: number;
valueColor?: string;
}
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(SPACING.LG)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={valueColor ?? theme.palette.primary.main}
fontWeight={500}
>
{value}
</Typography>
</Stack>
<ProgressBar fillRatio={fillRatio} />
</Stack>
);
};
@@ -1,58 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { Dot } from "@/Components/design-elements";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { Monitor } from "@/Types/Monitor";
interface CurrentlyDownCardProps {
monitors: Monitor[];
}
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown")}
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
>
{downMonitors.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.currentlyDown.empty")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{downMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
<Dot color={theme.palette.error.main} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{monitor.name}
</Typography>
</Stack>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -1,58 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import type { MonitorsSummary } from "@/Types/Monitor";
interface MonitorStatusCardProps {
summary: MonitorsSummary | null;
}
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const up = summary?.upMonitors ?? 0;
const total = summary?.totalMonitors ?? 0;
const ratio = total > 0 ? up / total : 0;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
{total === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
)}
</DashboardCard>
);
};
@@ -1,46 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { MonitorTypeCount } from "@/Types/Monitor";
import { getMonitorTypeLabel } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface MonitorsByTypeCardProps {
monitorsByType: MonitorTypeCount[];
}
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
const { t } = useTranslation();
const theme = useTheme();
const maxCount = monitorsByType[0]?.count ?? 1;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
{monitorsByType.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -1,90 +0,0 @@
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Icon } from "@/Components/design-elements";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
import type { Monitor } from "@/Types/Monitor";
import type { AppDispatch, RootState } from "@/store";
const TOP_N = 5;
interface UptimeCardProps {
monitors: Monitor[];
}
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
const direction = sortOrder === "ascending" ? 1 : -1;
const ranked = monitors
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
.slice(0, TOP_N);
return (
<DashboardCard
title={t("pages.dashboard.cards.uptime")}
topRight={
<IconButton
size="small"
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
onClick={() =>
dispatch(
setDashboardUptimeSortOrder(
sortOrder === "ascending" ? "descending" : "ascending"
)
)
}
>
<Icon
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
size={16}
color={theme.palette.text.secondary}
/>
</IconButton>
}
>
{ranked.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.map((monitor) => {
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<BarRow
key={monitor.id}
label={monitor.name}
value={formatPercentage(monitor.uptimePercentage)}
fillRatio={monitor.uptimePercentage}
valueColor={color}
/>
);
})}
</Stack>
)}
</DashboardCard>
);
};
+55 -82
View File
@@ -1,99 +1,72 @@
import { Fragment, useMemo, useState, type ReactNode } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { useState } from "react";
import { LayoutGrid } from "lucide-react";
import { BasePage } from "@/Components/design-elements/BasePage";
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
import { CardGrid } from "./Components/CardGrid";
import { CardPicker } from "./Components/CardPicker";
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
import { useCardSelection } from "./useCardSelection";
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const [editOpen, setEditOpen] = useState(false);
const visibleCardsList = useSelector(
(state: RootState) => state.ui.dashboardVisibleCards
);
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
const { monitors, summary } = useMonitorListController({
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "dashboard",
rowsPerPageDefault: 100,
});
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
const countMap = new Map<MonitorTypeCount["type"], number>();
(monitors ?? []).forEach((m) =>
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
);
return [...countMap.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}, [monitors]);
const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />,
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
uptime: <UptimeCard monitors={monitors ?? []} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
};
const monitorsRequest = useMonitorsRequest();
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
<BasePage headerKey="dashboard">
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
size="small"
onClick={() => setEditOpen(true)}
<MonitorsContext.Provider value={monitorsRequest}>
<BasePage headerKey="dashboard">
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack
direction="row"
justifyContent="flex-end"
>
{t("pages.dashboard.editCards")}
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => setIsPickerOpen(true)}
startIcon={
<Icon
icon={LayoutGrid}
size={16}
/>
}
>
{t("pages.dashboard.picker.trigger")}
</Button>
</Stack>
{cards.length === 0 ? (
<EmptyState
title={t("pages.dashboard.empty.title")}
description={t("pages.dashboard.empty.description")}
actionText={t("pages.dashboard.picker.trigger")}
onAction={() => setIsPickerOpen(true)}
/>
) : (
<CardGrid
cards={cards}
onMove={moveCard}
/>
)}
</Stack>
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<Fragment key={key}>{cardComponents[key]}</Fragment>
))}
</Box>
</Stack>
<EditCardsModal
open={editOpen}
visibleCards={visibleCards}
onClose={() => setEditOpen(false)}
onSave={(next) => {
dispatch(setDashboardVisibleCards([...next]));
setEditOpen(false);
}}
/>
</BasePage>
<CardPicker
open={isPickerOpen}
available={available}
selected={selected}
onAdd={addCard}
onRemove={removeCard}
onReset={resetCards}
onClose={() => setIsPickerOpen(false)}
/>
</BasePage>
</MonitorsContext.Provider>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type { CardDefinition } from "./cards";
export interface PlacedCard {
card: CardDefinition;
/** Columns rendered — currently always the declared width. */
renderedWidth: number;
}
/**
* Packs cards into rows of GRID_COLUMNS.
*
* With a single body width this is a straight pass-through: two halves fill a
* row exactly, so no card is ever stretched to close a gap.
*
* An odd number of cards leaves the last one alone on its row. It keeps its
* declared width rather than being promoted to full: widening one card to fill
* the row makes it look more important than it is, and an odd count is a
* property of the selection, not something the layout should hide.
*/
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
cards.map((card) => ({ card, renderedWidth: card.width }));
+161
View File
@@ -0,0 +1,161 @@
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
import {
IncidentStatsCard,
NotificationChannelsCard,
MaintenanceCard,
StatusPagesCard,
} from "./Components/Cards/SummaryCards";
import {
ChecksOnScheduleCard,
RecentErrorsCard,
TeamCard,
} from "./Components/Cards/AdminCards";
import type { CardDefinition, CardId } from "./cards";
// Declared width is the minimum number of columns a card needs. The grid may
// stretch the last card of a short row; nothing here is a user setting.
//
// Order is the render order, and the order the picker lists them in.
//
// Every card is half-width and pairs up down the page. The grid promotes a
// lone trailing card to full width so the page does not end on a ragged half.
export const CARD_REGISTRY: CardDefinition[] = [
{
id: "monitorStatus",
key: "monitorStatus",
group: "uptime",
width: 6,
component: MonitorStatusCard,
},
{
id: "currentlyDown",
key: "currentlyDown",
group: "uptime",
width: 6,
component: CurrentlyDownCard,
},
{
id: "failedChecks",
key: "failedChecks",
group: "checks",
width: 6,
component: FailedChecksCard,
},
{
id: "slowestMonitors",
key: "slowestMonitors",
group: "uptime",
width: 6,
component: SlowestMonitorsCard,
},
{
id: "lowestUptime",
key: "lowestUptime",
group: "uptime",
width: 6,
component: LowestUptimeCard,
},
{
id: "monitorsByType",
key: "monitorsByType",
group: "uptime",
width: 6,
component: MonitorsByTypeCard,
},
{
id: "monitorsByGroup",
key: "monitorsByGroup",
group: "uptime",
width: 6,
component: MonitorsByGroupCard,
},
{
id: "busiestServers",
key: "busiestServers",
group: "infrastructure",
width: 6,
component: BusiestServersCard,
},
{
id: "recentIncidents",
key: "recentIncidents",
group: "incidents",
width: 6,
component: RecentIncidentsCard,
},
{
id: "incidentStats",
key: "incidentStats",
group: "incidents",
width: 6,
component: IncidentStatsCard,
},
{
id: "notificationChannels",
key: "notificationChannels",
group: "notifications",
width: 6,
component: NotificationChannelsCard,
},
{
id: "maintenance",
key: "maintenance",
group: "maintenance",
width: 6,
component: MaintenanceCard,
},
{
id: "statusPages",
key: "statusPages",
group: "statusPages",
width: 6,
component: StatusPagesCard,
},
{
id: "checkmateServer",
key: "checkmateServer",
group: "logs",
width: 6,
adminOnly: true,
component: CheckmateServerCard,
},
{
id: "checksOnSchedule",
key: "checksOnSchedule",
group: "logs",
width: 6,
adminOnly: true,
component: ChecksOnScheduleCard,
},
{
id: "recentErrors",
key: "recentErrors",
group: "logs",
width: 6,
adminOnly: true,
component: RecentErrorsCard,
},
{
id: "team",
key: "team",
group: "settings",
width: 6,
adminOnly: true,
component: TeamCard,
},
];
const BY_ID = new Map<CardId, CardDefinition>(
CARD_REGISTRY.map((card) => [card.id, card])
);
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { logger } from "@/Utils/logger";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import {
CardIds,
DASHBOARD_CARDS_STORAGE_KEY,
DEFAULT_CARD_IDS,
type CardId,
} from "./cards";
import { CARD_REGISTRY, getCard } from "./registry";
import type { CardDefinition } from "./cards";
const isCardId = (value: unknown): value is CardId =>
typeof value === "string" && (CardIds as readonly string[]).includes(value);
/** Stored ids are validated: a card removed from a release must not break the page. */
const readStored = (): CardId[] | null => {
try {
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return null;
}
// An empty array is a real choice and must not fall back to the defaults;
// only an absent or malformed key returns null. Unknown ids are dropped
// so a card removed in a later release cannot break the page.
return parsed.filter(isCardId);
} catch (error) {
logger.error(
"Could not read dashboard card selection",
error instanceof Error ? error : undefined
);
return null;
}
};
/**
* Which cards are on the page, persisted per browser. Admin-only cards are
* filtered out entirely for non-admins — hidden, not disabled.
*/
export const useCardSelection = () => {
const isAdmin = useIsAdmin();
const [selected, setSelected] = useState<CardId[]>(
() => readStored() ?? [...DEFAULT_CARD_IDS]
);
// Skip the write on mount: if the read failed and fell back to the defaults,
// writing would overwrite the user's real selection permanently.
const hasMounted = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
try {
window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected));
} catch (error) {
logger.error(
"Could not persist dashboard card selection",
error instanceof Error ? error : undefined
);
}
}, [selected]);
const isVisible = useCallback(
(card: CardDefinition) => !card.adminOnly || isAdmin,
[isAdmin]
);
// Selection order is the render order.
const cards = useMemo(
() =>
selected
.map(getCard)
.filter((card): card is CardDefinition => card !== undefined)
.filter(isVisible),
[selected, isVisible]
);
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
const addCard = useCallback((id: CardId) => {
setSelected((current) => (current.includes(id) ? current : [...current, id]));
}, []);
const removeCard = useCallback((id: CardId) => {
setSelected((current) => current.filter((cardId) => cardId !== id));
}, []);
const resetCards = useCallback(() => setSelected([...DEFAULT_CARD_IDS]), []);
/**
* Swaps a card with its neighbour; `delta` is -1 for up, +1 for down.
* Resolved against the visible order, so moving past a card hidden from a
* non-admin does not appear to do nothing.
*/
const moveCard = useCallback(
(id: CardId, delta: -1 | 1) => {
setSelected((current) => {
const visible = current.filter((cardId) => {
const card = getCard(cardId);
return card !== undefined && isVisible(card);
});
const from = visible.indexOf(id);
const to = from + delta;
if (from === -1 || to < 0 || to >= visible.length) {
return current;
}
// Swap in the visible order, then write that order back over the
// stored positions the visible cards occupy, leaving hidden cards
// exactly where they were.
const reordered = [...visible];
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
let next = 0;
return current.map((cardId) =>
visible.includes(cardId) ? reordered[next++] : cardId
);
});
},
[isVisible]
);
return {
cards,
available,
selected,
addCard,
removeCard,
moveCard,
resetCards,
};
};
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { CardId } from "./cards";
/** Long enough to follow a card across a row, short enough not to feel slow. */
const DURATION_MS = 260;
/** Decelerating: quick to leave, gentle to settle. */
const EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
export const useCardTransitions = () => {
const nodes = useRef(new Map<CardId, HTMLElement>());
const positions = useRef(new Map<CardId, DOMRect>());
/** Set only for the render that follows a reorder, so nothing animates on data refresh. */
const isAnimating = useRef(false);
/** The card the user moved, lifted above the rest while it travels. */
const movedCard = useRef<CardId | null>(null);
/** Pending frame and settle handles, cancelled on unmount and on a new move. */
const frames = useRef<number[]>([]);
const timers = useRef<number[]>([]);
const cancelPending = useCallback(() => {
frames.current.forEach(cancelAnimationFrame);
timers.current.forEach(clearTimeout);
frames.current = [];
timers.current = [];
}, []);
useEffect(() => cancelPending, [cancelPending]);
const registerCard = useCallback((id: CardId, node: HTMLElement | null) => {
if (node) {
nodes.current.set(id, node);
} else {
nodes.current.delete(id);
}
}, []);
/**
* Call immediately before the state change that reorders the cards.
* `movedId` is the card the user acted on; it rides above the others so the
* swap reads as one card moving rather than two sliding through each other.
*/
const capturePositions = useCallback(
(movedId: CardId) => {
// A move while a previous one is still settling would otherwise have
// its inversion wiped by the earlier timeout, and the card would jump.
cancelPending();
positions.current.clear();
for (const [id, node] of nodes.current) {
positions.current.set(id, node.getBoundingClientRect());
}
movedCard.current = movedId;
isAnimating.current = true;
},
[cancelPending]
);
useLayoutEffect(() => {
if (!isAnimating.current) {
return;
}
isAnimating.current = false;
const reducedMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
)?.matches;
if (reducedMotion) {
positions.current.clear();
movedCard.current = null;
return;
}
for (const [id, node] of nodes.current) {
const previous = positions.current.get(id);
if (!previous) {
continue;
}
const current = node.getBoundingClientRect();
const deltaX = previous.left - current.left;
const deltaY = previous.top - current.top;
if (deltaX === 0 && deltaY === 0) {
continue;
}
// Invert: jump back to the old position with no transition...
node.style.transition = "none";
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
if (id === movedCard.current) {
node.style.zIndex = "1";
}
// ...then play: on the next frame, release to the new one.
frames.current.push(
requestAnimationFrame(() => {
node.style.transition = `transform ${DURATION_MS}ms ${EASING}`;
node.style.transform = "";
// Clear the inline styles once settled. Left in place they would
// animate the card again on any later reflow it did not ask for.
timers.current.push(
window.setTimeout(() => {
node.style.transition = "";
node.style.zIndex = "";
}, DURATION_MS)
);
})
);
}
positions.current.clear();
movedCard.current = null;
});
return { registerCard, capturePositions };
};
@@ -0,0 +1,32 @@
import { createContext, useContext } from "react";
import { useGet } from "@/Hooks/UseApi";
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
import { REFRESH_INTERVAL_MS } from "./cards";
// One request feeds every monitor-derived card, read from context so adding a
// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
// fleet, and the repository treats its absence as "no limit".
const buildMonitorsUrl = () => {
const params = new URLSearchParams();
MonitorTypes.forEach((type) => params.append("type", type));
return `/monitors/team/with-checks?${params.toString()}`;
};
export const useMonitorsRequest = () =>
useGet<MonitorsWithChecksResponse>(
buildMonitorsUrl(),
{},
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
);
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
export const useMonitors = (): MonitorsRequest => {
const context = useContext(MonitorsContext);
if (!context) {
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
}
return context;
};
Whitespace-only changes.
@@ -10,8 +10,6 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -24,10 +22,8 @@ export const DockerContainerStatusBoxes = ({
if (!container) return null;
const { state, restartCount, health } = container;
const runningFor = container.startedAt
? Date.now() - new Date(container.startedAt).getTime()
: undefined;
const palette = getDockerPalette(state);
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
return (
<Stack
direction="row"
@@ -37,15 +33,14 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
@@ -23,7 +23,6 @@ import {
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
@@ -51,7 +50,6 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const isAdmin = useIsAdmin();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
@@ -61,7 +59,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
isAdmin && enabled && monitorId && containerName
enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
@@ -170,15 +168,6 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
if (!enabled) {
return (
<EmptyState
@@ -13,11 +13,10 @@ import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
@@ -25,29 +24,20 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
},
];
export const TabOverview = ({
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
@@ -68,9 +58,6 @@ export const TabOverview = ({
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/>
</Grid>
);
@@ -88,17 +75,11 @@ export const TabOverview = ({
Ports
</Typography>
<Stack>
{dedupedPorts.length === 0 && <Typography>{PLACEHOLDER}</Typography>}
{dedupedPorts.map((port) => {
const containerPort = `${port.privatePort}/${port.protocol}`;
const label =
port.publicPort === undefined
? containerPort
: `${port.hostIp ?? "0.0.0.0"}:${port.publicPort} -> ${containerPort}`;
{dedupedPorts?.map((port) => {
return (
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}>
{label}
</Typography>
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
);
})}
</Stack>
@@ -117,7 +98,6 @@ export const TabOverview = ({
Volumes
</Typography>
<Stack>
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
@@ -21,7 +21,7 @@ const TAB_MAP = {
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
@@ -69,12 +69,7 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && (
<TabOverview
stats={stats}
range={dateRange}
/>
)}
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
@@ -12,7 +12,7 @@ import type { Header } from "@/Components/design-elements/Table";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
@@ -91,7 +91,7 @@ export const DockerContainersTable = ({
id: "restarts",
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>;
return <Typography>{row.restartCount}</Typography>;
},
},
];
@@ -8,7 +8,6 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,7 +20,6 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
@@ -36,8 +34,9 @@ interface DockerMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -53,8 +52,9 @@ export const DockerMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSort,
setSortOrder,
count,
page,
setPage,
@@ -105,8 +105,14 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
+2 -2
View File
@@ -19,7 +19,6 @@ const DockerMonitorsPage = () => {
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<DockerMonitorsTable
monitors={c.monitors || []}
@@ -31,8 +30,9 @@ const DockerMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSort={c.setSort}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,7 +9,6 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -22,7 +21,6 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
@@ -31,8 +29,9 @@ interface InfraMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -48,8 +47,9 @@ export const InfraMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSort,
setSortOrder,
count,
page,
setPage,
@@ -100,8 +100,14 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -31,8 +31,9 @@ const InfrastructureMonitors = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSort={c.setSort}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -1,7 +1,7 @@
import Box from "@mui/material/Box";
import { DetailGauge } from "@/Components/design-elements";
import { getPercentage, formatPercentageFromWhole, PLACEHOLDER } from "@/Utils/FormatUtils";
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
@@ -11,6 +11,7 @@ interface StatGaugesProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
const theme = useTheme();
+1 -1
View File
@@ -6,12 +6,12 @@ import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
interface StatsProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation();
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(0);
const [activeTab, setActiveTab] = useState<number>(1);
return (
<BasePage headerKey="logs">
<Tabs
+5 -6
View File
@@ -1,4 +1,4 @@
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -36,11 +36,10 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center"
>
<Box
component={NotFoundSvg}
role="img"
aria-label="404"
width="100%"
maxHeight="25rem"
component="img"
src={NotFoundSvg}
alt="404"
maxHeight={"25rem"}
/>
<Typography
component="h1"
@@ -126,7 +126,6 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -202,31 +201,6 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
@@ -9,7 +9,6 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -23,7 +22,6 @@ import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({
@@ -32,8 +30,9 @@ export const PageSpeedMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSort,
setSortOrder,
count,
page,
setPage,
@@ -45,8 +44,9 @@ export const PageSpeedMonitorsTable = ({
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -81,8 +81,14 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,8 +46,9 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSort={c.setSort}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
/>
</MonitorListPage>
@@ -14,7 +14,6 @@ import {
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
import {
ThemedInfrastructure,
type GaugeFillLevel,
@@ -117,8 +116,6 @@ export const BaseStatusPage = ({
[config, tokens, mode]
);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const [selectedMonitorId, setSelectedMonitorId] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
const logoSrc = statusPage.logo?.data
@@ -309,10 +306,6 @@ export const BaseStatusPage = ({
cells={cells}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/>
) : (
<ThemedHistogram
@@ -320,10 +313,6 @@ export const BaseStatusPage = ({
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/>
))}
</Box>
@@ -344,16 +333,6 @@ export const BaseStatusPage = ({
Checkmate
</a>
</Box>
<IncidentModal
url={statusPage.url}
monitorId={selectedMonitorId}
date={selectedDate}
timezone={bucketTimezone}
onClose={() => {
setSelectedMonitorId(null);
setSelectedDate(null);
}}
/>
</Box>
);
};
@@ -25,7 +25,6 @@ export interface ChartCell {
responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null;
ariaLabel: string;
date: string | null;
}
const DAY_DOWN_THRESHOLD = 0.5;
@@ -62,7 +61,7 @@ export const classifyResponseTime = (
return "fast";
};
const emptyCell = (key: string, date: string | null = null): ChartCell => ({
const emptyCell = (key: string): ChartCell => ({
key,
barKind: "empty",
heatKind: "empty",
@@ -71,7 +70,6 @@ const emptyCell = (key: string, date: string | null = null): ChartCell => ({
tooltip: null,
ariaLabel: "",
severity: 0,
date,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -94,7 +92,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
date: null,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
@@ -111,7 +108,7 @@ export const dailyBucketsToCells = (
const enumerated = enumerateDays(days, bucketTimezone);
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`, date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
@@ -135,7 +132,6 @@ export const dailyBucketsToCells = (
date,
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
}),
date,
};
});
};
@@ -1,111 +0,0 @@
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import { X as XIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Incident } from "@/Types/Incident";
interface IncidentModalProps {
url: string;
monitorId: string | null;
date: string | null;
timezone: string;
onClose: () => void;
}
export const IncidentModal = ({
url,
monitorId,
date,
timezone,
onClose,
}: IncidentModalProps) => {
const theme = useTheme();
const { t } = useTranslation();
const open = Boolean(monitorId && date);
const { data, isLoading, error } = useGet<{ incidents: Incident[] }>(
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
{},
{ revalidateOnFocus: false, keepPreviousData: true }
);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogTitle
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone})
<IconButton
onClick={onClose}
size="small"
aria-label="close"
>
<XIcon size={20} />
</IconButton>
</DialogTitle>
<DialogContent>
{isLoading ? (
<Typography mt={SPACING.SM}>
{t("pages.statusPages.monitorsList.incidents.loading")}
</Typography>
) : error ? (
<Typography
mt={SPACING.SM}
color={theme.palette.error.main}
>
{t("pages.statusPages.monitorsList.incidents.errorLoading")}
</Typography>
) : data?.incidents && data.incidents.length > 0 ? (
<Stack
gap={SPACING.LG}
mt={SPACING.SM}
>
{data.incidents.map((incident) => (
<Stack
key={incident.id}
p={SPACING.LG}
border={1}
borderColor="divider"
borderRadius={theme.shape.borderRadius}
>
<Typography
variant="subtitle2"
fontWeight="bold"
>
{incident.message ||
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
</Typography>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "}
{incident.endTime
? formatDateWithTz(incident.endTime, "h:mm A", timezone)
: t("pages.statusPages.monitorsList.incidents.ongoing")}
</Typography>
</Stack>
))}
</Stack>
) : (
<Typography mt={SPACING.SM}>
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
</Typography>
)}
</DialogContent>
</Dialog>
);
};
@@ -2,8 +2,6 @@ import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type {
ChartCell,
HeatCellKind,
@@ -13,15 +11,17 @@ interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
onCellClick?: (date: string) => void;
}
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => {
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
return (
<Box
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
@@ -30,15 +30,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props
return (
<Box
key={cell.key}
sx={mergeSx(
cellSx("empty"),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
sx={cellSx("empty")}
/>
);
}
@@ -50,16 +42,8 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props
placement="top"
>
<Box
sx={mergeSx(
cellSx(cell.heatKind, cell.severity),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
sx={cellSx(cell.heatKind, cell.severity)}
aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</Tooltip>
);
@@ -5,8 +5,6 @@ import Stack from "@mui/material/Stack";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { formatMs } from "@/Utils/TimeUtils";
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type {
BarKind,
ChartCell,
@@ -18,7 +16,6 @@ interface Props {
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
onCellClick?: (date: string) => void;
}
export const ThemedHistogram = ({
@@ -27,7 +24,6 @@ export const ThemedHistogram = ({
barSx,
statsSx,
statsGap = 1,
onCellClick,
}: Props) => {
const { t } = useTranslation();
@@ -43,22 +39,17 @@ export const ThemedHistogram = ({
return (
<Stack gap={statsGap}>
<Box
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
>
{cells.map((cell) => {
if (cell.barKind === "empty") {
return (
<Box
key={cell.key}
sx={mergeSx(
barSx("empty", cell.heightPct),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
sx={barSx("empty", cell.heightPct)}
/>
);
}
@@ -70,16 +61,8 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={mergeSx(
barSx(cell.barKind, cell.heightPct, cell.severity),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</Tooltip>
);
@@ -1,16 +0,0 @@
import type { SxProps, Theme } from "@mui/material/styles";
export const mergeSx = (
...styles: Array<SxProps<Theme> | false | null | undefined>
): SxProps<Theme> => {
const result: SxProps<Theme>[] = [];
for (const style of styles) {
if (!style) continue;
if (Array.isArray(style)) {
result.push(...(style.filter(Boolean) as SxProps<Theme>[]));
} else {
result.push(style);
}
}
return result as SxProps<Theme>;
};
@@ -24,9 +24,8 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
@@ -35,8 +34,9 @@ interface MonitorTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,8 +52,9 @@ export const MonitorTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSort,
setSortOrder,
count,
page,
setPage,
@@ -90,8 +91,14 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const isBrowserOpenable = (monitor: Monitor): boolean => {
+2 -1
View File
@@ -30,8 +30,9 @@ const UptimeMonitorsPage = () => {
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSort={monitorListController.setSort}
setSortOrder={monitorListController.setSortOrder}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
+1 -1
View File
@@ -96,7 +96,7 @@ const Routes = () => {
element={<Navigate to="/uptime" />}
/>
<Route
path="dashboard"
path="/dashboard"
element={<Dashboard />}
/>
<Route
-10
View File
@@ -1,10 +0,0 @@
export const dashboardCardKeys = [
"monitorStatus",
"currentlyDown",
"uptime",
"monitorsByType",
] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
export const dashboardSortOrders = ["ascending", "descending"] as const;
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
-19
View File
@@ -60,15 +60,6 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
pagespeed: "optionPagespeed",
};
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const monitorLabel = monitorTypeLabelKey[type];
return monitorLabel ? t(`pages.common.monitors.monitorTypes.${monitorLabel}`) : type;
};
export const MIN_MONITOR_INTERVAL_MS = 10000;
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
@@ -169,11 +160,6 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerAlertOnStopped?: boolean;
dockerAlertOnUnhealthy?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -194,11 +180,6 @@ export interface MonitorsSummary {
breachedMonitors: number;
}
export interface MonitorTypeCount {
type: MonitorType;
count: number;
}
export interface MonitorsWithChecksResponse {
count: number;
monitors: MonitorWithChecks[];
-1
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
-3
View File
@@ -1,5 +1,2 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
-3
View File
@@ -47,6 +47,3 @@ export const getPercentage = (value: number, total: number) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
// Shown wherever a value is not available for the current data source.
export const PLACEHOLDER = "—";
+1 -20
View File
@@ -1,5 +1,4 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import {
@@ -11,16 +10,6 @@ import {
type DockerLogLine,
} from "@/Types/Check";
export const getNextMonitorSort = (
field: string,
sortField: string,
sortOrder: SortOrder
): { field: string; order: SortOrder } => {
if (field !== sortField) return { field, order: "asc" };
if (sortOrder === "asc") return { field, order: "desc" };
return { field: "", order: "asc" };
};
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
@@ -31,7 +20,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "docker/host",
docker: "docker",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
@@ -51,14 +40,6 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
-8
View File
@@ -85,10 +85,6 @@ export const lightPalette = {
sidebar: {
accent: colors.brandGreen,
},
scrollbar: {
track: colors.rule200,
thumb: colors.brandGreen,
},
rowStatus: {
running: colors.green200,
paused: colors.yellow200,
@@ -141,10 +137,6 @@ export const darkPalette = {
sidebar: {
accent: colors.brandGreenLight,
},
scrollbar: {
track: colors.ruleDark,
thumb: colors.brandGreen,
},
rowStatus: {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
-24
View File
@@ -3,7 +3,6 @@ import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palet
import type { Theme } from "@mui/material/styles";
import type {} from "@mui/material/styles";
import { SCROLLBAR_SIZE } from "@/Utils/Theme/constants";
declare module "@mui/material/styles" {
interface TypographyVariants {
@@ -21,7 +20,6 @@ declare module "@mui/material/styles" {
}
interface Palette {
sidebar: { accent: string };
scrollbar: { track: string; thumb: string };
rowStatus: { running: string; paused: string };
chart: {
phases: {
@@ -36,7 +34,6 @@ declare module "@mui/material/styles" {
}
interface PaletteOptions {
sidebar?: { accent: string };
scrollbar?: { track: string; thumb: string };
rowStatus?: { running: string; paused: string };
chart?: {
phases: {
@@ -115,27 +112,6 @@ export const theme = (mode: string, palette: any) =>
},
components: {
MuiCssBaseline: {
styleOverrides: (theme) => ({
"*": {
scrollbarWidth: "auto",
scrollbarColor: `${theme.palette.scrollbar.thumb} ${theme.palette.scrollbar.track}`,
},
"*::-webkit-scrollbar": {
width: SCROLLBAR_SIZE,
height: SCROLLBAR_SIZE,
},
"*::-webkit-scrollbar-track": {
background: theme.palette.scrollbar.track,
borderRadius: theme.shape.borderRadius,
},
"*::-webkit-scrollbar-thumb": {
background: theme.palette.scrollbar.thumb,
borderRadius: theme.shape.borderRadius,
},
"*::-webkit-scrollbar-button": { display: "none" },
}),
},
MuiTouchRipple: {
styleOverrides: {
root: {
+1 -1
View File
@@ -19,7 +19,7 @@ export const LAYOUT = {
} as const;
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
export const SCROLLBAR_SIZE = 16;
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards
+3 -46
View File
@@ -104,52 +104,9 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
return formatted;
};
type DurationUnit = "day" | "hour" | "minute" | "second";
interface FormatDurationOptions {
long?: boolean;
relative?: boolean;
hasSpace?: boolean;
locale?: string;
}
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
{ unit: "day", milliseconds: MS_PER_DAY },
{ unit: "hour", milliseconds: MS_PER_HOUR },
{ unit: "minute", milliseconds: MS_PER_MINUTE },
{ unit: "second", milliseconds: MS_PER_SECOND },
];
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
const getDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
FALLBACK_DURATION_UNIT;
const getExactDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
FALLBACK_DURATION_UNIT;
export const formatDuration = (
ms: number,
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
): string => {
if (relative) {
const { unit, milliseconds } = getDurationUnit(ms);
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
return new Intl.RelativeTimeFormat(locale, {
numeric: "always",
style: long ? "long" : "narrow",
}).format(-value, unit);
}
if (long) {
const { unit, milliseconds } = getExactDurationUnit(ms);
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
}).format(ms / milliseconds);
export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
if (verbose) {
return prettyMilliseconds(ms, { verbose: true });
}
const formatted = prettyMilliseconds(ms, {
+4 -78
View File
@@ -6,7 +6,6 @@ import {
HttpMethods,
PageSpeedStrategies,
ProxyModes,
MIN_MONITOR_INTERVAL_MS,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -28,8 +27,7 @@ const baseSchema = z.object({
description: z.string().optional(),
interval: z
.number()
.int("Interval must be a whole number of milliseconds")
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
.min(15000, "Interval must be at least 15 seconds")
.register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
@@ -110,50 +108,11 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema. The URL forms mirror the Docker source helpers in
// server/src/utils/dockerHost.ts.
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean =>
dockerTlsUrlRegex.test(url?.trim() ?? "");
export const isCaptureDockerUrl = (url: string | undefined): boolean => {
try {
const parsed = new URL(url?.trim() ?? "");
const pathname = parsed.pathname.replace(/\/+$/, "");
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
pathname.endsWith("/metrics/docker")
);
} catch {
return false;
}
};
// Docker monitor schema
const dockerSchema = baseSchema.extend({
type: z.literal("docker"),
url: z
.string()
.min(1, "Docker host URL is required")
.refine(
(url) =>
dockerSocketUrlRegex.test(url.trim()) ||
isDockerTlsUrl(url) ||
isCaptureDockerUrl(url),
"Docker host must be a socket path, a TLS daemon URL, or a Capture /metrics/docker endpoint"
),
secret: z.string(),
ignoreTlsErrors: z.boolean(),
dockerTlsCa: z.string(),
dockerTlsCert: z.string(),
// blank on edit keeps the stored key.
dockerTlsKey: z.string(),
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
url: z.string().min(1, "Docker host URL is required"),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
dockerAlertOnStopped: z.boolean().register(monitorStepRegistry, { step: 1 }),
dockerAlertOnUnhealthy: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
// Game server monitor schema
@@ -258,8 +217,7 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection and refineDockerTls
// (monitorValidation.ts). PEM contents are only parsed on the server.
// Mirrors the server's refineProxySelection (monitorValidation.ts)
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
@@ -268,38 +226,6 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
if (data.type === "docker" && isCaptureDockerUrl(data.url) && !data.secret.trim()) {
ctx.addIssue({
code: "custom",
path: ["secret"],
message: "Capture API secret is required",
});
}
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
if (!data.dockerTlsCert.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCert"],
message: "Client certificate is required for a TLS Docker host",
});
}
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsKey"],
message: "Client key is required for a TLS Docker host",
});
}
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCa"],
message: "CA certificate is required unless TLS errors are ignored",
});
}
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
-7
View File
@@ -70,12 +70,6 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -101,7 +95,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 536 KiB

+141 -3
View File
@@ -182,6 +182,7 @@
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات",
"dashboard": "لوحة المعلومات",
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "لوحة المعلومات",
"description": "اطّلع على كل أنواع المراقبات في مكان واحد ولاحظ ما يحتاج إلى انتباه."
},
"card": {
"error": "تعذّر تحميل هذه البطاقة."
},
"cards": {
"monitorStatus": {
"title": "حالة المراقبات",
"empty": "لا توجد مراقبات بعد.",
"upOfTotal": "من {{total}} تعمل"
},
"currentlyDown": {
"title": "متوقفة حاليًا",
"empty": "جميع المراقبات تعمل. لا توجد حوادث مفتوحة.",
"count": "{{count}} تحتاج إلى انتباه"
},
"monitorsByType": {
"title": "المراقبات حسب النوع",
"empty": "لا توجد مراقبات بعد.",
"down": "{{count}} متوقفة"
},
"recentIncidents": {
"title": "الحوادث الأخيرة",
"empty": "لا توجد حوادث — جميع الأنظمة سليمة.",
"active": "{{count}} مفتوحة",
"ongoing": "جارية",
"manual": "يدوي",
"unknownMonitor": "مراقبة غير معروفة"
},
"failedChecks": {
"title": "الفحوصات الفاشلة",
"empty": "لا توجد فحوصات بعد.",
"ratio": "{{failed}} من {{total}} في آخر يوم"
},
"checkmateServer": {
"title": "خادم Checkmate",
"memory": "الذاكرة",
"eventLoop": "تأخير حلقة الأحداث",
"uptime": "مدة التشغيل",
"database": "قاعدة البيانات",
"readsWrites": "{{reads}} قراءة/ث · {{writes}} كتابة/ث"
},
"ranking": {
"topOf": "أعلى {{shown}} من {{total}}",
"notEnough": "لا توجد مراقبات كافية للترتيب."
},
"slowestMonitors": {
"title": "أبطأ المراقبات"
},
"lowestUptime": {
"title": "أدنى وقت تشغيل"
},
"monitorsByGroup": {
"title": "المراقبات حسب المجموعة",
"empty": "لا توجد مجموعات بعد. تُحدَّد المجموعات لكل مراقبة."
},
"busiestServers": {
"title": "أكثر الخوادم انشغالًا",
"empty": "لا توجد مراقبات بنية تحتية بعد.",
"cpu": "المعالج",
"memory": "الذاكرة",
"disk": "القرص"
},
"incidentStats": {
"title": "إحصائيات الحوادث",
"openNow": "مفتوحة الآن",
"avgResolution": "متوسط وقت الحل",
"hours": "{{hours}} س",
"mostIncidents": "الأكثر حوادث",
"total": "الإجمالي",
"resolved": "تم حلها",
"automatic": "تلقائي",
"manual": "يدوي",
"autoManual": "{{automatic}} تلقائي · {{manual}} يدوي"
},
"notificationChannels": {
"title": "قنوات الإشعارات",
"empty": "لا توجد قنوات بعد."
},
"maintenance": {
"title": "نوافذ الصيانة",
"empty": "لا توجد صيانة مجدولة.",
"runningNow": "قيد التشغيل",
"next": "النافذة التالية"
},
"statusPages": {
"title": "صفحات الحالة",
"empty": "لا توجد صفحات حالة بعد.",
"monitorCount": "{{count}} مراقبة",
"published": "منشورة",
"draft": "مسودة"
},
"checksOnSchedule": {
"title": "طابور الفحوصات",
"healthy": "جميع المهام البالغ عددها {{jobs}} تسير وفق الجدول على {{workers}} عامل.",
"failing": "المهام الفاشلة",
"scheduled": "المهام المجدولة",
"runningNow": "قيد التشغيل"
},
"recentErrors": {
"title": "الأخطاء الأخيرة",
"empty": "لم تُسجَّل أخطاء.",
"count": "{{count}} أخطاء",
"warnings": "التحذيرات"
},
"team": {
"title": "أعضاء الفريق",
"members": "{{count}} عضو",
"unverified": "غير مُوثَّقين"
}
},
"picker": {
"trigger": "تحرير البطاقات",
"title": "بطاقات لوحة المعلومات",
"done": "تم",
"reset": "إعادة التعيين إلى الافتراضي",
"groups": {
"uptime": "وقت التشغيل",
"infrastructure": "البنية التحتية",
"incidents": "الحوادث",
"notifications": "الإشعارات",
"maintenance": "الصيانة",
"statusPages": "صفحات الحالة",
"checks": "الفحوصات",
"logs": "النظام",
"settings": "الإعدادات"
}
},
"empty": {
"title": "لا توجد بطاقات في لوحة المعلومات",
"description": "اختر البطاقات التي تريد رؤيتها. يُحفظ اختيارك في هذا المتصفح."
},
"reorder": {
"moveUp": "نقل لأعلى",
"moveDown": "نقل لأسفل"
}
},
"notFound": {
"title": "يا إلهي! لقد أسقطت السوشي!",
"subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
@@ -1377,9 +1518,6 @@
"maintenance": "صيانة",
"paused": "متوقف مؤقتاً",
"up": "قيد التشغيل"
},
"incidents": {
"errorLoading": "فشل تحميل الحوادث."
}
},
"statusBar": {
+141 -3
View File
@@ -190,6 +190,7 @@
"maintenance": "Manteniment",
"logs": "Registres",
"settings": "Configuració",
"dashboard": "Tauler",
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Tauler",
"description": "Vegeu cada tipus de monitor en un sol lloc i detecteu què necessita atenció."
},
"card": {
"error": "No s'ha pogut carregar aquesta targeta."
},
"cards": {
"monitorStatus": {
"title": "Estat dels monitors",
"empty": "Encara no hi ha monitors.",
"upOfTotal": "de {{total}} actius"
},
"currentlyDown": {
"title": "Actualment caiguts",
"empty": "Tots els monitors estan actius. Cap incident obert.",
"count": "{{count}} requereixen atenció"
},
"monitorsByType": {
"title": "Monitors per tipus",
"empty": "Encara no hi ha monitors.",
"down": "{{count}} caiguts"
},
"recentIncidents": {
"title": "Incidents recents",
"empty": "Cap incident — tots els sistemes sans.",
"active": "{{count}} oberts",
"ongoing": "En curs",
"manual": "Manual",
"unknownMonitor": "Monitor desconegut"
},
"failedChecks": {
"title": "Comprovacions fallides",
"empty": "Encara no hi ha comprovacions.",
"ratio": "{{failed}} de {{total}} el darrer dia"
},
"checkmateServer": {
"title": "Servidor de Checkmate",
"memory": "Memòria",
"eventLoop": "Retard del bucle d'esdeveniments",
"uptime": "Temps actiu",
"database": "Base de dades",
"readsWrites": "{{reads}} lectures/s · {{writes}} escriptures/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "No hi ha prou monitors per fer una classificació."
},
"slowestMonitors": {
"title": "Monitors més lents"
},
"lowestUptime": {
"title": "Temps en línia més baix"
},
"monitorsByGroup": {
"title": "Monitors per grup",
"empty": "Encara no hi ha grups. Els grups es defineixen per monitor."
},
"busiestServers": {
"title": "Servidors més carregats",
"empty": "Encara no hi ha monitors d'infraestructura.",
"cpu": "CPU",
"memory": "Memòria",
"disk": "Disc"
},
"incidentStats": {
"title": "Estadístiques d'incidents",
"openNow": "Oberts ara",
"avgResolution": "Temps mitjà de resolució",
"hours": "{{hours}} h",
"mostIncidents": "Més incidents",
"total": "total",
"resolved": "Resolts",
"automatic": "Automàtica",
"manual": "Manual",
"autoManual": "{{automatic}} automàtiques · {{manual}} manuals"
},
"notificationChannels": {
"title": "Canals de notificació",
"empty": "Encara no hi ha canals."
},
"maintenance": {
"title": "Finestres de manteniment",
"empty": "Cap manteniment programat.",
"runningNow": "En execució",
"next": "Propera finestra"
},
"statusPages": {
"title": "Pàgines d'estat",
"empty": "Encara no hi ha pàgines d'estat.",
"monitorCount": "{{count}} monitors",
"published": "Publicada",
"draft": "Esborrany"
},
"checksOnSchedule": {
"title": "Cua de comprovacions",
"healthy": "Les {{jobs}} tasques van segons el previst en {{workers}} worker(s).",
"failing": "Tasques fallides",
"scheduled": "Tasques programades",
"runningNow": "En execució"
},
"recentErrors": {
"title": "Errors recents",
"empty": "Cap error registrat.",
"count": "{{count}} errors",
"warnings": "Advertiments"
},
"team": {
"title": "Membres de l'equip",
"members": "{{count}} membres",
"unverified": "Sense verificar"
}
},
"picker": {
"trigger": "Edita les targetes",
"title": "Targetes del tauler",
"done": "Fet",
"reset": "Restableix els valors predeterminats",
"groups": {
"uptime": "Temps en línia",
"infrastructure": "Infraestructura",
"incidents": "Incidents",
"notifications": "Notificacions",
"maintenance": "Manteniment",
"statusPages": "Pàgines d'estat",
"checks": "Comprovacions",
"logs": "Sistema",
"settings": "Configuració"
}
},
"empty": {
"title": "No hi ha targetes al vostre tauler",
"description": "Trieu les targetes que voleu veure. La vostra selecció es recorda en aquest navegador."
},
"reorder": {
"moveUp": "Mou amunt",
"moveDown": "Mou avall"
}
},
"notFound": {
"title": "Oh no! Has deixat caure el teu sushi!",
"subtitle": "O bé la URL no existeix, o bé no tens accés."
@@ -1377,9 +1518,6 @@
"uptime": {
"title": "Temps en línia",
"responseTime": "Temps de resposta"
},
"incidents": {
"errorLoading": "No s'han pogut carregar les incidències."
}
},
"statusBar": {
+141 -3
View File
@@ -182,6 +182,7 @@
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení",
"dashboard": "Přehled",
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Přehled",
"description": "Zobrazte každý typ monitoru na jednom místě a poznejte, co vyžaduje pozornost."
},
"card": {
"error": "Tuto kartu se nepodařilo načíst."
},
"cards": {
"monitorStatus": {
"title": "Stav monitorů",
"empty": "Zatím žádné monitory.",
"upOfTotal": "z {{total}} běží"
},
"currentlyDown": {
"title": "Aktuálně nedostupné",
"empty": "Všechny monitory běží. Žádné otevřené incidenty.",
"count": "{{count}} vyžaduje pozornost"
},
"monitorsByType": {
"title": "Monitory podle typu",
"empty": "Zatím žádné monitory.",
"down": "{{count}} nedostupných"
},
"recentIncidents": {
"title": "Nedávné incidenty",
"empty": "Žádné incidenty — všechny systémy v pořádku.",
"active": "{{count}} otevřených",
"ongoing": "Probíhá",
"manual": "Ručně",
"unknownMonitor": "Neznámý monitor"
},
"failedChecks": {
"title": "Neúspěšné kontroly",
"empty": "Zatím žádné kontroly.",
"ratio": "{{failed}} z {{total}} za poslední den"
},
"checkmateServer": {
"title": "Server Checkmate",
"memory": "Paměť",
"eventLoop": "Zpoždění event loop",
"uptime": "Doba běhu",
"database": "Databáze",
"readsWrites": "{{reads}} čtení/s · {{writes}} zápisů/s"
},
"ranking": {
"topOf": "Top {{shown}} z {{total}}",
"notEnough": "Zatím není dost monitorů pro žebříček."
},
"slowestMonitors": {
"title": "Nejpomalejší monitory"
},
"lowestUptime": {
"title": "Nejnižší dostupnost"
},
"monitorsByGroup": {
"title": "Monitory podle skupiny",
"empty": "Zatím žádné skupiny. Skupiny se nastavují u monitoru."
},
"busiestServers": {
"title": "Nejvytíženější servery",
"empty": "Zatím žádné monitory infrastruktury.",
"cpu": "CPU",
"memory": "Paměť",
"disk": "Disk"
},
"incidentStats": {
"title": "Statistiky incidentů",
"openNow": "Nyní otevřené",
"avgResolution": "Průměrná doba vyřešení",
"hours": "{{hours}} h",
"mostIncidents": "Nejvíce incidentů",
"total": "celkem",
"resolved": "Vyřešeno",
"automatic": "Automaticky",
"manual": "Ručně",
"autoManual": "{{automatic}} automaticky · {{manual}} ručně"
},
"notificationChannels": {
"title": "Kanály oznámení",
"empty": "Zatím žádné kanály."
},
"maintenance": {
"title": "Okna údržby",
"empty": "Žádná naplánovaná údržba.",
"runningNow": "Právě probíhá",
"next": "Další okno"
},
"statusPages": {
"title": "Stavové stránky",
"empty": "Zatím žádné stavové stránky.",
"monitorCount": "{{count}} monitorů",
"published": "Publikováno",
"draft": "Koncept"
},
"checksOnSchedule": {
"title": "Fronta kontrol",
"healthy": "Všech {{jobs}} úloh běží podle plánu na {{workers}} workerech.",
"failing": "Selhávající úlohy",
"scheduled": "Naplánované úlohy",
"runningNow": "Právě probíhá"
},
"recentErrors": {
"title": "Nedávné chyby",
"empty": "Žádné zaznamenané chyby.",
"count": "{{count}} chyb",
"warnings": "Varování"
},
"team": {
"title": "Členové týmu",
"members": "{{count}} členů",
"unverified": "Neověření"
}
},
"picker": {
"trigger": "Upravit karty",
"title": "Karty přehledu",
"done": "Hotovo",
"reset": "Obnovit výchozí",
"groups": {
"uptime": "Dostupnost",
"infrastructure": "Infrastruktura",
"incidents": "Incidenty",
"notifications": "Notifikace",
"maintenance": "Údržba",
"statusPages": "Stavové stránky",
"checks": "Kontroly",
"logs": "Systém",
"settings": "Nastavení"
}
},
"empty": {
"title": "Ve vašem přehledu nejsou žádné karty",
"description": "Vyberte karty, které chcete zobrazit. Váš výběr se uchová v tomto prohlížeči."
},
"reorder": {
"moveUp": "Posunout nahoru",
"moveDown": "Posunout dolů"
}
},
"notFound": {
"title": "Ale ne! Upustili jste své sushi!",
"subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup."
@@ -1377,9 +1518,6 @@
"maintenance": "Údržba",
"paused": "Pozastaveno",
"up": "V provozu"
},
"incidents": {
"errorLoading": "Nepodařilo se načíst incidenty."
}
},
"statusBar": {
+141 -4
View File
@@ -182,6 +182,7 @@
"maintenance": "Wartung",
"logs": "Protokolle",
"settings": "Einstellungen",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "Sehen Sie jeden Monitortyp an einem Ort und erkennen Sie, was Aufmerksamkeit braucht."
},
"card": {
"error": "Diese Karte konnte nicht geladen werden."
},
"cards": {
"monitorStatus": {
"title": "Monitor-Status",
"empty": "Noch keine Monitore.",
"upOfTotal": "von {{total}} online"
},
"currentlyDown": {
"title": "Derzeit offline",
"empty": "Alle Monitore sind online. Keine offenen Vorfälle.",
"count": "{{count}} benötigen Aufmerksamkeit"
},
"monitorsByType": {
"title": "Monitore nach Typ",
"empty": "Noch keine Monitore.",
"down": "{{count}} offline"
},
"recentIncidents": {
"title": "Letzte Vorfälle",
"empty": "Keine Vorfälle — alle Systeme gesund.",
"active": "{{count}} offen",
"ongoing": "Laufend",
"manual": "Manuell",
"unknownMonitor": "Unbekannter Monitor"
},
"failedChecks": {
"title": "Fehlgeschlagene Prüfungen",
"empty": "Noch keine Prüfungen.",
"ratio": "{{failed}} von {{total}} am letzten Tag"
},
"checkmateServer": {
"title": "Checkmate-Server",
"memory": "Arbeitsspeicher",
"eventLoop": "Event-Loop-Verzögerung",
"uptime": "Laufzeit",
"database": "Datenbank",
"readsWrites": "{{reads}} Lesevorgänge/s · {{writes}} Schreibvorgänge/s"
},
"ranking": {
"topOf": "Top {{shown}} von {{total}}",
"notEnough": "Noch nicht genug Monitore für ein Ranking."
},
"slowestMonitors": {
"title": "Langsamste Monitore"
},
"lowestUptime": {
"title": "Niedrigste Uptime"
},
"monitorsByGroup": {
"title": "Monitore nach Gruppe",
"empty": "Noch keine Gruppen. Gruppen werden pro Monitor gesetzt."
},
"busiestServers": {
"title": "Ausgelastetste Server",
"empty": "Noch keine Infrastruktur-Monitore.",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"disk": "Festplatte"
},
"incidentStats": {
"title": "Vorfallstatistik",
"openNow": "Jetzt offen",
"avgResolution": "Durchschnittliche Lösungszeit",
"hours": "{{hours}} Std.",
"mostIncidents": "Meiste Vorfälle",
"total": "gesamt",
"resolved": "Gelöst",
"automatic": "Automatisch",
"manual": "Manuell",
"autoManual": "{{automatic}} automatisch · {{manual}} manuell"
},
"notificationChannels": {
"title": "Benachrichtigungskanäle",
"empty": "Noch keine Kanäle."
},
"maintenance": {
"title": "Wartungsfenster",
"empty": "Keine Wartung geplant.",
"runningNow": "Läuft gerade",
"next": "Nächstes Fenster"
},
"statusPages": {
"title": "Statusseiten",
"empty": "Noch keine Statusseiten.",
"monitorCount": "{{count}} Monitore",
"published": "Veröffentlicht",
"draft": "Entwurf"
},
"checksOnSchedule": {
"title": "Prüfungs-Warteschlange",
"healthy": "Alle {{jobs}} Jobs planmäßig auf {{workers}} Worker(n).",
"failing": "Fehlgeschlagene Jobs",
"scheduled": "Geplante Jobs",
"runningNow": "Läuft gerade"
},
"recentErrors": {
"title": "Letzte Fehler",
"empty": "Keine Fehler protokolliert.",
"count": "{{count}} Fehler",
"warnings": "Warnungen"
},
"team": {
"title": "Teammitglieder",
"members": "{{count}} Mitglieder",
"unverified": "Nicht verifiziert"
}
},
"picker": {
"trigger": "Karten bearbeiten",
"title": "Dashboard-Karten",
"done": "Fertig",
"reset": "Auf Standard zurücksetzen",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infrastruktur",
"incidents": "Vorfälle",
"notifications": "Benachrichtigungen",
"maintenance": "Wartung",
"statusPages": "Statusseiten",
"checks": "Prüfungen",
"logs": "System",
"settings": "Einstellungen"
}
},
"empty": {
"title": "Keine Karten auf Ihrem Dashboard",
"description": "Wählen Sie die Karten, die Sie sehen möchten. Ihre Auswahl wird in diesem Browser gespeichert."
},
"reorder": {
"moveUp": "Nach oben",
"moveDown": "Nach unten"
}
},
"notFound": {
"title": "Oh nein! Sie haben Ihr Sushi fallen lassen!",
"subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf."
@@ -448,7 +589,6 @@
},
"statBoxes": {
"activeFor": "Aktiv seit",
"checkingEvery": "Check alle {{interval}}",
"certificateExpiry": "Zertifikatablauf",
"lastCheck": "Letzter Check",
"lastResponseTime": "Letzte Antwortzeit",
@@ -1378,9 +1518,6 @@
"maintenance": "Wartung",
"paused": "Pausiert",
"up": "Betriebsbereit"
},
"incidents": {
"errorLoading": "Vorfälle konnten nicht geladen werden."
}
},
"statusBar": {
+150 -99
View File
@@ -80,9 +80,7 @@
"avg": "Avg: {{value}}",
"max": "Max: {{value}}",
"responseTime": "Response time: {{value}}",
"total": "Total: {{value}}",
"avgCpuUsage": "Average CPU usage: {{value}}",
"avgMemoryUsage": "Average memory usage: {{value}}"
"total": "Total: {{value}}"
},
"phases": {
"dns": "DNS",
@@ -221,13 +219,13 @@
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
},
"dashboard": "Dashboard"
}
},
"bottomMenu": {
"support": "Support",
@@ -261,6 +259,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention."
},
"card": {
"error": "Could not load this card."
},
"cards": {
"monitorStatus": {
"title": "Monitor status",
"empty": "No monitors yet.",
"upOfTotal": "of {{total}} up"
},
"currentlyDown": {
"title": "Currently down",
"empty": "All monitors are up. No incidents open.",
"count": "{{count}} needing attention"
},
"monitorsByType": {
"title": "Monitors by type",
"empty": "No monitors yet.",
"down": "{{count}} down"
},
"recentIncidents": {
"title": "Recent incidents",
"empty": "No incidents — all systems healthy.",
"active": "{{count}} open",
"ongoing": "Ongoing",
"manual": "Manual",
"unknownMonitor": "Unknown monitor"
},
"failedChecks": {
"title": "Failed checks",
"empty": "No checks yet.",
"ratio": "{{failed}} of {{total}} in the last day"
},
"checkmateServer": {
"title": "Checkmate server",
"memory": "Memory",
"eventLoop": "Event loop delay",
"uptime": "Uptime",
"database": "Database",
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
},
"ranking": {
"topOf": "Top {{shown}} of {{total}}",
"notEnough": "Not enough monitors to rank yet."
},
"slowestMonitors": {
"title": "Slowest monitors"
},
"lowestUptime": {
"title": "Lowest uptime"
},
"monitorsByGroup": {
"title": "Monitors by group",
"empty": "No groups yet. Groups are set per monitor."
},
"busiestServers": {
"title": "Busiest servers",
"empty": "No infrastructure monitors yet.",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk"
},
"incidentStats": {
"title": "Incident stats",
"openNow": "Open now",
"avgResolution": "Average time to resolve",
"hours": "{{hours}} h",
"mostIncidents": "Most incidents",
"total": "total",
"resolved": "Resolved",
"automatic": "Automatic",
"manual": "Manual",
"autoManual": "{{automatic}} automatic · {{manual}} manual"
},
"notificationChannels": {
"title": "Notification channels",
"empty": "No channels yet."
},
"maintenance": {
"title": "Maintenance windows",
"empty": "No maintenance scheduled.",
"runningNow": "Running now",
"next": "Next window"
},
"statusPages": {
"title": "Status pages",
"empty": "No status pages yet.",
"monitorCount": "{{count}} monitors",
"published": "Published",
"draft": "Draft"
},
"checksOnSchedule": {
"title": "Check queue",
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
"failing": "Failing jobs",
"scheduled": "Scheduled jobs",
"runningNow": "Running now"
},
"recentErrors": {
"title": "Recent errors",
"empty": "No errors logged.",
"count": "{{count}} errors",
"warnings": "Warnings"
},
"team": {
"title": "Team members",
"members": "{{count}} members",
"unverified": "Unverified"
}
},
"picker": {
"trigger": "Edit cards",
"title": "Dashboard cards",
"done": "Done",
"reset": "Reset to defaults",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infrastructure",
"incidents": "Incidents",
"notifications": "Notifications",
"maintenance": "Maintenance",
"statusPages": "Status pages",
"checks": "Checks",
"logs": "System",
"settings": "Settings"
}
},
"empty": {
"title": "No cards on your dashboard",
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
},
"reorder": {
"moveUp": "Move up",
"moveDown": "Move down"
}
},
"proxies": {
"header": {
"title": "Proxies",
@@ -548,7 +686,6 @@
},
"statBoxes": {
"activeFor": "Active for",
"checkingEvery": "Checking every {{interval}}",
"certificateExpiry": "Certificate expiry",
"domainExpiry": "Domain expiry",
"lastCheck": "Last check",
@@ -557,7 +694,7 @@
},
"status": {
"down": "down",
"breached": "alert",
"breached": "threshold exceeded",
"initializing": "initializing",
"maintenance": "maintenance",
"paused": "paused",
@@ -720,7 +857,7 @@
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"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 a Docker socket such as unix:///var/run/docker.sock, a remote daemon such as tcp://host:2376 with TLS client certificates, or a Capture endpoint ending in /metrics/docker with its authorization secret. Every container on the host is monitored.",
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.",
"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.",
@@ -827,18 +964,6 @@
}
}
},
"dockerAlerts": {
"title": "Container alerts",
"description": "Mark this host as breached while any container on it is stopped or failing its health check, and notify on the change. Containers that exit with code 0 do not count as stopped. Capture does not report exit codes, so on Capture hosts every stopped container counts.",
"option": {
"onStopped": {
"label": "Alert when a container is stopped"
},
"onUnhealthy": {
"label": "Alert when a container is unhealthy"
}
}
},
"stats": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -848,29 +973,6 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats"
}
},
"dockerTls": {
"title": "TLS credentials",
"description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
"option": {
"ca": {
"label": "CA certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"cert": {
"label": "Client certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"key": {
"label": "Client key",
"placeholder": "-----BEGIN PRIVATE KEY-----",
"stored": "A key is stored. Leave blank to keep it."
},
"ignoreTls": {
"label": "Ignore TLS/SSL errors",
"description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
}
}
}
}
},
@@ -1024,10 +1126,6 @@
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
@@ -1290,7 +1388,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"option": {
"duration": {
"label": "Duration"
@@ -1372,7 +1470,6 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1393,14 +1490,6 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
@@ -1697,7 +1786,7 @@
"status": {
"up": "Operational",
"down": "Down",
"breached": "Alert",
"breached": "Threshold exceeded",
"maintenance": "Maintenance",
"paused": "Paused",
"initializing": "Initializing"
@@ -1724,26 +1813,19 @@
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
},
"incidents": {
"modalTitle": "Incidents for {{date}}",
"loading": "Loading...",
"noIncidents": "No incidents reported for this day.",
"ongoing": "Ongoing",
"errorLoading": "Failed to load incidents."
}
},
"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 active alerts",
"allBreached": "All systems have exceeded thresholds",
"allMaintenance": "All systems are under maintenance",
"allDown": "All systems are down",
"allPaused": "All systems are paused",
"allInitializing": "All systems are initializing",
"breachedAndDown": "Some systems have active alerts and some are down",
"breached": "Some systems have active alerts",
"breachedAndDown": "Some systems have exceeded thresholds and are down",
"breached": "Some systems have exceeded thresholds",
"degraded": "Some systems are experiencing issues",
"maintenanceAndDown": "Some systems are down during maintenance",
"maintenance": "Some systems are under maintenance",
@@ -1928,37 +2010,6 @@
"title": "Uptime monitors",
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
}
},
"dashboard": {
"noData": "No data available at this moment.",
"monitorStatus": {
"ofUp": "of {{total}} up"
},
"cards": {
"monitorStatus": "Monitor status",
"currentlyDown": "Currently down",
"uptime": "Monitor uptime",
"monitorsByType": "Monitors by type"
},
"uptimeCard": {
"toggleSortOrder": "Toggle sort order"
},
"currentlyDown": {
"empty": "No monitors are down at this moment."
},
"header": {
"title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention."
},
"editCards": "Edit cards",
"editCardsModal": {
"title": "Dashboard cards",
"description": "Select the cards to display on your dashboard.",
"groups": {
"uptime": "Uptime"
},
"done": "Done"
}
}
}
}
+141 -3
View File
@@ -182,6 +182,7 @@
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración",
"dashboard": "Panel",
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Panel",
"description": "Vea cada tipo de monitor en un solo lugar y detecte lo que necesita atención."
},
"card": {
"error": "No se pudo cargar esta tarjeta."
},
"cards": {
"monitorStatus": {
"title": "Estado de los monitores",
"empty": "Aún no hay monitores.",
"upOfTotal": "de {{total}} activos"
},
"currentlyDown": {
"title": "Caídos ahora",
"empty": "Todos los monitores están activos. Sin incidentes abiertos.",
"count": "{{count}} requieren atención"
},
"monitorsByType": {
"title": "Monitores por tipo",
"empty": "Aún no hay monitores.",
"down": "{{count}} caídos"
},
"recentIncidents": {
"title": "Incidentes recientes",
"empty": "Sin incidentes: todos los sistemas están sanos.",
"active": "{{count}} abiertos",
"ongoing": "En curso",
"manual": "Manual",
"unknownMonitor": "Monitor desconocido"
},
"failedChecks": {
"title": "Comprobaciones fallidas",
"empty": "Aún no hay comprobaciones.",
"ratio": "{{failed}} de {{total}} en el último día"
},
"checkmateServer": {
"title": "Servidor de Checkmate",
"memory": "Memoria",
"eventLoop": "Retardo del bucle de eventos",
"uptime": "Tiempo activo",
"database": "Base de datos",
"readsWrites": "{{reads}} lecturas/s · {{writes}} escrituras/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "No hay suficientes monitores para clasificar."
},
"slowestMonitors": {
"title": "Monitores más lentos"
},
"lowestUptime": {
"title": "Menor uptime"
},
"monitorsByGroup": {
"title": "Monitores por grupo",
"empty": "Aún no hay grupos. Los grupos se definen por monitor."
},
"busiestServers": {
"title": "Servidores más cargados",
"empty": "Aún no hay monitores de infraestructura.",
"cpu": "CPU",
"memory": "Memoria",
"disk": "Disco"
},
"incidentStats": {
"title": "Estadísticas de incidentes",
"openNow": "Abiertos ahora",
"avgResolution": "Tiempo medio de resolución",
"hours": "{{hours}} h",
"mostIncidents": "Más incidentes",
"total": "total",
"resolved": "Resueltos",
"automatic": "Automática",
"manual": "Manual",
"autoManual": "{{automatic}} automáticas · {{manual}} manuales"
},
"notificationChannels": {
"title": "Canales de notificación",
"empty": "Aún no hay canales."
},
"maintenance": {
"title": "Ventanas de mantenimiento",
"empty": "Sin mantenimiento programado.",
"runningNow": "En ejecución",
"next": "Próxima ventana"
},
"statusPages": {
"title": "Páginas de estado",
"empty": "Aún no hay páginas de estado.",
"monitorCount": "{{count}} monitores",
"published": "Publicada",
"draft": "Borrador"
},
"checksOnSchedule": {
"title": "Cola de comprobaciones",
"healthy": "Los {{jobs}} trabajos van según lo previsto en {{workers}} worker(s).",
"failing": "Trabajos fallidos",
"scheduled": "Trabajos programados",
"runningNow": "En ejecución"
},
"recentErrors": {
"title": "Errores recientes",
"empty": "No se registraron errores.",
"count": "{{count}} errores",
"warnings": "Advertencias"
},
"team": {
"title": "Miembros del equipo",
"members": "{{count}} miembros",
"unverified": "Sin verificar"
}
},
"picker": {
"trigger": "Editar tarjetas",
"title": "Tarjetas del panel",
"done": "Listo",
"reset": "Restablecer valores predeterminados",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infraestructura",
"incidents": "Incidentes",
"notifications": "Notificaciones",
"maintenance": "Mantenimiento",
"statusPages": "Páginas de estado",
"checks": "Comprobaciones",
"logs": "Sistema",
"settings": "Configuración"
}
},
"empty": {
"title": "No hay tarjetas en tu panel",
"description": "Elige las tarjetas que quieres ver. Tu selección se recuerda en este navegador."
},
"reorder": {
"moveUp": "Mover arriba",
"moveDown": "Mover abajo"
}
},
"notFound": {
"title": "¡Oh no! ¡Se le cayó el sushi!",
"subtitle": "La URL no existe o no tiene acceso a ella."
@@ -1377,9 +1518,6 @@
"maintenance": "Mantenimiento",
"paused": "Pausado",
"up": "Operativo"
},
"incidents": {
"errorLoading": "Error al cargar incidentes."
}
},
"statusBar": {
+141 -3
View File
@@ -182,6 +182,7 @@
"maintenance": "Huolto",
"logs": "Lokit",
"settings": "Asetukset",
"dashboard": "Kojelauta",
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Kojelauta",
"description": "Näe kaikki monitorityypit yhdessä paikassa ja huomaa huomiota vaativat asiat."
},
"card": {
"error": "Tätä korttia ei voitu ladata."
},
"cards": {
"monitorStatus": {
"title": "Monitorien tila",
"empty": "Ei vielä monitoreja.",
"upOfTotal": "/ {{total}} käynnissä"
},
"currentlyDown": {
"title": "Tällä hetkellä alhaalla",
"empty": "Kaikki monitorit ovat käynnissä. Ei avoimia tapauksia.",
"count": "{{count}} vaatii huomiota"
},
"monitorsByType": {
"title": "Monitorit tyypeittäin",
"empty": "Ei vielä monitoreja.",
"down": "{{count}} alhaalla"
},
"recentIncidents": {
"title": "Viimeisimmät tapaukset",
"empty": "Ei tapauksia — kaikki järjestelmät kunnossa.",
"active": "{{count}} avoinna",
"ongoing": "Käynnissä",
"manual": "Manuaalinen",
"unknownMonitor": "Tuntematon monitori"
},
"failedChecks": {
"title": "Epäonnistuneet tarkistukset",
"empty": "Ei vielä tarkistuksia.",
"ratio": "{{failed}} / {{total}} viimeisen vuorokauden aikana"
},
"checkmateServer": {
"title": "Checkmate-palvelin",
"memory": "Muisti",
"eventLoop": "Tapahtumasilmukan viive",
"uptime": "Käyntiaika",
"database": "Tietokanta",
"readsWrites": "{{reads}} lukua/s · {{writes}} kirjoitusta/s"
},
"ranking": {
"topOf": "Top {{shown}} / {{total}}",
"notEnough": "Ei tarpeeksi monitoreja järjestykseen."
},
"slowestMonitors": {
"title": "Hitaimmat monitorit"
},
"lowestUptime": {
"title": "Alhaisin käytettävyys"
},
"monitorsByGroup": {
"title": "Monitorit ryhmittäin",
"empty": "Ei vielä ryhmiä. Ryhmät asetetaan monitorikohtaisesti."
},
"busiestServers": {
"title": "Kuormitetuimmat palvelimet",
"empty": "Ei vielä infrastruktuurimonitoreja.",
"cpu": "Suoritin",
"memory": "Muisti",
"disk": "Levy"
},
"incidentStats": {
"title": "Tapaustilastot",
"openNow": "Avoinna nyt",
"avgResolution": "Keskimääräinen ratkaisuaika",
"hours": "{{hours}} t",
"mostIncidents": "Eniten tapauksia",
"total": "yhteensä",
"resolved": "Ratkaistu",
"automatic": "Automaattinen",
"manual": "Manuaalinen",
"autoManual": "{{automatic}} automaattista · {{manual}} manuaalista"
},
"notificationChannels": {
"title": "Ilmoituskanavat",
"empty": "Ei vielä kanavia."
},
"maintenance": {
"title": "Huoltoikkunat",
"empty": "Ei suunniteltua huoltoa.",
"runningNow": "Käynnissä nyt",
"next": "Seuraava ikkuna"
},
"statusPages": {
"title": "Tilasivut",
"empty": "Ei vielä tilasivuja.",
"monitorCount": "{{count}} monitoria",
"published": "Julkaistu",
"draft": "Luonnos"
},
"checksOnSchedule": {
"title": "Tarkistusjono",
"healthy": "Kaikki {{jobs}} työtä aikataulussa {{workers}} työntekijällä.",
"failing": "Epäonnistuvat työt",
"scheduled": "Ajastetut työt",
"runningNow": "Käynnissä nyt"
},
"recentErrors": {
"title": "Viimeisimmät virheet",
"empty": "Ei kirjattuja virheitä.",
"count": "{{count}} virhettä",
"warnings": "Varoitukset"
},
"team": {
"title": "Tiimin jäsenet",
"members": "{{count}} jäsentä",
"unverified": "Vahvistamattomat"
}
},
"picker": {
"trigger": "Muokkaa kortteja",
"title": "Kojelaudan kortit",
"done": "Valmis",
"reset": "Palauta oletukset",
"groups": {
"uptime": "Käytettävyys",
"infrastructure": "Infrastruktuuri",
"incidents": "Tapaukset",
"notifications": "Ilmoitukset",
"maintenance": "Huolto",
"statusPages": "Tilasivut",
"checks": "Tarkistukset",
"logs": "Järjestelmä",
"settings": "Asetukset"
}
},
"empty": {
"title": "Kojelaudallasi ei ole kortteja",
"description": "Valitse näytettävät kortit. Valintasi muistetaan tässä selaimessa."
},
"reorder": {
"moveUp": "Siirrä ylös",
"moveDown": "Siirrä alas"
}
},
"notFound": {
"title": "Voi ei! Sushisi putosi!",
"subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen."
@@ -1377,9 +1518,6 @@
"maintenance": "Huolto",
"paused": "Keskeytetty",
"up": "Toiminnassa"
},
"incidents": {
"errorLoading": "Häiriöiden lataaminen epäonnistui."
}
},
"statusBar": {
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more