Compare commits

...
Author SHA1 Message Date
Alexander Holliday 5ad82d1e02 Merge pull request #3953 from bluewave-labs/develop
develop -> demo
2026-09-12 15:04:34 -07:00
Alexander Holliday f41e0f0619 Merge pull request #3949 from kah-ja/fix/register-invite-token-validation
validate invite token type on registration
2026-09-12 15:02:48 -07:00
Alexander Holliday 3f10b6e567 Merge pull request #3952 from bluewave-labs/chore/release-prep
chore: bump minor version, fix unused import
2026-09-12 14:57:45 -07:00
Alex Holliday 9ad20daa51 bump minor version, fix unused import 2026-09-12 14:56:07 -07:00
Alexander Holliday 6a9068a151 Merge pull request #3951 from bluewave-labs/fix/svg-optimization
fix: reoptimize svg
2026-09-12 14:52:01 -07:00
Alex Holliday b31ffa5318 reoptimize svg 2026-09-12 14:46:05 -07:00
Alexander Holliday 102a9f1484 Merge pull request #3950 from bluewave-labs/chore/readme
chore: update readme
2026-09-12 14:17:01 -07:00
Alex Holliday 581ec9382b docker monitor instructions 2026-09-12 14:16:14 -07:00
Alex Holliday cebe9350f7 update readme 2026-09-12 14:07:31 -07:00
Jan Kahmen 406518e0a5 validate invite token type on registration 2026-09-12 12:08:33 +00:00
Alexander Holliday c458d5fb65 Merge pull request #3948 from bluewave-labs/feat/client-side-docker-tls
feat: client side docker tls
2026-09-11 12:10:28 -07:00
Alex Holliday ba0d685c6d commetns 2026-09-11 12:07:45 -07:00
Alex Holliday 912f1f960d add missing timeout 2026-09-11 11:50:26 -07:00
Alex Holliday 1de0edf7e3 Merge branch 'develop' into feat/client-side-docker-tls 2026-09-11 11:37:11 -07:00
Alex Holliday 8c9cb4be77 update create monitor form 2026-09-11 11:23:11 -07:00
Alex Holliday 727b904ca9 add docker TLS to monitor type 2026-09-11 11:21:39 -07:00
Alex Holliday 5c0279a6a7 add dockerTLS to form 2026-09-11 11:21:13 -07:00
Alex Holliday 9d72d20dd2 docker TLS validation 2026-09-11 11:20:47 -07:00
Alexander Holliday 62d0630702 Merge pull request #3947 from bluewave-labs/feat/validate-and-encrypt-docker-tls-key
feat: add validation for docker TLS fields, encrypt TLS key on create/edit monitor
2026-09-11 11:13:52 -07:00
Alex Holliday 2260b4a4d2 add validation for docker TLS fields, encrypt TLS key on create/edit monitor 2026-09-11 11:10:37 -07:00
Alexander Holliday cfb50afe42 Merge pull request #3946 from bluewave-labs/feat/secret-rotation
feat: secret rotation
2026-09-11 10:13:58 -07:00
Alex Holliday b14b2142e3 add secrets rotation service and tests 2026-09-11 10:09:30 -07:00
Alex Holliday c64c4cc5ff update tests 2026-09-10 12:26:25 -07:00
Alex Holliday 7071de294f add docker TLS fields to toEntity 2026-09-10 12:26:12 -07:00
Alex Holliday 5eb16fdbdb add validation, helpers, tests 2026-09-10 12:23:20 -07:00
Alex Holliday 131fd1a6b0 update tests 2026-09-10 11:58:26 -07:00
Alex Holliday f9bc02ed1c update msgs 2026-09-10 11:58:12 -07:00
Alex Holliday 1035e397e0 inject encryptionService into DockerProvider 2026-09-10 11:57:43 -07:00
Alex Holliday 9c788561a7 update docker provider to add a TLS path 2026-09-10 11:49:53 -07:00
Alex Holliday 449e96d842 add dockerTlsKey to context for docker type monitors 2026-09-10 11:30:26 -07:00
Alex Holliday c7626c669a Add docker tls key to check context 2026-09-10 11:29:41 -07:00
Alex Holliday ede5bc4ecd add docker TLS fields to type, model, access methods to repo 2026-09-10 11:15:20 -07:00
Alexander Holliday 8bb60bfd03 Merge pull request #3938 from bluewave-labs/feat/encryption-module
feat: encryption module
2026-09-10 10:43:02 -07:00
Alex Holliday f009aa17d9 Add encryption service and tests. Update README 2026-09-10 10:31:41 -07:00
Alexander Holliday ef7ac8bef8 Merge pull request #3936 from bluewave-labs/feat/docker-chart-tooltips
feat: add docker chart tooltips
2026-09-09 10:26:15 -07:00
Alex Holliday b75f895a1f add docker chart tooltips 2026-09-09 10:22:02 -07:00
Alexander Holliday 53f5b61e8e Merge pull request #3935 from bluewave-labs/fix/404-illustration-credit
fix(client): credit Annie Hsu on the 404 illustration
2026-09-09 09:37:30 -07:00
Alex Holliday 64a2c69540 convert styles 2026-09-09 09:35:20 -07:00
Alex Holliday c4ec14db5d add link to SVG 2026-09-09 09:28:40 -07:00
gorkem-bwl 4560820649 fix(client): balance the 404 credit colour across both themes
The signature was #9a9a9a, picked against the dark ground. The app
switches the whole tree between lightTheme and darkTheme at App.tsx:25
and the 404 page sits inside that, so it also renders on the light
background - where #9a9a9a manages only 2.64:1 and reads as washed out.

#787878 is the balance point: 4.13:1 on the dark ground (#151518) and
4.14:1 on the light one (#F6F8F7), chosen by maximising the worse of
the two rather than tuning for either. Checked by rendering on both.
2026-09-08 19:31:22 -04:00
gorkem-bwl 3051bc7edc fix(client): credit Annie Hsu on the 404 illustration
The sushi artwork on the 404 page was designed by Annie Hsu and carried
no attribution. Adds her name in the bottom-right corner, in a script
face at a size that reads as a signature rather than a label.

The glyphs are embedded as vector outlines rather than a `<text>`
element. The asset is loaded through an `<img>` tag in
Pages/NotFound/index.tsx, and an img-loaded SVG cannot fetch external
resources, so a web font would silently fall back to whatever cursive
face the viewer happens to have - different on every platform. Outlines
render identically everywhere and stay sharp at any size, which a raster
export would not.

Adds ~21KB to the asset.
2026-09-08 19:23:44 -04: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
Alexander Holliday a1c54997bb Merge pull request #3912 from bluewave-labs/feat/docker-container-details
feat: docker container details
2026-09-02 09:53:58 -07:00
Alex Holliday 5657d39589 remove unused prop 2026-09-02 09:52:36 -07:00
Alex Holliday cdd4953e03 remove unused 2026-09-02 09:50:47 -07:00
Alex Holliday 786c803847 add port/mount display 2026-09-02 09:45:28 -07:00
signalgridco c13f26a4c3 feat: add Signalgrid notification provider 2026-09-01 23:17:11 +02:00
Alex Holliday 56153b38a5 containers detail charts 2026-09-01 11:23:03 -07:00
Alex Holliday f85ed902e4 openapi docs, tests 2026-09-01 10:09:37 -07:00
Alex Holliday 8ccc7cbe9a new container endpoint,service, repo methods 2026-09-01 10:05:34 -07:00
Alexander Holliday d347e29a28 Merge pull request #3906 from bluewave-labs/feat/first-class-docker-monitoring-client
feat: first class docker monitoring, Monitor and Host Details pages
2026-08-31 12:12:51 -07:00
Alex Holliday f10fb452ba extract strings 2026-08-31 12:11:09 -07:00
Alex Holliday fc9d2fe69d fix docker details import path 2026-08-31 12:04:20 -07:00
Alex Holliday ca896fa22f implement docker host details page 2026-08-31 12:02:40 -07:00
Alex Holliday a7ee90e1ee merge develop 2026-08-31 10:26:38 -07:00
Alexander Holliday 06190a885a Merge pull request #3902 from bluewave-labs/feat/settings-tabs
feat(client): group settings into five tabs
2026-08-31 10:23:16 -07:00
Alexander Holliday 5f07860217 Merge pull request #3905 from eralpozcan/feat/maintenance-window-monitor-tags
feat(maintenance): show monitor tags in maintenance selector
2026-08-31 10:20:27 -07:00
Alex Holliday 073635d786 add localizations, exclude ref from recursion 2026-08-31 10:17:00 -07:00
Alexander Holliday af1d964e44 Merge pull request #3901 from bluewave-labs/feat/sidebar-groups
feat(client): group sidebar navigation into four labelled sections
2026-08-31 10:01:05 -07:00
Alex Holliday 3b6b7a1e5d add header icon divider 2026-08-31 09:59:26 -07:00
Alex Holliday bc44a3baeb retain header size on collapse 2026-08-31 09:56:59 -07:00
Alexander Holliday 4e4bc5f319 Merge pull request #3900 from bluewave-labs/fix/hover-states
fix(client): add hover states to tables, cards, stat boxes and icon buttons
2026-08-31 09:49:14 -07:00
Alex Holliday 2fec09df62 apply hover states to components directly rather than to parent 2026-08-31 09:47:20 -07:00
Alex Holliday 13e49c518d remove dead code, fix mobile regeression 2026-08-31 09:42:04 -07:00
Alexander Holliday a1f9c0304f Merge pull request #3897 from bluewave-labs/fix/network-notice-gating
fix(infrastructure): show the container-namespace notice when it applies
2026-08-31 09:36:15 -07:00
Alex Holliday d158bcdce1 pruge comment, fix busy connection regression 2026-08-31 09:34:59 -07:00
Alexander Holliday c45a21b4c8 Merge pull request #3896 from bluewave-labs/fix/gauge-arc-clamp-and-key
fix(infrastructure): clamp gauge arc and fix disk key fallback
2026-08-31 09:30:45 -07:00
Alex Holliday a0738dff63 purge narrative comment 2026-08-31 09:29:17 -07:00
Alexander Holliday 3cc4db9af5 Merge pull request #3895 from bluewave-labs/fix/infrastructure-gauges-overflow
fix(infrastructure): wrap gauge row so every disk stays reachable
2026-08-31 09:26:28 -07:00
Alex Holliday 94ecbfe365 add missing props 2026-08-31 09:24:56 -07:00
Alex Holliday 5343241036 add a flex basis prop instead of one-off fix 2026-08-31 09:21:35 -07:00
eralpozcan 261fdd08f1 feat(maintenance): show monitor tags in selector 2026-08-30 02:35:36 +03:00
gorkem-bwl 5470f44ebb fix(client): align sidebar group labels with the nav icons
The group labels sat 11px left of the icon column and rendered at the
same 13px as the nav items, so the groups read as indented blocks rather
than headings over a list.

The label used paddingLeft LAYOUT.LG (16px) while NavItem uses
theme.spacing(5) (10px); matching NavItem puts the label flush with the
icons. The eyebrow variant is 13px, so the size is overridden to
typographyLevels.s (11px) to sit below the item text. Top padding goes
from XS to SM for a little more separation above each group.

Measured in the browser: label and first icon both start at x=12, label
renders 11px against the items' 13px.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 23:40:41 -04:00
gorkem-bwl b24e42e516 fix(client): align the validation alert with the save button
Browser testing showed the alert stretching the full width of the save
bar while Save was pushed against the right edge, and the two sat on
different vertical centres.

The alert carried flexGrow: 1, so it consumed all the free space in the
row, plus mb: 2 and mr: 2, which offset it against a button that had
neither. The row itself set no alignItems, so the items aligned by
stretch rather than centre.

The row now centres its items and uses gap for spacing, and the alert
drops its sx entirely: growing was what stretched it, and the margins
are the gap's job. Measured: the alert sizes to its content (297px, was
1168px), 16px from Save, centres within 2px.

