Compare commits

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

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

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

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

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

Correctness:

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

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

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

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

Redundancy:

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

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

Comments and conventions:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things this deliberately avoids:

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

prefers-reduced-motion skips the animation entirely.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Componentization — there was real duplication:

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

Titles, checked against what each card actually renders:

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

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

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

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

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

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

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

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

Styling fixes:

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

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

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

Data caveats honoured from the specs:

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

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

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

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

Also from review:

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

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

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

Six cards ship as the default set:

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

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

Notes on the data:

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

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

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

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

No files matched your search

+22 -58
View File
@@ -9,9 +9,6 @@
"version": "3.11.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
@@ -96,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",
@@ -365,59 +363,6 @@
"node": ">=14"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"license": "MIT",
@@ -464,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",
@@ -501,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",
@@ -1289,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",
@@ -1393,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",
@@ -2437,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2452,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2542,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",
@@ -2822,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3178,6 +3131,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3621,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -4020,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",
@@ -4832,6 +4788,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5643,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",
@@ -6424,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"
@@ -6435,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6638,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6788,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"
},
@@ -7629,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7766,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",
-3
View File
@@ -20,9 +20,6 @@
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
@@ -81,21 +81,6 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -117,10 +102,7 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -274,8 +256,7 @@ export function DataTable<
}),
...(getRowSx?.(row) as object),
}}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
onClick={() => {
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
+18 -1
View File
@@ -9,6 +9,7 @@ import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material/styles";
import type { ReactNode } from "react";
export const DialogInput = ({
@@ -25,6 +26,8 @@ export const DialogInput = ({
maxWidth,
fullWidth = false,
additionalButtons,
cancelSx,
onEscape,
}: {
open: boolean;
title?: string;
@@ -39,6 +42,14 @@ export const DialogInput = ({
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
additionalButtons?: ReactNode;
/** Styles the cancel button — e.g. an auto margin to push it left. */
cancelSx?: SxProps;
/**
* Called when Escape is pressed. Defaults to `onCancel`, which is right when
* cancel simply dismisses — but not when it does something else, so a dialog
* whose cancel slot carries another action passes its own dismiss here.
*/
onEscape?(): void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -48,7 +59,12 @@ export const DialogInput = ({
maxWidth={maxWidth}
fullWidth={fullWidth}
onClose={(_event, reason) => {
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
if (reason === "backdropClick") return;
if (onEscape) {
onEscape();
return;
}
if (onCancel) onCancel(undefined);
}}
>
{title && (
@@ -94,6 +110,7 @@ export const DialogInput = ({
loading={loading}
variant="outlined"
onClick={onCancel}
sx={cancelSx}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
@@ -60,22 +60,20 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
<Button
variant="contained"
color="secondary"
-7
View File
@@ -1,5 +1,4 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { CardId } from "@/Pages/Dashboard/cards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -36,7 +35,6 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardCards: CardId[] | null;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -76,7 +74,6 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardCards: null,
};
const uiSlice = createSlice({
@@ -120,9 +117,6 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
state.dashboardCards = action.payload;
},
},
});
@@ -139,5 +133,4 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardCards,
} = uiSlice.actions;
@@ -1,123 +1,78 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { GripVertical } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
import { useMemo } from "react";
import { Icon } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { layoutCards } from "../layout";
import { useCardTransitions } from "../useCardTransitions";
import { CardReorderControls } from "./CardReorderControls";
import { CardSlotContext } from "./CardSlotContext";
const SortableCard = ({ card }: { card: CardDefinition }) => {
const theme = useTheme();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id });
const Component = card.component;
return (
<Box
ref={setNodeRef}
minWidth={0}
sx={{
width: {
xs: "100%",
md:
card.width >= GRID_COLUMNS
? "100%"
: `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
},
transform: transform
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
: undefined,
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isDragging ? 1 : "auto",
}}
>
<CardSlotContext.Provider
value={
<Box
{...attributes}
{...listeners}
display="flex"
alignItems="center"
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
touchAction: "none",
}}
>
<Icon
icon={GripVertical}
size={16}
color={theme.palette.text.secondary}
/>
</Box>
}
>
<Component />
</CardSlotContext.Provider>
</Box>
);
};
export const CardGrid = ({
cards,
onReorder,
onMove,
}: {
cards: CardDefinition[];
onReorder: (sourceIndex: number, destIndex: number) => void;
onMove: (id: CardId, delta: -1 | 1) => void;
}) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
const { registerCard, capturePositions } = useCardTransitions();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const sourceIndex = cards.findIndex((c) => c.id === active.id);
const destIndex = cards.findIndex((c) => c.id === over.id);
if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
// Positions are measured before the state change so the cards can be
// animated from where they were to where they land.
const handleMove = (id: CardId, delta: -1 | 1) => {
capturePositions(id);
onMove(id, delta);
};
const cardIds = cards.map((c) => c.id);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
<Box
display="grid"
gap={theme.spacing(LAYOUT.MD)}
sx={{
gridTemplateColumns: {
xs: "1fr",
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
alignItems: "stretch",
}}
>
<SortableContext
items={cardIds}
strategy={rectSortingStrategy}
>
<Box
display="flex"
flexWrap="wrap"
gap={theme.spacing(LAYOUT.MD)}
alignItems="stretch"
>
{cards.map((card) => (
<SortableCard
key={card.id}
card={card}
/>
))}
</Box>
</SortableContext>
</DndContext>
{placed.map(({ card, renderedWidth }, index) => {
const Component = card.component;
return (
<Box
key={card.id}
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
minWidth={0}
// Positioned so the moving card can be lifted above its
// neighbours with a z-index while it travels.
position="relative"
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
// Hints the compositor before a transform is applied, so the
// first frame of a move does not stutter.
sx={{ willChange: "transform" }}
>
{/*
* The controls go through context rather than a prop so the 17
* card components stay unaware of their own position.
*/}
<CardSlotContext.Provider
value={
<CardReorderControls
canMoveUp={index > 0}
canMoveDown={index < placed.length - 1}
onMoveUp={() => handleMove(card.id, -1)}
onMoveDown={() => handleMove(card.id, 1)}
/>
}
>
<Component />
</CardSlotContext.Provider>
</Box>
);
})}
</Box>
);
};
@@ -1,12 +1,12 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Dialog, Checkbox, Button } from "@/Components/inputs";
import { Dialog, Checkbox } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
@@ -14,11 +14,8 @@ interface CardPickerProps {
open: boolean;
available: CardDefinition[];
selected: CardId[];
allSelected: boolean;
onAdd: (id: CardId) => void;
onRemove: (id: CardId) => void;
onSelectAll: () => void;
onDeselectAll: () => void;
onReset: () => void;
onClose: () => void;
}
@@ -32,11 +29,8 @@ export const CardPicker = ({
open,
available,
selected,
allSelected,
onAdd,
onRemove,
onSelectAll,
onDeselectAll,
onReset,
onClose,
}: CardPickerProps) => {
@@ -51,7 +45,6 @@ export const CardPicker = ({
}, [available]);
const chosen = new Set(selected);
const someSelected = selected.length > 0 && !allSelected;
return (
<Dialog
@@ -59,35 +52,27 @@ export const CardPicker = ({
maxWidth="sm"
fullWidth
title={t("pages.dashboard.picker.title")}
// Reset sits alone on the left, away from Done — a destructive action
// The dialog lays its actions out as cancel → additional → confirm, all
// right-aligned. Reset takes the cancel slot with an auto right margin
// so it sits alone on the left, away from Done — a destructive action
// should not be adjacent to the one people reach for by reflex.
onCancel={onReset}
cancelText={t("pages.dashboard.picker.reset")}
cancelSx={{ marginRight: "auto" }}
// Escape must dismiss, not reset. The dialog sends Escape to onCancel by
// default, and this one's cancel slot resets the selection — so without
// this, Escape would wipe the user's cards instead of closing.
onEscape={onClose}
// Changes apply immediately, so Done only dismisses; it is the primary
// action here and carries the usual contained styling.
onConfirm={onClose}
confirmText={t("pages.dashboard.picker.done")}
additionalButtons={
<Button
variant="outlined"
color="secondary"
onClick={allSelected ? onDeselectAll : onSelectAll}
startIcon={
<Checkbox
checked={allSelected}
indeterminate={someSelected}
size="small"
sx={{ p: 0 }}
onChange={() => {}}
/>
}
>
{t("pages.dashboard.picker.selectAll")}
</Button>
}
>
{/*
* Two columns: seventeen cards in one list makes the dialog scroll,
* which hides most of the catalogue. Groups flow down the columns and
* are kept whole so a group is never split across the gap.
* which hides most of the catalogue behind a scrollbar. Groups flow
* down the columns and are kept whole, so a group is never split
* across the gap.
*/}
<Box
pt={theme.spacing(LAYOUT.SM)}
@@ -107,6 +92,11 @@ export const CardPicker = ({
variant="eyebrow"
color={theme.palette.text.disabled}
>
{/*
* The picker has its own group labels rather than reusing the
* sidebar's: the Logs page hosts logs, queue and diagnostics
* tabs, so "Logs" would misdescribe the three cards under it.
*/}
{t(`pages.dashboard.picker.groups.${group}`, {
defaultValue: group,
})}
@@ -0,0 +1,90 @@
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Icon, Tooltip } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
// Snug around the 16px icon without inflating the card header.
const CONTROL_SIZE = 24;
const ICON_SIZE = 16;
/**
* Move-up / move-down controls, revealed on hover over the card they belong to.
* Kept out of DashboardCard so the 17 card components stay unaware of their own
* position — the grid owns ordering.
*/
export const CardReorderControls = ({
onMoveUp,
onMoveDown,
canMoveUp,
canMoveDown,
}: {
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const buttonSx = {
width: CONTROL_SIZE,
height: CONTROL_SIZE,
borderRadius: theme.shape.borderRadius,
color: theme.palette.text.secondary,
"&:hover": {
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.primary,
},
"&.Mui-disabled": { color: theme.palette.text.disabled },
};
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XXS)}
p={theme.spacing(LAYOUT.XXS)}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.background.paper}
border={`1px solid ${theme.palette.divider}`}
>
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
{/* span keeps the tooltip working while the button is disabled */}
<span>
<IconButton
size="small"
disabled={!canMoveUp}
onClick={onMoveUp}
aria-label={t("pages.dashboard.reorder.moveUp")}
sx={buttonSx}
>
<Icon
icon={ChevronUp}
size={ICON_SIZE}
/>
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
<span>
<IconButton
size="small"
disabled={!canMoveDown}
onClick={onMoveDown}
aria-label={t("pages.dashboard.reorder.moveDown")}
sx={buttonSx}
>
<Icon
icon={ChevronDown}
size={ICON_SIZE}
/>
</IconButton>
</span>
</Tooltip>
</Stack>
);
};
@@ -51,8 +51,12 @@ export const DashboardCard = ({
height="100%"
sx={{
overflow: "hidden",
"&:hover .dashboard-card-slot": {
"&:hover .dashboard-card-slot, &:has(:focus-visible) .dashboard-card-slot": {
opacity: 1,
pointerEvents: "auto",
},
"&:hover .dashboard-card-action, &:has(:focus-visible) .dashboard-card-action": {
opacity: 0,
},
}}
>
@@ -92,12 +96,22 @@ export const DashboardCard = ({
color="primary"
/>
)}
{action && <Box>{action}</Box>}
{action && (
<Box
className="dashboard-card-action"
sx={{ transition: "opacity 0.15s ease" }}
>
{action}
</Box>
)}
{slot && (
<Box
className="dashboard-card-slot"
position="absolute"
right={0}
sx={{
opacity: 0.3,
opacity: 0,
pointerEvents: "none",
transition: "opacity 0.15s ease",
}}
>
+3 -16
View File
@@ -16,18 +16,8 @@ const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const monitorsRequest = useMonitorsRequest();
const {
cards,
available,
selected,
addCard,
removeCard,
reorderCards,
resetCards,
selectAll,
deselectAll,
allSelected,
} = useCardSelection();
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
@@ -62,7 +52,7 @@ const Dashboard = () => {
) : (
<CardGrid
cards={cards}
onReorder={reorderCards}
onMove={moveCard}
/>
)}
</Stack>
@@ -70,11 +60,8 @@ const Dashboard = () => {
open={isPickerOpen}
available={available}
selected={selected}
allSelected={allSelected}
onAdd={addCard}
onRemove={removeCard}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
onReset={resetCards}
onClose={() => setIsPickerOpen(false)}
/>
+95 -65
View File
@@ -1,33 +1,79 @@
import { useCallback, useMemo } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { logger } from "@/Utils/logger";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { setDashboardCards } from "@/Features/UI/uiSlice";
import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
import {
CardIds,
DASHBOARD_CARDS_STORAGE_KEY,
DEFAULT_CARD_IDS,
type CardId,
} from "./cards";
import { CARD_REGISTRY, getCard } from "./registry";
import type { CardDefinition } from "./cards";
import type { RootState } from "@/store";
const isCardId = (value: unknown): value is CardId =>
typeof value === "string" && (CardIds as readonly string[]).includes(value);
export const useCardSelection = () => {
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
/** Stored ids are validated: a card removed from a release must not break the page. */
const readStored = (): CardId[] | null => {
try {
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return null;
}
// An empty array is a real choice and must not fall back to the defaults;
// only an absent or malformed key returns null. Unknown ids are dropped
// so a card removed in a later release cannot break the page.
return parsed.filter(isCardId);
} catch (error) {
logger.error(
"Could not read dashboard card selection",
error instanceof Error ? error : undefined
);
return null;
}
};
// null means the user has never customised; fall back to defaults.
const stored = useSelector((state: RootState) => state.ui.dashboardCards);
const selected: CardId[] = useMemo(
() => (stored !== null ? stored.filter(isCardId) : [...DEFAULT_CARD_IDS]),
[stored]
/**
* Which cards are on the page, persisted per browser. Admin-only cards are
* filtered out entirely for non-admins — hidden, not disabled.
*/
export const useCardSelection = () => {
const isAdmin = useIsAdmin();
const [selected, setSelected] = useState<CardId[]>(
() => readStored() ?? [...DEFAULT_CARD_IDS]
);
// Skip the write on mount: if the read failed and fell back to the defaults,
// writing would overwrite the user's real selection permanently.
const hasMounted = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
try {
window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected));
} catch (error) {
logger.error(
"Could not persist dashboard card selection",
error instanceof Error ? error : undefined
);
}
}, [selected]);
const isVisible = useCallback(
(card: CardDefinition) => !card.adminOnly || isAdmin,
[isAdmin]
);
// Selection order is the render order.
const cards = useMemo(
() =>
selected
@@ -39,60 +85,47 @@ export const useCardSelection = () => {
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
const addCard = useCallback(
(id: CardId) => {
if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
},
[dispatch, selected]
);
const addCard = useCallback((id: CardId) => {
setSelected((current) => (current.includes(id) ? current : [...current, id]));
}, []);
const removeCard = useCallback(
(id: CardId) => {
dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
},
[dispatch, selected]
);
const removeCard = useCallback((id: CardId) => {
setSelected((current) => current.filter((cardId) => cardId !== id));
}, []);
const resetCards = useCallback(() => dispatch(setDashboardCards(null)), [dispatch]);
const selectAll = useCallback(
() =>
dispatch(
setDashboardCards(
available.map((card) => card.id).filter((id) => !selected.includes(id)).length >
0
? [...new Set([...selected, ...available.map((card) => card.id)])]
: selected
)
),
[dispatch, available, selected]
);
const deselectAll = useCallback(() => dispatch(setDashboardCards([])), [dispatch]);
const resetCards = useCallback(() => setSelected([...DEFAULT_CARD_IDS]), []);
/**
* Reorders cards based on DnD source/dest indices into the visible `cards` array.
* Hidden admin cards keep their stored positions unchanged.
* Swaps a card with its neighbour; `delta` is -1 for up, +1 for down.
* Resolved against the visible order, so moving past a card hidden from a
* non-admin does not appear to do nothing.
*/
const reorderCards = useCallback(
(sourceIndex: number, destIndex: number) => {
const visibleIds = cards.map((c) => c.id);
const reordered = [...visibleIds];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(destIndex, 0, moved);
let visibleIdx = 0;
dispatch(
setDashboardCards(
selected.map((cardId) =>
visibleIds.includes(cardId) ? reordered[visibleIdx++] : cardId
)
)
);
},
[dispatch, cards, selected]
);
const moveCard = useCallback(
(id: CardId, delta: -1 | 1) => {
setSelected((current) => {
const visible = current.filter((cardId) => {
const card = getCard(cardId);
return card !== undefined && isVisible(card);
});
const from = visible.indexOf(id);
const to = from + delta;
if (from === -1 || to < 0 || to >= visible.length) {
return current;
}
// Swap in the visible order, then write that order back over the
// stored positions the visible cards occupy, leaving hidden cards
// exactly where they were.
const reordered = [...visible];
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
const allSelected = available.every((card) => selected.includes(card.id));
let next = 0;
return current.map((cardId) =>
visible.includes(cardId) ? reordered[next++] : cardId
);
});
},
[isVisible]
);
return {
cards,
@@ -100,10 +133,7 @@ export const useCardSelection = () => {
selected,
addCard,
removeCard,
reorderCards,
moveCard,
resetCards,
selectAll,
deselectAll,
allSelected,
};
};
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { CardId } from "./cards";
/** Long enough to follow a card across a row, short enough not to feel slow. */
const DURATION_MS = 260;
/** Decelerating: quick to leave, gentle to settle. */
const EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
export const useCardTransitions = () => {
const nodes = useRef(new Map<CardId, HTMLElement>());
const positions = useRef(new Map<CardId, DOMRect>());
/** Set only for the render that follows a reorder, so nothing animates on data refresh. */
const isAnimating = useRef(false);
/** The card the user moved, lifted above the rest while it travels. */
const movedCard = useRef<CardId | null>(null);
/** Pending frame and settle handles, cancelled on unmount and on a new move. */
const frames = useRef<number[]>([]);
const timers = useRef<number[]>([]);
const cancelPending = useCallback(() => {
frames.current.forEach(cancelAnimationFrame);
timers.current.forEach(clearTimeout);
frames.current = [];
timers.current = [];
}, []);
useEffect(() => cancelPending, [cancelPending]);
const registerCard = useCallback((id: CardId, node: HTMLElement | null) => {
if (node) {
nodes.current.set(id, node);
} else {
nodes.current.delete(id);
}
}, []);
/**
* Call immediately before the state change that reorders the cards.
* `movedId` is the card the user acted on; it rides above the others so the
* swap reads as one card moving rather than two sliding through each other.
*/
const capturePositions = useCallback(
(movedId: CardId) => {
// A move while a previous one is still settling would otherwise have
// its inversion wiped by the earlier timeout, and the card would jump.
cancelPending();
positions.current.clear();
for (const [id, node] of nodes.current) {
positions.current.set(id, node.getBoundingClientRect());
}
movedCard.current = movedId;
isAnimating.current = true;
},
[cancelPending]
);
useLayoutEffect(() => {
if (!isAnimating.current) {
return;
}
isAnimating.current = false;
const reducedMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
)?.matches;
if (reducedMotion) {
positions.current.clear();
movedCard.current = null;
return;
}
for (const [id, node] of nodes.current) {
const previous = positions.current.get(id);
if (!previous) {
continue;
}
const current = node.getBoundingClientRect();
const deltaX = previous.left - current.left;
const deltaY = previous.top - current.top;
if (deltaX === 0 && deltaY === 0) {
continue;
}
// Invert: jump back to the old position with no transition...
node.style.transition = "none";
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
if (id === movedCard.current) {
node.style.zIndex = "1";
}
// ...then play: on the next frame, release to the new one.
frames.current.push(
requestAnimationFrame(() => {
node.style.transition = `transform ${DURATION_MS}ms ${EASING}`;
node.style.transform = "";
// Clear the inline styles once settled. Left in place they would
// animate the card again on any later reflow it did not ask for.
timers.current.push(
window.setTimeout(() => {
node.style.transition = "";
node.style.zIndex = "";
}, DURATION_MS)
);
})
);
}
positions.current.clear();
movedCard.current = null;
});
return { registerCard, capturePositions };
};
@@ -10,7 +10,6 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -25,7 +24,6 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -35,7 +33,6 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -23,7 +23,6 @@ import {
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
@@ -51,7 +50,6 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const isAdmin = useIsAdmin();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
@@ -61,7 +59,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
isAdmin && enabled && monitorId && containerName
enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
@@ -170,15 +168,6 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
if (!enabled) {
return (
<EmptyState
@@ -126,7 +126,6 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -202,31 +201,6 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
+1 -1
View File
@@ -93,7 +93,7 @@ const Routes = () => {
>
<Route
path="/"
element={<Navigate to="/dashboard" />}
element={<Navigate to="/uptime" />}
/>
<Route
path="/dashboard"
-1
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
-8
View File
@@ -40,14 +40,6 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
-7
View File
@@ -70,12 +70,6 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -101,7 +95,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات",
"dashboard": "لوحة المعلومات",
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "لوحة المعلومات",
"description": "اطّلع على كل أنواع المراقبات في مكان واحد ولاحظ ما يحتاج إلى انتباه."
},
"card": {
"error": "تعذّر تحميل هذه البطاقة."
},
"cards": {
"monitorStatus": {
"title": "حالة المراقبات",
"empty": "لا توجد مراقبات بعد.",
"upOfTotal": "من {{total}} تعمل"
},
"currentlyDown": {
"title": "متوقفة حاليًا",
"empty": "جميع المراقبات تعمل. لا توجد حوادث مفتوحة.",
"count": "{{count}} تحتاج إلى انتباه"
},
"monitorsByType": {
"title": "المراقبات حسب النوع",
"empty": "لا توجد مراقبات بعد.",
"down": "{{count}} متوقفة"
},
"recentIncidents": {
"title": "الحوادث الأخيرة",
"empty": "لا توجد حوادث — جميع الأنظمة سليمة.",
"active": "{{count}} مفتوحة",
"ongoing": "جارية",
"manual": "يدوي",
"unknownMonitor": "مراقبة غير معروفة"
},
"failedChecks": {
"title": "الفحوصات الفاشلة",
"empty": "لا توجد فحوصات بعد.",
"ratio": "{{failed}} من {{total}} في آخر يوم"
},
"checkmateServer": {
"title": "خادم Checkmate",
"memory": "الذاكرة",
"eventLoop": "تأخير حلقة الأحداث",
"uptime": "مدة التشغيل",
"database": "قاعدة البيانات",
"readsWrites": "{{reads}} قراءة/ث · {{writes}} كتابة/ث"
},
"ranking": {
"topOf": "أعلى {{shown}} من {{total}}",
"notEnough": "لا توجد مراقبات كافية للترتيب."
},
"slowestMonitors": {
"title": "أبطأ المراقبات"
},
"lowestUptime": {
"title": "أدنى وقت تشغيل"
},
"monitorsByGroup": {
"title": "المراقبات حسب المجموعة",
"empty": "لا توجد مجموعات بعد. تُحدَّد المجموعات لكل مراقبة."
},
"busiestServers": {
"title": "أكثر الخوادم انشغالًا",
"empty": "لا توجد مراقبات بنية تحتية بعد.",
"cpu": "المعالج",
"memory": "الذاكرة",
"disk": "القرص"
},
"incidentStats": {
"title": "إحصائيات الحوادث",
"openNow": "مفتوحة الآن",
"avgResolution": "متوسط وقت الحل",
"hours": "{{hours}} س",
"mostIncidents": "الأكثر حوادث",
"total": "الإجمالي",
"resolved": "تم حلها",
"automatic": "تلقائي",
"manual": "يدوي",
"autoManual": "{{automatic}} تلقائي · {{manual}} يدوي"
},
"notificationChannels": {
"title": "قنوات الإشعارات",
"empty": "لا توجد قنوات بعد."
},
"maintenance": {
"title": "نوافذ الصيانة",
"empty": "لا توجد صيانة مجدولة.",
"runningNow": "قيد التشغيل",
"next": "النافذة التالية"
},
"statusPages": {
"title": "صفحات الحالة",
"empty": "لا توجد صفحات حالة بعد.",
"monitorCount": "{{count}} مراقبة",
"published": "منشورة",
"draft": "مسودة"
},
"checksOnSchedule": {
"title": "طابور الفحوصات",
"healthy": "جميع المهام البالغ عددها {{jobs}} تسير وفق الجدول على {{workers}} عامل.",
"failing": "المهام الفاشلة",
"scheduled": "المهام المجدولة",
"runningNow": "قيد التشغيل"
},
"recentErrors": {
"title": "الأخطاء الأخيرة",
"empty": "لم تُسجَّل أخطاء.",
"count": "{{count}} أخطاء",
"warnings": "التحذيرات"
},
"team": {
"title": "أعضاء الفريق",
"members": "{{count}} عضو",
"unverified": "غير مُوثَّقين"
}
},
"picker": {
"trigger": "تحرير البطاقات",
"title": "بطاقات لوحة المعلومات",
"done": "تم",
"reset": "إعادة التعيين إلى الافتراضي",
"groups": {
"uptime": "وقت التشغيل",
"infrastructure": "البنية التحتية",
"incidents": "الحوادث",
"notifications": "الإشعارات",
"maintenance": "الصيانة",
"statusPages": "صفحات الحالة",
"checks": "الفحوصات",
"logs": "النظام",
"settings": "الإعدادات"
}
},
"empty": {
"title": "لا توجد بطاقات في لوحة المعلومات",
"description": "اختر البطاقات التي تريد رؤيتها. يُحفظ اختيارك في هذا المتصفح."
},
"reorder": {
"moveUp": "نقل لأعلى",
"moveDown": "نقل لأسفل"
}
},
"notFound": {
"title": "يا إلهي! لقد أسقطت السوشي!",
"subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
+141
View File
@@ -190,6 +190,7 @@
"maintenance": "Manteniment",
"logs": "Registres",
"settings": "Configuració",
"dashboard": "Tauler",
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Tauler",
"description": "Vegeu cada tipus de monitor en un sol lloc i detecteu què necessita atenció."
},
"card": {
"error": "No s'ha pogut carregar aquesta targeta."
},
"cards": {
"monitorStatus": {
"title": "Estat dels monitors",
"empty": "Encara no hi ha monitors.",
"upOfTotal": "de {{total}} actius"
},
"currentlyDown": {
"title": "Actualment caiguts",
"empty": "Tots els monitors estan actius. Cap incident obert.",
"count": "{{count}} requereixen atenció"
},
"monitorsByType": {
"title": "Monitors per tipus",
"empty": "Encara no hi ha monitors.",
"down": "{{count}} caiguts"
},
"recentIncidents": {
"title": "Incidents recents",
"empty": "Cap incident — tots els sistemes sans.",
"active": "{{count}} oberts",
"ongoing": "En curs",
"manual": "Manual",
"unknownMonitor": "Monitor desconegut"
},
"failedChecks": {
"title": "Comprovacions fallides",
"empty": "Encara no hi ha comprovacions.",
"ratio": "{{failed}} de {{total}} el darrer dia"
},
"checkmateServer": {
"title": "Servidor de Checkmate",
"memory": "Memòria",
"eventLoop": "Retard del bucle d'esdeveniments",
"uptime": "Temps actiu",
"database": "Base de dades",
"readsWrites": "{{reads}} lectures/s · {{writes}} escriptures/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "No hi ha prou monitors per fer una classificació."
},
"slowestMonitors": {
"title": "Monitors més lents"
},
"lowestUptime": {
"title": "Temps en línia més baix"
},
"monitorsByGroup": {
"title": "Monitors per grup",
"empty": "Encara no hi ha grups. Els grups es defineixen per monitor."
},
"busiestServers": {
"title": "Servidors més carregats",
"empty": "Encara no hi ha monitors d'infraestructura.",
"cpu": "CPU",
"memory": "Memòria",
"disk": "Disc"
},
"incidentStats": {
"title": "Estadístiques d'incidents",
"openNow": "Oberts ara",
"avgResolution": "Temps mitjà de resolució",
"hours": "{{hours}} h",
"mostIncidents": "Més incidents",
"total": "total",
"resolved": "Resolts",
"automatic": "Automàtica",
"manual": "Manual",
"autoManual": "{{automatic}} automàtiques · {{manual}} manuals"
},
"notificationChannels": {
"title": "Canals de notificació",
"empty": "Encara no hi ha canals."
},
"maintenance": {
"title": "Finestres de manteniment",
"empty": "Cap manteniment programat.",
"runningNow": "En execució",
"next": "Propera finestra"
},
"statusPages": {
"title": "Pàgines d'estat",
"empty": "Encara no hi ha pàgines d'estat.",
"monitorCount": "{{count}} monitors",
"published": "Publicada",
"draft": "Esborrany"
},
"checksOnSchedule": {
"title": "Cua de comprovacions",
"healthy": "Les {{jobs}} tasques van segons el previst en {{workers}} worker(s).",
"failing": "Tasques fallides",
"scheduled": "Tasques programades",
"runningNow": "En execució"
},
"recentErrors": {
"title": "Errors recents",
"empty": "Cap error registrat.",
"count": "{{count}} errors",
"warnings": "Advertiments"
},
"team": {
"title": "Membres de l'equip",
"members": "{{count}} membres",
"unverified": "Sense verificar"
}
},
"picker": {
"trigger": "Edita les targetes",
"title": "Targetes del tauler",
"done": "Fet",
"reset": "Restableix els valors predeterminats",
"groups": {
"uptime": "Temps en línia",
"infrastructure": "Infraestructura",
"incidents": "Incidents",
"notifications": "Notificacions",
"maintenance": "Manteniment",
"statusPages": "Pàgines d'estat",
"checks": "Comprovacions",
"logs": "Sistema",
"settings": "Configuració"
}
},
"empty": {
"title": "No hi ha targetes al vostre tauler",
"description": "Trieu les targetes que voleu veure. La vostra selecció es recorda en aquest navegador."
},
"reorder": {
"moveUp": "Mou amunt",
"moveDown": "Mou avall"
}
},
"notFound": {
"title": "Oh no! Has deixat caure el teu sushi!",
"subtitle": "O bé la URL no existeix, o bé no tens accés."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení",
"dashboard": "Přehled",
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Přehled",
"description": "Zobrazte každý typ monitoru na jednom místě a poznejte, co vyžaduje pozornost."
},
"card": {
"error": "Tuto kartu se nepodařilo načíst."
},
"cards": {
"monitorStatus": {
"title": "Stav monitorů",
"empty": "Zatím žádné monitory.",
"upOfTotal": "z {{total}} běží"
},
"currentlyDown": {
"title": "Aktuálně nedostupné",
"empty": "Všechny monitory běží. Žádné otevřené incidenty.",
"count": "{{count}} vyžaduje pozornost"
},
"monitorsByType": {
"title": "Monitory podle typu",
"empty": "Zatím žádné monitory.",
"down": "{{count}} nedostupných"
},
"recentIncidents": {
"title": "Nedávné incidenty",
"empty": "Žádné incidenty — všechny systémy v pořádku.",
"active": "{{count}} otevřených",
"ongoing": "Probíhá",
"manual": "Ručně",
"unknownMonitor": "Neznámý monitor"
},
"failedChecks": {
"title": "Neúspěšné kontroly",
"empty": "Zatím žádné kontroly.",
"ratio": "{{failed}} z {{total}} za poslední den"
},
"checkmateServer": {
"title": "Server Checkmate",
"memory": "Paměť",
"eventLoop": "Zpoždění event loop",
"uptime": "Doba běhu",
"database": "Databáze",
"readsWrites": "{{reads}} čtení/s · {{writes}} zápisů/s"
},
"ranking": {
"topOf": "Top {{shown}} z {{total}}",
"notEnough": "Zatím není dost monitorů pro žebříček."
},
"slowestMonitors": {
"title": "Nejpomalejší monitory"
},
"lowestUptime": {
"title": "Nejnižší dostupnost"
},
"monitorsByGroup": {
"title": "Monitory podle skupiny",
"empty": "Zatím žádné skupiny. Skupiny se nastavují u monitoru."
},
"busiestServers": {
"title": "Nejvytíženější servery",
"empty": "Zatím žádné monitory infrastruktury.",
"cpu": "CPU",
"memory": "Paměť",
"disk": "Disk"
},
"incidentStats": {
"title": "Statistiky incidentů",
"openNow": "Nyní otevřené",
"avgResolution": "Průměrná doba vyřešení",
"hours": "{{hours}} h",
"mostIncidents": "Nejvíce incidentů",
"total": "celkem",
"resolved": "Vyřešeno",
"automatic": "Automaticky",
"manual": "Ručně",
"autoManual": "{{automatic}} automaticky · {{manual}} ručně"
},
"notificationChannels": {
"title": "Kanály oznámení",
"empty": "Zatím žádné kanály."
},
"maintenance": {
"title": "Okna údržby",
"empty": "Žádná naplánovaná údržba.",
"runningNow": "Právě probíhá",
"next": "Další okno"
},
"statusPages": {
"title": "Stavové stránky",
"empty": "Zatím žádné stavové stránky.",
"monitorCount": "{{count}} monitorů",
"published": "Publikováno",
"draft": "Koncept"
},
"checksOnSchedule": {
"title": "Fronta kontrol",
"healthy": "Všech {{jobs}} úloh běží podle plánu na {{workers}} workerech.",
"failing": "Selhávající úlohy",
"scheduled": "Naplánované úlohy",
"runningNow": "Právě probíhá"
},
"recentErrors": {
"title": "Nedávné chyby",
"empty": "Žádné zaznamenané chyby.",
"count": "{{count}} chyb",
"warnings": "Varování"
},
"team": {
"title": "Členové týmu",
"members": "{{count}} členů",
"unverified": "Neověření"
}
},
"picker": {
"trigger": "Upravit karty",
"title": "Karty přehledu",
"done": "Hotovo",
"reset": "Obnovit výchozí",
"groups": {
"uptime": "Dostupnost",
"infrastructure": "Infrastruktura",
"incidents": "Incidenty",
"notifications": "Notifikace",
"maintenance": "Údržba",
"statusPages": "Stavové stránky",
"checks": "Kontroly",
"logs": "Systém",
"settings": "Nastavení"
}
},
"empty": {
"title": "Ve vašem přehledu nejsou žádné karty",
"description": "Vyberte karty, které chcete zobrazit. Váš výběr se uchová v tomto prohlížeči."
},
"reorder": {
"moveUp": "Posunout nahoru",
"moveDown": "Posunout dolů"
}
},
"notFound": {
"title": "Ale ne! Upustili jste své sushi!",
"subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Wartung",
"logs": "Protokolle",
"settings": "Einstellungen",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "Sehen Sie jeden Monitortyp an einem Ort und erkennen Sie, was Aufmerksamkeit braucht."
},
"card": {
"error": "Diese Karte konnte nicht geladen werden."
},
"cards": {
"monitorStatus": {
"title": "Monitor-Status",
"empty": "Noch keine Monitore.",
"upOfTotal": "von {{total}} online"
},
"currentlyDown": {
"title": "Derzeit offline",
"empty": "Alle Monitore sind online. Keine offenen Vorfälle.",
"count": "{{count}} benötigen Aufmerksamkeit"
},
"monitorsByType": {
"title": "Monitore nach Typ",
"empty": "Noch keine Monitore.",
"down": "{{count}} offline"
},
"recentIncidents": {
"title": "Letzte Vorfälle",
"empty": "Keine Vorfälle — alle Systeme gesund.",
"active": "{{count}} offen",
"ongoing": "Laufend",
"manual": "Manuell",
"unknownMonitor": "Unbekannter Monitor"
},
"failedChecks": {
"title": "Fehlgeschlagene Prüfungen",
"empty": "Noch keine Prüfungen.",
"ratio": "{{failed}} von {{total}} am letzten Tag"
},
"checkmateServer": {
"title": "Checkmate-Server",
"memory": "Arbeitsspeicher",
"eventLoop": "Event-Loop-Verzögerung",
"uptime": "Laufzeit",
"database": "Datenbank",
"readsWrites": "{{reads}} Lesevorgänge/s · {{writes}} Schreibvorgänge/s"
},
"ranking": {
"topOf": "Top {{shown}} von {{total}}",
"notEnough": "Noch nicht genug Monitore für ein Ranking."
},
"slowestMonitors": {
"title": "Langsamste Monitore"
},
"lowestUptime": {
"title": "Niedrigste Uptime"
},
"monitorsByGroup": {
"title": "Monitore nach Gruppe",
"empty": "Noch keine Gruppen. Gruppen werden pro Monitor gesetzt."
},
"busiestServers": {
"title": "Ausgelastetste Server",
"empty": "Noch keine Infrastruktur-Monitore.",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"disk": "Festplatte"
},
"incidentStats": {
"title": "Vorfallstatistik",
"openNow": "Jetzt offen",
"avgResolution": "Durchschnittliche Lösungszeit",
"hours": "{{hours}} Std.",
"mostIncidents": "Meiste Vorfälle",
"total": "gesamt",
"resolved": "Gelöst",
"automatic": "Automatisch",
"manual": "Manuell",
"autoManual": "{{automatic}} automatisch · {{manual}} manuell"
},
"notificationChannels": {
"title": "Benachrichtigungskanäle",
"empty": "Noch keine Kanäle."
},
"maintenance": {
"title": "Wartungsfenster",
"empty": "Keine Wartung geplant.",
"runningNow": "Läuft gerade",
"next": "Nächstes Fenster"
},
"statusPages": {
"title": "Statusseiten",
"empty": "Noch keine Statusseiten.",
"monitorCount": "{{count}} Monitore",
"published": "Veröffentlicht",
"draft": "Entwurf"
},
"checksOnSchedule": {
"title": "Prüfungs-Warteschlange",
"healthy": "Alle {{jobs}} Jobs planmäßig auf {{workers}} Worker(n).",
"failing": "Fehlgeschlagene Jobs",
"scheduled": "Geplante Jobs",
"runningNow": "Läuft gerade"
},
"recentErrors": {
"title": "Letzte Fehler",
"empty": "Keine Fehler protokolliert.",
"count": "{{count}} Fehler",
"warnings": "Warnungen"
},
"team": {
"title": "Teammitglieder",
"members": "{{count}} Mitglieder",
"unverified": "Nicht verifiziert"
}
},
"picker": {
"trigger": "Karten bearbeiten",
"title": "Dashboard-Karten",
"done": "Fertig",
"reset": "Auf Standard zurücksetzen",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infrastruktur",
"incidents": "Vorfälle",
"notifications": "Benachrichtigungen",
"maintenance": "Wartung",
"statusPages": "Statusseiten",
"checks": "Prüfungen",
"logs": "System",
"settings": "Einstellungen"
}
},
"empty": {
"title": "Keine Karten auf Ihrem Dashboard",
"description": "Wählen Sie die Karten, die Sie sehen möchten. Ihre Auswahl wird in diesem Browser gespeichert."
},
"reorder": {
"moveUp": "Nach oben",
"moveDown": "Nach unten"
}
},
"notFound": {
"title": "Oh nein! Sie haben Ihr Sushi fallen lassen!",
"subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf."
+2 -16
View File
@@ -206,7 +206,6 @@
},
"sidebar": {
"menu": {
"dashboard": "Dashboard",
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
@@ -220,6 +219,7 @@
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
@@ -376,7 +376,6 @@
"picker": {
"trigger": "Edit cards",
"title": "Dashboard cards",
"selectAll": "Select all",
"done": "Done",
"reset": "Reset to defaults",
"groups": {
@@ -1127,10 +1126,6 @@
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
@@ -1393,7 +1388,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"option": {
"duration": {
"label": "Duration"
@@ -1475,7 +1470,6 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1496,14 +1490,6 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración",
"dashboard": "Panel",
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Panel",
"description": "Vea cada tipo de monitor en un solo lugar y detecte lo que necesita atención."
},
"card": {
"error": "No se pudo cargar esta tarjeta."
},
"cards": {
"monitorStatus": {
"title": "Estado de los monitores",
"empty": "Aún no hay monitores.",
"upOfTotal": "de {{total}} activos"
},
"currentlyDown": {
"title": "Caídos ahora",
"empty": "Todos los monitores están activos. Sin incidentes abiertos.",
"count": "{{count}} requieren atención"
},
"monitorsByType": {
"title": "Monitores por tipo",
"empty": "Aún no hay monitores.",
"down": "{{count}} caídos"
},
"recentIncidents": {
"title": "Incidentes recientes",
"empty": "Sin incidentes: todos los sistemas están sanos.",
"active": "{{count}} abiertos",
"ongoing": "En curso",
"manual": "Manual",
"unknownMonitor": "Monitor desconocido"
},
"failedChecks": {
"title": "Comprobaciones fallidas",
"empty": "Aún no hay comprobaciones.",
"ratio": "{{failed}} de {{total}} en el último día"
},
"checkmateServer": {
"title": "Servidor de Checkmate",
"memory": "Memoria",
"eventLoop": "Retardo del bucle de eventos",
"uptime": "Tiempo activo",
"database": "Base de datos",
"readsWrites": "{{reads}} lecturas/s · {{writes}} escrituras/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "No hay suficientes monitores para clasificar."
},
"slowestMonitors": {
"title": "Monitores más lentos"
},
"lowestUptime": {
"title": "Menor uptime"
},
"monitorsByGroup": {
"title": "Monitores por grupo",
"empty": "Aún no hay grupos. Los grupos se definen por monitor."
},
"busiestServers": {
"title": "Servidores más cargados",
"empty": "Aún no hay monitores de infraestructura.",
"cpu": "CPU",
"memory": "Memoria",
"disk": "Disco"
},
"incidentStats": {
"title": "Estadísticas de incidentes",
"openNow": "Abiertos ahora",
"avgResolution": "Tiempo medio de resolución",
"hours": "{{hours}} h",
"mostIncidents": "Más incidentes",
"total": "total",
"resolved": "Resueltos",
"automatic": "Automática",
"manual": "Manual",
"autoManual": "{{automatic}} automáticas · {{manual}} manuales"
},
"notificationChannels": {
"title": "Canales de notificación",
"empty": "Aún no hay canales."
},
"maintenance": {
"title": "Ventanas de mantenimiento",
"empty": "Sin mantenimiento programado.",
"runningNow": "En ejecución",
"next": "Próxima ventana"
},
"statusPages": {
"title": "Páginas de estado",
"empty": "Aún no hay páginas de estado.",
"monitorCount": "{{count}} monitores",
"published": "Publicada",
"draft": "Borrador"
},
"checksOnSchedule": {
"title": "Cola de comprobaciones",
"healthy": "Los {{jobs}} trabajos van según lo previsto en {{workers}} worker(s).",
"failing": "Trabajos fallidos",
"scheduled": "Trabajos programados",
"runningNow": "En ejecución"
},
"recentErrors": {
"title": "Errores recientes",
"empty": "No se registraron errores.",
"count": "{{count}} errores",
"warnings": "Advertencias"
},
"team": {
"title": "Miembros del equipo",
"members": "{{count}} miembros",
"unverified": "Sin verificar"
}
},
"picker": {
"trigger": "Editar tarjetas",
"title": "Tarjetas del panel",
"done": "Listo",
"reset": "Restablecer valores predeterminados",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infraestructura",
"incidents": "Incidentes",
"notifications": "Notificaciones",
"maintenance": "Mantenimiento",
"statusPages": "Páginas de estado",
"checks": "Comprobaciones",
"logs": "Sistema",
"settings": "Configuración"
}
},
"empty": {
"title": "No hay tarjetas en tu panel",
"description": "Elige las tarjetas que quieres ver. Tu selección se recuerda en este navegador."
},
"reorder": {
"moveUp": "Mover arriba",
"moveDown": "Mover abajo"
}
},
"notFound": {
"title": "¡Oh no! ¡Se le cayó el sushi!",
"subtitle": "La URL no existe o no tiene acceso a ella."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Huolto",
"logs": "Lokit",
"settings": "Asetukset",
"dashboard": "Kojelauta",
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Kojelauta",
"description": "Näe kaikki monitorityypit yhdessä paikassa ja huomaa huomiota vaativat asiat."
},
"card": {
"error": "Tätä korttia ei voitu ladata."
},
"cards": {
"monitorStatus": {
"title": "Monitorien tila",
"empty": "Ei vielä monitoreja.",
"upOfTotal": "/ {{total}} käynnissä"
},
"currentlyDown": {
"title": "Tällä hetkellä alhaalla",
"empty": "Kaikki monitorit ovat käynnissä. Ei avoimia tapauksia.",
"count": "{{count}} vaatii huomiota"
},
"monitorsByType": {
"title": "Monitorit tyypeittäin",
"empty": "Ei vielä monitoreja.",
"down": "{{count}} alhaalla"
},
"recentIncidents": {
"title": "Viimeisimmät tapaukset",
"empty": "Ei tapauksia — kaikki järjestelmät kunnossa.",
"active": "{{count}} avoinna",
"ongoing": "Käynnissä",
"manual": "Manuaalinen",
"unknownMonitor": "Tuntematon monitori"
},
"failedChecks": {
"title": "Epäonnistuneet tarkistukset",
"empty": "Ei vielä tarkistuksia.",
"ratio": "{{failed}} / {{total}} viimeisen vuorokauden aikana"
},
"checkmateServer": {
"title": "Checkmate-palvelin",
"memory": "Muisti",
"eventLoop": "Tapahtumasilmukan viive",
"uptime": "Käyntiaika",
"database": "Tietokanta",
"readsWrites": "{{reads}} lukua/s · {{writes}} kirjoitusta/s"
},
"ranking": {
"topOf": "Top {{shown}} / {{total}}",
"notEnough": "Ei tarpeeksi monitoreja järjestykseen."
},
"slowestMonitors": {
"title": "Hitaimmat monitorit"
},
"lowestUptime": {
"title": "Alhaisin käytettävyys"
},
"monitorsByGroup": {
"title": "Monitorit ryhmittäin",
"empty": "Ei vielä ryhmiä. Ryhmät asetetaan monitorikohtaisesti."
},
"busiestServers": {
"title": "Kuormitetuimmat palvelimet",
"empty": "Ei vielä infrastruktuurimonitoreja.",
"cpu": "Suoritin",
"memory": "Muisti",
"disk": "Levy"
},
"incidentStats": {
"title": "Tapaustilastot",
"openNow": "Avoinna nyt",
"avgResolution": "Keskimääräinen ratkaisuaika",
"hours": "{{hours}} t",
"mostIncidents": "Eniten tapauksia",
"total": "yhteensä",
"resolved": "Ratkaistu",
"automatic": "Automaattinen",
"manual": "Manuaalinen",
"autoManual": "{{automatic}} automaattista · {{manual}} manuaalista"
},
"notificationChannels": {
"title": "Ilmoituskanavat",
"empty": "Ei vielä kanavia."
},
"maintenance": {
"title": "Huoltoikkunat",
"empty": "Ei suunniteltua huoltoa.",
"runningNow": "Käynnissä nyt",
"next": "Seuraava ikkuna"
},
"statusPages": {
"title": "Tilasivut",
"empty": "Ei vielä tilasivuja.",
"monitorCount": "{{count}} monitoria",
"published": "Julkaistu",
"draft": "Luonnos"
},
"checksOnSchedule": {
"title": "Tarkistusjono",
"healthy": "Kaikki {{jobs}} työtä aikataulussa {{workers}} työntekijällä.",
"failing": "Epäonnistuvat työt",
"scheduled": "Ajastetut työt",
"runningNow": "Käynnissä nyt"
},
"recentErrors": {
"title": "Viimeisimmät virheet",
"empty": "Ei kirjattuja virheitä.",
"count": "{{count}} virhettä",
"warnings": "Varoitukset"
},
"team": {
"title": "Tiimin jäsenet",
"members": "{{count}} jäsentä",
"unverified": "Vahvistamattomat"
}
},
"picker": {
"trigger": "Muokkaa kortteja",
"title": "Kojelaudan kortit",
"done": "Valmis",
"reset": "Palauta oletukset",
"groups": {
"uptime": "Käytettävyys",
"infrastructure": "Infrastruktuuri",
"incidents": "Tapaukset",
"notifications": "Ilmoitukset",
"maintenance": "Huolto",
"statusPages": "Tilasivut",
"checks": "Tarkistukset",
"logs": "Järjestelmä",
"settings": "Asetukset"
}
},
"empty": {
"title": "Kojelaudallasi ei ole kortteja",
"description": "Valitse näytettävät kortit. Valintasi muistetaan tässä selaimessa."
},
"reorder": {
"moveUp": "Siirrä ylös",
"moveDown": "Siirrä alas"
}
},
"notFound": {
"title": "Voi ei! Sushisi putosi!",
"subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Maintenance",
"logs": "Journaux",
"settings": "Paramètres",
"dashboard": "Tableau de bord",
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Tableau de bord",
"description": "Voyez chaque type de moniteur au même endroit et repérez ce qui demande votre attention."
},
"card": {
"error": "Impossible de charger cette carte."
},
"cards": {
"monitorStatus": {
"title": "État des moniteurs",
"empty": "Aucun moniteur pour l'instant.",
"upOfTotal": "sur {{total}} en ligne"
},
"currentlyDown": {
"title": "Actuellement hors ligne",
"empty": "Tous les moniteurs sont en ligne. Aucun incident ouvert.",
"count": "{{count}} à surveiller"
},
"monitorsByType": {
"title": "Moniteurs par type",
"empty": "Aucun moniteur pour l'instant.",
"down": "{{count}} hors ligne"
},
"recentIncidents": {
"title": "Incidents récents",
"empty": "Aucun incident — tous les systèmes sont sains.",
"active": "{{count}} ouverts",
"ongoing": "En cours",
"manual": "Manuel",
"unknownMonitor": "Moniteur inconnu"
},
"failedChecks": {
"title": "Vérifications échouées",
"empty": "Aucune vérification pour l'instant.",
"ratio": "{{failed}} sur {{total}} le dernier jour"
},
"checkmateServer": {
"title": "Serveur Checkmate",
"memory": "Mémoire",
"eventLoop": "Latence de la boucle d'événements",
"uptime": "Durée de fonctionnement",
"database": "Base de données",
"readsWrites": "{{reads}} lectures/s · {{writes}} écritures/s"
},
"ranking": {
"topOf": "Top {{shown}} sur {{total}}",
"notEnough": "Pas assez de moniteurs pour établir un classement."
},
"slowestMonitors": {
"title": "Moniteurs les plus lents"
},
"lowestUptime": {
"title": "Disponibilité la plus faible"
},
"monitorsByGroup": {
"title": "Moniteurs par groupe",
"empty": "Aucun groupe pour l'instant. Les groupes se définissent par moniteur."
},
"busiestServers": {
"title": "Serveurs les plus chargés",
"empty": "Aucun moniteur d'infrastructure pour l'instant.",
"cpu": "Processeur",
"memory": "Mémoire",
"disk": "Disque"
},
"incidentStats": {
"title": "Statistiques d'incidents",
"openNow": "Ouverts maintenant",
"avgResolution": "Temps moyen de résolution",
"hours": "{{hours}} h",
"mostIncidents": "Le plus d'incidents",
"total": "total",
"resolved": "Résolus",
"automatic": "Automatique",
"manual": "Manuel",
"autoManual": "{{automatic}} automatiques · {{manual}} manuels"
},
"notificationChannels": {
"title": "Canaux de notification",
"empty": "Aucun canal pour l'instant."
},
"maintenance": {
"title": "Fenêtres de maintenance",
"empty": "Aucune maintenance planifiée.",
"runningNow": "En cours",
"next": "Prochaine fenêtre"
},
"statusPages": {
"title": "Pages de statut",
"empty": "Aucune page de statut pour l'instant.",
"monitorCount": "{{count}} moniteurs",
"published": "Publiée",
"draft": "Brouillon"
},
"checksOnSchedule": {
"title": "File de vérifications",
"healthy": "Les {{jobs}} tâches sont à l'heure sur {{workers}} worker(s).",
"failing": "Tâches en échec",
"scheduled": "Tâches planifiées",
"runningNow": "En cours"
},
"recentErrors": {
"title": "Erreurs récentes",
"empty": "Aucune erreur enregistrée.",
"count": "{{count}} erreurs",
"warnings": "Avertissements"
},
"team": {
"title": "Membres de l'équipe",
"members": "{{count}} membres",
"unverified": "Non vérifiés"
}
},
"picker": {
"trigger": "Modifier les cartes",
"title": "Cartes du tableau de bord",
"done": "Terminé",
"reset": "Réinitialiser par défaut",
"groups": {
"uptime": "Disponibilité",
"infrastructure": "Infrastructure",
"incidents": "Incidents",
"notifications": "Notifications",
"maintenance": "Maintenance",
"statusPages": "Pages de statut",
"checks": "Vérifications",
"logs": "Système",
"settings": "Paramètres"
}
},
"empty": {
"title": "Aucune carte sur votre tableau de bord",
"description": "Choisissez les cartes à afficher. Votre sélection est mémorisée dans ce navigateur."
},
"reorder": {
"moveUp": "Monter",
"moveDown": "Descendre"
}
},
"notFound": {
"title": "Oh non ! Vous avez fait tomber vos sushis !",
"subtitle": "Soit l'URL n'existe pas, soit vous n'y avez pas accès."
+141
View File
@@ -194,6 +194,7 @@
"maintenance": "Manutenzione",
"logs": "Log",
"settings": "Impostazioni",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoraggio",
"alerting": "Avvisi",
@@ -233,6 +234,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Dashboard",
"description": "Vedi ogni tipo di monitor in un unico posto e individua ciò che richiede attenzione."
},
"card": {
"error": "Impossibile caricare questa scheda."
},
"cards": {
"monitorStatus": {
"title": "Stato dei monitor",
"empty": "Nessun monitor ancora.",
"upOfTotal": "su {{total}} attivi"
},
"currentlyDown": {
"title": "Attualmente offline",
"empty": "Tutti i monitor sono attivi. Nessun incidente aperto.",
"count": "{{count}} richiedono attenzione"
},
"monitorsByType": {
"title": "Monitor per tipo",
"empty": "Nessun monitor ancora.",
"down": "{{count}} offline"
},
"recentIncidents": {
"title": "Incidenti recenti",
"empty": "Nessun incidente — tutti i sistemi sono sani.",
"active": "{{count}} aperti",
"ongoing": "In corso",
"manual": "Manuale",
"unknownMonitor": "Monitor sconosciuto"
},
"failedChecks": {
"title": "Verifiche fallite",
"empty": "Nessuna verifica ancora.",
"ratio": "{{failed}} su {{total}} nell'ultimo giorno"
},
"checkmateServer": {
"title": "Server Checkmate",
"memory": "Memoria",
"eventLoop": "Ritardo dell'event loop",
"uptime": "Tempo di attività",
"database": "Database",
"readsWrites": "{{reads}} letture/s · {{writes}} scritture/s"
},
"ranking": {
"topOf": "Top {{shown}} su {{total}}",
"notEnough": "Non ci sono abbastanza monitor per una classifica."
},
"slowestMonitors": {
"title": "Monitor più lenti"
},
"lowestUptime": {
"title": "Uptime più basso"
},
"monitorsByGroup": {
"title": "Monitor per gruppo",
"empty": "Nessun gruppo ancora. I gruppi si impostano per monitor."
},
"busiestServers": {
"title": "Server più carichi",
"empty": "Nessun monitor infrastruttura ancora.",
"cpu": "CPU",
"memory": "Memoria",
"disk": "Disco"
},
"incidentStats": {
"title": "Statistiche incidenti",
"openNow": "Aperti ora",
"avgResolution": "Tempo medio di risoluzione",
"hours": "{{hours}} h",
"mostIncidents": "Più incidenti",
"total": "totale",
"resolved": "Risolti",
"automatic": "Automatica",
"manual": "Manuale",
"autoManual": "{{automatic}} automatiche · {{manual}} manuali"
},
"notificationChannels": {
"title": "Canali di notifica",
"empty": "Nessun canale ancora."
},
"maintenance": {
"title": "Finestre di manutenzione",
"empty": "Nessuna manutenzione pianificata.",
"runningNow": "In esecuzione",
"next": "Prossima finestra"
},
"statusPages": {
"title": "Pagine di stato",
"empty": "Nessuna pagina di stato ancora.",
"monitorCount": "{{count}} monitor",
"published": "Pubblicata",
"draft": "Bozza"
},
"checksOnSchedule": {
"title": "Coda delle verifiche",
"healthy": "Tutti i {{jobs}} job sono in orario su {{workers}} worker.",
"failing": "Job falliti",
"scheduled": "Job pianificati",
"runningNow": "In esecuzione"
},
"recentErrors": {
"title": "Errori recenti",
"empty": "Nessun errore registrato.",
"count": "{{count}} errori",
"warnings": "Avvisi"
},
"team": {
"title": "Membri del team",
"members": "{{count}} membri",
"unverified": "Non verificati"
}
},
"picker": {
"trigger": "Modifica schede",
"title": "Schede della dashboard",
"done": "Fatto",
"reset": "Ripristina predefiniti",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infrastruttura",
"incidents": "Incidenti",
"notifications": "Notifiche",
"maintenance": "Manutenzione",
"statusPages": "Pagine di stato",
"checks": "Verifiche",
"logs": "Sistema",
"settings": "Impostazioni"
}
},
"empty": {
"title": "Nessuna scheda nella tua dashboard",
"description": "Scegli le schede da mostrare. La tua selezione viene ricordata in questo browser."
},
"reorder": {
"moveUp": "Sposta su",
"moveDown": "Sposta giù"
}
},
"notFound": {
"title": "Oh no! Hai fatto cadere il tuo sushi!",
"subtitle": "L'URL non esiste, oppure non hai accesso."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "メンテナンス",
"logs": "ログ",
"settings": "設定",
"dashboard": "ダッシュボード",
"groups": {
"monitoring": "監視",
"alerting": "アラート",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "ダッシュボード",
"description": "すべての監視タイプを一か所で確認し、対応が必要な箇所を把握できます。"
},
"card": {
"error": "このカードを読み込めませんでした。"
},
"cards": {
"monitorStatus": {
"title": "監視の状態",
"empty": "監視はまだありません。",
"upOfTotal": "/ {{total}} が稼働中"
},
"currentlyDown": {
"title": "現在ダウン中",
"empty": "すべての監視が稼働中です。未解決のインシデントはありません。",
"count": "{{count}} 件が要対応"
},
"monitorsByType": {
"title": "種類別の監視",
"empty": "監視はまだありません。",
"down": "{{count}} 件ダウン"
},
"recentIncidents": {
"title": "最近のインシデント",
"empty": "インシデントなし — すべて正常です。",
"active": "{{count}} 件未解決",
"ongoing": "進行中",
"manual": "手動",
"unknownMonitor": "不明な監視"
},
"failedChecks": {
"title": "失敗したチェック",
"empty": "チェックはまだありません。",
"ratio": "直近1日で {{total}} 件中 {{failed}} 件"
},
"checkmateServer": {
"title": "Checkmate サーバー",
"memory": "メモリ",
"eventLoop": "イベントループ遅延",
"uptime": "稼働時間",
"database": "データベース",
"readsWrites": "{{reads}} 読み取り/秒 · {{writes}} 書き込み/秒"
},
"ranking": {
"topOf": "{{total}} 件中の上位 {{shown}} 件",
"notEnough": "ランキングに十分な監視がありません。"
},
"slowestMonitors": {
"title": "最も遅い監視"
},
"lowestUptime": {
"title": "稼働率が最も低い監視"
},
"monitorsByGroup": {
"title": "グループ別の監視",
"empty": "グループはまだありません。グループは監視ごとに設定します。"
},
"busiestServers": {
"title": "最も負荷の高いサーバー",
"empty": "インフラ監視はまだありません。",
"cpu": "CPU",
"memory": "メモリ",
"disk": "ディスク"
},
"incidentStats": {
"title": "インシデント統計",
"openNow": "現在未解決",
"avgResolution": "平均解決時間",
"hours": "{{hours}} 時間",
"mostIncidents": "インシデント最多",
"total": "合計",
"resolved": "解決済み",
"automatic": "自動",
"manual": "手動",
"autoManual": "自動 {{automatic}} 件 · 手動 {{manual}} 件"
},
"notificationChannels": {
"title": "通知チャンネル",
"empty": "チャンネルはまだありません。"
},
"maintenance": {
"title": "メンテナンスウィンドウ",
"empty": "予定されたメンテナンスはありません。",
"runningNow": "実行中",
"next": "次のウィンドウ"
},
"statusPages": {
"title": "ステータスページ",
"empty": "ステータスページはまだありません。",
"monitorCount": "{{count}} 件の監視",
"published": "公開中",
"draft": "下書き"
},
"checksOnSchedule": {
"title": "チェックキュー",
"healthy": "{{workers}} 個のワーカーで {{jobs}} 件のジョブがすべて予定どおりです。",
"failing": "失敗中のジョブ",
"scheduled": "予定されたジョブ",
"runningNow": "実行中"
},
"recentErrors": {
"title": "最近のエラー",
"empty": "エラーは記録されていません。",
"count": "{{count}} 件のエラー",
"warnings": "警告"
},
"team": {
"title": "チームメンバー",
"members": "{{count}} 人のメンバー",
"unverified": "未確認"
}
},
"picker": {
"trigger": "カードを編集",
"title": "ダッシュボードのカード",
"done": "完了",
"reset": "既定に戻す",
"groups": {
"uptime": "稼働監視",
"infrastructure": "インフラ",
"incidents": "インシデント",
"notifications": "通知",
"maintenance": "メンテナンス",
"statusPages": "ステータスページ",
"checks": "チェック",
"logs": "システム",
"settings": "設定"
}
},
"empty": {
"title": "ダッシュボードにカードがありません",
"description": "表示したいカードを選択してください。選択内容はこのブラウザーに保存されます。"
},
"reorder": {
"moveUp": "上へ移動",
"moveDown": "下へ移動"
}
},
"notFound": {
"title": "おっと!お寿司を落としてしまいました!",
"subtitle": "URLが存在しないか、アクセス権限がありません。"
+141
View File
@@ -215,6 +215,7 @@
"statusPages": "Strony statusu",
"tags": "Tagi",
"uptime": "Czas działania",
"dashboard": "Pulpit",
"groups": {
"monitoring": "Monitorowanie",
"alerting": "Alerty",
@@ -233,6 +234,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Pulpit",
"description": "Zobacz każdy typ monitora w jednym miejscu i dostrzeż, co wymaga uwagi."
},
"card": {
"error": "Nie udało się załadować tej karty."
},
"cards": {
"monitorStatus": {
"title": "Stan monitorów",
"empty": "Brak monitorów.",
"upOfTotal": "z {{total}} działa"
},
"currentlyDown": {
"title": "Obecnie niedostępne",
"empty": "Wszystkie monitory działają. Brak otwartych incydentów.",
"count": "{{count}} wymaga uwagi"
},
"monitorsByType": {
"title": "Monitory według typu",
"empty": "Brak monitorów.",
"down": "{{count}} niedostępnych"
},
"recentIncidents": {
"title": "Ostatnie incydenty",
"empty": "Brak incydentów — wszystkie systemy sprawne.",
"active": "{{count}} otwartych",
"ongoing": "Trwa",
"manual": "Ręcznie",
"unknownMonitor": "Nieznany monitor"
},
"failedChecks": {
"title": "Nieudane checki",
"empty": "Brak checków.",
"ratio": "{{failed}} z {{total}} w ostatnim dniu"
},
"checkmateServer": {
"title": "Serwer Checkmate",
"memory": "Pamięć",
"eventLoop": "Opóźnienie pętli zdarzeń",
"uptime": "Czas działania",
"database": "Baza danych",
"readsWrites": "{{reads}} odczytów/s · {{writes}} zapisów/s"
},
"ranking": {
"topOf": "Top {{shown}} z {{total}}",
"notEnough": "Za mało monitorów, aby utworzyć ranking."
},
"slowestMonitors": {
"title": "Najwolniejsze monitory"
},
"lowestUptime": {
"title": "Najniższa dostępność"
},
"monitorsByGroup": {
"title": "Monitory według grupy",
"empty": "Brak grup. Grupy ustawia się przy monitorze."
},
"busiestServers": {
"title": "Najbardziej obciążone serwery",
"empty": "Brak monitorów infrastruktury.",
"cpu": "CPU",
"memory": "Pamięć",
"disk": "Dysk"
},
"incidentStats": {
"title": "Statystyki incydentów",
"openNow": "Otwarte teraz",
"avgResolution": "Średni czas rozwiązania",
"hours": "{{hours}} godz.",
"mostIncidents": "Najwięcej incydentów",
"total": "łącznie",
"resolved": "Rozwiązane",
"automatic": "Automatycznie",
"manual": "Ręcznie",
"autoManual": "{{automatic}} automatycznie · {{manual}} ręcznie"
},
"notificationChannels": {
"title": "Kanały powiadomień",
"empty": "Brak kanałów."
},
"maintenance": {
"title": "Przerwy techniczne",
"empty": "Brak zaplanowanych przerw.",
"runningNow": "Trwa teraz",
"next": "Następna przerwa"
},
"statusPages": {
"title": "Strony statusu",
"empty": "Brak stron statusu.",
"monitorCount": "{{count}} monitorów",
"published": "Opublikowana",
"draft": "Wersja robocza"
},
"checksOnSchedule": {
"title": "Kolejka checków",
"healthy": "Wszystkie {{jobs}} zadania zgodnie z planem na {{workers}} workerach.",
"failing": "Nieudane zadania",
"scheduled": "Zaplanowane zadania",
"runningNow": "Trwa teraz"
},
"recentErrors": {
"title": "Ostatnie błędy",
"empty": "Nie zarejestrowano błędów.",
"count": "{{count}} błędów",
"warnings": "Ostrzeżenia"
},
"team": {
"title": "Członkowie zespołu",
"members": "{{count}} członków",
"unverified": "Niezweryfikowani"
}
},
"picker": {
"trigger": "Edytuj karty",
"title": "Karty pulpitu",
"done": "Gotowe",
"reset": "Przywróć domyślne",
"groups": {
"uptime": "Dostępność",
"infrastructure": "Infrastruktura",
"incidents": "Incydenty",
"notifications": "Powiadomienia",
"maintenance": "Przerwy techniczne",
"statusPages": "Strony statusu",
"checks": "Checki",
"logs": "System",
"settings": "Ustawienia"
}
},
"empty": {
"title": "Brak kart na pulpicie",
"description": "Wybierz karty, które chcesz widzieć. Twój wybór zostanie zapamiętany w tej przeglądarce."
},
"reorder": {
"moveUp": "Przenieś w górę",
"moveDown": "Przenieś w dół"
}
},
"account": {
"form": {
"currentPassword": {
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Manutenção",
"logs": "Registros",
"settings": "Configurações",
"dashboard": "Painel",
"groups": {
"monitoring": "Monitoramento",
"alerting": "Alertas",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Painel",
"description": "Veja cada tipo de monitor em um só lugar e identifique o que precisa de atenção."
},
"card": {
"error": "Não foi possível carregar este cartão."
},
"cards": {
"monitorStatus": {
"title": "Status dos monitores",
"empty": "Ainda não há monitores.",
"upOfTotal": "de {{total}} no ar"
},
"currentlyDown": {
"title": "Fora do ar agora",
"empty": "Todos os monitores estão no ar. Nenhum incidente aberto.",
"count": "{{count}} precisam de atenção"
},
"monitorsByType": {
"title": "Monitores por tipo",
"empty": "Ainda não há monitores.",
"down": "{{count}} fora do ar"
},
"recentIncidents": {
"title": "Incidentes recentes",
"empty": "Nenhum incidente — todos os sistemas saudáveis.",
"active": "{{count}} abertos",
"ongoing": "Em andamento",
"manual": "Manual",
"unknownMonitor": "Monitor desconhecido"
},
"failedChecks": {
"title": "Verificações com falha",
"empty": "Ainda não há verificações.",
"ratio": "{{failed}} de {{total}} no último dia"
},
"checkmateServer": {
"title": "Servidor Checkmate",
"memory": "Memória",
"eventLoop": "Atraso do event loop",
"uptime": "Tempo no ar",
"database": "Banco de dados",
"readsWrites": "{{reads}} leituras/s · {{writes}} gravações/s"
},
"ranking": {
"topOf": "Top {{shown}} de {{total}}",
"notEnough": "Não há monitores suficientes para classificar."
},
"slowestMonitors": {
"title": "Monitores mais lentos"
},
"lowestUptime": {
"title": "Menor uptime"
},
"monitorsByGroup": {
"title": "Monitores por grupo",
"empty": "Ainda não há grupos. Grupos são definidos por monitor."
},
"busiestServers": {
"title": "Servidores mais ocupados",
"empty": "Ainda não há monitores de infraestrutura.",
"cpu": "CPU",
"memory": "Memória",
"disk": "Disco"
},
"incidentStats": {
"title": "Estatísticas de incidentes",
"openNow": "Abertos agora",
"avgResolution": "Tempo médio de resolução",
"hours": "{{hours}} h",
"mostIncidents": "Mais incidentes",
"total": "total",
"resolved": "Resolvidos",
"automatic": "Automática",
"manual": "Manual",
"autoManual": "{{automatic}} automáticas · {{manual}} manuais"
},
"notificationChannels": {
"title": "Canais de notificação",
"empty": "Ainda não há canais."
},
"maintenance": {
"title": "Janelas de manutenção",
"empty": "Nenhuma manutenção agendada.",
"runningNow": "Em execução",
"next": "Próxima janela"
},
"statusPages": {
"title": "Páginas de status",
"empty": "Ainda não há páginas de status.",
"monitorCount": "{{count}} monitores",
"published": "Publicada",
"draft": "Rascunho"
},
"checksOnSchedule": {
"title": "Fila de verificações",
"healthy": "Todos os {{jobs}} jobs em dia em {{workers}} worker(s).",
"failing": "Jobs com falha",
"scheduled": "Jobs agendados",
"runningNow": "Em execução"
},
"recentErrors": {
"title": "Erros recentes",
"empty": "Nenhum erro registrado.",
"count": "{{count}} erros",
"warnings": "Avisos"
},
"team": {
"title": "Membros da equipe",
"members": "{{count}} membros",
"unverified": "Não verificados"
}
},
"picker": {
"trigger": "Editar cartões",
"title": "Cartões do painel",
"done": "Concluído",
"reset": "Restaurar padrões",
"groups": {
"uptime": "Uptime",
"infrastructure": "Infraestrutura",
"incidents": "Incidentes",
"notifications": "Notificações",
"maintenance": "Manutenção",
"statusPages": "Páginas de status",
"checks": "Verificações",
"logs": "Sistema",
"settings": "Configurações"
}
},
"empty": {
"title": "Nenhum cartão no seu painel",
"description": "Escolha os cartões que deseja ver. Sua seleção é lembrada neste navegador."
},
"reorder": {
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo"
}
},
"notFound": {
"title": "Oh não! Você derrubou seu sushi!",
"subtitle": "A URL não existe ou você não tem acesso a ela."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Обслуживание",
"logs": "Логи",
"settings": "Настройки",
"dashboard": "Панель",
"groups": {
"monitoring": "Мониторинг",
"alerting": "Оповещения",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Панель",
"description": "Смотрите все типы мониторов в одном месте и замечайте требующее внимания."
},
"card": {
"error": "Не удалось загрузить эту карточку."
},
"cards": {
"monitorStatus": {
"title": "Состояние мониторов",
"empty": "Мониторов пока нет.",
"upOfTotal": "из {{total}} работают"
},
"currentlyDown": {
"title": "Сейчас недоступны",
"empty": "Все мониторы работают. Открытых инцидентов нет.",
"count": "{{count}} требуют внимания"
},
"monitorsByType": {
"title": "Мониторы по типу",
"empty": "Мониторов пока нет.",
"down": "{{count}} недоступны"
},
"recentIncidents": {
"title": "Последние инциденты",
"empty": "Инцидентов нет — все системы в порядке.",
"active": "{{count}} открыто",
"ongoing": "В процессе",
"manual": "Вручную",
"unknownMonitor": "Неизвестный монитор"
},
"failedChecks": {
"title": "Неудачные проверки",
"empty": "Проверок пока нет.",
"ratio": "{{failed}} из {{total}} за последние сутки"
},
"checkmateServer": {
"title": "Сервер Checkmate",
"memory": "Память",
"eventLoop": "Задержка event loop",
"uptime": "Время работы",
"database": "База данных",
"readsWrites": "{{reads}} чтений/с · {{writes}} записей/с"
},
"ranking": {
"topOf": "Топ {{shown}} из {{total}}",
"notEnough": "Недостаточно мониторов для рейтинга."
},
"slowestMonitors": {
"title": "Самые медленные мониторы"
},
"lowestUptime": {
"title": "Наименьшая работоспособность"
},
"monitorsByGroup": {
"title": "Мониторы по группе",
"empty": "Групп пока нет. Группы задаются у монитора."
},
"busiestServers": {
"title": "Самые загруженные серверы",
"empty": "Инфраструктурных мониторов пока нет.",
"cpu": "ЦП",
"memory": "Память",
"disk": "Диск"
},
"incidentStats": {
"title": "Статистика инцидентов",
"openNow": "Открыто сейчас",
"avgResolution": "Среднее время решения",
"hours": "{{hours}} ч",
"mostIncidents": "Больше всего инцидентов",
"total": "всего",
"resolved": "Решено",
"automatic": "Автоматически",
"manual": "Вручную",
"autoManual": "{{automatic}} автоматически · {{manual}} вручную"
},
"notificationChannels": {
"title": "Каналы уведомлений",
"empty": "Каналов пока нет."
},
"maintenance": {
"title": "Окна обслуживания",
"empty": "Обслуживание не запланировано.",
"runningNow": "Выполняется",
"next": "Следующее окно"
},
"statusPages": {
"title": "Страницы состояния",
"empty": "Страниц состояния пока нет.",
"monitorCount": "{{count}} мониторов",
"published": "Опубликована",
"draft": "Черновик"
},
"checksOnSchedule": {
"title": "Очередь проверок",
"healthy": "Все {{jobs}} задач идут по расписанию на {{workers}} воркерах.",
"failing": "Сбойные задачи",
"scheduled": "Запланированные задачи",
"runningNow": "Выполняется"
},
"recentErrors": {
"title": "Последние ошибки",
"empty": "Ошибок не зафиксировано.",
"count": "{{count}} ошибок",
"warnings": "Предупреждения"
},
"team": {
"title": "Участники команды",
"members": "{{count}} участников",
"unverified": "Не подтверждены"
}
},
"picker": {
"trigger": "Изменить карточки",
"title": "Карточки панели",
"done": "Готово",
"reset": "Сбросить по умолчанию",
"groups": {
"uptime": "Работоспособность",
"infrastructure": "Инфраструктура",
"incidents": "Инциденты",
"notifications": "Уведомления",
"maintenance": "Обслуживание",
"statusPages": "Страницы состояния",
"checks": "Проверки",
"logs": "Система",
"settings": "Настройки"
}
},
"empty": {
"title": "На вашей панели нет карточек",
"description": "Выберите карточки, которые хотите видеть. Ваш выбор сохраняется в этом браузере."
},
"reorder": {
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз"
}
},
"notFound": {
"title": "Ой! Вы уронили суши!",
"subtitle": "Либо URL не существует, либо у Вас нет к нему доступа."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "การบำรุงรักษา",
"logs": "บันทึก",
"settings": "การตั้งค่า",
"dashboard": "แดชบอร์ด",
"groups": {
"monitoring": "การตรวจสอบ",
"alerting": "การแจ้งเตือน",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "แดชบอร์ด",
"description": "ดูตัวตรวจสอบทุกประเภทในที่เดียว และสังเกตสิ่งที่ต้องดูแล"
},
"card": {
"error": "ไม่สามารถโหลดการ์ดนี้ได้"
},
"cards": {
"monitorStatus": {
"title": "สถานะตัวตรวจสอบ",
"empty": "ยังไม่มีตัวตรวจสอบ",
"upOfTotal": "จาก {{total}} ที่ทำงาน"
},
"currentlyDown": {
"title": "ขณะนี้ล่ม",
"empty": "ตัวตรวจสอบทั้งหมดทำงานอยู่ ไม่มีเหตุการณ์ที่เปิดค้าง",
"count": "{{count}} รายการต้องดูแล"
},
"monitorsByType": {
"title": "ตัวตรวจสอบตามประเภท",
"empty": "ยังไม่มีตัวตรวจสอบ",
"down": "ล่ม {{count}} รายการ"
},
"recentIncidents": {
"title": "เหตุการณ์ล่าสุด",
"empty": "ไม่มีเหตุการณ์ — ทุกระบบปกติ",
"active": "เปิดอยู่ {{count}} รายการ",
"ongoing": "กำลังดำเนินอยู่",
"manual": "ด้วยตนเอง",
"unknownMonitor": "ตัวตรวจสอบที่ไม่รู้จัก"
},
"failedChecks": {
"title": "การตรวจสอบที่ล้มเหลว",
"empty": "ยังไม่มีการตรวจสอบ",
"ratio": "{{failed}} จาก {{total}} ในวันที่ผ่านมา"
},
"checkmateServer": {
"title": "เซิร์ฟเวอร์ Checkmate",
"memory": "หน่วยความจำ",
"eventLoop": "ความหน่วงของ event loop",
"uptime": "เวลาทำงาน",
"database": "ฐานข้อมูล",
"readsWrites": "อ่าน {{reads}}/วิ · เขียน {{writes}}/วิ"
},
"ranking": {
"topOf": "อันดับต้น {{shown}} จาก {{total}}",
"notEnough": "มีตัวตรวจสอบไม่พอสำหรับจัดอันดับ"
},
"slowestMonitors": {
"title": "ตัวตรวจสอบที่ช้าที่สุด"
},
"lowestUptime": {
"title": "uptime ต่ำที่สุด"
},
"monitorsByGroup": {
"title": "ตัวตรวจสอบตามกลุ่ม",
"empty": "ยังไม่มีกลุ่ม กลุ่มถูกกำหนดในแต่ละตัวตรวจสอบ"
},
"busiestServers": {
"title": "เซิร์ฟเวอร์ที่ใช้งานหนักที่สุด",
"empty": "ยังไม่มีตัวตรวจสอบโครงสร้างพื้นฐาน",
"cpu": "CPU",
"memory": "หน่วยความจำ",
"disk": "ดิสก์"
},
"incidentStats": {
"title": "สถิติเหตุการณ์",
"openNow": "เปิดอยู่ตอนนี้",
"avgResolution": "เวลาแก้ไขโดยเฉลี่ย",
"hours": "{{hours}} ชม.",
"mostIncidents": "เหตุการณ์มากที่สุด",
"total": "ทั้งหมด",
"resolved": "แก้ไขแล้ว",
"automatic": "อัตโนมัติ",
"manual": "ด้วยตนเอง",
"autoManual": "อัตโนมัติ {{automatic}} · ด้วยตนเอง {{manual}}"
},
"notificationChannels": {
"title": "ช่องทางการแจ้งเตือน",
"empty": "ยังไม่มีช่องทาง"
},
"maintenance": {
"title": "ช่วงเวลาบำรุงรักษา",
"empty": "ไม่มีการบำรุงรักษาที่กำหนดไว้",
"runningNow": "กำลังดำเนินการ",
"next": "ช่วงถัดไป"
},
"statusPages": {
"title": "หน้าสถานะ",
"empty": "ยังไม่มีหน้าสถานะ",
"monitorCount": "{{count}} ตัวตรวจสอบ",
"published": "เผยแพร่แล้ว",
"draft": "ฉบับร่าง"
},
"checksOnSchedule": {
"title": "คิวการตรวจสอบ",
"healthy": "งานทั้ง {{jobs}} รายการเป็นไปตามกำหนดบน {{workers}} worker",
"failing": "งานที่ล้มเหลว",
"scheduled": "งานที่กำหนดเวลาไว้",
"runningNow": "กำลังดำเนินการ"
},
"recentErrors": {
"title": "ข้อผิดพลาดล่าสุด",
"empty": "ไม่มีข้อผิดพลาดที่บันทึกไว้",
"count": "{{count}} ข้อผิดพลาด",
"warnings": "คำเตือน"
},
"team": {
"title": "สมาชิกทีม",
"members": "{{count}} สมาชิก",
"unverified": "ยังไม่ยืนยัน"
}
},
"picker": {
"trigger": "แก้ไขการ์ด",
"title": "การ์ดแดชบอร์ด",
"done": "เสร็จสิ้น",
"reset": "รีเซ็ตเป็นค่าเริ่มต้น",
"groups": {
"uptime": "Uptime",
"infrastructure": "โครงสร้างพื้นฐาน",
"incidents": "เหตุการณ์",
"notifications": "การแจ้งเตือน",
"maintenance": "การบำรุงรักษา",
"statusPages": "หน้าสถานะ",
"checks": "การตรวจสอบ",
"logs": "ระบบ",
"settings": "การตั้งค่า"
}
},
"empty": {
"title": "ไม่มีการ์ดบนแดชบอร์ดของคุณ",
"description": "เลือกการ์ดที่ต้องการเห็น การเลือกของคุณจะถูกจดจำในเบราว์เซอร์นี้"
},
"reorder": {
"moveUp": "เลื่อนขึ้น",
"moveDown": "เลื่อนลง"
}
},
"notFound": {
"title": "โอ้ไม่! ซูชิของคุณหล่นแล้ว!",
"subtitle": "URL ไม่มีอยู่ หรือคุณไม่มีสิทธิ์เข้าถึง"
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Bakım",
"logs": "Günlükler",
"settings": "Ayarlar",
"dashboard": "Panel",
"groups": {
"monitoring": "İzleme",
"alerting": "Uyarılar",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Panel",
"description": "Her izleyici türünü tek yerde görün ve neyin ilgi gerektirdiğini fark edin."
},
"card": {
"error": "Bu kart yüklenemedi."
},
"cards": {
"monitorStatus": {
"title": "İzleyici durumu",
"empty": "Henüz izleyici yok.",
"upOfTotal": "/ {{total}} çalışıyor"
},
"currentlyDown": {
"title": "Şu anda kapalı",
"empty": "Tüm izleyiciler çalışıyor. Açık olay yok.",
"count": "{{count}} ilgi bekliyor"
},
"monitorsByType": {
"title": "Türe göre izleyiciler",
"empty": "Henüz izleyici yok.",
"down": "{{count}} kapalı"
},
"recentIncidents": {
"title": "Son olaylar",
"empty": "Olay yok — tüm sistemler sağlıklı.",
"active": "{{count}} açık",
"ongoing": "Devam ediyor",
"manual": "Manuel",
"unknownMonitor": "Bilinmeyen izleyici"
},
"failedChecks": {
"title": "Başarısız kontroller",
"empty": "Henüz kontrol yok.",
"ratio": "son günde {{total}} kontrolün {{failed}} tanesi"
},
"checkmateServer": {
"title": "Checkmate sunucusu",
"memory": "Bellek",
"eventLoop": "Olay döngüsü gecikmesi",
"uptime": "Çalışma süresi",
"database": "Veritabanı",
"readsWrites": "{{reads}} okuma/sn · {{writes}} yazma/sn"
},
"ranking": {
"topOf": "{{total}} içinden ilk {{shown}}",
"notEnough": "Sıralama için yeterli izleyici yok."
},
"slowestMonitors": {
"title": "En yavaş izleyiciler"
},
"lowestUptime": {
"title": "En düşük çalışma süresi"
},
"monitorsByGroup": {
"title": "Gruba göre izleyiciler",
"empty": "Henüz grup yok. Gruplar izleyici başına ayarlanır."
},
"busiestServers": {
"title": "En yoğun sunucular",
"empty": "Henüz altyapı izleyicisi yok.",
"cpu": "CPU",
"memory": "Bellek",
"disk": "Disk"
},
"incidentStats": {
"title": "Olay istatistikleri",
"openNow": "Şu anda açık",
"avgResolution": "Ortalama çözüm süresi",
"hours": "{{hours}} sa",
"mostIncidents": "En çok olay",
"total": "toplam",
"resolved": "Çözüldü",
"automatic": "Otomatik",
"manual": "Manuel",
"autoManual": "{{automatic}} otomatik · {{manual}} manuel"
},
"notificationChannels": {
"title": "Bildirim kanalları",
"empty": "Henüz kanal yok."
},
"maintenance": {
"title": "Bakım pencereleri",
"empty": "Planlanmış bakım yok.",
"runningNow": "Şu anda çalışıyor",
"next": "Sonraki pencere"
},
"statusPages": {
"title": "Durum sayfaları",
"empty": "Henüz durum sayfası yok.",
"monitorCount": "{{count}} izleyici",
"published": "Yayında",
"draft": "Taslak"
},
"checksOnSchedule": {
"title": "Kontrol kuyruğu",
"healthy": "{{workers}} işçide {{jobs}} işin tümü planlandığı gibi.",
"failing": "Başarısız işler",
"scheduled": "Planlanmış işler",
"runningNow": "Şu anda çalışıyor"
},
"recentErrors": {
"title": "Son hatalar",
"empty": "Kaydedilmiş hata yok.",
"count": "{{count}} hata",
"warnings": "Uyarılar"
},
"team": {
"title": "Ekip üyeleri",
"members": "{{count}} üye",
"unverified": "Doğrulanmamış"
}
},
"picker": {
"trigger": "Kartları düzenle",
"title": "Panel kartları",
"done": "Tamam",
"reset": "Varsayılanlara sıfırla",
"groups": {
"uptime": "Çalışma süresi",
"infrastructure": "Altyapı",
"incidents": "Olaylar",
"notifications": "Bildirimler",
"maintenance": "Bakım",
"statusPages": "Durum sayfaları",
"checks": "Kontroller",
"logs": "Sistem",
"settings": "Ayarlar"
}
},
"empty": {
"title": "Panelinizde kart yok",
"description": "Görmek istediğiniz kartları seçin. Seçiminiz bu tarayıcıda hatırlanır."
},
"reorder": {
"moveUp": "Yukarı taşı",
"moveDown": "Aşağı taşı"
}
},
"notFound": {
"title": "Eyvah! Sushini düşürdün!",
"subtitle": "URL mevcut değil veya erişim yetkiniz yok."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Обслуговування",
"logs": "Журнали",
"settings": "Налаштування",
"dashboard": "Панель",
"groups": {
"monitoring": "Моніторинг",
"alerting": "Сповіщення",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Панель",
"description": "Переглядайте всі типи моніторів в одному місці та помічайте те, що потребує уваги."
},
"card": {
"error": "Не вдалося завантажити цю картку."
},
"cards": {
"monitorStatus": {
"title": "Стан моніторів",
"empty": "Моніторів поки немає.",
"upOfTotal": "з {{total}} працюють"
},
"currentlyDown": {
"title": "Зараз недоступні",
"empty": "Усі монітори працюють. Відкритих інцидентів немає.",
"count": "{{count}} потребують уваги"
},
"monitorsByType": {
"title": "Монітори за типом",
"empty": "Моніторів поки немає.",
"down": "{{count}} недоступні"
},
"recentIncidents": {
"title": "Останні інциденти",
"empty": "Інцидентів немає — усі системи справні.",
"active": "{{count}} відкрито",
"ongoing": "Триває",
"manual": "Вручну",
"unknownMonitor": "Невідомий монітор"
},
"failedChecks": {
"title": "Невдалі перевірки",
"empty": "Перевірок поки немає.",
"ratio": "{{failed}} з {{total}} за останню добу"
},
"checkmateServer": {
"title": "Сервер Checkmate",
"memory": "Пам'ять",
"eventLoop": "Затримка event loop",
"uptime": "Час роботи",
"database": "База даних",
"readsWrites": "{{reads}} читань/с · {{writes}} записів/с"
},
"ranking": {
"topOf": "Топ {{shown}} з {{total}}",
"notEnough": "Недостатньо моніторів для рейтингу."
},
"slowestMonitors": {
"title": "Найповільніші монітори"
},
"lowestUptime": {
"title": "Найнижча працездатність"
},
"monitorsByGroup": {
"title": "Монітори за групою",
"empty": "Груп поки немає. Групи задаються в моніторі."
},
"busiestServers": {
"title": "Найзавантаженіші сервери",
"empty": "Інфраструктурних моніторів поки немає.",
"cpu": "ЦП",
"memory": "Пам'ять",
"disk": "Диск"
},
"incidentStats": {
"title": "Статистика інцидентів",
"openNow": "Відкрито зараз",
"avgResolution": "Середній час вирішення",
"hours": "{{hours}} год",
"mostIncidents": "Найбільше інцидентів",
"total": "усього",
"resolved": "Вирішено",
"automatic": "Автоматично",
"manual": "Вручну",
"autoManual": "{{automatic}} автоматично · {{manual}} вручну"
},
"notificationChannels": {
"title": "Канали сповіщень",
"empty": "Каналів поки немає."
},
"maintenance": {
"title": "Вікна обслуговування",
"empty": "Обслуговування не заплановано.",
"runningNow": "Виконується",
"next": "Наступне вікно"
},
"statusPages": {
"title": "Сторінки статусу",
"empty": "Сторінок статусу поки немає.",
"monitorCount": "{{count}} моніторів",
"published": "Опубліковано",
"draft": "Чернетка"
},
"checksOnSchedule": {
"title": "Черга перевірок",
"healthy": "Усі {{jobs}} завдань за розкладом на {{workers}} воркерах.",
"failing": "Невдалі завдання",
"scheduled": "Заплановані завдання",
"runningNow": "Виконується"
},
"recentErrors": {
"title": "Останні помилки",
"empty": "Помилок не зафіксовано.",
"count": "{{count}} помилок",
"warnings": "Попередження"
},
"team": {
"title": "Учасники команди",
"members": "{{count}} учасників",
"unverified": "Не підтверджені"
}
},
"picker": {
"trigger": "Редагувати картки",
"title": "Картки панелі",
"done": "Готово",
"reset": "Скинути до типових",
"groups": {
"uptime": "Працездатність",
"infrastructure": "Інфраструктура",
"incidents": "Інциденти",
"notifications": "Сповіщення",
"maintenance": "Обслуговування",
"statusPages": "Сторінки статусу",
"checks": "Перевірки",
"logs": "Система",
"settings": "Налаштування"
}
},
"empty": {
"title": "На вашій панелі немає карток",
"description": "Виберіть картки, які хочете бачити. Ваш вибір зберігається в цьому браузері."
},
"reorder": {
"moveUp": "Перемістити вгору",
"moveDown": "Перемістити вниз"
}
},
"notFound": {
"title": "Ой ні! Ви впустили суші!",
"subtitle": "Або URL не існує, або у Вас немає доступу до нього."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "Bảo trì",
"logs": "Nhật ký",
"settings": "Cài đặt",
"dashboard": "Bảng điều khiển",
"groups": {
"monitoring": "Giám sát",
"alerting": "Cảnh báo",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "Bảng điều khiển",
"description": "Xem mọi loại trình giám sát ở cùng một nơi và nhận ra điều cần chú ý."
},
"card": {
"error": "Không thể tải thẻ này."
},
"cards": {
"monitorStatus": {
"title": "Trạng thái giám sát",
"empty": "Chưa có trình giám sát nào.",
"upOfTotal": "trên {{total}} đang hoạt động"
},
"currentlyDown": {
"title": "Hiện đang ngừng",
"empty": "Tất cả trình giám sát đang hoạt động. Không có sự cố nào đang mở.",
"count": "{{count}} cần chú ý"
},
"monitorsByType": {
"title": "Trình giám sát theo loại",
"empty": "Chưa có trình giám sát nào.",
"down": "{{count}} đang ngừng"
},
"recentIncidents": {
"title": "Sự cố gần đây",
"empty": "Không có sự cố — mọi hệ thống đều ổn.",
"active": "{{count}} đang mở",
"ongoing": "Đang diễn ra",
"manual": "Thủ công",
"unknownMonitor": "Trình giám sát không xác định"
},
"failedChecks": {
"title": "Kiểm tra thất bại",
"empty": "Chưa có lần kiểm tra nào.",
"ratio": "{{failed}} trên {{total}} trong ngày qua"
},
"checkmateServer": {
"title": "Máy chủ Checkmate",
"memory": "Bộ nhớ",
"eventLoop": "Độ trễ vòng lặp sự kiện",
"uptime": "Thời gian hoạt động",
"database": "Cơ sở dữ liệu",
"readsWrites": "{{reads}} lượt đọc/giây · {{writes}} lượt ghi/giây"
},
"ranking": {
"topOf": "Top {{shown}} trên {{total}}",
"notEnough": "Chưa đủ trình giám sát để xếp hạng."
},
"slowestMonitors": {
"title": "Trình giám sát chậm nhất"
},
"lowestUptime": {
"title": "Uptime thấp nhất"
},
"monitorsByGroup": {
"title": "Trình giám sát theo nhóm",
"empty": "Chưa có nhóm nào. Nhóm được đặt cho từng trình giám sát."
},
"busiestServers": {
"title": "Máy chủ bận nhất",
"empty": "Chưa có trình giám sát hạ tầng nào.",
"cpu": "CPU",
"memory": "Bộ nhớ",
"disk": "Ổ đĩa"
},
"incidentStats": {
"title": "Thống kê sự cố",
"openNow": "Đang mở",
"avgResolution": "Thời gian xử lý trung bình",
"hours": "{{hours}} giờ",
"mostIncidents": "Nhiều sự cố nhất",
"total": "tổng cộng",
"resolved": "Đã xử lý",
"automatic": "Tự động",
"manual": "Thủ công",
"autoManual": "{{automatic}} tự động · {{manual}} thủ công"
},
"notificationChannels": {
"title": "Kênh thông báo",
"empty": "Chưa có kênh nào."
},
"maintenance": {
"title": "Cửa sổ bảo trì",
"empty": "Không có bảo trì nào được lên lịch.",
"runningNow": "Đang chạy",
"next": "Cửa sổ tiếp theo"
},
"statusPages": {
"title": "Trang trạng thái",
"empty": "Chưa có trang trạng thái nào.",
"monitorCount": "{{count}} trình giám sát",
"published": "Đã xuất bản",
"draft": "Bản nháp"
},
"checksOnSchedule": {
"title": "Hàng đợi kiểm tra",
"healthy": "Tất cả {{jobs}} tác vụ đúng lịch trên {{workers}} worker.",
"failing": "Tác vụ thất bại",
"scheduled": "Tác vụ đã lên lịch",
"runningNow": "Đang chạy"
},
"recentErrors": {
"title": "Lỗi gần đây",
"empty": "Không có lỗi nào được ghi.",
"count": "{{count}} lỗi",
"warnings": "Cảnh báo"
},
"team": {
"title": "Thành viên nhóm",
"members": "{{count}} thành viên",
"unverified": "Chưa xác minh"
}
},
"picker": {
"trigger": "Chỉnh sửa thẻ",
"title": "Thẻ bảng điều khiển",
"done": "Xong",
"reset": "Đặt lại mặc định",
"groups": {
"uptime": "Uptime",
"infrastructure": "Hạ tầng",
"incidents": "Sự cố",
"notifications": "Thông báo",
"maintenance": "Bảo trì",
"statusPages": "Trang trạng thái",
"checks": "Kiểm tra",
"logs": "Hệ thống",
"settings": "Cài đặt"
}
},
"empty": {
"title": "Không có thẻ nào trên bảng điều khiển",
"description": "Chọn các thẻ bạn muốn xem. Lựa chọn của bạn được ghi nhớ trong trình duyệt này."
},
"reorder": {
"moveUp": "Di chuyển lên",
"moveDown": "Di chuyển xuống"
}
},
"notFound": {
"title": "Ôi không! Bạn đã làm rơi sushi!",
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "维护",
"logs": "日志",
"settings": "设置",
"dashboard": "仪表板",
"groups": {
"monitoring": "监控",
"alerting": "告警",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "仪表板",
"description": "在一个页面查看所有类型的监控器,快速发现需要关注的问题。"
},
"card": {
"error": "无法加载此卡片。"
},
"cards": {
"monitorStatus": {
"title": "监控状态",
"empty": "暂无监控器。",
"upOfTotal": "/ {{total}} 正常"
},
"currentlyDown": {
"title": "当前离线",
"empty": "所有监控器均正常。没有未处理的事件。",
"count": "{{count}} 项需要关注"
},
"monitorsByType": {
"title": "按类型分组的监控器",
"empty": "暂无监控器。",
"down": "{{count}} 个离线"
},
"recentIncidents": {
"title": "最近事件",
"empty": "没有事件 — 所有系统正常。",
"active": "{{count}} 个未处理",
"ongoing": "进行中",
"manual": "手动",
"unknownMonitor": "未知监控器"
},
"failedChecks": {
"title": "失败的检查",
"empty": "暂无检查。",
"ratio": "最近一天 {{total}} 次中的 {{failed}} 次"
},
"checkmateServer": {
"title": "Checkmate 服务器",
"memory": "内存",
"eventLoop": "事件循环延迟",
"uptime": "运行时间",
"database": "数据库",
"readsWrites": "{{reads}} 读/秒 · {{writes}} 写/秒"
},
"ranking": {
"topOf": "{{total}} 个中的前 {{shown}} 个",
"notEnough": "监控器数量不足,无法排名。"
},
"slowestMonitors": {
"title": "最慢的监控器"
},
"lowestUptime": {
"title": "运行时间最低"
},
"monitorsByGroup": {
"title": "按分组的监控器",
"empty": "暂无分组。分组在每个监控器上设置。"
},
"busiestServers": {
"title": "最繁忙的服务器",
"empty": "暂无基础设施监控器。",
"cpu": "CPU",
"memory": "内存",
"disk": "磁盘"
},
"incidentStats": {
"title": "事件统计",
"openNow": "当前未处理",
"avgResolution": "平均解决时间",
"hours": "{{hours}} 小时",
"mostIncidents": "事件最多",
"total": "总计",
"resolved": "已解决",
"automatic": "自动",
"manual": "手动",
"autoManual": "{{automatic}} 个自动 · {{manual}} 个手动"
},
"notificationChannels": {
"title": "通知渠道",
"empty": "暂无渠道。"
},
"maintenance": {
"title": "维护窗口",
"empty": "没有计划的维护。",
"runningNow": "正在进行",
"next": "下一个窗口"
},
"statusPages": {
"title": "状态页面",
"empty": "暂无状态页面。",
"monitorCount": "{{count}} 个监控器",
"published": "已发布",
"draft": "草稿"
},
"checksOnSchedule": {
"title": "检查队列",
"healthy": "{{workers}} 个工作进程上的 {{jobs}} 个任务均按计划运行。",
"failing": "失败的任务",
"scheduled": "计划任务",
"runningNow": "正在进行"
},
"recentErrors": {
"title": "最近错误",
"empty": "没有记录到错误。",
"count": "{{count}} 个错误",
"warnings": "警告"
},
"team": {
"title": "团队成员",
"members": "{{count}} 名成员",
"unverified": "未验证"
}
},
"picker": {
"trigger": "编辑卡片",
"title": "仪表板卡片",
"done": "完成",
"reset": "恢复默认",
"groups": {
"uptime": "运行时间",
"infrastructure": "基础设施",
"incidents": "事件",
"notifications": "通知",
"maintenance": "维护",
"statusPages": "状态页面",
"checks": "检查",
"logs": "系统",
"settings": "设置"
}
},
"empty": {
"title": "您的仪表板上没有卡片",
"description": "选择您想查看的卡片。您的选择会记录在此浏览器中。"
},
"reorder": {
"moveUp": "上移",
"moveDown": "下移"
}
},
"notFound": {
"title": "哦不!你的寿司掉了!",
"subtitle": "该 URL 不存在,或者您没有访问权限。"
+141
View File
@@ -182,6 +182,7 @@
"maintenance": "維護",
"logs": "日誌",
"settings": "設定",
"dashboard": "儀表板",
"groups": {
"monitoring": "監控",
"alerting": "警示",
@@ -229,6 +230,146 @@
}
},
"pages": {
"dashboard": {
"header": {
"title": "儀表板",
"description": "在同一個頁面檢視所有類型的監視器,快速發現需要留意的問題。"
},
"card": {
"error": "無法載入此卡片。"
},
"cards": {
"monitorStatus": {
"title": "監視狀態",
"empty": "尚無監視器。",
"upOfTotal": "/ {{total}} 正常"
},
"currentlyDown": {
"title": "目前離線",
"empty": "所有監視器均正常。沒有未處理的事件。",
"count": "{{count}} 項需要留意"
},
"monitorsByType": {
"title": "依類型分類的監視器",
"empty": "尚無監視器。",
"down": "{{count}} 個離線"
},
"recentIncidents": {
"title": "最近事件",
"empty": "沒有事件 — 所有系統正常。",
"active": "{{count}} 個未處理",
"ongoing": "進行中",
"manual": "手動",
"unknownMonitor": "未知監視器"
},
"failedChecks": {
"title": "失敗的檢查",
"empty": "尚無檢查。",
"ratio": "最近一天 {{total}} 次中的 {{failed}} 次"
},
"checkmateServer": {
"title": "Checkmate 伺服器",
"memory": "記憶體",
"eventLoop": "事件迴圈延遲",
"uptime": "運行時間",
"database": "資料庫",
"readsWrites": "{{reads}} 讀取/秒 · {{writes}} 寫入/秒"
},
"ranking": {
"topOf": "{{total}} 個中的前 {{shown}} 個",
"notEnough": "監視器數量不足,無法排名。"
},
"slowestMonitors": {
"title": "最慢的監視器"
},
"lowestUptime": {
"title": "運行時間最低"
},
"monitorsByGroup": {
"title": "依群組分類的監視器",
"empty": "尚無群組。群組在每個監視器上設定。"
},
"busiestServers": {
"title": "最繁忙的伺服器",
"empty": "尚無基礎設施監視器。",
"cpu": "CPU",
"memory": "記憶體",
"disk": "磁碟"
},
"incidentStats": {
"title": "事件統計",
"openNow": "目前未處理",
"avgResolution": "平均解決時間",
"hours": "{{hours}} 小時",
"mostIncidents": "事件最多",
"total": "總計",
"resolved": "已解決",
"automatic": "自動",
"manual": "手動",
"autoManual": "{{automatic}} 個自動 · {{manual}} 個手動"
},
"notificationChannels": {
"title": "通知管道",
"empty": "尚無管道。"
},
"maintenance": {
"title": "維護時段",
"empty": "沒有排定的維護。",
"runningNow": "進行中",
"next": "下一個時段"
},
"statusPages": {
"title": "狀態頁面",
"empty": "尚無狀態頁面。",
"monitorCount": "{{count}} 個監視器",
"published": "已發布",
"draft": "草稿"
},
"checksOnSchedule": {
"title": "檢查佇列",
"healthy": "{{workers}} 個工作處理程序上的 {{jobs}} 個工作均按計畫執行。",
"failing": "失敗的工作",
"scheduled": "排定的工作",
"runningNow": "進行中"
},
"recentErrors": {
"title": "最近錯誤",
"empty": "沒有記錄到錯誤。",
"count": "{{count}} 個錯誤",
"warnings": "警告"
},
"team": {
"title": "團隊成員",
"members": "{{count}} 位成員",
"unverified": "未驗證"
}
},
"picker": {
"trigger": "編輯卡片",
"title": "儀表板卡片",
"done": "完成",
"reset": "還原預設值",
"groups": {
"uptime": "運行時間",
"infrastructure": "基礎設施",
"incidents": "事件",
"notifications": "通知",
"maintenance": "維護",
"statusPages": "狀態頁面",
"checks": "檢查",
"logs": "系統",
"settings": "設定"
}
},
"empty": {
"title": "您的儀表板上沒有卡片",
"description": "選擇您想檢視的卡片。您的選擇會記錄在此瀏覽器中。"
},
"reorder": {
"moveUp": "上移",
"moveDown": "下移"
}
},
"notFound": {
"title": "哎呀!您的壽司掉了!",
"subtitle": "該 URL 不存在,或您沒有存取權限。"
-4
View File
@@ -65,10 +65,6 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
+1 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
router.delete("/:monitorId", checkController.deleteChecks);
return router;
};
@@ -1,16 +1,15 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+1 -5
View File
@@ -21,11 +21,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+5 -6
View File
@@ -1,15 +1,14 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
return router;
};
@@ -123,13 +123,6 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
-3
View File
@@ -33,7 +33,6 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -179,7 +178,6 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -194,7 +192,6 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -60,7 +60,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
@@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService {
this.incidentsRepository = incidentsRepository;
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
@@ -191,7 +191,6 @@ export class MonitorService implements IMonitorService {
}
this.scheduler.addJob(monitor.id, monitor);
return monitor;
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -37,7 +37,6 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -1,123 +0,0 @@
const SERVICE_NAME = "SignalgridProvider";
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class SignalgridProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: "Checkmate Test Notification",
body: getTestMessage(),
type: "INFO",
critical: false,
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.signalgrid.co/v1/push", {
form: {
client_key: notification.accessToken,
channel: notification.address,
title: message.content.title,
body: this.buildSignalgridText(message),
type: this.mapSeverity(message.severity),
critical: false,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Signalgrid notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Signalgrid alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
switch (severity) {
case "critical":
return "CRIT";
case "warning":
return "WARN";
case "success":
return "SUCCESS";
default:
return "INFO";
}
}
private buildSignalgridText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
@@ -1,79 +0,0 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { isAllowed } from "../../../src/api/middleware/isAllowed.ts";
import { AppError } from "../../../src/utils/AppError.ts";
describe("isAllowed middleware", () => {
it("calls next() without error when user has an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-1",
teamId: "team-1",
email: "admin@example.com",
role: ["admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
it("calls next(error) with 403 when user does not have an allowed role", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-2",
teamId: "team-1",
email: "user@example.com",
role: ["user"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next(error) with 403 when req.user is undefined", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {} as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
expect(error).toBeInstanceOf(AppError);
expect(error.status).toBe(403);
expect(error.message).toBe("Unauthorized");
});
it("calls next() when user has multiple roles and at least one matches", () => {
const middleware = isAllowed(["admin", "superadmin"]);
const req = {
user: {
id: "user-3",
teamId: "team-1",
email: "multi@example.com",
role: ["user", "admin"],
},
} as unknown as Request;
const res = {} as Response;
const next = jest.fn() as unknown as NextFunction;
middleware(req, res, next);
expect(next).toHaveBeenCalledWith();
});
});
@@ -1,155 +0,0 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new SignalgridProvider(logger as any), logger };
};
testNotificationProviderContract("SignalgridProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("SignalgridProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Signalgrid API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.signalgrid.co/v1/push",
expect.objectContaining({
form: expect.objectContaining({
client_key: "token-abc",
channel: "https://hooks.example.com/webhook",
type: "INFO",
critical: false,
}),
})
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends message and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const form = mockGotPost.mock.calls[0][1].form;
expect(form.body).toContain("Monitor Details:");
expect(form.title).toBeDefined();
expect(form.critical).toBe(false);
});
it("maps critical severity to CRIT", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "critical";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT");
});
it("maps warning severity to WARN", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "warning";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN");
});
it("maps success severity to SUCCESS", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "success";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS");
});
it("maps info severity to INFO", async () => {
const { provider } = createProvider();
const message = makeMessage();
message.severity = "info";
await provider.sendMessage(makeNotification() as any, message);
expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].form.body;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});
@@ -122,17 +122,16 @@ const createService = (
describe("MonitorService", () => {
describe("createMonitor", () => {
it("creates a monitor, adds a job and returns the created document", async () => {
it("creates a monitor and adds a job", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID);
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
expect(result).toBe(monitor);
});
it("strips a stray proxyId when proxyMode is not custom", async () => {