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
Alexander Holliday 26c0628f34 Merge pull request #3923 from bluewave-labs/feat/reorder-sidebar-options
feat(sidebar): reorder Docker and Pagespeed menu items
2026-09-06 07:58:54 -07:00
Malena AguiarandClaude Sonnet 4.6 4bbc84ac48 feat(sidebar): reorder Docker and Pagespeed menu items
Swap Docker and Pagespeed positions in the monitoring section of the
sidebar so Docker appears before Infrastructure and Pagespeed comes last.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-06 08:41:41 -06:00
Gorkem Cetin 7862b3c6f9 Update image in README.md
Replaced the old image with a new image in the README.
2026-09-06 04:14:17 +03:00
Gorkem Cetin 02b0daa37d Update README.md 2026-09-06 04:11:51 +03:00
Gorkem Cetin 1492569b73 Remove code owners from CODEOWNERS file 2026-09-06 04:10:05 +03:00
Alexander Holliday a545b81569 Merge pull request #3921 from bluewave-labs/fix/docker-permissions
fix: update docker permissions
2026-09-04 14:56:37 -07:00
Alex Holliday ce9dce48b3 update docker permissions 2026-09-04 14:55:07 -07:00
Alexander Holliday 7fcdcc0568 Merge pull request #3920 from bluewave-labs/feat/docker-container-logs
feat: docker container logs
2026-09-04 14:42:27 -07:00
Alex Holliday 9eea53aa5f add docker redirect 2026-09-04 14:41:10 -07:00
Alex Holliday bd7b6038f5 add empty view for no logs 2026-09-04 14:36:26 -07:00
Alex Holliday e2362ca809 add empty view if logs disabled 2026-09-04 14:34:24 -07:00
Alex Holliday 9a541dac21 mock missing dependency 2026-09-04 14:33:13 -07:00
Alex Holliday 71fed33ecd purge ssh 2026-09-04 14:33:13 -07:00
Alex Holliday 074b94b061 fix stale docker strings 2026-09-04 14:09:41 -07:00
Alex Holliday c1af8995e4 add opt in docker logs 2026-09-04 14:07:10 -07:00
Alex Holliday 431cada08e add opt in docker logs flag 2026-09-04 14:06:46 -07:00
Alex Holliday e012910332 add loading older logs 2026-09-04 13:16:22 -07:00
Alex Holliday 7dbc7b3064 wrap showToast in useMemo 2026-09-04 13:16:11 -07:00
Alex Holliday 1b97539eae export API response interface 2026-09-04 12:06:35 -07:00
Alex Holliday 6e0afe223e add after to docker logs query 2026-09-04 12:06:17 -07:00
Alex Holliday 37e1005479 add comment 2026-09-04 11:54:29 -07:00
Alex Holliday 795e1caca4 log display 2026-09-04 11:42:11 -07:00
Alex Holliday a5bb186814 wrap basebox in fowardRef 2026-09-04 11:28:10 -07:00
Alex Holliday bee3d4221e implement display of log head in logs tab 2026-09-03 12:57:06 -07:00
Alex Holliday f7e1793bac update tests 2026-09-03 12:56:44 -07:00
Alexander Holliday 9a8203b0d8 Merge pull request #3918 from bluewave-labs/fix/improve-code-readability
fix: improve code readability on uptime page components
2026-09-03 12:30:44 -07:00
Malena Aguiar 24d4595bb1 fix: improve code readability on uptime components 2026-09-03 12:55:43 -06:00
Alex Holliday 6587ddf389 wire up services/repositories 2026-09-03 11:32:48 -07:00
Alexander Holliday 9b939f2d94 Merge pull request #3915 from bluewave-labs/feat/theme-contrast-and-type
feat(client): define the neutral palette, switch to Geist, and standardize controls
2026-09-03 10:15:52 -07:00
Alex Holliday 530eeb0110 purge comments 2026-09-03 09:57:05 -07:00
Alex Holliday da0d2a47b4 use sx props 2026-09-03 09:47:38 -07:00
Alex Holliday 700baf2061 add compile script 2026-09-03 09:19:08 -07:00
Alex Holliday 6ea4ca1aa9 new docker-log domain,service, repo 2026-09-02 11:56:18 -07:00
Alex Holliday 3e06cd39b1 add docker logs to provider 2026-09-02 11:09:38 -07:00
Alex Holliday efcc7829d4 move docker types to docker domain 2026-09-02 10:08:48 -07:00
Alex Holliday 77974682fb add docker log/payload types 2026-09-02 10:00:37 -07: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
125 changed files with 7506 additions and 400 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
+2 -3
View File
@@ -36,8 +36,7 @@
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
</p>
<br>
@@ -212,7 +211,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [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.
+22 -2
View File
@@ -93,6 +93,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -408,6 +409,7 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -445,6 +447,7 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1233,6 +1236,7 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1337,6 +1341,7 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2381,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2396,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2486,6 +2493,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2766,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3122,6 +3131,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3565,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3964,6 +3975,7 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4776,6 +4788,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5587,6 +5600,7 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6368,6 +6382,7 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6379,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6582,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6732,6 +6749,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7573,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7710,6 +7729,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+2 -1
View File
@@ -12,7 +12,8 @@
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check ."
"format-check": "prettier --check .",
"compile": "tsc -b"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
@@ -1,10 +1,12 @@
import Box, { type BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { forwardRef } from "react";
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
const theme = useTheme();
return (
<Box
ref={ref}
{...rest}
sx={{
backgroundColor: theme.palette.background.paper,
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
}}
/>
);
};
});
BaseBox.displayName = "BaseBox";
@@ -8,9 +8,6 @@ interface IconProps {
color?: string;
}
// 16 is the house size: 17 call sites ask for it explicitly against 2 for 20,
// and the sidebar clamps to 16 in CSS. A 20px default put icons above the cap
// height of the 13px text they sit beside.
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
return (
<Icon
@@ -37,12 +37,6 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
// `error.dark` rather than `main`: in dark mode `main` is light
// enough that MUI derives near-black contrastText, which reads as
// a warning rather than an outage on the one surface where
// urgency matters. `dark` keeps a solid red bar with white text
// in both modes, and leaves `main` alone for the ~30 places that
// use it as text or a chart mark on a dark ground.
backgroundColor: theme.palette.error.dark,
color: "#FFFFFF",
px: theme.spacing(8),
@@ -6,7 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -110,9 +110,6 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
// Wide enough to hold a one-line page description at this
// size; still a cap, so it wraps rather than running the
// full width of a large monitor.
maxWidth: "108ch",
}}
>
@@ -146,7 +143,7 @@ export const PageHeader = ({
alignItems: "center",
justifyContent: "center",
width: 36,
height: CONTROL_HEIGHT,
height: INPUT_BASE_HEIGHT,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
@@ -1,5 +1,5 @@
import { Box, Stack, useTheme } from "@mui/material";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -45,7 +45,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
height: CONTROL_HEIGHT,
height: INPUT_BASE_HEIGHT,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
@@ -19,15 +19,8 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper;
// In light mode `paper` is white, and lightening white is a no-op - the
// gradient collapses flat. Darken slightly towards the page ground instead.
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperEnd = paper;
// Both ends stay close to `main` so MUI's derived `contrastText` still
// applies - that is white on the dark light-mode fills and near-black on
// the lighter dark-mode ones, so a bigger lift would break one or other.
// `light` is a mid-tone used for status dots and chart marks, too pale to
// use here, so the lift comes off `main` instead.
const bg = palette
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
@@ -33,12 +33,6 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
bottom={0}
sx={{
pointerEvents: "none",
// Deliberately far fainter than `divider` - this is texture
// behind the number, not a rule, and it must never compete
// with the box's own border. Note alpha() *replaces* the
// alpha channel, so this relies on `divider` being an opaque
// hex; if it ever goes translucent this gets stronger, not
// weaker.
backgroundImage: `
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
+13 -26
View File
@@ -52,11 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>;
/**
* Marks a row selected so it gets the shared selected + hover treatment.
* Desktop table only - the small-screen card branch does not style
* selection yet, same as `getRowSx`.
*/
isRowSelected?: (row: T) => boolean;
};
@@ -220,21 +215,6 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
// Cells paint `background.paper`, so a background set on the row
// never shows. Selection and hover both have to be applied at
// cell level, and selection has to come first so hover layers
// on top of it rather than replacing it.
"& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": {
backgroundColor: theme.palette.action.selected,
},
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.rowHover,
},
"& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.selectedHover,
},
}}
>
<TableHead>
@@ -255,18 +235,25 @@ export function DataTable<
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
const selected = isRowSelected?.(row);
return (
<Fragment key={key}>
<TableRow
className={[
isInteractive ? "is-clickable" : "",
isRowSelected?.(row) ? "is-selected" : "",
]
.filter(Boolean)
.join(" ")}
sx={{
cursor: isInteractive ? "pointer" : "default",
...(isInteractive && {
"&:hover .MuiTableCell-root": {
backgroundColor: selected
? theme.palette.action.selectedHover
: theme.palette.action.rowHover,
},
}),
...(selected && {
"& .MuiTableCell-root": {
backgroundColor: theme.palette.action.selected,
},
}),
...(getRowSx?.(row) as object),
}}
onClick={() => {
@@ -2,7 +2,7 @@ import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
@@ -10,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
<MuiTabs
sx={{
minHeight: CONTROL_HEIGHT,
minHeight: INPUT_BASE_HEIGHT,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
@@ -42,17 +42,13 @@ export const Tab = (props: CustomTabProps) => {
textTransform: "none",
fontSize: typographyLevels.m,
fontWeight: 500,
minHeight: CONTROL_HEIGHT,
minHeight: INPUT_BASE_HEIGHT,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
alignItems: "flex-start",
color: theme.palette.text.secondary,
"&.Mui-selected": {
// Weight stays put, as on ToggleButton: bolding the active tab
// widens it, shifting every tab to its right and dragging the
// indicator to a moving target. Colour and the indicator are
// already carrying the state.
color: theme.palette.primary.main,
},
"&:hover": {
+2 -5
View File
@@ -1,7 +1,7 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme, darken } from "@mui/material/styles";
import { CONTROL_HEIGHT, HOVER } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
@@ -43,10 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
height: CONTROL_HEIGHT,
// MUI's button line-height is 1.75 (22.75px at 13px), which with the
// 6px padding needs 34.75px - already over its own box at 34, and
// clipping at 32. 1.2 leaves room for tall scripts too.
height: INPUT_BASE_HEIGHT,
lineHeight: 1.2,
fontWeight: 400,
borderRadius: 2,
+2 -2
View File
@@ -4,7 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -19,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: CONTROL_HEIGHT,
height: INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
+2 -2
View File
@@ -8,7 +8,7 @@ import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -56,7 +56,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
minHeight: CONTROL_HEIGHT,
minHeight: INPUT_BASE_HEIGHT,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
+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>
+2 -2
View File
@@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
@@ -72,7 +72,7 @@ const SelectInputInner = <T,>(
/>
)}
sx={{
height: `${CONTROL_HEIGHT}px`,
height: `${INPUT_BASE_HEIGHT}px`,
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
+2 -2
View File
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { CONTROL_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : CONTROL_HEIGHT,
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
overflow: "hidden",
},
+2 -2
View File
@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -34,7 +34,7 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
minHeight: CONTROL_HEIGHT,
minHeight: INPUT_BASE_HEIGHT,
p: 0,
px: theme.spacing(5),
},
@@ -4,7 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -21,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
height: CONTROL_HEIGHT,
height: INPUT_BASE_HEIGHT,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -34,10 +34,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
},
"&.Mui-selected": {
backgroundColor: selectedBg,
// Weight stays at 400 so the group sits level with Button,
// which it is routinely placed beside. The fill and the
// darker ink carry the selected state instead - a weight
// change also reflows the label width as you switch.
color: theme.palette.text.primary,
"&:hover": {
backgroundColor: selectedHoverBg,
@@ -35,7 +35,7 @@ export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
controller,
bulkActions,
showTypeFilter,
summaryProps,
@@ -48,14 +48,14 @@ export const MonitorListPage = ({
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
const isLoading = controller.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
error={controller.error || extraError}
totalCount={controller.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
@@ -66,7 +66,7 @@ export const MonitorListPage = ({
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
summary={controller.summary}
{...summaryProps}
/>
<Stack
@@ -78,27 +78,27 @@ export const MonitorListPage = ({
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
selectedTypes: controller.selectedTypes,
setSelectedTypes: controller.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
selectedStatus={controller.selectedStatus}
setSelectedStatus={controller.setSelectedStatus}
selectedState={controller.selectedState}
setSelectedState={controller.setSelectedState}
tagOptions={controller.tags ?? []}
selectedTags={controller.selectedTags}
setSelectedTags={controller.setSelectedTags}
onClearFilters={controller.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
value={controller.search}
onChange={(event) => {
c.setSearch(event.target.value);
controller.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
{controller.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
@@ -108,8 +108,8 @@ export const MonitorListPage = ({
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
{controller.selectedTags.map((tagId) => {
const tag = controller.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
@@ -123,20 +123,20 @@ export const MonitorListPage = ({
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
selectedCount={controller.selectedRows.length}
onCancel={controller.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
onClick={controller.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
onClick={controller.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
)}
{children}
<Dialog
open={c.isDialogOpen}
open={controller.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
onConfirm={controller.handleConfirmDelete}
onCancel={controller.handleCancelDelete}
loading={controller.isDeleting}
/>
</MonitorBasePageWithStates>
);
+12 -6
View File
@@ -1,6 +1,7 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -26,15 +27,20 @@ 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",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
@@ -42,9 +48,9 @@ export const getMenu = (t: Function) => {
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
],
},
@@ -25,10 +25,6 @@ export const NavItem = ({
const iconStroke = selected
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
// Both states come from the same family so selected always reads stronger
// than a hovered neighbour. MUI's default action.hover is lighter than
// action.selected on a dark ground, which inverted the two.
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
? theme.palette.action.selectedHover
-3
View File
@@ -73,9 +73,6 @@ export const Sidebar = () => {
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
// On `paper` rather than inheriting the page ground, so the nav
// reads as a surface and the content area stays the darker
// `default`. Opaque, so the blur it used to carry is gone.
bgcolor={theme.palette.background.paper}
sx={{
touchAction: "none",
+1 -1
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
interface ApiResponse<T> {
export interface ApiResponse<T> {
success: boolean;
msg: string;
data: T;
+23 -20
View File
@@ -1,27 +1,30 @@
import { toast, type ToastOptions } from "react-toastify";
import { useMemo } from "react";
export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
return useMemo(() => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "warning" });
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "warning" });
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
}, []);
};
+1
View File
@@ -77,6 +77,7 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
case "game":
+21 -3
View File
@@ -118,8 +118,8 @@ const getGeneralSettingsConfig = (
showDnsRecordType: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
@@ -255,6 +255,7 @@ const CreateMonitorPage = () => {
const firstSegment = pathSegments[0];
if (firstSegment === "pagespeed") return "pagespeed";
if (firstSegment === "infrastructure") return "hardware";
if (firstSegment === "docker") return "docker";
return "uptime";
}, [location.pathname]);
@@ -264,7 +265,9 @@ const CreateMonitorPage = () => {
? "pagespeed"
: pageType === "hardware"
? "hardware"
: "http";
: pageType === "docker"
? "docker"
: "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null
@@ -373,6 +376,8 @@ const CreateMonitorPage = () => {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
} else if (pageType === "docker") {
navigate("/docker");
} else {
navigate("/uptime");
}
@@ -823,6 +828,19 @@ const CreateMonitorPage = () => {
/>
)}
{showStep(1) && watchedType === "docker" && (
<ConfigBox
title={t("pages.createMonitor.form.dockerLogs.title")}
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
rightContent={
<FormSwitchField
name="dockerLogsEnabled"
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
/>
}
/>
)}
{showStep(2) &&
(watchedType === "http" ||
watchedType === "grpc" ||
@@ -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;
};
@@ -0,0 +1,69 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Dot } from "@/Components/design-elements";
// Hooks
import { useEffect, useState } from "react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
// Utils
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
interface HeaderLogsProps {
lines: number;
loading: boolean;
lastUpdatedAt: number | null;
}
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (lastUpdatedAt === null) return;
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
return () => window.clearInterval(id);
}, [lastUpdatedAt]);
const renderStatus = () => {
if (loading) return t("pages.docker.container.logs.loading");
if (lastUpdatedAt === null) return null;
const ageMs = Math.max(0, now - lastUpdatedAt);
return ageMs < MS_PER_MINUTE
? t("pages.docker.container.logs.updatedSecondsAgo", {
count: Math.floor(ageMs / MS_PER_SECOND),
})
: t("pages.docker.container.logs.updatedMinutesAgo", {
count: Math.floor(ageMs / MS_PER_MINUTE),
});
};
return (
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.MD}
>
<Typography>
{t("pages.docker.container.logs.recentLines", { count: lines })}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={SPACING.SM}
>
{lastUpdatedAt !== null && !loading && (
<Dot
color={theme.palette.success.main}
size="md"
/>
)}
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
</Stack>
</Stack>
);
};
@@ -0,0 +1,78 @@
import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import { Fragment } from "react";
// Hooks
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
// Utils
import type { DockerLogRow } from "@/Utils/MonitorUtils";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/store";
export const RowLog = ({ row }: { row: DockerLogRow }) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { t } = useTranslation();
return (
<Fragment>
<Grid
container
spacing={{ xs: 0, md: LAYOUT.MD }}
py={{ xs: LAYOUT.LG, md: 0 }}
>
<Grid size={{ xs: 12, md: "auto" }}>
<Typography
fontFamily="monospace"
color={theme.palette.text.secondary}
>
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: "grow" }}>
<Typography
fontFamily={"monospace"}
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
sx={{
overflowWrap: { xs: "anywhere", md: "normal" },
}}
>
<Box
component={"span"}
color={
row.stream === "stderr"
? theme.palette.error.main
: theme.palette.success.main
}
>
{row.stream}
</Box>
: {row.text}
</Typography>
</Grid>
</Grid>
{row.gapBefore ? (
<Stack
width="100%"
direction="row"
alignItems={"center"}
py={SPACING.MD}
>
<Typography pr={SPACING.MD}>
{t("pages.docker.container.logs.skipped")}
</Typography>
<Divider sx={{ flex: 1 }} />
</Stack>
) : (
<Divider sx={{ display: { xs: "block", md: "none" } }} />
)}
</Fragment>
);
};
@@ -1,3 +1,241 @@
export const TabLogs = () => {
return "This is the logs tab";
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseBox, EmptyState } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
// Types
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
import type { DockerLog } from "@/Types/Check";
import type { ApiResponse } from "@/Hooks/UseApi";
// Hooks
import {
useState,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useCallback,
} from "react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
// Utils
import { get } from "@/Utils/ApiClient";
import { LAYOUT } from "@/Utils/Theme/constants";
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
interface TabLogsProps {
monitorId?: string;
containerName?: string;
enabled: boolean;
}
const SCROLL_PIN_THRESHOLD_PX = 8;
const TAIL_POLL_MS = 10_000;
const isScrolledToBottom = (el: HTMLElement) =>
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
return res.data.data;
};
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
const [olderExhausted, setOlderExhausted] = useState(false);
const [loadingInitial, setLoadingInitial] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
const newestCursor = logs[0]?.checkedAt;
const oldestCursor = logs[logs.length - 1]?.checkedAt;
// Initial page. Resets everything when the container changes.
useEffect(() => {
setLogs([]);
setOlderExhausted(false);
if (!logsUrl) return;
let cancelled = false;
setLoadingInitial(true);
fetchLogs(logsUrl, {})
.then((page) => {
if (cancelled) return;
setLogs(page.logs);
setOlderExhausted(page.nextCursor === null);
setLastUpdatedAt(Date.now());
})
.catch(() => {
if (cancelled) return;
toastError(t("pages.docker.container.logs.loadError"));
})
.finally(() => {
if (!cancelled) setLoadingInitial(false);
});
return () => {
cancelled = true;
};
}, [logsUrl, t, toastError]);
const newestCursorRef = useRef(newestCursor);
newestCursorRef.current = newestCursor;
useEffect(() => {
if (!logsUrl) return;
let inFlight = false;
const tick = async () => {
if (inFlight || document.hidden) return;
inFlight = true;
try {
const cursor = newestCursorRef.current;
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
setLastUpdatedAt(Date.now());
} catch (err: unknown) {
console.error(err);
} finally {
inFlight = false;
}
};
const id = window.setInterval(tick, TAIL_POLL_MS);
document.addEventListener("visibilitychange", tick);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, [logsUrl]);
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
const logBoxRef = useRef<HTMLDivElement>(null);
const pinnedToBottom = useRef(true);
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
// Always pin to bottom when container/monitor changes
useLayoutEffect(() => {
pinnedToBottom.current = true;
}, [monitorId, containerName]);
// Rows changed: restore position after a prepend, otherwise honour the pin
useLayoutEffect(() => {
const el = logBoxRef.current;
if (!el) return;
const anchor = prependAnchor.current;
if (anchor) {
prependAnchor.current = null;
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
return;
}
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
}, [rows]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
}, []);
const loadOlder = useCallback(async () => {
if (!logsUrl || !oldestCursor || loadingOlder) return;
setLoadingOlder(true);
try {
const page = await fetchLogs(logsUrl, { before: oldestCursor });
const el = logBoxRef.current;
if (el)
prependAnchor.current = {
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop,
};
setLogs((prev) => [...prev, ...page.logs]);
setOlderExhausted(page.nextCursor === null);
} catch {
toastError(t("pages.docker.container.logs.loadOlderError"));
} finally {
setLoadingOlder(false);
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!enabled) {
return (
<EmptyState
title={t("pages.docker.container.logs.disabled.title")}
description={t("pages.docker.container.logs.disabled.description")}
actionText={t("pages.docker.container.logs.disabled.action")}
actionTo={`/docker/configure/${monitorId}`}
/>
);
}
if (lastUpdatedAt !== null && rows.length === 0) {
return (
<EmptyState
title={t("pages.docker.container.logs.empty.title")}
description={t("pages.docker.container.logs.empty.description")}
/>
);
}
return (
<Stack gap={LAYOUT.MD}>
<HeaderLogs
lines={rows.length}
loading={loadingInitial}
lastUpdatedAt={lastUpdatedAt}
/>
<BaseBox
ref={logBoxRef}
onScroll={handleScroll}
minHeight={240}
maxHeight="50vh"
overflow="auto"
padding={LAYOUT.MD}
sx={{ overflowAnchor: "none" }}
>
{rows.length > 0 && (
<Stack
direction="row"
justifyContent="center"
pb={LAYOUT.MD}
>
{olderExhausted ? (
<Typography color={theme.palette.text.secondary}>
{t("pages.docker.container.logs.noOlder")}
</Typography>
) : (
<Button
variant="contained"
disabled={loadingOlder}
onClick={loadOlder}
>
{loadingOlder
? t("pages.docker.container.logs.loadingOlder")
: t("pages.docker.container.logs.loadOlder")}
</Button>
)}
</Stack>
)}
{rows.map((row) => {
return (
<RowLog
key={row.key}
row={row}
/>
);
})}
</BaseBox>
</Stack>
);
};
@@ -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<{
@@ -70,7 +70,13 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && <TabLogs />}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
containerName={containerName}
enabled={monitor.dockerLogsEnabled ?? false}
/>
)}
</BasePage>
);
};
@@ -30,9 +30,6 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
direction="row"
alignItems="center"
gap={theme.spacing(2)}
// The icons are supporting marks beside the label, not content,
// so they take secondary ink rather than lucide's currentColor
// default, which renders them heavier than the text.
sx={{ "& svg": { color: theme.palette.text.secondary } }}
>
{icon}
@@ -1,6 +1,3 @@
// No "Inter" here: its webfont was never self-hosted, so once the Google Fonts
// import went it resolved to a fallback anyway. Listing it only made the stack
// look intentional on machines that happen to have Inter installed.
export const SANS_STACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+17 -17
View File
@@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const UptimeMonitorsPage = () => {
const c = useMonitorListController({
const monitorListController = useMonitorListController({
types: "selectable",
checksLimit: 25,
refreshInterval: 5000,
@@ -17,25 +17,25 @@ const UptimeMonitorsPage = () => {
headerKey="uptime"
page="uptime"
actionLink="/uptime/create"
controller={c}
controller={monitorListController}
bulkActions
>
<MonitorTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
monitors={monitorListController.monitors || []}
tags={monitorListController.tags || []}
refetch={monitorListController.refetch}
setSelectedMonitor={monitorListController.setSelectedMonitor}
count={monitorListController.count || 0}
page={monitorListController.page}
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
/>
</MonitorListPage>
);
+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 />}
+25
View File
@@ -161,6 +161,31 @@ export interface DockerContainerMount {
rw: boolean;
}
export const DockerLogStreams = ["stdout", "stderr"] as const;
export type DockerLogStream = (typeof DockerLogStreams)[number];
export interface DockerLogLine {
ts: string;
stream: DockerLogStream;
text: string;
}
export interface DockerLog {
id: string;
metadata: {
monitorId: string;
teamId: string;
containerId: string;
containerName: string;
};
lines: DockerLogLine[];
gap: boolean;
checkedAt: string;
expiry: string;
createdAt: string;
updatedAt: string;
}
export interface DockerContainerInfo {
id: string;
name: string;
+6
View File
@@ -5,6 +5,7 @@ import type {
DailyCheckBucket,
DockerContainerInfo,
DockerContainerSummary,
DockerLog,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
@@ -158,6 +159,7 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -322,6 +324,10 @@ export interface DockerContainerResponse {
stats: DockerContainerStats;
}
export interface DockerContainerLogsResponse {
logs: DockerLog[];
nextCursor: string | null;
}
export interface Game {
name: string;
options?: {
+39 -4
View File
@@ -1,10 +1,13 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import type {
DockerContainerMount,
DockerContainerPort,
DockerContainerState,
import {
DockerLogStreams,
type DockerContainerMount,
type DockerContainerPort,
type DockerContainerState,
type DockerLog,
type DockerLogLine,
} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => {
@@ -144,3 +147,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.source) return mount.source;
return mount.type;
};
export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const;
export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number];
export interface DockerLogRow extends DockerLogLine {
key: string;
gapBefore: boolean; // This signifies lines skipped
}
export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => {
const rows: DockerLogRow[] = [];
for (const log of [...logs].reverse()) {
log.lines.forEach((line: DockerLogLine, idx: number) => {
rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap });
});
}
return rows;
};
export const filterDockerLogRows = (
rows: DockerLogRow[],
stream: DockerLogStreamFilter,
query: string
): DockerLogRow[] => {
const normalizedQuery = query.trim().toLowerCase();
return rows.filter((row) => {
return (
(stream === "all" || row.stream === stream) &&
(normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery))
);
});
};
-12
View File
@@ -18,14 +18,11 @@ export const colors = {
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
// Neutral ramp, biased slightly green so it sits with the brand rather than
// against it. Opaque values - alpha-black over white reads washed out.
ink900: "#101917",
ink600: "#55635C",
ink400: "#7C8983",
surface50: "#F6F8F7",
rule200: "#C7D2CC",
// Semantic colours derived from the brand rather than Material's defaults
successMain: "#0D6049",
errorMain: "#A02B24",
warningMain: "#8C5806",
@@ -60,8 +57,6 @@ export const lightPalette = {
action: {
rowHover: alpha(colors.gray900, HOVER.ROW),
controlHover: alpha(colors.gray900, HOVER.CONTROL),
// Tinted with the brand so a selected row reads as chosen rather than
// merely dirty, and the hover step stays clearly above the resting one.
selected: alpha(colors.brandGreen, 0.06),
selectedHover: alpha(colors.brandGreen, 0.1),
},
@@ -78,9 +73,6 @@ export const lightPalette = {
disabled: colors.ink400,
},
divider: colors.rule200,
// Only `main` is set. MUI derives `light`/`dark` from it, and `light` is
// consumed as a saturated colour - status dots, histogram bars, heatmap
// cells - so it must not be overridden with a pale tint.
success: {
main: colors.successMain,
},
@@ -112,10 +104,6 @@ export const lightPalette = {
export const darkPalette = {
primary: {
main: colors.brandGreen,
// The dark ground needs a lighter green for anything drawn as a line
// rather than a fill - focus rings especially, which were dimmer than
// their own hover state. Leaving `main` alone keeps contained buttons
// white-on-green instead of flipping them to dark text.
light: colors.brandGreenLight,
},
action: {
-13
View File
@@ -134,9 +134,6 @@ export const theme = (mode: string, palette: any) =>
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-focusVisible": {
// Same green as the input focus ring - `main` is only
// 2.86:1 on the dark ground, so keyboard focus was
// barely visible there.
outline: `1px solid ${
theme.palette.mode === "dark"
? theme.palette.primary.light
@@ -195,24 +192,14 @@ export const theme = (mode: string, palette: any) =>
}),
},
},
// One border story for every outlined input - text fields, selects,
// autocompletes, date and colour pickers all route through this.
// Resting: divider. Hover: a step darker, so there is a cue without
// jumping to near-black. Focus: the brand, at 2px.
MuiOutlinedInput: {
styleOverrides: {
// MUI pads the inner input 8.5px top and bottom, which with an
// 18.7px line-height needs 35.7px - taller than the control
// itself, so the text was being clipped by overflow:hidden.
input: {
paddingTop: 0,
paddingBottom: 0,
height: "100%",
},
root: ({ theme }) => ({
// The Select renders a div rather than an input, so it needs
// the same treatment to sit inside the control height.
"& .MuiSelect-select": {
paddingTop: 0,
paddingBottom: 0,
+1 -6
View File
@@ -18,12 +18,7 @@ export const LAYOUT = {
XXL: 16,
} as const;
/**
* Shared height for anything that can sit in a row with something else -
* buttons, inputs, selects, pickers, toggles, tabs, icon buttons. One number so
* they share a baseline instead of drifting 2px apart.
*/
export const CONTROL_HEIGHT = 32;
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
+2 -1
View File
@@ -111,7 +111,8 @@ const portSchema = baseSchema.extend({
// Docker monitor schema
const dockerSchema = baseSchema.extend({
type: z.literal("docker"),
url: z.string().min(1, "Container ID is required"),
url: z.string().min(1, "Docker host URL is required"),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
// Game server monitor schema
+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."
+176 -5
View File
@@ -219,6 +219,7 @@
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
@@ -258,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",
@@ -632,9 +773,9 @@
},
"general": {
"option": {
"container": {
"label": "Container name/ID",
"placeholder": "my-app or abcd1234"
"dockerHost": {
"label": "Docker host",
"placeholder": "unix:///var/run/docker.sock"
},
"host": {
"label": "Host",
@@ -716,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 container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"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.",
@@ -756,7 +897,7 @@
},
"type": {
"description": "Select the type of check to perform",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionDockerDescription": "Monitor a Docker host and every container running on it.",
"optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
@@ -814,6 +955,15 @@
}
}
},
"dockerLogs": {
"title": "Container logs",
"description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.",
"option": {
"enabled": {
"label": "Collect container logs"
}
}
},
"stats": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -956,6 +1106,27 @@
"tabs": {
"overview": "Overview",
"logs": "Logs"
},
"logs": {
"recentLines": "{{count}} recent lines",
"loading": "Loading...",
"updatedSecondsAgo": "Updated {{count}}s ago",
"updatedMinutesAgo": "Updated {{count}}m ago",
"skipped": "Some log entries were skipped",
"loadOlder": "Load older logs",
"loadingOlder": "Loading...",
"noOlder": "Beginning of retained logs",
"loadError": "Could not load logs",
"loadOlderError": "Could not load older logs",
"empty": {
"title": "No logs collected yet",
"description": "Lines will appear here after the next check. Only output written since collection was turned on is stored."
},
"disabled": {
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
}
}
},
"host": {
+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 不存在,或您沒有存取權限。"
@@ -24,6 +24,8 @@ services:
checkmate-demo:
image: ghcr.io/bluewave-labs/checkmate:master
container_name: checkmate-demo
group_add:
- "989"
restart: always
env_file:
- server-prod.env
@@ -68,6 +70,8 @@ services:
checkmate-staging:
image: ghcr.io/bluewave-labs/checkmate:develop
container_name: checkmate-staging
group_add:
- "989"
restart: always
env_file:
- server-staging.env
+12
View File
@@ -24,6 +24,8 @@ import {
dockerContainerDetailsResponseSchema,
monitorResponseSchema,
uptimeDetailsResponseSchema,
getDockerContainerLogsQueryValidation,
dockerContainerLogsResponseSchema,
} from "@/api/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
@@ -131,6 +133,16 @@ registry.registerPath({
responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs",
tags,
summary: "Get stored log lines for one container on a Docker host monitor, newest first",
security: bearer,
request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation },
responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
@@ -21,6 +21,7 @@ import {
getDockerDetailsByIdQueryValidation,
getDockerContainerNameParamValidation,
getDockerContainerByNameQueryValidation,
getDockerContainerLogsQueryValidation,
} from "@/api/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import * as whoiser from "whoiser";
@@ -37,6 +38,7 @@ export interface IMonitorController {
getPageSpeedDetailsById: RequestHandler;
getDockerDetailsById: RequestHandler;
getDockerContainerByName: RequestHandler;
getDockerContainerLogs: RequestHandler;
getGeoChecksByMonitorId: RequestHandler;
getMonitorById: RequestHandler;
createMonitor: RequestHandler;
@@ -198,6 +200,30 @@ class MonitorController implements IMonitorController {
});
});
getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query);
const { monitorId, containerName } = validatedParams;
const { before, after, limit } = validatedQuery;
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getDockerContainerLogs({
teamId,
monitorId,
containerName,
before: before ? new Date(before) : undefined,
after: after ? new Date(after) : undefined,
limit,
});
return res.status(200).json({
success: true,
msg: "Docker container logs retrieved successfully",
data,
});
});
getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
+1
View File
@@ -21,6 +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", monitorController.getDockerContainerLogs);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+48 -15
View File
@@ -13,7 +13,8 @@ import {
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.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";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -119,22 +120,15 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/;
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => {
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || data.url === undefined) return;
if (!dockerUrlRegex.test(data.url)) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path",
});
}
if (data.url.startsWith("ssh://") && !data.sshPrivateKey) {
ctx.addIssue({
code: "custom",
path: ["sshPrivateKey"],
message: "SSH Docker hosts require a private key",
message: "Docker host must be unix:///path or an absolute socket path",
});
}
};
@@ -163,7 +157,6 @@ export const createMonitorBodyValidation = z
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
@@ -176,6 +169,7 @@ export const createMonitorBodyValidation = z
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -199,7 +193,6 @@ export const editMonitorBodyValidation = z
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: proxyIdValidation,
@@ -221,6 +214,7 @@ export const editMonitorBodyValidation = z
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -280,7 +274,6 @@ const importedMonitorSchema = z
tags: z.array(z.string()).default([]),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
cpuAlertThreshold: z.number().default(100),
cpuAlertCounter: z.number().default(5),
memoryAlertThreshold: z.number().default(100),
@@ -297,6 +290,8 @@ const importedMonitorSchema = z
geoCheckEnabled: z.boolean().default(false),
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(),
@@ -332,6 +327,21 @@ export const getDockerContainerNameParamValidation = z.object({
});
export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
export const getDockerContainerLogsQueryValidation = z
.object({
before: z.iso.datetime().optional(),
after: z.iso.datetime().optional(),
limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT),
})
.superRefine((query, ctx) => {
if (query.before && query.after) {
ctx.addIssue({
code: "custom",
message: "Specify either before or after, not both",
path: ["after"],
});
}
});
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
// what the controllers actually serialize.
export const monitorResponseSchema = z
@@ -359,7 +369,6 @@ export const monitorResponseSchema = z
tags: z.array(z.string()),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
diskAlertThreshold: z.number(),
@@ -371,6 +380,7 @@ export const monitorResponseSchema = z
geoCheckEnabled: z.boolean(),
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
dockerLogsEnabled: z.boolean(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
@@ -529,3 +539,26 @@ export const dockerContainerDetailsResponseSchema = z.object({
.nullable(),
}),
});
export const dockerLogLineResponseSchema = z.object({
ts: z.string(),
stream: z.enum(DockerLogStreams),
text: z.string(),
});
export const dockerLogResponseSchema = z.object({
id: z.string(),
metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }),
lines: z.array(dockerLogLineResponseSchema),
gap: z.boolean(),
checkedAt: z.string(),
expiry: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs.
export const dockerContainerLogsResponseSchema = z.object({
logs: z.array(dockerLogResponseSchema),
nextCursor: z.string().nullable(),
});
+2
View File
@@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorsRepository,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
@@ -75,6 +76,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorsRepository,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
+9
View File
@@ -13,6 +13,7 @@ import { IDb } from "@/db/db.interface.js";
import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ICheckService, CheckService } from "@/domain/checks/check.service.js";
import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js";
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
@@ -58,6 +59,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js";
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
@@ -72,6 +74,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -83,6 +86,7 @@ export interface SharedServices {
incidentService: IIncidentService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
dockerLogsService: IDockerLogsService;
notificationsService: INotificationsService;
// Queue identity (one per process; the worker reuses these)
@@ -94,6 +98,7 @@ export interface SharedServices {
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
dockerLogsRepository: IDockerLogsRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -131,6 +136,7 @@ export const buildShared = async ({
const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository();
const dockerLogsRepository = new MongoDockerLogsRepository();
const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
@@ -161,6 +167,7 @@ export const buildShared = async ({
monitorsRepository,
});
const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository });
const webhookProvider = new WebhookProvider(logger);
const rocketChatProvider = new RocketChatProvider(logger);
const slackProvider = new SlackProvider(logger);
@@ -207,6 +214,7 @@ export const buildShared = async ({
incidentService,
checkService,
geoChecksService,
dockerLogsService,
notificationsService,
workerId,
jobsRepository,
@@ -214,6 +222,7 @@ export const buildShared = async ({
queueWorkersRepository,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
+6 -2
View File
@@ -52,6 +52,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
settingsService,
checkService,
geoChecksService,
dockerLogsService,
notificationsService,
incidentService,
workerId,
@@ -60,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
monitorsRepository,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
monitorStatsRepository,
incidentsRepository,
teamsRepository,
@@ -95,7 +97,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
]);
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
@@ -119,6 +121,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
networkService,
proxyResolver,
bufferService,
dockerLogsService,
logger
);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
@@ -138,7 +141,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksRepository
geoChecksRepository,
dockerLogsRepository
);
const worker = await DBQueueWorker.create({
@@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js";
* The old docker provider interpreted monitor.url as a container name/ID and only
* ever talked to the local socket, so every pre-existing docker monitor's host is
* definitionally unix:///var/run/docker.sock. The container name is preserved in
* the description. Urls already in host form (unix://, ssh://, or an absolute
* socket path) are left untouched, which makes the migration idempotent.
* the description. Urls already in host form (unix:// or an absolute socket
* path) are left untouched, which makes the migration idempotent.
*/
export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls";
@@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
try {
logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" });
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [
{
$set: {
description: {
+1 -1
View File
@@ -22,7 +22,7 @@ import {
DockerContainerSummary,
DockerHealthStatuses,
DockerPortProtocols,
} from "@/types/network.js";
} from "@/domain/docker/docker.type.js";
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more