Compare commits

...
Author SHA1 Message Date
Malena Aguiar e0e5a334bb feat: create main dashboard 2026-09-09 17:15:34 -06:00
Alexander Holliday 22232489a8 Merge pull request #3934 from bluewave-labs/fix/admin-gate-docker-logs
fix: admin gate docker logs
2026-09-08 15:59:17 -07:00
Alex Holliday 04277a6eb4 add admin check to fetch request 2026-09-08 15:55:17 -07:00
Alex Holliday a8dc867e78 add admin gate to API route 2026-09-08 15:52:43 -07:00
Alex Holliday 533783ba3e gate client side log page behind admin check 2026-09-08 15:51:17 -07:00
Alexander Holliday fd7b3e5f20 Merge pull request #3933 from bluewave-labs/feat/docker-state-palette
feat: docker state palette
2026-09-08 15:36:31 -07:00
Alex Holliday ae4b851620 pass palette to stat box for docker state 2026-09-08 15:33:50 -07:00
Alex Holliday e83f97a05a add function to translate docker state to palette keys 2026-09-08 15:33:31 -07:00
Alexander Holliday 070682af74 Merge pull request #3932 from bluewave-labs/fix/test-all-admin-guard
fix: add admin guard to test all button, fix tests
2026-09-08 11:01:21 -07:00
Alex Holliday 8609e4bca0 add admin guard to test all button, fix tests 2026-09-08 10:46:36 -07:00
Alexander Holliday 2661d7a384 Merge pull request #3922 from alishanahmad907/fix/rbac-auth
fix(api): enforce RBAC role checks on maintenance windows, notifications, and check deletion
2026-09-08 10:43:01 -07:00
Alexander Holliday 5b9d554f46 Merge pull request #3910 from bluewave-labs/fix/checks-table-copy-message
fix(client): don't navigate away when selecting text in a table row
2026-09-08 10:31:56 -07:00
Alex Holliday 7fb8bab4be purge narrative comment 2026-09-08 10:21:44 -07:00
Alexander Holliday 75d29124b6 Merge pull request #3909 from signalgridco/feat/signalgrid-notifications
feat: add Signalgrid notification provider
2026-09-08 10:14:19 -07:00
Alexander Holliday 1f0ea6cedc Merge pull request #3914 from eegrok/fix/maintenance-window-timezone-copy
fix Start Time description to accurately reflect that the time zone is local not UTC
2026-09-08 10:07:10 -07:00
Alexander Holliday 07a38b9c2c Merge pull request #3925 from egeoztass/fix/create-monitor-return-document
fix: return the created monitor from createMonitor
2026-09-08 10:03:34 -07:00
egeoztassandClaude Fable 5.1 67806c94b1 fix: return the created monitor from createMonitor
The controller writes the service's return value into the response, but
createMonitor was typed Promise<void> and returned nothing, so every
successful POST /monitors answered data: null and callers had no way to
read the new monitor's id.