Removing the sx also clears two of the four pre-existing lint warnings
on this file.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 23:06:27 -04:00
gorkem-bwl 09d20c1bc7 fix(client): show usable field names in the settings error list
Second review pass on the tab grouping.

The save bar listed raw schema keys, and react-hook-form nests errors by
path, so a threshold error arrived as { globalThresholds: { cpu: {...} } }.
Iterating only the top level produced an entry whose own message was
undefined, rendering "globalThresholds: Invalid value" -- naming none of
the four sliders and carrying no reason. Errors are now flattened to
their leaves, so the row names the field that actually failed.

Rows also showed untranslated identifiers such as
systemEmailRejectUnauthorized. Every field already owns a translated
label, so those are reused via FIELD_LABEL_KEY; no new strings. Labels
that append an explanation after a dash are trimmed to the name.

Both defects predate this branch, but the tab work promoted these rows
into clickable links, which made unusable names more prominent.

Also guards visibleTabs[0], which is safe only because "general" is
currently ungated: gating it later, or adding a second role, would throw
on an empty list and blank the page. And a "tab" parameter that does not
resolve -- an unknown key, or an admin tab followed by a non-admin -- is
now dropped, so the URL stops claiming a tab the page is not showing.
Valid deep links are untouched.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 21:45:56 -04:00
gorkem-bwl d59bdf6c0c fix(client): render settings tab panels conditionally
Code review caught that the tab panels never hid.

Each panel was a `<Stack hidden={activeTab !== key}>`. The `hidden`
attribute reaches the DOM, but the only rule acting on it is the user
agent's `[hidden] { display: none }`, and MUI's Stack sets
`display: flex` in an author-level emotion class
(@mui/system/Stack/createStack.js:70). Author origin beats user agent
regardless of specificity, so every panel stayed visible: all five tabs
rendered stacked down the page and clicking a tab changed nothing.

Panels are now conditionally rendered, matching Pages/Account. This also
fixes a second defect: BasePage wraps children in `Stack spacing`, whose
sibling selector keys on DOM position rather than visibility, so the four
empty panels a non-admin never sees still collected a margin-top each and
left four blank gaps below "About".

My commit message for the previous commit argued panels had to stay
mounted or a partial tree would compute a stale `isValid` and lock
saving. That was wrong. No Form*Field passes `shouldUnregister`, and
react-hook-form defaults it to false, so unmounting a panel preserves its
values and validation state.

Also moves the tab-reveal off `onError`, which was unreachable: the save
button is `disabled={!isValid}` under `mode: "onChange"`, so
handleSubmit's error branch never fires. The validation Alert renders
straight from `formState.errors`, so a field on a hidden tab could be
named with no way to reach it. Each field in the Alert is now a button
that switches to its own tab.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 21:36:10 -04:00
gorkem-bwl 9d0f0116f3 feat(client): group settings into five tabs
The settings page rendered all 13 ConfigBox sections in one column, so
finding anything meant scrolling past everything else.

The sections are now grouped under five tabs by subject:

  General       timezone, appearance, status page URL, about
  Monitoring    thresholds, check retention, demo monitors
  Email         email settings
  Integrations  global proxy, PageSpeed API key
  Data          import/export, monitor history, system reset

No section changed. Every ConfigBox moved verbatim with its props,
children and isAdmin guard intact; the only edit inside one is Prettier
rewrapping an arrow function that shifted indentation level.

Tabs are derived from a filtered array rather than conditional JSX, so
indices stay correct when admin-only tabs drop out. Nine of the thirteen
sections are admin-gated, which leaves a non-admin four sections in
General alone -- the bar is hidden entirely in that case rather than
showing four empty tabs.

Panels stay mounted and toggle with `hidden`. Conditionally rendering
them would unmount inputs belonging to the shared react-hook-form, and
since Save is disabled on !isValid, a partial tree can compute a stale
isValid and lock saving.

The active tab lives in the URL as ?tab=<key> so reloads and shared
links land in the right place, following the useSearchParams pattern in
StatusPage/Status. An unknown tab, or an admin tab requested by a
non-admin, falls back to the first visible tab.

Both confirmation dialogs and the sticky save bar stay outside the
panels: the dialogs would unmount mid-request otherwise, and the save
bar serves the whole form.

Because the save bar lists validation errors by field name, an error
could name a field on a hidden tab. onError now switches to the tab
owning the first error before the alert renders.

Two titles are corrected to sentence case while their sections move:
"Global Thresholds" and "Monitor IP/URL on Status Page".

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 21:31:09 -04:00
gorkem-bwl 3e56dc63a9 fix(client): correct StatBox hover and separate nested-control hover
Addresses review findings on the hover-states change.

StatBox's hover was a no-op. `GradientBox` always paints an opaque
`linear-gradient` via the `background` shorthand, which populates
background-image and covers any `backgroundColor` beneath it, so the new hover
was invisible — a regression from the `opacity: 0.95` it replaced. The overlay
now layers onto the same gradient, and moves into `GradientBox` where `bg` is
in scope, behind an `interactive` prop.

The mobile card branch gated its cursor and hover on `onRowClick` alone, so
expandable-row tables below the md breakpoint rendered cards that expand on
click but showed no pointer and no hover — the exact inconsistency this change
set out to fix, reintroduced on mobile. Both branches now share one
`isInteractive` flag.

The row action gear used the same overlay as the row beneath it. Alpha
overlays compose, so it was not invisible as reported, but the resulting delta
was 6 levels — too faint for a nested control. Adds `HOVER.CONTROL` (0.05) and
an `action.controlHover` token for controls nested inside a hoverable row: the
gear and the pagination arrows.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 19:53:39 -04:00
gorkem-bwl 30881a0af1 fix(client): drop invalid li wrapper from sidebar groups
The group wrapper was rendering as `<li>`, but the enclosing `List` is
`component="nav"` and so renders a `<nav>`, not a `<ul>`. That put list items
directly inside a nav — invalid HTML, and a screen reader announces orphaned
list items with no enclosing list or item count.

