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
122 changed files with 7541 additions and 3148 deletions

No files matched your search

+8 -123
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), [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,119 +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 connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's 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 check; logs are kept for 7 days.
The **Docker host** field accepts two 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. |
**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>
@@ -248,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)
@@ -266,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
@@ -328,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!
+2 -2
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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.12.0",
"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",
@@ -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>
@@ -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"
@@ -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
View File
@@ -1,6 +1,7 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -26,6 +27,11 @@ export const getMenu = (t: Function) => {
{
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.uptime"),
path: "uptime",
-6
View File
@@ -77,12 +77,6 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
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,
};
break;
-70
View File
@@ -50,7 +50,6 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -67,14 +66,6 @@ const httpMethodOptions = HttpMethods.map((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 GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -309,7 +300,6 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
@@ -344,8 +334,6 @@ const CreateMonitorPage = () => {
[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;
@@ -701,64 +689,6 @@ 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")}
@@ -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];
+73
View File
@@ -0,0 +1,73 @@
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { LayoutGrid } from "lucide-react";
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
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 monitorsRequest = useMonitorsRequest();
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
<MonitorsContext.Provider value={monitorsRequest}>
<BasePage headerKey="dashboard">
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<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>
<CardPicker
open={isPickerOpen}
available={available}
selected={selected}
onAdd={addCard}
onRemove={removeCard}
onReset={resetCards}
onClose={() => setIsPickerOpen(false)}
/>
</BasePage>
</MonitorsContext.Provider>
);
};
export default Dashboard;
+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,7 +10,6 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -25,7 +24,6 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -35,7 +33,6 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -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
@@ -17,7 +17,6 @@ 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>
);
@@ -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}
+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")}
+7
View File
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
// Dashboard
import Dashboard from "@/Pages/Dashboard";
// Uptime
import Uptime from "@/Pages/Uptime/Monitors";
import UptimeDetails from "@/Pages/Uptime/Details";
@@ -92,6 +95,10 @@ const Routes = () => {
path="/"
element={<Navigate to="/uptime" />}
/>
<Route
path="/dashboard"
element={<Dashboard />}
/>
<Route
path="/uptime"
element={<Uptime />}
-3
View File
@@ -160,9 +160,6 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
-1
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
-8
View File
@@ -40,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",
+3 -48
View File
@@ -108,30 +108,10 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
// DOCKER_SOCKET_URL 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() ?? "");
// 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),
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
),
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 }),
});
@@ -237,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({
@@ -247,30 +226,6 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
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
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": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
+141
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."
+141
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."
+141
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."
+144 -41
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,6 +219,7 @@
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
@@ -260,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",
@@ -718,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 the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. 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.",
@@ -834,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."
}
}
}
}
},
@@ -1010,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."
}
}
},
@@ -1276,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"
@@ -1358,7 +1470,6 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1379,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.",
+141
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."
+141
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."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Maintenance",
"logs": "Journaux",
"settings": "Paramètres",
"dashboard": "Tableau de bord",
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Tableau de bord",
"description": "Voyez chaque type de moniteur au même endroit et repérez ce qui demande votre attention."
},
"card": {
"error": "Impossible de charger cette carte."
},
"cards": {
"monitorStatus": {
"title": "État des moniteurs",
"empty": "Aucun moniteur pour l'instant.",
"upOfTotal": "sur {{total}} en ligne"
},
"currentlyDown": {
"title": "Actuellement hors ligne",
"empty": "Tous les moniteurs sont en ligne. Aucun incident ouvert.",
"count": "{{count}} à surveiller"
},
"monitorsByType": {
"title": "Moniteurs par type",
"empty": "Aucun moniteur pour l'instant.",
"down": "{{count}} hors ligne"
},
"recentIncidents": {
"title": "Incidents récents",
"empty": "Aucun incident — tous les systèmes sont sains.",
"active": "{{count}} ouverts",
"ongoing": "En cours",
"manual": "Manuel",
"unknownMonitor": "Moniteur inconnu"
},
"failedChecks": {
"title": "Vérifications échouées",
"empty": "Aucune vérification pour l'instant.",
"ratio": "{{failed}} sur {{total}} le dernier jour"
},
"checkmateServer": {
"title": "Serveur Checkmate",
"memory": "Mémoire",
"eventLoop": "Latence de la boucle d'événements",
"uptime": "Durée de fonctionnement",
"database": "Base de données",
"readsWrites": "{{reads}} lectures/s · {{writes}} écritures/s"
},
"ranking": {
"topOf": "Top {{shown}} sur {{total}}",
"notEnough": "Pas assez de moniteurs pour établir un classement."
},
"slowestMonitors": {
"title": "Moniteurs les plus lents"
},
"lowestUptime": {
"title": "Disponibilité la plus faible"
},
"monitorsByGroup": {
"title": "Moniteurs par groupe",
"empty": "Aucun groupe pour l'instant. Les groupes se définissent par moniteur."
},
"busiestServers": {
"title": "Serveurs les plus chargés",
"empty": "Aucun moniteur d'infrastructure pour l'instant.",
"cpu": "Processeur",
"memory": "Mémoire",
"disk": "Disque"
},
"incidentStats": {
"title": "Statistiques d'incidents",
"openNow": "Ouverts maintenant",
"avgResolution": "Temps moyen de résolution",
"hours": "{{hours}} h",
"mostIncidents": "Le plus d'incidents",
"total": "total",
"resolved": "Résolus",
"automatic": "Automatique",
"manual": "Manuel",
"autoManual": "{{automatic}} automatiques · {{manual}} manuels"
},
"notificationChannels": {
"title": "Canaux de notification",
"empty": "Aucun canal pour l'instant."
},
"maintenance": {
"title": "Fenêtres de maintenance",
"empty": "Aucune maintenance planifiée.",
"runningNow": "En cours",
"next": "Prochaine fenêtre"
},
"statusPages": {
"title": "Pages de statut",
"empty": "Aucune page de statut pour l'instant.",
"monitorCount": "{{count}} moniteurs",
"published": "Publiée",
"draft": "Brouillon"
},
"checksOnSchedule": {
"title": "File de vérifications",
"healthy": "Les {{jobs}} tâches sont à l'heure sur {{workers}} worker(s).",
"failing": "Tâches en échec",
"scheduled": "Tâches planifiées",
"runningNow": "En cours"
},
"recentErrors": {
"title": "Erreurs récentes",
"empty": "Aucune erreur enregistrée.",
"count": "{{count}} erreurs",
"warnings": "Avertissements"
},
"team": {
"title": "Membres de l'équipe",
"members": "{{count}} membres",
"unverified": "Non vérifiés"
}
},
"picker": {
"trigger": "Modifier les cartes",
"title": "Cartes du tableau de bord",
"done": "Terminé",
"reset": "Réinitialiser par défaut",
"groups": {
"uptime": "Disponibilité",
"infrastructure": "Infrastructure",
"incidents": "Incidents",
"notifications": "Notifications",
"maintenance": "Maintenance",
"statusPages": "Pages de statut",
"checks": "Vérifications",
"logs": "Système",
"settings": "Paramètres"
}
},
"empty": {
"title": "Aucune carte sur votre tableau de bord",
"description": "Choisissez les cartes à afficher. Votre sélection est mémorisée dans ce navigateur."
},
"reorder": {
"moveUp": "Monter",
"moveDown": "Descendre"
}
},
"notFound": {
"title": "Oh non ! Vous avez fait tomber vos sushis !",
"subtitle": "Soit l'URL n'existe pas, soit vous n'y avez pas accès."
+141
View File
@@ -194,6 +194,7 @@
"maintenance": "Manutenzione",
"logs": "Log",
"settings": "Impostazioni",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoraggio",
"alerting": "Avvisi",
@@ -233,6 +234,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "Vedi ogni tipo di monitor in un unico posto e individua ciò che richiede attenzione."
},
"card": {
"error": "Impossibile caricare questa scheda."
},
"cards": {
"monitorStatus": {
"title": "Stato dei monitor",
"empty": "Nessun monitor ancora.",
"upOfTotal": "su {{total}} attivi"
},
"currentlyDown": {
"title": "Attualmente offline",
"empty": "Tutti i monitor sono attivi. Nessun incidente aperto.",
"count": "{{count}} richiedono attenzione"
},
"monitorsByType": {
"title": "Monitor per tipo",
"empty": "Nessun monitor ancora.",
"down": "{{count}} offline"
},
"recentIncidents": {
"title": "Incidenti recenti",
"empty": "Nessun incidente — tutti i sistemi sono sani.",
"active": "{{count}} aperti",
"ongoing": "In corso",
"manual": "Manuale",
"unknownMonitor": "Monitor sconosciuto"
},
"failedChecks": {
"title": "Verifiche fallite",
"empty": "Nessuna verifica ancora.",
"ratio": "{{failed}} su {{total}} nell'ultimo giorno"
},
"checkmateServer": {
"title": "Server Checkmate",
"memory": "Memoria",
"eventLoop": "Ritardo dell'event loop",
"uptime": "Tempo di attività",
"database": "Database",
"readsWrites": "{{reads}} letture/s · {{writes}} scritture/s"
},
"ranking": {
"topOf": "Top {{shown}} su {{total}}",
"notEnough": "Non ci sono abbastanza monitor per una classifica."
},
"slowestMonitors": {
"title": "Monitor più lenti"
},
"lowestUptime": {
"title": "Uptime più basso"
},
"monitorsByGroup": {
"title": "Monitor per gruppo",
"empty": "Nessun gruppo ancora. I gruppi si impostano per monitor."
},
"busiestServers": {
"title": "Server più carichi",
"empty": "Nessun monitor infrastruttura ancora.",
"cpu": "CPU",
"memory": "Memoria",
"disk": "Disco"
},
"incidentStats": {
"title": "Statistiche incidenti",
"openNow": "Aperti ora",
"avgResolution": "Tempo medio di risoluzione",
"hours": "{{hours}} h",
"mostIncidents": "Più incidenti",
"total": "totale",
"resolved": "Risolti",
"automatic": "Automatica",
"manual": "Manuale",
"autoManual": "{{automatic}} automatiche · {{manual}} manuali"
},
"notificationChannels": {
"title": "Canali di notifica",
"empty": "Nessun canale ancora."
},
"maintenance": {
"title": "Finestre di manutenzione",
"empty": "Nessuna manutenzione pianificata.",
"runningNow": "In esecuzione",
"next": "Prossima finestra"
},
"statusPages": {
"title": "Pagine di stato",
"empty": "Nessuna pagina di stato ancora.",
"monitorCount": "{{count}} monitor",
"published": "Pubblicata",
"draft": "Bozza"
},
"checksOnSchedule": {
"title": "Coda delle verifiche",
"healthy": "Tutti i {{jobs}} job sono in orario su {{workers}} worker.",
"failing": "Job falliti",
"scheduled": "Job pianificati",
"runningNow": "In esecuzione"
},
"recentErrors": {
"title": "Errori recenti",
"empty": "Nessun errore registrato.",
"count": "{{count}} errori",
"warnings": "Avvisi"
},
"team": {
"title": "Membri del team",
"members": "{{count}} membri",
"unverified": "Non verificati"
}
},
"picker": {
"trigger": "Modifica schede",
"title": "Schede della dashboard",
"done": "Fatto",
"reset": "Ripristina predefiniti",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infrastruttura",
"incidents": "Incidenti",
"notifications": "Notifiche",
"maintenance": "Manutenzione",
"statusPages": "Pagine di stato",
"checks": "Verifiche",
"logs": "Sistema",
"settings": "Impostazioni"
}
},
"empty": {
"title": "Nessuna scheda nella tua dashboard",
"description": "Scegli le schede da mostrare. La tua selezione viene ricordata in questo browser."
},
"reorder": {
"moveUp": "Sposta su",
"moveDown": "Sposta giù"
}
},
"notFound": {
"title": "Oh no! Hai fatto cadere il tuo sushi!",
"subtitle": "L'URL non esiste, oppure non hai accesso."
+141
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": "直近1日で {{total}} 件中 {{failed}} 件"
},
"checkmateServer": {
"title": "Checkmate サーバー",
"memory": "メモリ",
"eventLoop": "イベントループ遅延",
"uptime": "稼働時間",
"database": "データベース",
"readsWrites": "{{reads}} 読み取り/秒 · {{writes}} 書き込み/秒"
},
"ranking": {
"topOf": "{{total}} 件中の上位 {{shown}} 件",
"notEnough": "ランキングに十分な監視がありません。"
},
"slowestMonitors": {
"title": "最も遅い監視"
},
"lowestUptime": {
"title": "稼働率が最も低い監視"
},
"monitorsByGroup": {
"title": "グループ別の監視",
"empty": "グループはまだありません。グループは監視ごとに設定します。"
},
"busiestServers": {
"title": "最も負荷の高いサーバー",
"empty": "インフラ監視はまだありません。",
"cpu": "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": "{{workers}} 個のワーカーで {{jobs}} 件のジョブがすべて予定どおりです。",
"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": "URLが存在しないか、アクセス権限がありません。"
+141
View File
@@ -215,6 +215,7 @@
"statusPages": "Strony statusu",
"tags": "Tagi",
"uptime": "Czas działania",
"dashboard": "Pulpit",
"groups": {
"monitoring": "Monitorowanie",
"alerting": "Alerty",
@@ -233,6 +234,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Pulpit",
"description": "Zobacz każdy typ monitora w jednym miejscu i dostrzeż, co wymaga uwagi."
},
"card": {
"error": "Nie udało się załadować tej karty."
},
"cards": {
"monitorStatus": {
"title": "Stan monitorów",
"empty": "Brak monitorów.",
"upOfTotal": "z {{total}} działa"
},
"currentlyDown": {
"title": "Obecnie niedostępne",
"empty": "Wszystkie monitory działają. Brak otwartych incydentów.",
"count": "{{count}} wymaga uwagi"
},
"monitorsByType": {
"title": "Monitory według typu",
"empty": "Brak monitorów.",
"down": "{{count}} niedostępnych"
},
"recentIncidents": {
"title": "Ostatnie incydenty",
"empty": "Brak incydentów — wszystkie systemy sprawne.",
"active": "{{count}} otwartych",
"ongoing": "Trwa",
"manual": "Ręcznie",
"unknownMonitor": "Nieznany monitor"
},
"failedChecks": {
"title": "Nieudane checki",
"empty": "Brak checków.",
"ratio": "{{failed}} z {{total}} w ostatnim dniu"
},
"checkmateServer": {
"title": "Serwer Checkmate",
"memory": "Pamięć",
"eventLoop": "Opóźnienie pętli zdarzeń",
"uptime": "Czas działania",
"database": "Baza danych",
"readsWrites": "{{reads}} odczytów/s · {{writes}} zapisów/s"
},
"ranking": {
"topOf": "Top {{shown}} z {{total}}",
"notEnough": "Za mało monitorów, aby utworzyć ranking."
},
"slowestMonitors": {
"title": "Najwolniejsze monitory"
},
"lowestUptime": {
"title": "Najniższa dostępność"
},
"monitorsByGroup": {
"title": "Monitory według grupy",
"empty": "Brak grup. Grupy ustawia się przy monitorze."
},
"busiestServers": {
"title": "Najbardziej obciążone serwery",
"empty": "Brak monitorów infrastruktury.",
"cpu": "CPU",
"memory": "Pamięć",
"disk": "Dysk"
},
"incidentStats": {
"title": "Statystyki incydentów",
"openNow": "Otwarte teraz",
"avgResolution": "Średni czas rozwiązania",
"hours": "{{hours}} godz.",
"mostIncidents": "Najwięcej incydentów",
"total": "łącznie",
"resolved": "Rozwiązane",
"automatic": "Automatycznie",
"manual": "Ręcznie",
"autoManual": "{{automatic}} automatycznie · {{manual}} ręcznie"
},
"notificationChannels": {
"title": "Kanały powiadomień",
"empty": "Brak kanałów."
},
"maintenance": {
"title": "Przerwy techniczne",
"empty": "Brak zaplanowanych przerw.",
"runningNow": "Trwa teraz",
"next": "Następna przerwa"
},
"statusPages": {
"title": "Strony statusu",
"empty": "Brak stron statusu.",
"monitorCount": "{{count}} monitorów",
"published": "Opublikowana",
"draft": "Wersja robocza"
},
"checksOnSchedule": {
"title": "Kolejka checków",
"healthy": "Wszystkie {{jobs}} zadania zgodnie z planem na {{workers}} workerach.",
"failing": "Nieudane zadania",
"scheduled": "Zaplanowane zadania",
"runningNow": "Trwa teraz"
},
"recentErrors": {
"title": "Ostatnie błędy",
"empty": "Nie zarejestrowano błędów.",
"count": "{{count}} błędów",
"warnings": "Ostrzeżenia"
},
"team": {
"title": "Członkowie zespołu",
"members": "{{count}} członków",
"unverified": "Niezweryfikowani"
}
},
"picker": {
"trigger": "Edytuj karty",
"title": "Karty pulpitu",
"done": "Gotowe",
"reset": "Przywróć domyślne",
"groups": {
"uptime": "Dostępność",
"infrastructure": "Infrastruktura",
"incidents": "Incydenty",
"notifications": "Powiadomienia",
"maintenance": "Przerwy techniczne",
"statusPages": "Strony statusu",
"checks": "Checki",
"logs": "System",
"settings": "Ustawienia"
}
},
"empty": {
"title": "Brak kart na pulpicie",
"description": "Wybierz karty, które chcesz widzieć. Twój wybór zostanie zapamiętany w tej przeglądarce."
},
"reorder": {
"moveUp": "Przenieś w górę",
"moveDown": "Przenieś w dół"
}
},
"account": {
"form": {
"currentPassword": {
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Manutenção",
"logs": "Registros",
"settings": "Configurações",
"dashboard": "Painel",
"groups": {
"monitoring": "Monitoramento",
"alerting": "Alertas",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Painel",
"description": "Veja cada tipo de monitor em um só lugar e identifique o que precisa de atenção."
},
"card": {
"error": "Não foi possível carregar este cartão."
},
"cards": {
"monitorStatus": {
"title": "Status dos monitores",
"empty": "Ainda não há monitores.",
"upOfTotal": "de {{total}} no ar"
},
"currentlyDown": {
"title": "Fora do ar agora",
"empty": "Todos os monitores estão no ar. Nenhum incidente aberto.",
"count": "{{count}} precisam de atenção"
},
"monitorsByType": {
"title": "Monitores por tipo",
"empty": "Ainda não há monitores.",
"down": "{{count}} fora do ar"
},
"recentIncidents": {
"title": "Incidentes recentes",
"empty": "Nenhum incidente — todos os sistemas saudáveis.",
"active": "{{count}} abertos",
"ongoing": "Em andamento",
"manual": "Manual",
"unknownMonitor": "Monitor desconhecido"
},
"failedChecks": {
"title": "Verificações com falha",
"empty": "Ainda não há verificações.",
"ratio": "{{failed}} de {{total}} no último dia"
},
"checkmateServer": {
"title": "Servidor Checkmate",
"memory": "Memória",
"eventLoop": "Atraso do event loop",
"uptime": "Tempo no ar",
"database": "Banco de dados",
"readsWrites": "{{reads}} leituras/s · {{writes}} gravações/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "Não há monitores suficientes para classificar."
},
"slowestMonitors": {
"title": "Monitores mais lentos"
},
"lowestUptime": {
"title": "Menor uptime"
},
"monitorsByGroup": {
"title": "Monitores por grupo",
"empty": "Ainda não há grupos. Grupos são definidos por monitor."
},
"busiestServers": {
"title": "Servidores mais ocupados",
"empty": "Ainda não há monitores de infraestrutura.",
"cpu": "CPU",
"memory": "Memória",
"disk": "Disco"
},
"incidentStats": {
"title": "Estatísticas de incidentes",
"openNow": "Abertos agora",
"avgResolution": "Tempo médio de resolução",
"hours": "{{hours}} h",
"mostIncidents": "Mais incidentes",
"total": "total",
"resolved": "Resolvidos",
"automatic": "Automática",
"manual": "Manual",
"autoManual": "{{automatic}} automáticas · {{manual}} manuais"
},
"notificationChannels": {
"title": "Canais de notificação",
"empty": "Ainda não há canais."
},
"maintenance": {
"title": "Janelas de manutenção",
"empty": "Nenhuma manutenção agendada.",
"runningNow": "Em execução",
"next": "Próxima janela"
},
"statusPages": {
"title": "Páginas de status",
"empty": "Ainda não há páginas de status.",
"monitorCount": "{{count}} monitores",
"published": "Publicada",
"draft": "Rascunho"
},
"checksOnSchedule": {
"title": "Fila de verificações",
"healthy": "Todos os {{jobs}} jobs em dia em {{workers}} worker(s).",
"failing": "Jobs com falha",
"scheduled": "Jobs agendados",
"runningNow": "Em execução"
},
"recentErrors": {
"title": "Erros recentes",
"empty": "Nenhum erro registrado.",
"count": "{{count}} erros",
"warnings": "Avisos"
},
"team": {
"title": "Membros da equipe",
"members": "{{count}} membros",
"unverified": "Não verificados"
}
},
"picker": {
"trigger": "Editar cartões",
"title": "Cartões do painel",
"done": "Concluído",
"reset": "Restaurar padrões",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infraestrutura",
"incidents": "Incidentes",
"notifications": "Notificações",
"maintenance": "Manutenção",
"statusPages": "Páginas de status",
"checks": "Verificações",
"logs": "Sistema",
"settings": "Configurações"
}
},
"empty": {
"title": "Nenhum cartão no seu painel",
"description": "Escolha os cartões que deseja ver. Sua seleção é lembrada neste navegador."
},
"reorder": {
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo"
}
},
"notFound": {
"title": "Oh não! Você derrubou seu sushi!",
"subtitle": "A URL não existe ou você não tem acesso a ela."
+141
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": "Задержка event loop",
"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": "Либо URL не существует, либо у Вас нет к нему доступа."
+141
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": "ความหน่วงของ event loop",
"uptime": "เวลาทำงาน",
"database": "ฐานข้อมูล",
"readsWrites": "อ่าน {{reads}}/วิ · เขียน {{writes}}/วิ"
},
"ranking": {
"topOf": "อันดับต้น {{shown}} จาก {{total}}",
"notEnough": "มีตัวตรวจสอบไม่พอสำหรับจัดอันดับ"
},
"slowestMonitors": {
"title": "ตัวตรวจสอบที่ช้าที่สุด"
},
"lowestUptime": {
"title": "uptime ต่ำที่สุด"
},
"monitorsByGroup": {
"title": "ตัวตรวจสอบตามกลุ่ม",
"empty": "ยังไม่มีกลุ่ม กลุ่มถูกกำหนดในแต่ละตัวตรวจสอบ"
},
"busiestServers": {
"title": "เซิร์ฟเวอร์ที่ใช้งานหนักที่สุด",
"empty": "ยังไม่มีตัวตรวจสอบโครงสร้างพื้นฐาน",
"cpu": "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}} worker",
"failing": "งานที่ล้มเหลว",
"scheduled": "งานที่กำหนดเวลาไว้",
"runningNow": "กำลังดำเนินการ"
},
"recentErrors": {
"title": "ข้อผิดพลาดล่าสุด",
"empty": "ไม่มีข้อผิดพลาดที่บันทึกไว้",
"count": "{{count}} ข้อผิดพลาด",
"warnings": "คำเตือน"
},
"team": {
"title": "สมาชิกทีม",
"members": "{{count}} สมาชิก",
"unverified": "ยังไม่ยืนยัน"
}
},
"picker": {
"trigger": "แก้ไขการ์ด",
"title": "การ์ดแดชบอร์ด",
"done": "เสร็จสิ้น",
"reset": "รีเซ็ตเป็นค่าเริ่มต้น",
"groups": {
"uptime": "Uptime",
"infrastructure": "โครงสร้างพื้นฐาน",
"incidents": "เหตุการณ์",
"notifications": "การแจ้งเตือน",
"maintenance": "การบำรุงรักษา",
"statusPages": "หน้าสถานะ",
"checks": "การตรวจสอบ",
"logs": "ระบบ",
"settings": "การตั้งค่า"
}
},
"empty": {
"title": "ไม่มีการ์ดบนแดชบอร์ดของคุณ",
"description": "เลือกการ์ดที่ต้องการเห็น การเลือกของคุณจะถูกจดจำในเบราว์เซอร์นี้"
},
"reorder": {
"moveUp": "เลื่อนขึ้น",
"moveDown": "เลื่อนลง"
}
},
"notFound": {
"title": "โอ้ไม่! ซูชิของคุณหล่นแล้ว!",
"subtitle": "URL ไม่มีอยู่ หรือคุณไม่มีสิทธิ์เข้าถึง"
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Bakım",
"logs": "Günlükler",
"settings": "Ayarlar",
"dashboard": "Panel",
"groups": {
"monitoring": "İzleme",
"alerting": "Uyarılar",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Panel",
"description": "Her izleyici türünü tek yerde görün ve neyin ilgi gerektirdiğini fark edin."
},
"card": {
"error": "Bu kart yüklenemedi."
},
"cards": {
"monitorStatus": {
"title": "İzleyici durumu",
"empty": "Henüz izleyici yok.",
"upOfTotal": "/ {{total}} çalışıyor"
},
"currentlyDown": {
"title": "Şu anda kapalı",
"empty": "Tüm izleyiciler çalışıyor. Açık olay yok.",
"count": "{{count}} ilgi bekliyor"
},
"monitorsByType": {
"title": "Türe göre izleyiciler",
"empty": "Henüz izleyici yok.",
"down": "{{count}} kapalı"
},
"recentIncidents": {
"title": "Son olaylar",
"empty": "Olay yok — tüm sistemler sağlıklı.",
"active": "{{count}} açık",
"ongoing": "Devam ediyor",
"manual": "Manuel",
"unknownMonitor": "Bilinmeyen izleyici"
},
"failedChecks": {
"title": "Başarısız kontroller",
"empty": "Henüz kontrol yok.",
"ratio": "son günde {{total}} kontrolün {{failed}} tanesi"
},
"checkmateServer": {
"title": "Checkmate sunucusu",
"memory": "Bellek",
"eventLoop": "Olay döngüsü gecikmesi",
"uptime": "Çalışma süresi",
"database": "Veritabanı",
"readsWrites": "{{reads}} okuma/sn · {{writes}} yazma/sn"
},
"ranking": {
"topOf": "{{total}} içinden ilk {{shown}}",
"notEnough": "Sıralama için yeterli izleyici yok."
},
"slowestMonitors": {
"title": "En yavaş izleyiciler"
},
"lowestUptime": {
"title": "En düşük çalışma süresi"
},
"monitorsByGroup": {
"title": "Gruba göre izleyiciler",
"empty": "Henüz grup yok. Gruplar izleyici başına ayarlanır."
},
"busiestServers": {
"title": "En yoğun sunucular",
"empty": "Henüz altyapı izleyicisi yok.",
"cpu": "CPU",
"memory": "Bellek",
"disk": "Disk"
},
"incidentStats": {
"title": "Olay istatistikleri",
"openNow": "Şu anda açık",
"avgResolution": "Ortalama çözüm süresi",
"hours": "{{hours}} sa",
"mostIncidents": "En çok olay",
"total": "toplam",
"resolved": "Çözüldü",
"automatic": "Otomatik",
"manual": "Manuel",
"autoManual": "{{automatic}} otomatik · {{manual}} manuel"
},
"notificationChannels": {
"title": "Bildirim kanalları",
"empty": "Henüz kanal yok."
},
"maintenance": {
"title": "Bakım pencereleri",
"empty": "Planlanmış bakım yok.",
"runningNow": "Şu anda çalışıyor",
"next": "Sonraki pencere"
},
"statusPages": {
"title": "Durum sayfaları",
"empty": "Henüz durum sayfası yok.",
"monitorCount": "{{count}} izleyici",
"published": "Yayında",
"draft": "Taslak"
},
"checksOnSchedule": {
"title": "Kontrol kuyruğu",
"healthy": "{{workers}} işçide {{jobs}} işin tümü planlandığı gibi.",
"failing": "Başarısız işler",
"scheduled": "Planlanmış işler",
"runningNow": "Şu anda çalışıyor"
},
"recentErrors": {
"title": "Son hatalar",
"empty": "Kaydedilmiş hata yok.",
"count": "{{count}} hata",
"warnings": "Uyarılar"
},
"team": {
"title": "Ekip üyeleri",
"members": "{{count}} üye",
"unverified": "Doğrulanmamış"
}
},
"picker": {
"trigger": "Kartları düzenle",
"title": "Panel kartları",
"done": "Tamam",
"reset": "Varsayılanlara sıfırla",
"groups": {
"uptime": "Çalışma süresi",
"infrastructure": "Altyapı",
"incidents": "Olaylar",
"notifications": "Bildirimler",
"maintenance": "Bakım",
"statusPages": "Durum sayfaları",
"checks": "Kontroller",
"logs": "Sistem",
"settings": "Ayarlar"
}
},
"empty": {
"title": "Panelinizde kart yok",
"description": "Görmek istediğiniz kartları seçin. Seçiminiz bu tarayıcıda hatırlanır."
},
"reorder": {
"moveUp": "Yukarı taşı",
"moveDown": "Aşağı taşı"
}
},
"notFound": {
"title": "Eyvah! Sushini düşürdün!",
"subtitle": "URL mevcut değil veya erişim yetkiniz yok."
+141
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": "Затримка event loop",
"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": "Або URL не існує, або у Вас немає доступу до нього."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Bảo trì",
"logs": "Nhật ký",
"settings": "Cài đặt",
"dashboard": "Bảng điều khiển",
"groups": {
"monitoring": "Giám sát",
"alerting": "Cảnh báo",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Bảng điều khiển",
"description": "Xem mọi loại trình giám sát ở cùng một nơi và nhận ra điều cần chú ý."
},
"card": {
"error": "Không thể tải thẻ này."
},
"cards": {
"monitorStatus": {
"title": "Trạng thái giám sát",
"empty": "Chưa có trình giám sát nào.",
"upOfTotal": "trên {{total}} đang hoạt động"
},
"currentlyDown": {
"title": "Hiện đang ngừng",
"empty": "Tất cả trình giám sát đang hoạt động. Không có sự cố nào đang mở.",
"count": "{{count}} cần chú ý"
},
"monitorsByType": {
"title": "Trình giám sát theo loại",
"empty": "Chưa có trình giám sát nào.",
"down": "{{count}} đang ngừng"
},
"recentIncidents": {
"title": "Sự cố gần đây",
"empty": "Không có sự cố — mọi hệ thống đều ổn.",
"active": "{{count}} đang mở",
"ongoing": "Đang diễn ra",
"manual": "Thủ công",
"unknownMonitor": "Trình giám sát không xác định"
},
"failedChecks": {
"title": "Kiểm tra thất bại",
"empty": "Chưa có lần kiểm tra nào.",
"ratio": "{{failed}} trên {{total}} trong ngày qua"
},
"checkmateServer": {
"title": "Máy chủ Checkmate",
"memory": "Bộ nhớ",
"eventLoop": "Độ trễ vòng lặp sự kiện",
"uptime": "Thời gian hoạt động",
"database": "Cơ sở dữ liệu",
"readsWrites": "{{reads}} lượt đọc/giây · {{writes}} lượt ghi/giây"
},
"ranking": {
"topOf": "Top {{shown}} trên {{total}}",
"notEnough": "Chưa đủ trình giám sát để xếp hạng."
},
"slowestMonitors": {
"title": "Trình giám sát chậm nhất"
},
"lowestUptime": {
"title": "Uptime thấp nhất"
},
"monitorsByGroup": {
"title": "Trình giám sát theo nhóm",
"empty": "Chưa có nhóm nào. Nhóm được đặt cho từng trình giám sát."
},
"busiestServers": {
"title": "Máy chủ bận nhất",
"empty": "Chưa có trình giám sát hạ tầng nào.",
"cpu": "CPU",
"memory": "Bộ nhớ",
"disk": "Ổ đĩa"
},
"incidentStats": {
"title": "Thống kê sự cố",
"openNow": "Đang mở",
"avgResolution": "Thời gian xử lý trung bình",
"hours": "{{hours}} giờ",
"mostIncidents": "Nhiều sự cố nhất",
"total": "tổng cộng",
"resolved": "Đã xử lý",
"automatic": "Tự động",
"manual": "Thủ công",
"autoManual": "{{automatic}} tự động · {{manual}} thủ công"
},
"notificationChannels": {
"title": "Kênh thông báo",
"empty": "Chưa có kênh nào."
},
"maintenance": {
"title": "Cửa sổ bảo trì",
"empty": "Không có bảo trì nào được lên lịch.",
"runningNow": "Đang chạy",
"next": "Cửa sổ tiếp theo"
},
"statusPages": {
"title": "Trang trạng thái",
"empty": "Chưa có trang trạng thái nào.",
"monitorCount": "{{count}} trình giám sát",
"published": "Đã xuất bản",
"draft": "Bản nháp"
},
"checksOnSchedule": {
"title": "Hàng đợi kiểm tra",
"healthy": "Tất cả {{jobs}} tác vụ đúng lịch trên {{workers}} worker.",
"failing": "Tác vụ thất bại",
"scheduled": "Tác vụ đã lên lịch",
"runningNow": "Đang chạy"
},
"recentErrors": {
"title": "Lỗi gần đây",
"empty": "Không có lỗi nào được ghi.",
"count": "{{count}} lỗi",
"warnings": "Cảnh báo"
},
"team": {
"title": "Thành viên nhóm",
"members": "{{count}} thành viên",
"unverified": "Chưa xác minh"
}
},
"picker": {
"trigger": "Chỉnh sửa thẻ",
"title": "Thẻ bảng điều khiển",
"done": "Xong",
"reset": "Đặt lại mặc định",
"groups": {
"uptime": "Uptime",
"infrastructure": "Hạ tầng",
"incidents": "Sự cố",
"notifications": "Thông báo",
"maintenance": "Bảo trì",
"statusPages": "Trang trạng thái",
"checks": "Kiểm tra",
"logs": "Hệ thống",
"settings": "Cài đặt"
}
},
"empty": {
"title": "Không có thẻ nào trên bảng điều khiển",
"description": "Chọn các thẻ bạn muốn xem. Lựa chọn của bạn được ghi nhớ trong trình duyệt này."
},
"reorder": {
"moveUp": "Di chuyển lên",
"moveDown": "Di chuyển xuống"
}
},
"notFound": {
"title": "Ôi không! Bạn đã làm rơi sushi!",
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
+141
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": "最近一天 {{total}} 次中的 {{failed}} 次"
},
"checkmateServer": {
"title": "Checkmate 服务器",
"memory": "内存",
"eventLoop": "事件循环延迟",
"uptime": "运行时间",
"database": "数据库",
"readsWrites": "{{reads}} 读/秒 · {{writes}} 写/秒"
},
"ranking": {
"topOf": "{{total}} 个中的前 {{shown}} 个",
"notEnough": "监控器数量不足,无法排名。"
},
"slowestMonitors": {
"title": "最慢的监控器"
},
"lowestUptime": {
"title": "运行时间最低"
},
"monitorsByGroup": {
"title": "按分组的监控器",
"empty": "暂无分组。分组在每个监控器上设置。"
},
"busiestServers": {
"title": "最繁忙的服务器",
"empty": "暂无基础设施监控器。",
"cpu": "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": "{{workers}} 个工作进程上的 {{jobs}} 个任务均按计划运行。",
"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": "该 URL 不存在,或者您没有访问权限。"
+141
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": "最近一天 {{total}} 次中的 {{failed}} 次"
},
"checkmateServer": {
"title": "Checkmate 伺服器",
"memory": "記憶體",
"eventLoop": "事件迴圈延遲",
"uptime": "運行時間",
"database": "資料庫",
"readsWrites": "{{reads}} 讀取/秒 · {{writes}} 寫入/秒"
},
"ranking": {
"topOf": "{{total}} 個中的前 {{shown}} 個",
"notEnough": "監視器數量不足,無法排名。"
},
"slowestMonitors": {
"title": "最慢的監視器"
},
"lowestUptime": {
"title": "運行時間最低"
},
"monitorsByGroup": {
"title": "依群組分類的監視器",
"empty": "尚無群組。群組在每個監視器上設定。"
},
"busiestServers": {
"title": "最繁忙的伺服器",
"empty": "尚無基礎設施監視器。",
"cpu": "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": "{{workers}} 個工作處理程序上的 {{jobs}} 個工作均按計畫執行。",
"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": "該 URL 不存在,或您沒有存取權限。"
-1
View File
@@ -9,7 +9,6 @@ services:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- CLIENT_HOST=${CLIENT_HOST:-http://localhost:52345}
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
stop_grace_period: 60s
healthcheck:
test:
-11
View File
@@ -27,17 +27,6 @@ CLIENT_HOST=http://localhost:5173
# ----------------------
ORIGIN=localhost
# Encryption
# ------------------
# Encrypts stored Docker TLS client keys. Not needed for socket-only Docker monitors.
# Generate a key: openssl rand -base64 32
# Key list: comma-separated; the first key encrypts, every key decrypts.
# Split deployments: set the same value on the API and every worker.
# Rotate a key: 1. deploy OLD_KEY,NEW_KEY everywhere so every process can read both
# 2. deploy NEW_KEY,OLD_KEY everywhere so new writes use the new key
# 3. wait for the worker to re-encrypt every row, then drop OLD_KEY
# ENCRYPTION_KEY=<output of openssl rand -base64 32>
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
+21 -385
View File
@@ -422,6 +422,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -463,18 +466,6 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -522,7 +513,6 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -650,6 +640,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -691,18 +684,6 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -752,7 +733,6 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -949,9 +929,6 @@
{
"$ref": "#/components/schemas/PushoverNotification"
},
{
"$ref": "#/components/schemas/SignalgridNotification"
},
{
"$ref": "#/components/schemas/TwilioNotification"
},
@@ -972,7 +949,6 @@
"teams": "#/components/schemas/TeamsNotification",
"telegram": "#/components/schemas/TelegramNotification",
"pushover": "#/components/schemas/PushoverNotification",
"signalgrid": "#/components/schemas/SignalgridNotification",
"twilio": "#/components/schemas/TwilioNotification",
"ntfy": "#/components/schemas/NtfyNotification"
}
@@ -1404,34 +1380,6 @@
"accessToken": "a1234567890abcdef"
}
},
"SignalgridNotification": {
"type": "object",
"properties": {
"notificationName": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["signalgrid"]
},
"address": {
"type": "string",
"minLength": 1
},
"accessToken": {
"type": "string",
"minLength": 1
}
},
"required": ["notificationName", "type", "address", "accessToken"],
"example": {
"notificationName": "Signalgrid",
"type": "signalgrid",
"address": "your-channel",
"accessToken": "your-client-key"
}
},
"TwilioNotification": {
"type": "object",
"properties": {
@@ -7142,6 +7090,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7183,18 +7134,6 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7244,7 +7183,6 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -7717,6 +7655,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7758,18 +7699,6 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7819,7 +7748,6 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -8056,231 +7984,6 @@
}
}
},
"/monitors/docker/details/{monitorId}/containers/{containerName}/logs": {
"get": {
"tags": ["monitors"],
"summary": "Get stored log lines for one container on a Docker host monitor, newest first",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "monitorId",
"in": "path"
},
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "containerName",
"in": "path"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "before",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "after",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 20
},
"required": false,
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"logs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"monitorId": {
"type": "string"
},
"teamId": {
"type": "string"
},
"containerId": {
"type": "string"
},
"containerName": {
"type": "string"
}
},
"required": ["monitorId", "teamId", "containerId", "containerName"]
},
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ts": {
"type": "string"
},
"stream": {
"type": "string",
"enum": ["stdout", "stderr"]
},
"text": {
"type": "string"
}
},
"required": ["ts", "stream", "text"]
}
},
"gap": {
"type": "boolean"
},
"checkedAt": {
"type": "string"
},
"expiry": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "metadata", "lines", "gap", "checkedAt", "expiry", "createdAt", "updatedAt"]
}
},
"nextCursor": {
"type": "string",
"nullable": true
}
},
"required": ["logs", "nextCursor"]
}
},
"required": ["success", "msg", "data"]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/monitors/pagespeed/details/{monitorId}": {
"get": {
"tags": ["monitors"],
@@ -9205,6 +8908,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"jsonPath": {
"anyOf": [
{
@@ -9305,42 +9011,6 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -9934,6 +9604,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number",
"default": 100
@@ -10029,10 +9702,6 @@
"minimum": 300000,
"default": 300000
},
"dockerLogsEnabled": {
"type": "boolean",
"default": false
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -10468,6 +10137,9 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"ignoreTlsErrors": {
"type": "boolean"
},
@@ -10597,42 +10269,6 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
-4
View File
@@ -65,10 +65,6 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
+1 -2
View File
@@ -5,7 +5,6 @@ import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from
import {
registrationBodyValidation,
registerInviteTokenValidation,
loginValidation,
recoveryValidation,
recoveryTokenBodyValidation,
@@ -49,7 +48,7 @@ class AuthController implements IAuthController {
registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user;
const newUserToken = registerInviteTokenValidation.parse(req.body.token);
const newUserToken = req.body.token;
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
+1 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
router.delete("/:monitorId", checkController.deleteChecks);
return router;
};
@@ -1,16 +1,15 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+1 -5
View File
@@ -21,11 +21,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+5 -6
View File
@@ -1,15 +1,14 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
return router;
};
@@ -23,8 +23,6 @@ export const registrationBodyValidation = z.object({
inviteToken: z.string().optional().default(""),
});
export const registerInviteTokenValidation = z.string().optional().default("");
export const recoveryValidation = z.object({
email: z.email("Must be a valid email address"),
});
+7 -61
View File
@@ -15,9 +15,6 @@ import {
import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { X509Certificate } from "node:crypto";
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -123,60 +120,19 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || data.url === undefined) return;
if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) {
if (!dockerUrlRegex.test(data.url)) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]",
message: "Docker host must be unix:///path or an absolute socket path",
});
}
};
type DockerTlsFields = {
type?: string;
url?: string;
ignoreTlsErrors?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKey?: string;
};
const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
let certificate: X509Certificate | undefined;
try {
if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
} catch (error: unknown) {
issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
}
try {
if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
} catch (error) {
issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
}
try {
if (data.dockerTlsKey) {
const key = parsePrivateKey(data.dockerTlsKey);
if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
}
} catch (error: unknown) {
issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -214,9 +170,6 @@ export const createMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -225,8 +178,7 @@ export const createMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("create"));
.superRefine(refineDockerUrl);
export const editMonitorBodyValidation = z
.object({
@@ -263,9 +215,6 @@ export const editMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -274,8 +223,7 @@ export const editMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("edit"));
.superRefine(refineDockerUrl);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -343,6 +291,7 @@ const importedMonitorSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
dockerLogsEnabled: z.boolean().default(false),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
@@ -432,9 +381,6 @@ export const monitorResponseSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
dockerLogsEnabled: z.boolean(),
dockerTlsCa: z.string().optional(),
dockerTlsCert: z.string().optional(),
dockerTlsKeySet: z.boolean().optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
@@ -123,13 +123,6 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
-36
View File
@@ -3,39 +3,6 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32`
const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/;
export const encryptionKeyList = z
.string()
.optional()
.transform((raw, ctx) => {
if (raw === undefined) return [];
const entries = raw
.split(",")
.map((entry, index) => ({ entry: entry.trim(), slot: index + 1 }))
.filter(({ entry }) => entry.length > 0);
const seen = new Set<string>();
const keys: Buffer[] = [];
entries.forEach(({ entry, slot }) => {
if (!ENCRYPTION_KEY_ENTRY.test(entry)) {
ctx.addIssue({
code: "custom",
message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`,
});
return;
}
const decoded = Buffer.from(entry, "base64");
const fingerprint = decoded.toString("hex");
if (seen.has(fingerprint)) {
ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` });
}
seen.add(fingerprint);
keys.push(decoded);
});
return keys;
});
const envSchema = z.object({
// Server Configuration
PORT: z.string().default("52345"),
@@ -69,9 +36,6 @@ const envSchema = z.object({
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
// Encryption
ENCRYPTION_KEY: encryptionKeyList,
});
export type ValidatedEnv = z.infer<typeof envSchema>;
-1
View File
@@ -80,7 +80,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: shared.encryptionService,
});
const maintenanceWindowService = new MaintenanceWindowService({
+2 -13
View File
@@ -1,6 +1,6 @@
import { Mongoose } from "mongoose";
import { hostname } from "node:os";
import * as nodeCrypto from "node:crypto";
import { randomUUID } from "node:crypto";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
@@ -20,7 +20,6 @@ import { INotificationsService, NotificationsService } from "@/domain/notificati
import { IEmailService, EmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
@@ -34,7 +33,6 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -84,7 +82,6 @@ export interface SharedServices {
db: IDb<Mongoose>;
settingsService: ISettingsService;
emailService: IEmailService;
encryptionService: IEncryptionService;
notificationMessageBuilder: INotificationMessageBuilder;
incidentService: IIncidentService;
checkService: ICheckService;
@@ -120,18 +117,13 @@ export const buildShared = async ({
logger,
envSettings,
settingsService,
encryptionKeys,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
encryptionKeys: Buffer[];
}): Promise<SharedServices> => {
// Shared worker ID
const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`;
// Create the encryption service
const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto);
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
// Create DB
let db: IDb<Mongoose> | null = null;
@@ -186,7 +178,6 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -201,7 +192,6 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -220,7 +210,6 @@ export const buildShared = async ({
db,
settingsService,
emailService,
encryptionService,
notificationMessageBuilder,
incidentService,
checkService,
+1 -22
View File
@@ -16,7 +16,6 @@ import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
@@ -40,7 +39,6 @@ import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
@@ -61,7 +59,6 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
jobsRepository,
queueWorkersRepository,
monitorsRepository,
encryptionService,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
@@ -79,7 +76,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
const dockerProvider = new DockerProvider(logger, Docker);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
@@ -167,23 +164,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
workerId,
});
const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger);
try {
const result = await secretsRotationService.run();
logger.debug({
message: "Secrets rotation run successfully",
service: "WorkerServices",
method: "buildWorker",
details: { ...result },
});
} catch (error: unknown) {
logger.warn({
message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`,
service: "WorkerServices",
method: "buildWorker",
details: { ...(error instanceof AppError ? error.details : {}) },
});
}
return { worker, networkService, bufferService, statusService };
};
@@ -307,20 +307,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
dockerTlsCa: {
type: String,
},
dockerTlsCert: {
type: String,
},
dockerTlsKey: {
type: String,
select: false,
},
dockerTlsKeySet: {
type: Boolean,
default: false,
},
dnsServer: {
type: String,
},
@@ -55,11 +55,6 @@ export interface IMonitorsRepository {
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// Docker TLS
findDockerTlsKeyById(monitorId: string): Promise<string | null>;
findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>;
updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise<void>; // Needed for worker to rotate keys, no teamId/userId
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
@@ -298,20 +298,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return { monitors: this.mapDocuments(monitors), deletedCount };
};
findDockerTlsKeyById = async (monitorId: string): Promise<string | null> => {
const doc = await MonitorModel.findOne({ _id: monitorId }, { dockerTlsKey: 1 }).lean();
return doc?.dockerTlsKey ?? null;
};
findAllDockerTlsKeys = async (): Promise<{ id: string; dockerTlsKey: string }[]> => {
const docs = await MonitorModel.find({ dockerTlsKeySet: true }, { dockerTlsKey: 1 }).lean();
return docs.flatMap((doc) => (doc.dockerTlsKey ? [{ id: doc._id.toString(), dockerTlsKey: doc.dockerTlsKey }] : []));
};
updateDockerTlsKey = async (monitorId: string, dockerTlsKey: string): Promise<void> => {
await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } });
};
findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise<MonitorsSummary> => {
const match = this.queryBuilder(config, teamId);
const pipeline = [
@@ -468,9 +454,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
dockerTlsCa: doc.dockerTlsCa ?? undefined,
dockerTlsCert: doc.dockerTlsCert ?? undefined,
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
+2 -57
View File
@@ -33,8 +33,6 @@ import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
const SERVICE_NAME = "MonitorService";
@@ -62,7 +60,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
@@ -146,7 +144,6 @@ export class MonitorService implements IMonitorService {
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
private encryptionService: IEncryptionService;
constructor({
scheduler,
@@ -159,7 +156,6 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService,
}: {
scheduler: IJobScheduler;
logger: ILogger;
@@ -171,7 +167,6 @@ export class MonitorService implements IMonitorService {
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
encryptionService: IEncryptionService;
}) {
this.scheduler = scheduler;
this.logger = logger;
@@ -183,25 +178,19 @@ export class MonitorService implements IMonitorService {
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
this.encryptionService = encryptionService;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
// Encrypt docker TLS keys
this.encryptDockerTls(body, "create");
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
}
this.scheduler.addJob(monitor.id, monitor);
return monitor;
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -547,20 +536,6 @@ export class MonitorService implements IMonitorService {
if (unsetProxyId) {
delete body.proxyId;
}
// Handle Docker TLS
this.encryptDockerTls(body, "edit");
if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) {
const stored = await this.monitorsRepository.findById(monitorId, teamId);
if (!stored.dockerTlsKeySet) {
throw new AppError({
message: "Key is required for a TLS Docker host",
status: 422,
service: SERVICE_NAME,
method: "editMonitor",
});
}
}
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
@@ -748,34 +723,4 @@ export class MonitorService implements IMonitorService {
return { imported: createdMonitors!.length, errors };
};
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
if (body.type !== "docker" && mode === "create") return;
if (body.url !== undefined && !isDockerTlsUrl(body.url)) {
body.dockerTlsCa = undefined;
body.dockerTlsCert = undefined;
body.dockerTlsKey = undefined;
body.dockerTlsKeySet = false;
return;
}
if (!body.dockerTlsKey) {
delete body.dockerTlsKey; // empty field means keeps stored key
return;
}
if (!this.encryptionService.isConfigured()) {
throw new AppError({
message:
'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.',
status: 422,
service: SERVICE_NAME,
method: "encryptDockerTls",
});
}
body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey);
body.dockerTlsKeySet = true;
};
}
@@ -109,11 +109,6 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
// EncryptionService ciphertext. Normally excluded
dockerTlsKey?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -37,7 +37,6 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -1,123 +0,0 @@
const SERVICE_NAME = "SignalgridProvider";
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class SignalgridProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: "Checkmate Test Notification",
body: getTestMessage(),
type: "INFO",
critical: false,
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: message.content.title,
body: this.buildSignalgridText(message),
type: this.mapSeverity(message.severity),
critical: false,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Signalgrid notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
switch (severity) {
case "critical":
return "CRIT";
case "warning":
return "WARN";
case "success":
return "SUCCESS";
default:
return "INFO";
}
}
private buildSignalgridText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ const startApp = async () => {
// ***********************
// Build shared services
// ***********************
const shared = await buildShared({ logger, envSettings, settingsService, encryptionKeys: env.ENCRYPTION_KEY });
const shared = await buildShared({ logger, envSettings, settingsService });
// ***********************
// Worker node path, don't need API
@@ -1,176 +0,0 @@
import { ILogger } from "@/utils/logger.js";
import type * as nodeCrypto from "node:crypto";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "EncryptionService";
const ENCRYPTION_KEY_BYTES = 32;
// Encryption consts
const FORMAT_VERSION = "v1";
const ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_ID_BYTES = 4;
const SEGMENT_COUNT = 5;
type V1Segments = [string, string, string, string, string];
const isV1Segments = (segments: string[]): segments is V1Segments => segments.length === SEGMENT_COUNT;
const BASE64URL = /^[A-Za-z0-9_-]*$/;
type KeyEntry = { id: string; key: Buffer };
export type EncryptionCryptoLib = Pick<typeof nodeCrypto, "createCipheriv" | "createDecipheriv" | "createHash" | "randomBytes">;
export interface IEncryptionService {
isConfigured(): boolean;
currentKeyId(): string;
keyIdOf(ciphertext: string): string;
needsReencryption(ciphertext: string): boolean;
encrypt(plaintext: string): string;
decrypt(ciphertext: string): string;
}
export class EncryptionService implements IEncryptionService {
static SERVICE_NAME = SERVICE_NAME;
private readonly entries: KeyEntry[];
constructor(
keys: Buffer[],
private logger: ILogger,
private cryptoLib: EncryptionCryptoLib
) {
this.entries = keys.map((key, index) => this.toEntry(key, index));
this.entries.forEach((entry, index) => {
if (this.entries.findIndex((other) => other.id === entry.id) !== index) {
throw this.fail("constructor", "ENCRYPTION_KEY key ids collide; regenerate one key", { index });
}
});
const current = this.entries[0];
this.logger.info({
message: current
? `Encryption configured with ${this.entries.length} key(s); current key id ${current.id}`
: "ENCRYPTION_KEY not set; encrypted fields are unavailable",
service: SERVICE_NAME,
method: "constructor",
});
}
isConfigured = () => {
return this.entries.length > 0;
};
currentKeyId = () => {
return this.requireCurrentKey("currentKeyId").id;
};
keyIdOf = (ciphertext: string) => {
return this.parse(ciphertext, "keyIdOf").keyId;
};
// Check if the ciphertext was encrypted with the current key
needsReencryption = (ciphertext: string) => {
const currentKey = this.requireCurrentKey("needsReencryption");
return this.parse(ciphertext, "needsReencryption").keyId !== currentKey.id;
};
encrypt = (plaintext: string) => {
const currentKey = this.requireCurrentKey("encrypt"); // Get the current key for encryption
const iv = this.cryptoLib.randomBytes(IV_BYTES); // Get IV_BYTES worth of random bytes for nonce
const cipher = this.cryptoLib.createCipheriv(ALGORITHM, currentKey.key, iv, { authTagLength: TAG_BYTES }); // Create the cipher
cipher.setAAD(this.aad(currentKey.id)); // set signing key as additional data so it is protected by the verification tag
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); // Encode plaintext as utf8 and run through cipher
const tag = cipher.getAuthTag(); // Get verification tag
return [FORMAT_VERSION, currentKey.id, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join(".");
};
decrypt = (ciphertext: string) => {
this.requireCurrentKey("decrypt"); // Config check
const { keyId, iv, tag, data } = this.parse(ciphertext, "decrypt");
const key = this.entries.find((entry) => entry.id === keyId)?.key;
if (!key) {
throw this.fail("decrypt", "No encryption key matches ciphertext", { keyId });
}
const decipher = this.cryptoLib.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAAD(this.aad(keyId)); // reconstruct the additional data, must be done BEFORE auth tag
decipher.setAuthTag(tag); // Give the decipher the tag to verify
try {
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
} catch {
throw this.fail("decrypt", "Ciphertext failed authentication");
}
};
private fail = (method: string, message: string, details?: Record<string, unknown>): AppError => {
return new AppError({
message,
status: 500,
service: SERVICE_NAME,
method,
details,
});
};
// Create an ID:Key pair for each key.
private toEntry = (key: Buffer, keyIdx: number): KeyEntry => {
if (key.length !== ENCRYPTION_KEY_BYTES) throw this.fail("constructor", "ENCRYPTION_KEY is not 32 bytes", { keyIdx });
const id = this.cryptoLib
.createHash("sha256") // Create a sha256 hasher
.update(key) // Feed it the key
.digest() // Digest a hash
.subarray(0, KEY_ID_BYTES) // Keep first KEY_ID_BYTES, enough to avoid collision
.toString("base64url");
return { id, key };
};
private requireCurrentKey = (method: string): KeyEntry => {
const current = this.entries[0];
if (!current) {
throw this.fail(method, "ENCRYPTION_KEY is not configured");
}
return current;
};
// v1 Ciphertext stored as a string with five segments:
// <version>.<keyId>.<iv>.<tag>.<encryptedData>
// iv and tag belong to aes-256-gcm standard
private parse = (ciphertext: string, method: string) => {
const segments = ciphertext.split(".");
const version = segments[0];
if (version !== FORMAT_VERSION) {
throw this.fail(method, "Unsupported ciphertext version", { version });
}
// **************************
// v1 parser
// **************************
if (!isV1Segments(segments)) {
throw this.fail(method, "Ciphertext is malformed");
}
const [, keyId, ivText, tagText, dataText] = segments; // Skip first segment, we already have version
if (![keyId, ivText, tagText, dataText].every((segment) => BASE64URL.test(segment))) {
throw this.fail(method, "Ciphertext is malformed");
}
const iv = Buffer.from(ivText, "base64url");
const tag = Buffer.from(tagText, "base64url");
if (keyId.length === 0 || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
throw this.fail(method, "Ciphertext is malformed");
}
return { keyId, iv, tag, data: Buffer.from(dataText, "base64url") };
// **************************
// End v1 parser
// **************************
};
private aad = (keyId: string) => {
return Buffer.from(`${FORMAT_VERSION}.${keyId}`, "utf8");
};
}
@@ -1,60 +0,0 @@
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "SecretsRotationService";
export interface ISecretsRotationService {
run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>;
}
export class SecretsRotationService implements ISecretsRotationService {
static SERVICE_NAME = SERVICE_NAME;
constructor(
private monitorsRepository: IMonitorsRepository,
private encryptionService: IEncryptionService,
private logger: ILogger
) {}
run = async () => {
const rows = await this.monitorsRepository.findAllDockerTlsKeys();
const result = { scanned: rows.length, reencrypted: 0, skipped: 0 };
if (rows.length === 0) return result;
if (!this.encryptionService.isConfigured()) {
this.logger.warn({
message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`,
service: SERVICE_NAME,
method: "run",
});
result.skipped = rows.length;
return result;
}
for (const monitor of rows) {
const { id, dockerTlsKey } = monitor;
try {
if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue;
const plainText = this.encryptionService.decrypt(dockerTlsKey);
await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText));
result.reencrypted += 1;
} catch (error: unknown) {
result.skipped += 1;
this.logger.warn({
message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
method: "run",
details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) },
});
}
}
this.logger.info({
message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`,
service: SERVICE_NAME,
method: "run",
});
return result;
};
}
+21 -81
View File
@@ -1,7 +1,5 @@
import https from "node:https";
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import { CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import {
DOCKER_LOG_TAIL_LINES,
@@ -25,21 +23,13 @@ import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { splitCertificateBundle } from "@/utils/pem.js";
type DockerodeType = typeof Dockerode;
type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number };
type TlsCredentials = { ok: true; key: string } | { ok: false; message: string };
const SERVICE_NAME = "DockerProvider";
const TIMEOUT_MS = 10000;
const STATS_CONCURRENCY = 5;
const DOCKER_LOG_MAX_LINE_BYTES = 4096;
const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
const DOCKER_TLS_DEFAULT_PORT = 2376;
export interface DockerError extends Error {
statusCode?: number;
@@ -52,73 +42,28 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
constructor(
private logger: ILogger,
private DockerLib: DockerodeType,
private encryptionService: IEncryptionService
private DockerLib: DockerodeType
) {}
supports(type: MonitorType): boolean {
return type === "docker";
}
private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => {
if (!isDockerTlsUrl(monitor.url)) return undefined;
if (!ctx?.dockerTlsKey) return { ok: false, message: "Docker TLS key is missing" };
try {
return { ok: true, key: this.encryptionService.decrypt(ctx.dockerTlsKey) };
} catch (error) {
if (error instanceof AppError) {
const keyId = typeof error.details?.keyId === "string" ? ` (key id ${error.details.keyId})` : "";
return { ok: false, message: `${error.message}${keyId}` };
}
return { ok: false, message: "Docker TLS key could not be decrypted" };
}
};
private isDockerError(error: unknown): error is DockerError {
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
}
private splitCaBundle = (pem: string | undefined): string[] | undefined => {
const blocks = pem ? splitCertificateBundle(pem) : [];
return blocks.length > 0 ? blocks : undefined;
};
private invalidUrl = (message: string) => new AppError({ message, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
private toDockerOptions = (monitor: Monitor, credentials?: TlsCredentials): DockerOptions => {
private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => {
const url = monitor.url?.trim();
if (!url) throw this.invalidUrl("Docker host URL is required");
// Unix socket
if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
if (url.startsWith("unix://")) {
const socketPath = url.slice("unix://".length);
if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
return { socketPath, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
if (!socketPath.startsWith("/"))
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
return { socketPath };
}
if (url.startsWith("/")) return { socketPath: url, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
// Docker TLS
const match = DOCKER_TLS_URL.exec(url);
if (!match) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
if (!credentials?.ok) throw this.invalidUrl(credentials?.message ?? "Docker TLS key is missing");
const [, , host, port] = match;
const ca = monitor.ignoreTlsErrors ? undefined : this.splitCaBundle(monitor.dockerTlsCa);
const cert = monitor.dockerTlsCert;
const key = credentials.key;
return {
host,
port: port ? Number(port) : DOCKER_TLS_DEFAULT_PORT,
protocol: "https",
ca,
cert,
key,
agent: new https.Agent({ ca, cert, key, rejectUnauthorized: !monitor.ignoreTlsErrors }),
socketPath: undefined,
username: undefined,
sshOptions: undefined,
timeout: TIMEOUT_MS,
connectionTimeout: TIMEOUT_MS,
};
if (url.startsWith("/")) return { socketPath: url };
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
};
private async mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
@@ -315,23 +260,9 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
return rawLogs.filter((log) => log !== null);
};
private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse<DockerStatusPayload> => ({
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code,
message,
responseTime,
payload: null,
});
handle = async (monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
try {
const credentials = this.resolveTlsCredentials(monitor, ctx);
if (credentials && !credentials.ok) return this.failed(monitor, NETWORK_ERROR, credentials.message, 0);
const docker = new this.DockerLib(this.toDockerOptions(monitor, credentials));
const docker = new this.DockerLib(this.toDockerOptions(monitor));
// Host reachability is the monitor's status; ping latency is the responseTime
const { responseTime, error } = await timeRequest(() => docker.ping());
if (error) {
@@ -343,7 +274,16 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
} else if (error instanceof Error) {
message = error.message;
}
return this.failed(monitor, code, message, responseTime);
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code,
message,
responseTime,
payload: null,
};
}
const summaries = await docker.listContainers({ all: true });
-1
View File
@@ -162,5 +162,4 @@ export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusRespon
export type CheckContext = {
proxyUrl?: string;
dockerTlsKey?: string;
};
-5
View File
@@ -1,5 +0,0 @@
export const DOCKER_TLS_URL = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
export const DOCKER_SOCKET_URL = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean => DOCKER_TLS_URL.test(url?.trim() ?? "");
export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? "");
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more