Fixes #3917

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt
2026-09-07 12:31:07 +02: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
Ali Shan Ahmad 2eeb088b61 [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. 2026-09-06 08:35:43 +00: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
gorkem-bwl de8eb53dd4 feat(client): one control height of 32px
Buttons, inputs, selects, pickers, toggles and tabs all declared 34 while
IconButton and the PageHeader action sat at 32, so anything mixing them
in a row was 2px out of alignment.

Everything now reads CONTROL_HEIGHT (32) from constants, so the number
lives in one place instead of nine.

Two content-box fixes were needed to make 32 safe, both measured in the
browser rather than assumed:

- MUI's button line-height is 1.75 - 22.75px at our 13px base - which
  with 6px padding needs 34.75px. It was already over its own box at 34
  and would have clipped at 32. Now 1.2, which leaves headroom for the
  taller scripts we ship (ar, th, vi, ja, zh).
- MUI pads the inner input 8.5px top and bottom, so an input's content
  needed 35.7px inside a 32px shell and was being cut off by
  overflow:hidden. Padding is zeroed in the theme for both the input and
  the Select's div, which centres instead.

Verified in the DOM: button, select and input all render at exactly 32
with scrollHeight equal to height, so nothing is clipped.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:38:51 -04:00
gorkem-bwl dbe0e2f4e3 fix(client): sidebar selection ordering, and clear second review pass
The sidebar regression is the real fix here. NavItem paints the selected
item with action.selected but hovers unselected items with MUI's default
action.hover. Once the sidebar moved onto `paper`, selected composited to
rgb(31,38,41) - darker than the ground - while hover came out lighter at
rgb(46,46,51), so hovering any other item made it read as more selected
than the actual selection. Both states now come from the same family
(selectedHover / rowHover), so selected always reads stronger.

Also from review:

- Dropped "Inter" from the status page SANS_STACK. Its webfont went with
  the Google Fonts import, so on Windows and Linux the stack was silently
  falling through to Segoe UI / Roboto on pages users publish.
- Button focus rings now use the same dark-mode green as input focus
  rings. They were still on primary.main at 2.86:1, which is the exact
  problem primary.light was added to solve.
- Removed 13 size={16} props made redundant by the Icon default change.
  The load-bearing pins stay: size={20} on the public status page, and
  the header pins that were relying on the old default.
- Documented that alpha() replaces rather than multiplies the alpha
  channel in StatusBox, so the grid is coupled to `divider` being opaque.
- Noted on the isRowSelected JSDoc that the small-screen card branch does
  not style selection, same limitation getRowSx already had.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:30:02 -04:00
gorkem-bwl 3ddc6ef1cb fix(client): one border story for every input
Inputs disagreed on both hover and focus. The theme's MuiOutlinedInput
hovered to text.primary - near-black in light mode - while Select,
TextInput and DatePicker each overrode hover back to `divider`, which is
the resting colour, so those had no hover cue at all. Four behaviours
across four components.

Now defined once in the theme and inherited by every outlined input:

  rest   divider           1.55:1 light / 1.73:1 dark
  hover  text.disabled     3.64:1 light / 4.66:1 dark
  focus  primary, 2px      5.93:1 light / 6.36:1 dark
  error  error.main

The per-component overrides in Select, TextInput and DatePicker are gone,
so there is one place to change this next time.

Focus in dark mode takes primary.light. The brand green is only 2.86:1 on
the dark ground, which made a focused input dimmer than a hovered one.
`main` is deliberately left alone: it is the fill for contained buttons,
and lightening it would flip their label to dark text across 31 sites.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:27:42 -04:00
gorkem-bwl ac94ae0fdd fix(client): make 16 the Icon default and size the stragglers
Third time this bug has surfaced, so this fixes the cause. Icon defaulted
to 20px, but 17 call sites already asked for 16 explicitly against 2 for
20, and the sidebar clamps to 16 in CSS - so 16 was the house size and
the 20 default was one nobody chose. Anything bare rendered above the cap
height of the 13px text beside it.

The default is now 16. Five sites that were relying on the old default
and sit next to labels are pinned explicitly anyway (team header, status
page controls, status page delete, theme toggle), and the public status
page's warning icon is pinned at 20 so the default change does not alter
a page users publish.

Sidebar icons are unaffected either way - their CSS already forced 16.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:24:44 -04:00
gorkem-bwl acbf26b03d fix(client): solid red offline banner, and lighter incident icons
The offline banner used error.main, which in dark mode is light enough
that MUI derives near-black contrastText - a pale salmon bar with dark
text on the one surface where urgency is the point. It now uses
error.dark with explicit white text: 11.12:1 in light mode, 5.04:1 in
dark, and a solid red bar in both.

Darkening error.main itself was the other option, but roughly 30 places
use it as text or a chart mark on a dark ground, where the lighter value
is correct - it would have cost all of them to fix one banner.

The incident summary icons rendered at the shared Icon default of 20px
in lucide's currentColor, so they sat larger and darker than the 13px
label beside them. Now 16px, matching the sidebar and the uptime header
buttons, and coloured from SummaryItem so the icon is a supporting mark
rather than competing with its own label. The 32px status icon on the
active-incidents card keeps its size and semantic colour; it is a focal
indicator, not a label decoration.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:22:12 -04:00
gorkem-bwl f931954997 fix(client): make row selection visible, and clear review findings
Selection never showed. getRowSx set a background on the <tr>, but every
<td> paints background.paper over it, and the hover rule targets cells -
so hover won and selection was invisible. Selection and hover are now
both cell-level rules in DataTable, with selection first so hover layers
on top, and rows carry an `is-selected` class from a new isRowSelected
prop. Uptime, Docker and Infrastructure tables all had the same broken
copy of this; they now share the one implementation, and any future table
gets it for free.

action.selected/selectedHover are defined explicitly and tinted with the
brand, so a selected row reads as chosen rather than dirty. Kept low
(6-11%) so the highlight does not swamp the row, especially in dark mode.

From code review:

- Dropped the Google Fonts @import at the top of index.css. Nothing has
  used Inter since the switch to Geist, and it was a render-blocking
  third-party request on every load - exactly what self-hosting the font
  was meant to avoid on air-gapped installs.
- TextInput's autofill override painted background.default, which used
  to equal paper and no longer does, so Chrome-autofilled fields showed
  a grey block on white cards. Now paper.
- Sidebar background moved from sx to the bgcolor prop (convention rule
  1, and it was a new lint warning), and the backdropFilter dropped -
  the background is opaque, so the blur only cost a compositing layer.
- Tab no longer bolds the selected label. That is the same reflow the
  ToggleButton change removed, and worse here because the indicator
  animates toward a target that moves as the label widens.
- Corrected the StatBox gradient comment: contrastText is white in light
  mode but near-black in dark, so the constraint is staying near `main`,
  not staying dark.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:14:49 -04:00
gorkem-bwl 2cfc2915ce fix(client): let page descriptions hold one line
PageHeader capped its description at maxWidth: 640, which wraps every
page description well before the text runs out - the uptime one is 100
characters and needs roughly 700px at 14px.

Now 108ch, sized against the longest description in en.json (113 chars,
the notifications page). Still a cap, so the line wraps on narrow
viewports and does not run the full width of a large display, and `ch`
tracks the font size rather than assuming 14px forever.

The empty-state description keeps its 360px cap - that copy is centred
and reads better narrow.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:10:57 -04:00
gorkem-bwl d965a82cc9 feat(client): put the sidebar on paper
The sidebar set no background, so it inherited background.default. Once
that became an off-white ground rather than plain white, the nav read as
a grey slab against the content.

It now sits on background.paper, which also gives the layout a real
figure/ground: white nav, slightly darker content area behind it. The
existing right border and the mobile Backdrop still do their jobs, and
an opaque panel is the correct behaviour over that backdrop.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:03:08 -04:00
gorkem-bwl 62fb17f64c fix(client): match header button icons and toggle weight to the rest of the UI
Two mismatches in the monitor detail header:

- The action button icons rendered at the shared Icon default of 20px,
  while the sidebar clamps the same lucide icons to 16px in CSS. The
  buttons were therefore 25% larger than every other icon in the app,
  and taller than the cap height of the 13px label beside them. Now 16px
  at all eight call sites. Left at full opacity - the sidebar dims to
  0.81 because nav is recessive, but these are active controls.

- ToggleButton went to fontWeight 500 when selected while Button is a
  flat 400, so the time range group sat heavier than the button row
  above it. Weight now stays at 400 and the tinted fill plus text.primary
  ink carry the selected state; that is a 2.9x difference in ink against
  the unselected label, so selection is still obvious. It also stops the
  label reflowing as you switch range.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 01:00:23 -04:00
gorkem-bwl 8582958ad5 fix(client): rebuild the stat box gradient from main
The coloured stat box ran its gradient from `<palette>.light` to
`<palette>.main`. With Material's stock warning that was #ff9800 ->
#ed6c02, a bright orange. The new warning.main is a dark amber chosen so
the "Paused" chip text clears 4.5:1, and MUI derives `light` from it, so
the gradient became brown mud.

The two roles were never compatible: `main` has to be dark enough for
small text on white, and the same value was being used as a large filled
background. The gradient now runs from lighten(main, 0.12) to main,
which keeps the box in the status colour, restores the depth that the
flat brown lost, and holds white contrastText at 4.64:1 or better at the
lightest point for all four palettes.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:56:29 -04:00
gorkem-bwl 1372e66749 fix(client): calm the stat box grid and firm up borders
Three fixes, all fallout from giving `divider` a real value:

- The graph-paper texture behind the status counts is drawn with
  `divider`. That used to be rgba(0,0,0,.12) and near-invisible; now it
  is opaque, so the grid jumped forward and read as the loudest thing on
  the page. It is texture, not a rule, so it now takes divider at 40%.

- GradientBox called lighten() on background.paper to build its
  gradient. paper is white in light mode and lightening white is a
  no-op, so the gradient collapsed flat. Darkens slightly toward the
  page ground instead.

- Borders were too faint at #D9E1DD (1.33:1 against paper). Now #C7D2CC
  (1.55:1), and dark mode #2C3532 -> #3A4642 (1.73:1), so tables and
  cards have a visible edge without turning into hard rules.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:52:41 -04:00
gorkem-bwl bdad949170 fix(client): put the tab bar back on the 13px base
Tabs.tsx hardcoded fontSize: 14, so the tab bar sat a pixel above the
13px base the rest of the chrome uses. Now typographyLevels.m, which
also satisfies the no-hardcoded-literals rule.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:48:02 -04:00
gorkem-bwl e69a2f05af feat(client): define the neutral palette and switch to Geist
The palette declared a brand green and six chart hues but never defined
the neutrals, so MUI's defaults filled the gap: body text at 60% black,
dividers at 12%, and background.default equal to background.paper. That
is the "washed out" everything sat on - table cells render through
text.secondary, and cards had no ground to sit on.

- Opaque neutral ramp, biased slightly green so it sits with the brand.
  background.default is now off-white so paper separates from the page.
- Semantic success/error/warning derived from the brand instead of
  Material's stock red/green/orange. Only `main` is set: `light` is
  consumed as a saturated colour by status dots, histogram bars and
  heatmap cells, so MUI must keep deriving it.
- Dark mode gets the same treatment; it defined background and secondary
  but its text and divider were still MUI defaults.
- Geist Variable, self-hosted via @fontsource-variable so air-gapped
  installs keep the typeface. Base size stays at 13px.
- h1 700 / h2 600 against 400 body, and table headers 500 -> 600. Two
  weights was why everything read at the same visual weight.

All contrast pairs pass WCAG AA in both themes (lowest 5.98:1).
No component markup changed.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:45:40 -04:00
gorkem-bwl 9c796edbde refactor(client): drop the copy button, keep the selection fix
The selection guard alone closes #3907: drag-selecting a check's error
message no longer navigates away, so the text can be copied. The copy
button was a second, larger answer to the same problem, and it brought a
reusable component, an HTTP clipboard fallback, four translation keys and
a timer to maintain.

Reverting it leaves the fix at ~29 lines in one file, and keeps the
behaviour change to the thing that was actually broken. CopyButton is
worth having for monitor URLs, tokens and incident ids, but it should
land on its own merits rather than inside a bug fix.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl 84a1b994a1 fix(client): address review feedback on the copy-message change
Four issues from code review:

- The selection guard read the document-wide selection, so a leftover
  selection anywhere on the page could swallow the first click on any
  row. It now checks that the selection is inside the row that was
  clicked, with the event threaded through to both call sites.

- CopyButton's reset timer was never cleared, so it fired against an
  unmounted component when a row went away on pagination, and a rapid
  second click could not cancel the pending reset. Held in a ref and
  cleared on unmount and on re-copy.

- navigator.clipboard only exists on secure origins, and the README
  documents reaching Checkmate over plain HTTP on a LAN address. There
  the API is undefined, so every click showed "could not copy" with no
  way forward. Falls back to a hidden textarea.

- The two message cells were byte-identical, so they are now one shared
  CopyableText component.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
gorkem-bwl db4689207a fix(client): make check error messages selectable and copyable
Closes #3907.

Selecting the text of a failed check's error message navigated away
before the selection could be copied. The mouseup that ends a drag-select
lands on the table row, which fires the row's onClick.

Two changes:

- DataTable ignores a row click when the user has an active text
  selection, so drag-selecting text inside any row no longer navigates.
  This applies to all eleven tables that pass onRowClick, not just checks.
- The message column gets an explicit copy button, which is the more
  discoverable of the two fixes the reporter suggested. Added as a shared
  CopyButton in design-elements so other columns can reuse it.

Note the row click on the uptime details checks table navigates to
/checks/<check id>, but that route's parameter is :monitorId. A check id
never matches a monitor, so the destination page renders empty - which is
the "all this information disappears" in the report. Left alone here to
keep this change reviewable; it needs its own decision about what that
row should link to.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
eegrok af465766c8 fix Start Time description to accurately reflect that the time zone is local time, not UTC 2026-09-03 09:13:32 +12: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
signalgridco c13f26a4c3 feat: add Signalgrid notification provider 2026-09-01 23:17:11 +02:00
129 changed files with 5479 additions and 382 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.
+66
View File
@@ -9,8 +9,12 @@
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
@@ -361,6 +365,59 @@
"node": ">=14"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"license": "MIT",
@@ -950,6 +1007,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"license": "BSD-3-Clause"
+6 -1
View File
@@ -12,15 +12,20 @@
"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"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
@@ -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,7 +8,7 @@ interface IconProps {
color?: string;
}
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
return (
<Icon
size={size}
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
backgroundColor: theme.palette.error.dark,
color: "#FFFFFF",
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
@@ -6,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -109,7 +110,7 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
maxWidth: 640,
maxWidth: "108ch",
}}
>
{description}
@@ -142,7 +143,7 @@ export const PageHeader = ({
alignItems: "center",
justifyContent: "center",
width: 36,
height: 32,
height: INPUT_BASE_HEIGHT,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
@@ -1,4 +1,5 @@
import { Box, Stack, useTheme } from "@mui/material";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
height: 34,
height: INPUT_BASE_HEIGHT,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { lighten } from "@mui/material/styles";
import { darken, lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
@@ -19,10 +19,10 @@ 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;
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperEnd = paper;
const bg = palette
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return (
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { alpha, useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
sx={{
pointerEvents: "none",
backgroundImage: `
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
@@ -52,6 +52,7 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>;
isRowSelected?: (row: T) => boolean;
};
export function DataTable<
@@ -70,6 +71,7 @@ export function DataTable<
emptyViewText,
emptyViewPositive,
getRowSx,
isRowSelected,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -79,6 +81,21 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -100,7 +117,10 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -197,7 +217,7 @@ export function DataTable<
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 500,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -213,10 +233,6 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.rowHover,
},
}}
>
<TableHead>
@@ -237,16 +253,29 @@ 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" : undefined}
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={() => {
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
@@ -1,6 +1,8 @@
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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
<MuiTabs
sx={{
minHeight: 34,
minHeight: INPUT_BASE_HEIGHT,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
@@ -38,9 +40,9 @@ export const Tab = (props: CustomTabProps) => {
iconPosition="start"
sx={{
textTransform: "none",
fontSize: 14,
fontSize: typographyLevels.m,
fontWeight: 500,
minHeight: 34,
minHeight: INPUT_BASE_HEIGHT,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
+3 -2
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 { HOVER } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
height: 34,
height: INPUT_BASE_HEIGHT,
lineHeight: 1.2,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
+2 -1
View File
@@ -4,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
height: INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
+5 -2
View File
@@ -8,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
minHeight: 34,
minHeight: INPUT_BASE_HEIGHT,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderColor: error
? theme.palette.error.main
: theme.palette.text.disabled,
},
},
},
+2 -5
View File
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
@@ -71,7 +72,7 @@ const SelectInputInner = <T,>(
/>
)}
sx={{
height: "34px",
height: `${INPUT_BASE_HEIGHT}px`,
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
@@ -83,10 +84,6 @@ const SelectInputInner = <T,>(
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
...props.sx,
}}
+3 -9
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 { LAYOUT } from "@/Utils/Theme/constants";
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
@@ -22,16 +22,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : 34,
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
@@ -39,7 +33,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
+2 -1
View File
@@ -7,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
minHeight: 34,
minHeight: INPUT_BASE_HEIGHT,
p: 0,
px: theme.spacing(5),
},
@@ -4,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
height: 34,
height: INPUT_BASE_HEIGHT,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
fontWeight: 500,
"&:hover": {
backgroundColor: selectedHoverBg,
},
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
@@ -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} />,
},
],
},
+2 -3
View File
@@ -25,11 +25,10 @@ export const NavItem = ({
const iconStroke = selected
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
? theme.palette.action.selected
: theme.palette.action.hover;
? theme.palette.action.selectedHover
: theme.palette.action.rowHover;
const fontWeight = selected ? 600 : 400;
return (
<Tooltip
+1 -1
View File
@@ -73,11 +73,11 @@ export const Sidebar = () => {
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
bgcolor={theme.palette.background.paper}
sx={{
touchAction: "none",
transition: transition,
zIndex: 1000,
backdropFilter: "blur(8px)",
}}
>
<List
+7
View File
@@ -1,4 +1,5 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { CardId } from "@/Pages/Dashboard/cards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -35,6 +36,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardCards: CardId[] | null;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +76,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardCards: null,
};
const uiSlice = createSlice({
@@ -117,6 +120,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
state.dashboardCards = action.payload;
},
},
});
@@ -133,4 +139,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardCards,
} = uiSlice.actions;
+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":
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={<Icon icon={Mail} />}
startIcon={
<Icon
icon={Mail}
size={16}
/>
}
onClick={onInviteClick}
>
{t("common.buttons.inviteMember")}
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={<Icon icon={UserPlus} />}
startIcon={
<Icon
icon={UserPlus}
size={16}
/>
}
onClick={onAddMemberClick}
>
{t("common.buttons.addMember")}
+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,123 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { GripVertical } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
import { Icon } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { CardSlotContext } from "./CardSlotContext";
const SortableCard = ({ card }: { card: CardDefinition }) => {
const theme = useTheme();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id });
const Component = card.component;
return (
<Box
ref={setNodeRef}
minWidth={0}
sx={{
width: {
xs: "100%",
md:
card.width >= GRID_COLUMNS
? "100%"
: `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
},
transform: transform
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
: undefined,
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 1 : "auto",
}}
>
<CardSlotContext.Provider
value={
<Box
{...attributes}
{...listeners}
display="flex"
alignItems="center"
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
touchAction: "none",
}}
>
<Icon
icon={GripVertical}
size={16}
color={theme.palette.text.secondary}
/>
</Box>
}
>
<Component />
</CardSlotContext.Provider>
</Box>
);
};
export const CardGrid = ({
cards,
onReorder,
}: {
cards: CardDefinition[];
onReorder: (sourceIndex: number, destIndex: number) => void;
}) => {
const theme = useTheme();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const sourceIndex = cards.findIndex((c) => c.id === active.id);
const destIndex = cards.findIndex((c) => c.id === over.id);
if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
};
const cardIds = cards.map((c) => c.id);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cardIds}
strategy={rectSortingStrategy}
>
<Box
display="flex"
flexWrap="wrap"
gap={theme.spacing(LAYOUT.MD)}
alignItems="stretch"
>
{cards.map((card) => (
<SortableCard
key={card.id}
card={card}
/>
))}
</Box>
</SortableContext>
</DndContext>
);
};
@@ -0,0 +1,137 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Dialog, Checkbox, Button } 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[];
allSelected: boolean;
onAdd: (id: CardId) => void;
onRemove: (id: CardId) => void;
onSelectAll: () => void;
onDeselectAll: () => 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,
allSelected,
onAdd,
onRemove,
onSelectAll,
onDeselectAll,
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);
const someSelected = selected.length > 0 && !allSelected;
return (
<Dialog
open={open}
maxWidth="sm"
fullWidth
title={t("pages.dashboard.picker.title")}
// Reset 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")}
onConfirm={onClose}
confirmText={t("pages.dashboard.picker.done")}
additionalButtons={
<Button
variant="outlined"
color="secondary"
onClick={allSelected ? onDeselectAll : onSelectAll}
startIcon={
<Checkbox
checked={allSelected}
indeterminate={someSelected}
size="small"
sx={{ p: 0 }}
onChange={() => {}}
/>
}
>
{t("pages.dashboard.picker.selectAll")}
</Button>
}
>
{/*
* Two columns: seventeen cards in one list makes the dialog scroll,
* which hides most of the catalogue. 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}
>
{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,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,159 @@
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": {
opacity: 1,
},
}}
>
<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>{action}</Box>}
{slot && (
<Box
className="dashboard-card-slot"
sx={{
opacity: 0.3,
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];
+86
View File
@@ -0,0 +1,86 @@
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,
reorderCards,
resetCards,
selectAll,
deselectAll,
allSelected,
} = 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}
onReorder={reorderCards}
/>
)}
</Stack>
<CardPicker
open={isPickerOpen}
available={available}
selected={selected}
allSelected={allSelected}
onAdd={addCard}
onRemove={removeCard}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
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,109 @@
import { useCallback, useMemo } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { setDashboardCards } from "@/Features/UI/uiSlice";
import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
import { CARD_REGISTRY, getCard } from "./registry";
import type { CardDefinition } from "./cards";
import type { RootState } from "@/store";
const isCardId = (value: unknown): value is CardId =>
typeof value === "string" && (CardIds as readonly string[]).includes(value);
export const useCardSelection = () => {
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
// null means the user has never customised; fall back to defaults.
const stored = useSelector((state: RootState) => state.ui.dashboardCards);
const selected: CardId[] = useMemo(
() => (stored !== null ? stored.filter(isCardId) : [...DEFAULT_CARD_IDS]),
[stored]
);
const isVisible = useCallback(
(card: CardDefinition) => !card.adminOnly || isAdmin,
[isAdmin]
);
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) => {
if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
},
[dispatch, selected]
);
const removeCard = useCallback(
(id: CardId) => {
dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
},
[dispatch, selected]
);
const resetCards = useCallback(() => dispatch(setDashboardCards(null)), [dispatch]);
const selectAll = useCallback(
() =>
dispatch(
setDashboardCards(
available.map((card) => card.id).filter((id) => !selected.includes(id)).length >
0
? [...new Set([...selected, ...available.map((card) => card.id)])]
: selected
)
),
[dispatch, available, selected]
);
const deselectAll = useCallback(() => dispatch(setDashboardCards([])), [dispatch]);
/**
* Reorders cards based on DnD source/dest indices into the visible `cards` array.
* Hidden admin cards keep their stored positions unchanged.
*/
const reorderCards = useCallback(
(sourceIndex: number, destIndex: number) => {
const visibleIds = cards.map((c) => c.id);
const reordered = [...visibleIds];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(destIndex, 0, moved);
let visibleIdx = 0;
dispatch(
setDashboardCards(
selected.map((cardId) =>
visibleIds.includes(cardId) ? reordered[visibleIdx++] : cardId
)
)
);
},
[dispatch, cards, selected]
);
const allSelected = available.every((card) => selected.includes(card.id));
return {
cards,
available,
selected,
addCard,
removeCard,
reorderCards,
resetCards,
selectAll,
deselectAll,
allSelected,
};
};
@@ -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>
);
};
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -24,6 +25,7 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -33,6 +35,7 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -1,3 +1,252 @@
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";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// 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();
const isAdmin = useIsAdmin();
// 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 =
isAdmin && 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 (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
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>
);
};
@@ -327,11 +327,7 @@ export const DockerMonitorsTable = ({
onRowClick={(row) => {
navigate(`/docker/host/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
@@ -30,6 +30,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={{ "& svg": { color: theme.palette.text.secondary } }}
>
{icon}
<Typography variant="body1">{label}</Typography>
@@ -143,7 +144,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
<Icon icon={Globe} />
<Icon
icon={Globe}
color={theme.palette.text.secondary}
/>
<Typography
variant="body1"
fontWeight={500}
@@ -331,11 +331,7 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
@@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({
<Button
variant="contained"
color="error"
startIcon={<Icon icon={Trash} />}
startIcon={
<Icon
icon={Trash}
size={16}
/>
}
onClick={onDelete}
>
{translate.t("common.buttons.delete")}
@@ -77,7 +77,12 @@ export const HeaderStatusPageControls = ({
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
startIcon={
<Icon
icon={Settings}
size={16}
/>
}
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
@@ -211,6 +211,7 @@ export const BaseStatusPage = ({
>
<Icon
icon={TriangleAlert}
size={20}
color={theme.palette.warning.main}
/>
<Typography>
@@ -1,5 +1,5 @@
export const SANS_STACK =
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
@@ -350,11 +350,7 @@ export const MonitorTable = ({
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
+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>
);
+8 -1
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";
@@ -90,7 +93,11 @@ const Routes = () => {
>
<Route
path="/"
element={<Navigate to="/uptime" />}
element={<Navigate to="/dashboard" />}
/>
<Route
path="/dashboard"
element={<Dashboard />}
/>
<Route
path="/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?: {
+1
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
+47 -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 => {
@@ -37,6 +40,14 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
@@ -144,3 +155,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))
);
});
};
+53
View File
@@ -18,6 +18,20 @@ export const colors = {
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
ink900: "#101917",
ink600: "#55635C",
ink400: "#7C8983",
surface50: "#F6F8F7",
rule200: "#C7D2CC",
successMain: "#0D6049",
errorMain: "#A02B24",
warningMain: "#8C5806",
inkDark900: "#ECEFED",
inkDark600: "#A3ADA8",
ruleDark: "#3A4642",
successDark: "#4BC49E",
errorDark: "#E8837A",
warningDark: "#E0A458",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
@@ -43,10 +57,31 @@ export const lightPalette = {
action: {
rowHover: alpha(colors.gray900, HOVER.ROW),
controlHover: alpha(colors.gray900, HOVER.CONTROL),
selected: alpha(colors.brandGreen, 0.06),
selectedHover: alpha(colors.brandGreen, 0.1),
},
secondary: {
main: colors.gray200,
},
background: {
default: colors.surface50,
paper: "#FFFFFF",
},
text: {
primary: colors.ink900,
secondary: colors.ink600,
disabled: colors.ink400,
},
divider: colors.rule200,
success: {
main: colors.successMain,
},
error: {
main: colors.errorMain,
},
warning: {
main: colors.warningMain,
},
sidebar: {
accent: colors.brandGreen,
},
@@ -69,10 +104,13 @@ export const lightPalette = {
export const darkPalette = {
primary: {
main: colors.brandGreen,
light: colors.brandGreenLight,
},
action: {
rowHover: alpha("#FFFFFF", HOVER.ROW),
controlHover: alpha("#FFFFFF", HOVER.CONTROL),
selected: alpha(colors.brandGreenLight, 0.07),
selectedHover: alpha(colors.brandGreenLight, 0.11),
},
secondary: {
main: colors.gray700,
@@ -81,6 +119,21 @@ export const darkPalette = {
default: colors.gray900,
paper: colors.gray850,
},
text: {
primary: colors.inkDark900,
secondary: colors.inkDark600,
disabled: colors.ink400,
},
divider: colors.ruleDark,
success: {
main: colors.successDark,
},
error: {
main: colors.errorDark,
},
warning: {
main: colors.warningDark,
},
sidebar: {
accent: colors.brandGreenLight,
},
+37 -10
View File
@@ -16,6 +16,7 @@ declare module "@mui/material/styles" {
interface TypeAction {
rowHover: string;
controlHover: string;
selectedHover: string;
}
interface Palette {
sidebar: { accent: string };
@@ -57,7 +58,7 @@ export type PaletteKey = {
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
}[keyof Theme["palette"]];
const fontFamilyPrimary = "system-ui, sans-serif";
const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif';
const fontFamilyMonospace =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
const shadow =
@@ -85,11 +86,13 @@ export const theme = (mode: string, palette: any) =>
fontSize: typographyLevels.base,
h1: {
fontSize: typographyLevels.xxl,
fontWeight: 500,
fontWeight: 700,
letterSpacing: "-0.015em",
},
h2: {
fontSize: typographyLevels.l,
fontWeight: 400,
fontWeight: 600,
letterSpacing: "-0.01em",
},
body1: {
fontSize: typographyLevels.m,
@@ -131,7 +134,11 @@ export const theme = (mode: string, palette: any) =>
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-focusVisible": {
outline: `1px solid ${theme.palette.primary.main}`,
outline: `1px solid ${
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.main
}`,
outlineOffset: 2,
},
}),
@@ -185,18 +192,38 @@ export const theme = (mode: string, palette: any) =>
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
input: {
paddingTop: 0,
paddingBottom: 0,
height: "100%",
},
root: ({ theme }) => ({
"&:hover .MuiOutlinedInput-notchedOutline": {
"& .MuiSelect-select": {
paddingTop: 0,
paddingBottom: 0,
display: "flex",
alignItems: "center",
minHeight: "unset",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
transition: "border-color 0.15s ease",
},
"&:hover:not(.Mui-focused):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline":
{
borderColor: theme.palette.text.disabled,
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.23)"
: theme.palette.text.primary,
? theme.palette.primary.light
: theme.palette.primary.main,
borderWidth: 2,
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.main,
"&.Mui-error .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.error.main,
},
}),
},
+2
View File
@@ -18,6 +18,8 @@ export const LAYOUT = {
XXL: 16,
} as const;
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards
+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
+7
View File
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
-2
View File
@@ -1,5 +1,3 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
* {
box-sizing: border-box;
}
+191 -6
View File
@@ -206,6 +206,7 @@
},
"sidebar": {
"menu": {
"dashboard": "Dashboard",
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
@@ -258,6 +259,147 @@
}
},
"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",
"selectAll": "Select all",
"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 +774,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 +858,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 +898,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 +956,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 +1107,31 @@
"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"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
"host": {
@@ -1217,7 +1393,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"option": {
"duration": {
"label": "Duration"
@@ -1299,6 +1475,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1319,6 +1496,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
+2
View File
@@ -1,5 +1,7 @@
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
// Self-hosted so the app keeps its typeface on air-gapped installs
import "@fontsource-variable/geist";
import "./index.css";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider } from "react-redux";
@@ -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}",
+4
View File
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
@@ -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 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", checkController.deleteChecks);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
return router;
};
@@ -1,15 +1,16 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+5
View File
@@ -21,6 +21,11 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+6 -5
View File
@@ -1,14 +1,15 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
return router;
};
+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(),
});
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+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,
+12
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";
@@ -32,6 +33,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -58,6 +60,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 +75,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 +87,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 +99,7 @@ export interface SharedServices {
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
dockerLogsRepository: IDockerLogsRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -131,6 +137,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 +168,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);
@@ -171,6 +179,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -185,6 +194,7 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -207,6 +217,7 @@ export const buildShared = async ({
incidentService,
checkService,
geoChecksService,
dockerLogsService,
notificationsService,
workerId,
jobsRepository,
@@ -214,6 +225,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;
+1 -1
View File
@@ -11,7 +11,7 @@ import type {
SnapshotHostInfo,
SnapshotMemoryInfo,
} from "@/domain/checks/check.type.js";
import { DockerContainerSummary } from "@/types/network.js";
import { DockerContainerSummary } from "@/domain/docker/docker.type.js";
import { toDateString } from "@/utils/mongoMappers.js";
export type CheckSnapshotSource = Pick<
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more