`NavItem` renders a `div` via `ListItemButton`, so there is no list structure
for the group wrapper to join. Makes it a plain `Box` and drops the
now-redundant `listStyle: "none"`.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 19:28:29 -04:00
gorkem-bwl ebbf1a8b1e feat(client): group sidebar navigation into four labelled sections
The sidebar listed twelve items in a flat column ordered roughly by when each
feature was added, so nothing signalled that Uptime, Pagespeed and
Infrastructure are the same kind of thing, or that Tags and Proxies are
configuration rather than views. Adding a new monitor type made this worse:
the natural place for a new entry is the end of the list, three rows below the
item it most resembles.

Groups the menu into Monitoring, Alerting, Reporting and Configuration.
`getMenu` now returns groups of items rather than a flat array; the sidebar
renders an eyebrow label per group.

When collapsed to 64px there is no room for a label, so the labels are
replaced by dividers between groups — the grouping survives as rhythm.

Settings moves from the middle of the list to the end of Configuration.

Adds four `components.sidebar.menu.groups.*` keys across all 19 locales.

Also corrects the i18n note in CLAUDE.md, which claimed translations are
managed through PoEditor.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 19:25:38 -04:00
gorkem-bwl 9a6602b9e1 fix(client): add hover states to tables, cards, stat boxes and icon buttons
Clickable surfaces across the app set `cursor: pointer` but gave no visual
response on hover, so rows and cards read as static text.

Two separate causes:

- `DataTable` never passed MUI's opt-in `hover` prop on `TableRow`, which is
  `false` by default, so no table in the app shaded on hover. One change here
  covers all 16 page tables (Uptime, Incidents, Tags, Team, Notifications,
  Maintenance, Proxies, Status pages, Checks, Geo checks, Infrastructure,
  PageSpeed and the three Logs tables).
- `IconButton` gates its hover style behind `props => !props.disableRipple`,
  and the theme sets `disableRipple: true` globally, so the row action gear
  and the pagination arrows lost their background. `MenuItem` is unaffected —
  its hover sits on the base style — so action menu items already worked and
  are left alone.

Adds a `HOVER.ROW` (0.025) constant and an `action.rowHover` palette token,
defined in both light and dark palettes so the overlay direction is correct on
each surface. Kept separate from MUI's shared `action.hover` so menu items,
which read that token, are not restyled as a side effect.

Also fixes `cursor` on expandable rows, which showed `default` despite being
clickable to expand.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 19:13:33 -04:00
Alexander Holliday 46211ac61d Merge pull request #3899 from bluewave-labs/feat/first-class-docker-monitoring
feat: first class docker monitoring
2026-08-28 15:12:19 -07:00
Alex Holliday 791a0c8483 add docker monitors page, table, and details skeleton 2026-08-28 15:08:23 -07:00
Alex Holliday e964234f54 add tests and migration 2026-08-28 14:42:46 -07:00
Alex Holliday e60bfd18ff add route 2026-08-28 14:03:27 -07:00
Alex Holliday 560d64a482 add controller method for docker monitors 2026-08-28 14:00:34 -07:00
Alex Holliday c8826d90d5 update openapi 2026-08-28 13:59:54 -07:00
Alex Holliday cacedfd7d8 add validation 2026-08-28 13:59:24 -07:00
Alex Holliday 4ec03dfd04 fix return type, copy paste bug 2026-08-28 13:49:44 -07:00
Alex Holliday 78d3b23e93 handle docker checks 2026-08-28 13:48:37 -07:00
Alex Holliday 1a1dc7d807 add docker check aggregation 2026-08-28 13:30:32 -07:00
Alex Holliday 03174d98dd update monitors 2026-08-28 13:30:21 -07:00
Alex Holliday 686ded2137 update check and monitor domains 2026-08-28 13:02:38 -07:00
Alex Holliday 9c8bf4bb05 update tests 2026-08-28 12:39:56 -07:00
Alex Holliday 8f6c909acd add missing const, add key check 2026-08-28 12:28:26 -07:00
Alex Holliday 1ca38a31c9 add new docker payload types, promote docker type monitor to first class monitor 2026-08-28 12:24:44 -07:00
gorkem-bwl 9ffcbd1242 fix(infrastructure): show the container-namespace notice when it applies
The network tab warns when Capture can only see a container's own
network namespace, but the condition required EVERY reported interface
to be named lo, lo0 or docker0. A containerised Capture usually also
reports the container's veth under a host-looking name (eth0, ens3,
...), so the common case failed that test and the notice never
appeared - leaving those users with plausible-but-meaningless traffic
and nothing explaining why.

Widening it to "any container-only name present" would have been wrong
in the other direction: a host-level Capture on a machine running
Docker legitimately reports docker0 alongside its real NIC, and would
then have been warned incorrectly.

Interface names alone cannot separate the two cases. What does is that
in the container case nothing carries real traffic, while a genuine
host has a busy NIC. The notice now fires when a container-only
interface is present AND every interface is idle, using a 1 KB/s
threshold - real NICs sit orders of magnitude above it, a container's
veth sits at or near zero.

Checked against six scenarios: the reported case (lo + ens3 + docker0,
all near zero) now warns; a host with a busy NIC plus an idle docker0
does not; a fully namespaced container still warns as before; and a
quiet host with no container-only names stays silent.

The notice text is updated to match, since it previously described
only the all-container-names case and did not explain that a
host-looking interface can be the container's own.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 12:44:14 -04:00
gorkem-bwl 840e12eb9a fix(infrastructure): clamp gauge arc and fix disk key fallback
Two small pre-existing bugs in the infrastructure gauges.

Arc overdraw. Gauge computed strokeLength from the raw `progress` prop
while the label and the fill colour both used `progressWithinRange`,
the value clamped to 0-100. A reading above 100% therefore swept the
arc past a full circle - the offset (circumference - strokeLength)
went negative - while the label still read "100.0%". A negative
reading drew the arc backwards for the same reason. strokeLength now
uses the clamped value, so out-of-range input stops at exactly one
turn and matches what the label and colour already showed. Values in
range are unaffected.

The effect that animates the arc also listed `progress` in its
dependency array while only reading `strokeLength`, so it re-ran the
100ms animation on changes that could not alter the arc (105% to 110%,
say). Now that strokeLength is derived from the clamped value, the
stale dependency is dropped.

Disk key fallback. `disk?.device || 0 + idx` parses as
`disk?.device || (0 + idx)` because + binds tighter than ||, so the
fallback was just `idx` and the leading 0 did nothing. It happened to
produce unique keys, but by accident rather than intent, and a disk
whose device is the empty string collided with index 0. Replaced with
a `disk-${idx}` template so the intent is explicit.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 11:22:29 -04:00
gorkem-bwl 96b2266a9d fix(infrastructure): wrap gauge row so every disk stays reachable
The gauge row on the infrastructure details page laid out memory, CPU
and one gauge per disk in a single flex row with no wrapping and no
overflow handling. Each gauge is capped at 260px, so on a host with
enough disks the row ran past the content column.

RootLayout sets overflow: "hidden" on that column, so the excess was
clipped outright rather than becoming scrollable - the later disks
rendered but could not be reached by any means.

Three changes, all scoped to this row:

flexWrap moves the overflow onto additional rows.

useFlexGap is required alongside it. MUI's Stack implements `spacing`
as `& > :not(style) ~ :not(style) { marginLeft }`, and that selector is
DOM-order based rather than line-aware, so the first card of each
wrapped row would carry a spurious 16px left margin and misalign
against the row above. useFlexGap switches to a real flex gap.

flexBasis on the children is what actually makes the line break.
BaseChart sets `flex: 1`, which expands to `flex: 1 1 0%`; wrapping is
decided by the sum of items' hypothetical main sizes, so with a zero
basis that sum is 0 and the row never overflows. The cards would
instead shrink to min-content (roughly the 70px gauge plus the widest
label) and stay on one cramped line. Giving each child a 260px basis
makes the row wrap at the intended card width.

The basis is applied through the row's own `& > *` selector rather than
by changing BaseChart, which has eight other consumers that default to
maxWidth: "100%" and would have had their layout altered.

The repeated 260 literals are now a named constant.

Only affects viewports at or above the md breakpoint; below it the
stack is already a column, where flexWrap is inert.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-08-28 10:36:50 -04:00
Alexander Holliday 54eaef725a Merge pull request #3888 from bluewave-labs/develop
develop -> demo
2026-08-26 12:08:23 -07:00
Alexander Holliday 2a2226456c Merge pull request #3884 from bluewave-labs/develop
develop -> demo
2026-08-26 10:42:45 -07:00
Alexander Holliday b8d4e4fc59 Merge pull request #3874 from bluewave-labs/develop
develop -> demo
2026-08-26 10:37:20 -07:00
Alexander Holliday 24cf1ec6c8 Merge pull request #3806 from bluewave-labs/develop
develop -> demo
2026-07-28 09:49:24 -07:00
Alexander Holliday a76f837803 Merge pull request #3759 from bluewave-labs/develop
develop -> demo
2026-07-07 10:19:16 -07:00
Alexander Holliday 9da2653d7b Merge pull request #3752 from bluewave-labs/develop
develop -> demo
2026-07-02 11:07:25 -07:00
Alexander Holliday 0092c9c006 Merge pull request #3746 from bluewave-labs/develop
develop -> demo
2026-07-02 10:15:41 -07:00
Alexander Holliday 348502834c Merge pull request #3745 from bluewave-labs/develop
develop -> demo
2026-07-02 08:50:31 -07:00
Alexander Holliday 1e007551ef Merge pull request #3706 from bluewave-labs/develop
develop -> demo
2026-06-05 12:03:44 -07:00
Alexander Holliday 07c62970b5 Merge pull request #3701 from bluewave-labs/develop
develop -> demo
2026-06-05 10:38:35 -07:00
Alexander Holliday f0123d8052 Merge pull request #3698 from bluewave-labs/develop
develop -> demo
2026-06-05 10:32:50 -07:00
Alexander Holliday de7d5b38ef Merge pull request #3608 from bluewave-labs/develop
develop -> demo
2026-04-29 13:03:15 -07:00
Alexander Holliday e51a55b14d Merge pull request #3562 from bluewave-labs/develop
develop -> demo
2026-04-23 10:13:33 -07:00
Alexander Holliday 1a5e8dc77d Merge pull request #3393 from bluewave-labs/develop
develop->demo 3.5.1 staging
2026-03-06 12:50:53 -08:00
Alexander Holliday 3b9e162315 Merge pull request #3367 from bluewave-labs/develop
develop -> demo
2026-03-04 10:30:22 -08:00
Alexander Holliday 89dec1c82b Merge pull request #3318 from bluewave-labs/develop
develop -> demo
2026-02-20 12:05:30 -08:00
Alexander Holliday 1a44080b84 Merge pull request #3317 from bluewave-labs/develop
develop -> demo
2026-02-20 10:32:55 -08:00
Alexander Holliday 762615b258 Merge pull request #3210 from bluewave-labs/develop
develop -> demo
2026-01-28 12:59:50 -08:00
Alexander Holliday f9036f0863 Merge pull request #3206 from bluewave-labs/develop
develop -> demo
2026-01-27 13:19:44 -08:00
Alexander Holliday 990f353040 Merge pull request #3203 from bluewave-labs/develop
develop -> demo
2026-01-27 13:05:13 -08:00
Alexander Holliday 37836e56b8 Merge pull request #3201 from bluewave-labs/develop
develop -> demo
2026-01-27 12:52:48 -08:00
Alexander Holliday 5e4060cfe3 Merge pull request #3198 from bluewave-labs/develop
develop -> demo
2026-01-26 20:08:38 -08:00
Alexander Holliday c90bef4cf8 Merge pull request #3196 from bluewave-labs/develop
develop->demo
2026-01-26 19:40:56 -08:00
Alexander Holliday 61bcae9aff Merge pull request #3194 from bluewave-labs/develop
develop -> demo
2026-01-26 15:58:39 -08:00
Alexander Holliday aa8dec6ca1 Merge pull request #3192 from bluewave-labs/develop
develop -> demo
2026-01-26 15:48:07 -08:00
Alexander Holliday e9bd8150fa Merge pull request #2965 from bluewave-labs/develop
develop -> demo
2025-09-18 13:41:13 -07:00
Alexander Holliday ce2808ccbc Merge pull request #2961 from bluewave-labs/develop
develop -> demo
2025-09-18 12:04:14 -07:00
Alexander Holliday 58692f8b72 Merge pull request #2934 from bluewave-labs/develop
develop -> demo
2025-09-08 20:32:53 -07:00
Alexander Holliday 421f1635ef Merge pull request #2930 from bluewave-labs/develop
develop->demo
2025-09-08 07:24:53 -07:00
Alexander Holliday b9f24f290d Merge pull request #2922 from bluewave-labs/develop
develop -> demo
2025-09-04 15:47:24 -07:00
Alexander Holliday a84cb95c50 Merge pull request #2903 from bluewave-labs/develop
develop -> demo
2025-09-01 09:00:47 -07:00
Alexander Holliday ac6e4ded73 Merge pull request #2891 from bluewave-labs/develop
develop -> demo
2025-08-30 19:32:05 -07:00
Alexander Holliday 5a6d59576a Merge pull request #2886 from bluewave-labs/develop
develop->demo
2025-08-30 16:24:11 -07:00
Alexander Holliday 3a34ee89d4 Merge pull request #2859 from bluewave-labs/develop
develop -> demo
2025-08-27 15:11:13 -07:00
Alexander Holliday ba2218490b Merge pull request #2856 from bluewave-labs/develop
develop -> demo
2025-08-25 14:38:31 -07:00
Alexander Holliday 188966db49 Merge pull request #2835 from bluewave-labs/develop
develop -> demo
2025-08-21 10:13:59 -07:00
Alexander Holliday 35b10b0a23 Merge pull request #2832 from bluewave-labs/develop
develop -> demo
2025-08-21 09:16:12 -07:00
Alexander Holliday 082d44cac7 Merge pull request #2825 from bluewave-labs/develop
develop->demo
2025-08-20 14:05:17 -07:00
Alexander Holliday a88f5def87 Merge pull request #2796 from bluewave-labs/develop
develop -> demo
2025-08-15 09:19:00 -07:00
Alexander Holliday 033a8e6ca1 Merge pull request #2786 from bluewave-labs/develop
develop -> demo
2025-08-14 12:49:50 -07:00
Alexander Holliday bd7016483d Merge pull request #2783 from bluewave-labs/develop
develop -> demo
2025-08-14 11:27:49 -07:00
Alexander Holliday 6bb885c98b Merge pull request #2781 from bluewave-labs/develop
develop -> demo
2025-08-14 10:53:11 -07:00
Alexander Holliday 2ca441548b Merge pull request #2779 from bluewave-labs/develop
develop -> Demo
2025-08-14 10:07:26 -07:00
Alexander Holliday 53461bbb59 Merge pull request #2776 from bluewave-labs/develop
Develop -> Demo
2025-08-14 09:56:38 -07:00
Alexander Holliday 82ad0bee9d Merge pull request #2648 from bluewave-labs/develop
develop -> demo
2025-07-20 19:50:19 -07:00
Alexander Holliday 250793ccdd Merge pull request #2611 from bluewave-labs/develop
Develop -> Demo
2025-07-14 14:23:10 -07:00
Alexander Holliday 93b3cc8b68 Merge pull request #2585 from bluewave-labs/develop
bump version
2025-07-08 09:51:51 -07:00
Alexander Holliday 19f7c30876 Merge pull request #2583 from bluewave-labs/develop
develop -> demo
2025-07-07 15:39:20 -07:00
Alexander Holliday c3cfdb68a5 Merge pull request #2546 from bluewave-labs/master
Master -> Demo
2025-06-25 12:43:06 +08:00
Alexander Holliday bad7a3c7ea Merge pull request #2537 from bluewave-labs/master
Master -> Demo for demo deploy
2025-06-25 09:40:06 +08:00
Alexander Holliday 4ef0ef53ed Merge pull request #2521 from bluewave-labs/develop
develop -> demo
2025-06-24 10:11:15 +08:00
Alexander Holliday 07da6ce4ce Merge pull request #2507 from bluewave-labs/develop
develop -> demo
2025-06-23 09:21:52 +08:00
Alexander Holliday 48b604bef9 Merge pull request #2377 from bluewave-labs/develop
Develop -> Demo
2025-05-30 15:12:06 -07:00
Alexander Holliday f14cd68fcc Merge pull request #2371 from bluewave-labs/develop
develop -> demo
2025-05-30 14:05:08 -07:00
Alexander Holliday b06aa07b3d Merge pull request #2364 from bluewave-labs/develop
develop -> demo
2025-05-28 09:50:13 -07:00
Alexander Holliday 1ba63985dd Merge pull request #2341 from bluewave-labs/develop
Develop -> Demo
2025-05-26 16:38:10 -07:00
Alexander Holliday 8968d01781 Merge pull request #2337 from bluewave-labs/develop
Develop -> Demo
2025-05-26 16:12:42 -07:00
Alexander Holliday daa63e7c54 Merge pull request #2310 from bluewave-labs/develop
develop -> demo
2025-05-19 12:45:04 -07:00
Alexander Holliday 305586d55b Merge pull request #2266 from bluewave-labs/develop
hotfix: remove version from returned settings
2025-05-13 13:53:23 -07:00
Alexander Holliday 881fbdeeb4 Merge pull request #2263 from bluewave-labs/develop
develop->demo
2025-05-13 13:35:45 -07:00
Alexander Holliday dd109c0e5c Merge pull request #2228 from bluewave-labs/develop
Demo deploy
2025-05-09 09:39:55 -07:00
186 changed files with 10159 additions and 3122 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
+1 -1
View File
@@ -109,7 +109,7 @@ client/src/
- **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (translations via PoEditor)
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
---
+125 -11
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>
@@ -93,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
### Configuration
@@ -105,7 +104,15 @@ The image is configured entirely through environment variables on the server con
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
| `PORT` | No | Port the API and web client are served on (default `52345`) |
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
@@ -119,12 +126,119 @@ The web client needs no configuration by default: it calls the API on the same o
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
### Docker monitors
A Docker monitor connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every check; logs are kept for 7 days.
The **Docker host** field accepts two forms:
| Host | Example | Notes |
|---|---|---|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a>
@@ -134,7 +248,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
@@ -152,16 +266,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Status pages with 5 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
## Monitor Lifecycle
@@ -212,9 +326,9 @@ 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.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+34 -4
View File
@@ -1,16 +1,17 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@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",
@@ -92,6 +93,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -407,6 +409,7 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -444,6 +447,7 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -950,6 +954,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"
@@ -1223,6 +1236,7 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1327,6 +1341,7 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2371,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2386,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2476,6 +2493,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2756,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3112,6 +3131,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3555,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3954,6 +3975,7 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4766,6 +4788,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5577,6 +5600,7 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6358,6 +6382,7 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6369,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6572,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6722,6 +6749,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7563,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7700,6 +7729,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.12.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
@@ -12,7 +12,8 @@
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check ."
"format-check": "prettier --check .",
"compile": "tsc -b"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
@@ -21,6 +22,7 @@
"@csstools/css-tokenizer": "^3.0.4",
"@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",
+10 -1
View File
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = {
id: number | string;
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
<div>
<IconButton onClick={handleClick}>
<IconButton
onClick={handleClick}
sx={{
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
}}
>
<Settings
size={20}
strokeWidth={1.5}
@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps {
isLoading?: boolean;
hasDateRange?: boolean;
dateRange: string;
setDateRange: (dateRange: string) => void;
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
}
export const HeaderTimeRange = ({
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: string | null
newValue: DateRange | null
) => {
if (newValue !== null) {
setDateRange(newValue);
@@ -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";
@@ -10,8 +10,10 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode;
title: string;
rightTitle?: string;
width?: number | string;
maxWidth?: number | string;
flexBasis?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
rightTitle,
width = "100%",
maxWidth = "100%",
flexBasis = "0%",
padding,
onClick,
}: BaseChartProps) => {
@@ -32,6 +36,7 @@ export const BaseChart = ({
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
flexBasis,
width: width,
maxWidth: maxWidth,
...(onClick && {
@@ -44,13 +49,27 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
<Stack
direction="row"
justifyContent={"space-between"}
>
{title}
</Typography>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
const hasSubPage = isActionPage || isDetailsPage;
// page/section/:id routes such as docker/host/:monitorId
const isSectionDetailsPage = Boolean(
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
);
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage) {
if (isActionPage || isSectionDetailsPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
[radius, strokeWidth, progressWithinRange]
);
const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
}, [circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,6 +123,7 @@ export const DetailGauge = ({
lowerLabel,
lowerValue,
maxWidth = 225,
flexBasis = "0%",
}: {
title: string;
progress: number;
@@ -131,6 +132,7 @@ export const DetailGauge = ({
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
flexBasis?: number | string;
}) => {
const theme = useTheme();
return (
@@ -138,6 +140,7 @@ export const DetailGauge = ({
icon={null}
title={title}
maxWidth={maxWidth}
flexBasis={flexBasis}
>
<Stack
alignItems={"center"}
@@ -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";
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
interactive?: boolean;
}>;
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
const theme = useTheme();
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 (
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
...(interactive && {
cursor: "pointer",
"&:hover": {
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
},
}),
...sx,
}}
>
@@ -62,10 +69,8 @@ export const StatBox = ({
return (
<GradientBox
palette={palette}
sx={{
...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
interactive={Boolean(onClick)}
sx={{ ...(sx as object) }}
>
<Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
@@ -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:
@@ -1,12 +1,19 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
import {
getDockerStatePalette,
getStatusPalette,
getValuePalette,
} from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { DockerContainerState } from "@/Types/Check";
import { SPACING } from "@/Utils/Theme/constants";
export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number];
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
</BaseBox>
);
};
export const DockerStateLabel = ({
state,
name,
image,
}: {
state: DockerContainerState;
name: string;
image: string;
}) => {
const palette = getDockerStatePalette(state);
const theme = useTheme();
return (
<Stack
direction="row"
gap={SPACING.LG}
alignItems={"center"}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Stack>
<Typography>{name}</Typography>
<Typography variant="body2">{image}</Typography>
</Stack>
</Stack>
);
};
@@ -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);
@@ -80,6 +82,22 @@ 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) {
return (
<EmptyView
@@ -99,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",
@@ -108,6 +129,11 @@ export function DataTable<
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
...(onRowClick && {
"&:hover": {
backgroundColor: theme.palette.action.rowHover,
},
}),
}}
key={key}
>
@@ -191,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)}`,
@@ -227,15 +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
sx={{
cursor: onRowClick ? "pointer" : "default",
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);
}}
@@ -292,6 +332,12 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
});
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
@@ -321,6 +367,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -338,6 +385,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -355,6 +403,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -372,6 +421,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsLeft
@@ -419,6 +469,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -436,6 +487,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -453,6 +505,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -470,6 +523,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
<IconButton
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
<Ellipsis
size={20}
@@ -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,
},
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
@@ -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>
);
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -0,0 +1,113 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -0,0 +1,132 @@
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import Typography from "@mui/material/Typography";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
export const HistogramInfrastructure = ({
dateRange,
+78 -52
View File
@@ -19,68 +19,94 @@ import {
Users,
Tag,
Waypoints,
Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
group: t("components.sidebar.menu.groups.monitoring"),
items: [
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
],
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
group: t("components.sidebar.menu.groups.alerting"),
items: [
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
],
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
group: t("components.sidebar.menu.groups.reporting"),
items: [
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
],
},
{
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
{
name: t("components.sidebar.menu.settings"),
icon: <Icon icon={Settings} />,
path: "settings",
group: t("components.sidebar.menu.groups.configuration"),
items: [
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.settings"),
path: "settings",
icon: <Icon icon={Settings} />,
},
],
},
];
};
+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
+51 -13
View File
@@ -1,9 +1,12 @@
import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -29,7 +32,7 @@ export const Sidebar = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { width, transition, collapsed } = useSidebar();
const { width, transition, transitionTiming, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
@@ -70,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
@@ -89,17 +92,52 @@ export const Sidebar = () => {
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
{menu.map((group) => (
<Box key={group.group}>
<Box
position="relative"
overflow="hidden"
>
<Typography
variant="eyebrow"
component="div"
color={theme.palette.text.disabled}
fontSize={typographyLevels.s}
paddingLeft={theme.spacing(5)}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.XXS)}
whiteSpace="nowrap"
sx={{
opacity: collapsed ? 0 : 1,
transition: `opacity ${transitionTiming}`,
}}
>
{group.group}
</Typography>
<Divider
sx={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
opacity: collapsed ? 1 : 0,
transition: `opacity ${transitionTiming}`,
}}
/>
</Box>
{group.items.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</Box>
))}
</List>
<StarPrompt />
+6 -1
View File
@@ -8,7 +8,8 @@ type TableName =
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed";
| "pagespeed"
| "docker";
interface TableState {
rowsPerPage: number;
@@ -24,6 +25,7 @@ interface UIState {
team: TableState;
maintenance: TableState;
infrastructure: TableState;
docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -56,6 +58,9 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
docker: {
rowsPerPage: 5,
},
logs: {
rowsPerPage: 15,
},
+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 };
}, []);
};
+7
View File
@@ -77,6 +77,13 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
case "game":
+3 -1
View File
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
export const useSidebar = () => {
const collapsed = useSelector(
@@ -19,6 +20,7 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION,
transitionTiming: SIDEBAR_TRANSITION_TIMING,
};
};
@@ -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")}
+2 -1
View File
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
import type { DateRange } from "@/Types/Query";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
+91 -3
View File
@@ -50,6 +50,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -66,6 +67,14 @@ const httpMethodOptions = HttpMethods.map((method) => ({
label: method,
}));
const PEM_FIELD_ROWS = 6;
// Hides the pasted key while the field is not focused. Ignored by browsers
// without -webkit-text-security, which then show the key as plain text.
const maskedInputSx = {
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
};
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -118,8 +127,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 +264,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 +274,9 @@ const CreateMonitorPage = () => {
? "pagespeed"
: pageType === "hardware"
? "hardware"
: "http";
: pageType === "docker"
? "docker"
: "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null
@@ -297,6 +309,7 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
@@ -331,6 +344,8 @@ const CreateMonitorPage = () => {
[watchedType, t]
);
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
@@ -373,6 +388,8 @@ const CreateMonitorPage = () => {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
} else if (pageType === "docker") {
navigate("/docker");
} else {
navigate("/uptime");
}
@@ -684,6 +701,64 @@ const CreateMonitorPage = () => {
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
@@ -823,6 +898,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,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>
);
};
@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
}: {
container: DockerContainerInfo | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!container) return null;
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"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
subtitle={health}
/>
</Stack>
);
};
@@ -0,0 +1,252 @@
// 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>
);
};
@@ -0,0 +1,126 @@
// Components
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
// Hooks
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
// Types
import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
title: t("common.charts.labels.cpuUsage"),
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
},
];
export const TabOverview = ({
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
<Grid
container
spacing={LAYOUT.MD}
>
{getChartConfigs(theme, t, stats).map((config) => {
return (
<Grid
key={config.key}
size={isSmall ? 12 : 6}
>
<HistogramDockerContainer
title={config.title}
rightTitle={config.rightTitle}
stats={stats}
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/>
</Grid>
);
})}
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Ports
</Typography>
<Stack>
{dedupedPorts?.map((port) => {
return (
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Volumes
</Typography>
<Stack>
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
</Grid>
);
};
@@ -0,0 +1,89 @@
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { DockerContainerResponse } from "@/Types/Monitor";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
import type { DateRange } from "@/Types/Query";
import { HeaderTimeRange } from "@/Components/common";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
const TAB_MAP = {
overview: 0,
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
monitorId: string;
containerName: string;
}>();
const { t } = useTranslation();
const containerDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
: null;
const { data: containerData } = useGet<DockerContainerResponse>(
containerDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = containerData?.monitor;
if (!monitor) return null;
const stats = containerData.stats;
const container = stats?.latest?.container;
if (!container) return null;
const { state, name, image } = container;
return (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes container={container} />
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && (
<TabOverview
stats={stats}
range={dateRange}
/>
)}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
containerName={containerName}
enabled={monitor.dockerLogsEnabled ?? false}
/>
)}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -0,0 +1,123 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { DockerStateLabel, Table } from "@/Components/design-elements";
// Hooks
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
// Types
import type { Header } from "@/Components/design-elements/Table";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
containers: DockerContainerInfo[];
onRowClick?: (container: DockerContainerInfo) => void;
}
export const DockerContainersTable = ({
monitorId,
containers,
}: DockerContainersTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const getHeaders = () => {
// const renderSortIcon = (isActive: boolean) => (
// <Box
// width={16}
// display="inline-flex"
// justifyContent="center"
// >
// {isActive ? (
// sortOrder === "asc" ? (
// <ArrowUp size={16} />
// ) : (
// <ArrowDown size={16} />
// )
// ) : null}
// </Box>
// );
const headers: Header<DockerContainerInfo>[] = [
{
id: "container",
content: t("pages.docker.host.table.headers.container"),
render: (row) => {
return (
<DockerStateLabel
state={row.state}
name={row.name}
image={row.image}
/>
);
},
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
return <Typography>{row.state}</Typography>;
},
},
{
id: "health",
content: t("common.labels.health"),
render: (row) => {
return <Typography>{row.health}</Typography>;
},
},
{
id: "cpu",
content: t("pages.docker.host.table.headers.cpu"),
render: (row) => {
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
},
},
{
id: "memory",
content: t("pages.docker.host.table.headers.memory"),
render: (row) => {
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
},
},
{
id: "restarts",
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={containers}
onRowClick={(row) => {
navigate(`/docker/host/${monitorId}/container/${row.name}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
// ? theme.palette.action.selected
// : "inherit",
// })}
/>
</Box>
);
};
@@ -0,0 +1,47 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
import type { DockerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
// Utils
export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
const { t } = useTranslation();
const theme = useTheme();
if (!stats) return null;
const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("pages.docker.host.statBoxes.containers")}
subtitle={String(total)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.running")}
subtitle={String(running)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.stopped")}
subtitle={String(stopped)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.unhealthy")}
subtitle={String(unhealthy)}
/>
</Stack>
);
};
@@ -0,0 +1,55 @@
import { BasePage } from "@/Components/design-elements";
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
// Types
import type { DockerDetailsResponse } from "@/Types/Monitor";
// Hooks
import { useGet } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useParams } from "react-router-dom";
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
const DockerHostDetailsPage = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const isAdmin = useIsAdmin();
// The containers list renders the latest check only, so no date range is
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
const { data: monitorDetailsData, refetch: refetchMonitor } =
useGet<DockerDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = monitorDetailsData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const stats = monitorDetailsData?.stats;
const containers = stats?.latest?.containers ?? [];
return (
<BasePage>
<HeaderMonitorControls
path="docker"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetchMonitor}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable
monitorId={monitorId}
containers={containers}
/>
</BasePage>
);
};
export default DockerHostDetailsPage;
@@ -0,0 +1,343 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import {
Table,
Pagination,
StatusLabel,
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
const recentChecks = monitor.recentChecks ?? [];
return recentChecks[recentChecks.length - 1]?.containerSummary;
};
interface DockerMonitorsTableProps {
monitors: Monitor[];
tags: Tag[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const DockerMonitorsTable = ({
monitors,
tags,
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
selectedRows = [],
onSelectionChange,
}: DockerMonitorsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const { post } = usePost<Record<string, never>, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 2,
label: t("pages.common.monitors.actions.details"),
action: () => {
navigate(`${monitor.id}`);
},
},
{
id: 3,
label: t("pages.common.monitors.actions.incidents"),
action: () => {
navigate(`/incidents?monitorId=${monitor.id}`);
},
},
{
id: 4,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/docker/configure/${monitor.id}`);
},
},
{
id: 6,
label:
monitor.isActive === false
? t("common.buttons.resume")
: t("common.buttons.pause"),
action: async () => {
await post(`/monitors/pause/${monitor.id}`, {});
refetch();
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
action: () => {
setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("pages.docker.table.headers.host"),
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("pages.docker.table.headers.host")}
{renderSortIcon(sortField === "name")}
</Typography>
),
render: (row) => {
return (
<Stack>
<Typography variant="body2">{row.name}</Typography>
<Typography
variant="caption"
color={theme.palette.text.secondary}
>
{row.url}
</Typography>
</Stack>
);
},
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
),
render: (row) => {
return <StatusLabel status={row.status} />;
},
},
{
id: "containers",
content: t("pages.docker.table.headers.containers"),
render: (row) => {
const summary = summaryOf(row);
return (
<Typography variant="body2">
{summary
? t("pages.docker.table.containersRunning", {
running: summary.running,
total: summary.total,
})
: "—"}
</Typography>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tagId) => {
const fullTag = tags.find((t) => t.id === tagId);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/docker/host/${row.id}`);
}}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
};
@@ -0,0 +1,44 @@
import { MonitorListPage } from "@/Components/monitors";
import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const DockerMonitorsPage = () => {
const c = useMonitorListController({
types: ["docker"],
checksLimit: 25,
refreshInterval: 5000,
rowsPerPageTable: "docker",
rowsPerPageDefault: 10,
});
return (
<MonitorListPage
headerKey="docker"
page="docker"
actionLink="/docker/create"
controller={c}
bulkActions
showTypeFilter={false}
>
<DockerMonitorsTable
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}
/>
</MonitorListPage>
);
};
export default DockerMonitorsPage;
@@ -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}
+2 -1
View File
@@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import type { DateRange } from "@/Types/Query";
import { useTheme } from "@mui/material";
const IncidentsPage = () => {
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("pages.infrastructure.charts.labels.memory"),
title: t("common.charts.labels.memoryUsage"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.cpu"),
title: t("common.charts.labels.cpuUsage"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
@@ -63,19 +63,42 @@ const getChartConfigs = (
return configs;
};
// Loopback / bridge names Capture sees when it can only observe the container's
// own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
// Loopback / bridge names Capture reports when it can only observe the
// container's own network namespace.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
// Below this, an interface is carrying no traffic worth charting. Real host
// NICs sit orders of magnitude above it; a container's veth sits at or near
// zero.
const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
checks.every((check) => {
const iface = check.net?.find((candidate) => candidate.name === name);
if (!iface) {
return true;
}
return (
(iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
(iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
);
});
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) return false;
for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
if (named.size === 0) {
return false;
}
return true;
const names = [...named];
if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
return true;
}
if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
return false;
}
return names.every((name) => interfaceIsIdle(checks, name));
};
export const InfraNetworkCharts = ({
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check";
const GAUGE_MAX_WIDTH = 260;
export const InfraDetailsGauges = ({
snapshot,
}: {
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
flexWrap={"wrap"}
useFlexGap
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
maxWidth={GAUGE_MAX_WIDTH}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
maxWidth={GAUGE_MAX_WIDTH}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
{snapshot?.disk?.map((disk, idx) => {
return (
<DetailGauge
key={disk?.device || 0 + idx}
key={disk?.device || `disk-${idx}`}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
maxWidth={GAUGE_MAX_WIDTH}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={prettyBytes(disk?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
);
})}
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => {
const { t } = useTranslation();
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => {
@@ -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"
+24 -1
View File
@@ -1,7 +1,8 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { useTheme } from "@mui/material";
@@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => {
);
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch();
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>{monitor.name}</Typography>
{monitor.tags.map((tagId) => {
const tag = tags?.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
/>
}
/>
+6 -5
View File
@@ -1,4 +1,4 @@
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center"
>
<Box
component="img"
src={NotFoundSvg}
alt="404"
maxHeight={"25rem"}
component={NotFoundSvg}
role="img"
aria-label="404"
width="100%"
maxHeight="25rem"
/>
<Typography
component="h1"
@@ -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")}
+2 -1
View File
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
import type { DateRange } from "@/Types/Query";
const PageSpeedDetails = () => {
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
const [dateRange, setDateRange] = useState<string>("day");
const [dateRange, setDateRange] = useState<DateRange>("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
File diff suppressed because it is too large. Load diff
@@ -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}`;
+2 -1
View File
@@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
import type { DateRange } from "@/Types/Query";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -53,7 +54,7 @@ const UptimeDetailsPage = () => {
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
@@ -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>
);
+25
View File
@@ -20,6 +20,11 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
import Infrastructure from "@/Pages/Infrastructure/Monitors";
import InfrastructureDetails from "@/Pages/Infrastructure/Details";
// Docker
import Docker from "@/Pages/Docker/Monitors";
import DockerHostDetails from "@/Pages/Docker/HostDetails";
import DockerDetails from "@/Pages/Docker/ContainerDetails";
// Checks
import Checks from "@/Pages/Checks";
@@ -139,6 +144,26 @@ const Routes = () => {
path="infrastructure/:monitorId"
element={<InfrastructureDetails />}
/>
<Route
path="docker"
element={<Docker />}
/>
<Route
path="docker/create"
element={<CreateMonitor />}
/>
<Route
path="/docker/configure/:monitorId"
element={<CreateMonitor />}
/>
<Route
path="docker/host/:monitorId/container/:containerName"
element={<DockerDetails />}
/>
<Route
path="docker/host/:monitorId"
element={<DockerHostDetails />}
/>
<Route
path="checks/:monitorId?"
element={<Checks />}
+86
View File
@@ -119,6 +119,90 @@ export interface CheckTimings {
};
}
// Mirrors DockerContainerSummary in server/src/types/network.ts.
export interface DockerContainerSummary {
total: number;
running: number;
stopped: number;
unhealthy: number;
}
// Mirrors DockerContainerStates in server/src/types/network.ts.
export const DockerContainerStates = [
"created",
"running",
"paused",
"restarting",
"removing",
"exited",
"dead",
] as const;
export type DockerContainerState = (typeof DockerContainerStates)[number];
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
export interface DockerContainerPort {
privatePort: number;
protocol: DockerPortProtocol;
publicPort?: number;
hostIp?: string;
}
export interface DockerContainerMount {
type: string;
name?: string;
source: string;
destination: string;
mode: string;
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;
image: string;
state: DockerContainerState;
status: string;
health: DockerHealthStatus;
cpuPct?: number;
memoryUsedBytes?: number;
memoryLimitBytes?: number;
memoryPct?: number;
restartCount?: number;
startedAt?: string;
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
}
export interface Check {
id: string;
metadata: CheckMetadata;
@@ -133,6 +217,7 @@ export interface Check {
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
@@ -287,6 +372,7 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
containerSummary?: DockerContainerSummary;
};
export interface HasResponseTime {
responseTime: number;
+63 -1
View File
@@ -3,6 +3,9 @@ import type {
CheckSnapshot,
GroupedUptimeCheck,
DailyCheckBucket,
DockerContainerInfo,
DockerContainerSummary,
DockerLog,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
@@ -30,7 +33,6 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const SelectableMonitorTypes = [
"http",
"ping",
"docker",
"port",
"game",
"grpc",
@@ -157,6 +159,10 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -269,6 +275,62 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
export interface DockerStatsBucket {
_id: string;
avgResponseTime: number | null;
upCount: number;
totalCount: number;
avgRunning: number | null;
avgTotal: number | null;
avgUnhealthy: number | null;
}
export interface DockerStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
aggregate: DockerStatsBucket[];
latest: {
containers: DockerContainerInfo[];
summary?: DockerContainerSummary;
checkedAt: string;
} | null;
}
export interface DockerDetailsResponse {
monitor: Monitor;
stats: DockerStats;
monitorStats: MonitorStats | null;
}
export interface DockerContainerStatsBucket {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
}
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
export interface DockerContainerResponse {
monitor: Monitor;
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;
+2
View File
@@ -0,0 +1,2 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
+91 -1
View File
@@ -1,6 +1,14 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import {
DockerLogStreams,
type DockerContainerMount,
type DockerContainerPort,
type DockerContainerState,
type DockerLog,
type DockerLogLine,
} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
@@ -12,7 +20,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "uptime",
docker: "docker",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
@@ -32,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",
@@ -41,6 +57,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value];
};
export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
const paletteMap: Record<DockerContainerState, PaletteKey> = {
paused: "warning",
created: "warning",
running: "success",
restarting: "warning",
removing: "warning",
exited: "error",
dead: "error",
};
return paletteMap[state];
};
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === "up") {
return theme.palette.success.light;
@@ -97,3 +126,64 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl;
};
export const dedupeDockerPorts = (
ports: DockerContainerPort[]
): DockerContainerPort[] => {
return ports.filter((port) => {
// Discard ipv6 wildcards
if (port.hostIp !== "::") return port;
const isDuplicate = ports.some((otherPort) => {
if (otherPort.hostIp !== "0.0.0.0") return false;
return (
otherPort.privatePort === port.privatePort &&
otherPort.protocol === port.protocol &&
otherPort.publicPort === port.publicPort
);
});
return !isDuplicate;
});
};
const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.type === "volume" && mount.name)
return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
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))
);
});
};
+62
View File
@@ -1,4 +1,5 @@
import { alpha } from "@mui/material/styles";
import { HOVER } from "@/Utils/Theme/constants";
const typographyBase = 13;
@@ -17,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",
@@ -39,9 +54,34 @@ export const lightPalette = {
primary: {
main: colors.brandGreen,
},
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,
},
@@ -64,6 +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,
@@ -72,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,
},
+41 -10
View File
@@ -13,6 +13,11 @@ declare module "@mui/material/styles" {
eyebrow?: React.CSSProperties;
fontFamilyMonospace?: string;
}
interface TypeAction {
rowHover: string;
controlHover: string;
selectedHover: string;
}
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
@@ -53,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 =
@@ -81,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,
@@ -127,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,
},
}),
@@ -181,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,
},
}),
},
+4
View File
@@ -18,6 +18,10 @@ 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
CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row
} as const;
+49 -3
View File
@@ -108,10 +108,31 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean =>
dockerTlsUrlRegex.test(url?.trim() ?? "");
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")
.refine(
(url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
),
ignoreTlsErrors: z.boolean(),
dockerTlsCa: z.string(),
dockerTlsCert: z.string(),
// blank on edit keeps the stored key.
dockerTlsKey: z.string(),
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
// Game server monitor schema
@@ -216,7 +237,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection (monitorValidation.ts)
// Mirrors the server's refineProxySelection and refineDockerTls
// (monitorValidation.ts). PEM contents are only parsed on the server.
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
@@ -225,6 +247,30 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
if (!data.dockerTlsCert.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCert"],
message: "Client certificate is required for a TLS Docker host",
});
}
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsKey"],
message: "Client key is required for a TLS Docker host",
});
}
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCa"],
message: "CA certificate is required unless TLS errors are ignored",
});
}
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
+7
View File
@@ -70,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,
]);
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 286 KiB

-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;
}
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "صفحات الحالة",
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات"
"settings": "الإعدادات",
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
"reporting": "التقارير",
"configuration": "الإعدادات العامة"
}
},
"bottomMenu": {
"support": "الدعم",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "عام",
"monitoring": "المراقبة",
"email": "البريد الإلكتروني",
"integrations": "التكاملات",
"data": "البيانات"
},
"form": {
"timezone": {
"title": "المنطقة الزمنية للعرض",
+14 -1
View File
@@ -189,7 +189,13 @@
"statusPages": "Pàgines d'estat",
"maintenance": "Manteniment",
"logs": "Registres",
"settings": "Configuració"
"settings": "Configuració",
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
"reporting": "Informes",
"configuration": "Configuració"
}
},
"bottomMenu": {
"support": "Suport",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoratge",
"email": "Correu electrònic",
"integrations": "Integracions",
"data": "Dades"
},
"form": {
"timezone": {
"title": "Zona horària de visualització",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Stavové stránky",
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení"
"settings": "Nastavení",
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
"reporting": "Přehledy",
"configuration": "Konfigurace"
}
},
"bottomMenu": {
"support": "Podpora",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "Obecné",
"monitoring": "Monitorování",
"email": "E-mail",
"integrations": "Integrace",
"data": "Data"
},
"form": {
"timezone": {
"title": "Zobrazované časové pásmo",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Statusseite",
"maintenance": "Wartung",
"logs": "Protokolle",
"settings": "Einstellungen"
"settings": "Einstellungen",
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
"reporting": "Berichte",
"configuration": "Konfiguration"
}
},
"bottomMenu": {
"support": "Support",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "Allgemein",
"monitoring": "Überwachung",
"email": "E-Mail",
"integrations": "Integrationen",
"data": "Daten"
},
"form": {
"timezone": {
"title": "Anzeigesprache Zeitzone",
+143 -14
View File
@@ -66,9 +66,11 @@
"charts": {
"labels": {
"averageResponseTime": "Average response time",
"cpuUsage": "CPU usage",
"downtime": "Downtime",
"high": "High",
"low": "Low",
"memoryUsage": "Memory usage",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
@@ -78,7 +80,9 @@
"avg": "Avg: {{value}}",
"max": "Max: {{value}}",
"responseTime": "Response time: {{value}}",
"total": "Total: {{value}}"
"total": "Total: {{value}}",
"avgCpuUsage": "Average CPU usage: {{value}}",
"avgMemoryUsage": "Average memory usage: {{value}}"
},
"phases": {
"dns": "DNS",
@@ -102,7 +106,11 @@
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time"
"responseTime": "Response time",
"state": "State",
"uptime": "Uptime",
"health": "Health",
"restarts": "Restarts"
},
"form": {
"autocomplete": {
@@ -203,6 +211,7 @@
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
"docker": "Docker",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
@@ -211,7 +220,13 @@
"statusPages": "Status pages",
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings"
"settings": "Settings",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
}
},
"bottomMenu": {
"support": "Support",
@@ -619,9 +634,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",
@@ -703,7 +718,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, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. 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.",
@@ -743,7 +758,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.",
@@ -801,6 +816,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.",
@@ -810,6 +834,29 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats"
}
},
"dockerTls": {
"title": "TLS credentials",
"description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
"option": {
"ca": {
"label": "CA certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"cert": {
"label": "Client certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"key": {
"label": "Client key",
"placeholder": "-----BEGIN PRIVATE KEY-----",
"stored": "A key is stored. Leave blank to keep it."
},
"ignoreTls": {
"label": "Ignore TLS/SSL errors",
"description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
}
}
}
}
},
@@ -918,15 +965,81 @@
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
}
},
"docker": {
"header": {
"title": "Docker monitors",
"description": "Monitor your Docker hosts and every container running on them."
},
"fallback": {
"title": "No Docker monitors yet",
"description": "Add a Docker host to monitor its containers.",
"actionButton": "Create a monitor"
},
"table": {
"headers": {
"host": "Host",
"containers": "Containers"
},
"containersRunning": "{{running}} of {{total}} running"
},
"container": {
"header": {
"title": "Container details",
"description": "State, resource usage, and logs for this container."
},
"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": {
"statBoxes": {
"containers": "Containers",
"running": "Running",
"stopped": "Stopped",
"unhealthy": "Unhealthy"
},
"table": {
"headers": {
"container": "Containers",
"cpu": "CPU",
"memory": "Memory"
}
}
}
},
"infrastructure": {
"charts": {
"labels": {
"cpu": "CPU usage",
"disk": "Disk usage",
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",
"temp": "CPU temperature"
}
},
@@ -1163,7 +1276,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"
@@ -1245,6 +1358,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1265,6 +1379,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.",
@@ -1333,6 +1455,13 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoring",
"email": "Email",
"integrations": "Integrations",
"data": "Data"
},
"form": {
"timezone": {
"title": "Display timezone",
@@ -1390,7 +1519,7 @@
}
},
"url": {
"title": "Monitor IP/URL on Status Page",
"title": "Monitor IP/URL on status page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
"option": {
"showURL": {
@@ -1422,7 +1551,7 @@
}
},
"thresholds": {
"title": "Global Thresholds",
"title": "Global thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
"option": {
"cpu": {
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Páginas de estado",
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración"
"settings": "Configuración",
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
"reporting": "Informes",
"configuration": "Configuración"
}
},
"bottomMenu": {
"support": "Soporte",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitorización",
"email": "Correo electrónico",
"integrations": "Integraciones",
"data": "Datos"
},
"form": {
"timezone": {
"title": "Zona horaria de visualización",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Tilasivut",
"maintenance": "Huolto",
"logs": "Lokit",
"settings": "Asetukset"
"settings": "Asetukset",
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
"reporting": "Raportointi",
"configuration": "Määritykset"
}
},
"bottomMenu": {
"support": "Tuki",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "Yleiset",
"monitoring": "Valvonta",
"email": "Sähköposti",
"integrations": "Integraatiot",
"data": "Tiedot"
},
"form": {
"timezone": {
"title": "Näytettävä aikavyöhyke",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Pages de statut",
"maintenance": "Maintenance",
"logs": "Journaux",
"settings": "Paramètres"
"settings": "Paramètres",
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
"reporting": "Rapports",
"configuration": "Configuration"
}
},
"bottomMenu": {
"support": "Support",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "Général",
"monitoring": "Surveillance",
"email": "E-mail",
"integrations": "Intégrations",
"data": "Données"
},
"form": {
"timezone": {
"title": "Fuseau horaire d'affichage",
+14 -1
View File
@@ -193,7 +193,13 @@
"statusPages": "Pagine di stato",
"maintenance": "Manutenzione",
"logs": "Log",
"settings": "Impostazioni"
"settings": "Impostazioni",
"groups": {
"monitoring": "Monitoraggio",
"alerting": "Avvisi",
"reporting": "Report",
"configuration": "Configurazione"
}
},
"bottomMenu": {
"support": "Supporto",
@@ -1237,6 +1243,13 @@
}
},
"settings": {
"tabs": {
"general": "Generale",
"monitoring": "Monitoraggio",
"email": "Email",
"integrations": "Integrazioni",
"data": "Dati"
},
"form": {
"timezone": {
"title": "Fuso orario di visualizzazione",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "ステータスページ",
"maintenance": "メンテナンス",
"logs": "ログ",
"settings": "設定"
"settings": "設定",
"groups": {
"monitoring": "監視",
"alerting": "アラート",
"reporting": "レポート",
"configuration": "設定"
}
},
"bottomMenu": {
"support": "サポート",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "一般",
"monitoring": "監視",
"email": "メール",
"integrations": "連携",
"data": "データ"
},
"form": {
"timezone": {
"title": "表示タイムゾーン",
+14 -1
View File
@@ -214,7 +214,13 @@
"settings": "Ustawienia",
"statusPages": "Strony statusu",
"tags": "Tagi",
"uptime": "Czas działania"
"uptime": "Czas działania",
"groups": {
"monitoring": "Monitorowanie",
"alerting": "Alerty",
"reporting": "Raporty",
"configuration": "Konfiguracja"
}
},
"starPrompt": {
"description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub",
@@ -1207,6 +1213,13 @@
}
},
"settings": {
"tabs": {
"general": "Ogólne",
"monitoring": "Monitorowanie",
"email": "E-mail",
"integrations": "Integracje",
"data": "Dane"
},
"form": {
"about": {
"developedBy": "Opracowane przez Bluewave Labs",
+14 -1
View File
@@ -181,7 +181,13 @@
"statusPages": "Pagina de status",
"maintenance": "Manutenção",
"logs": "Registros",
"settings": "Configurações"
"settings": "Configurações",
"groups": {
"monitoring": "Monitoramento",
"alerting": "Alertas",
"reporting": "Relatórios",
"configuration": "Configuração"
}
},
"bottomMenu": {
"support": "Suporte",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
"tabs": {
"general": "Geral",
"monitoring": "Monitoramento",
"email": "E-mail",
"integrations": "Integrações",
"data": "Dados"
},
"form": {
"timezone": {
"title": "Fuso horário de exibição",
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more