Compare commits

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

Fixes #3917

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt
2026-09-07 12:31:07 +02:00
Ali Shan Ahmad 2eeb088b61 [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. 2026-09-06 08:35:43 +00:00
gorkem-bwl 9c796edbde refactor(client): drop the copy button, keep the selection fix
The selection guard alone closes #3907: drag-selecting a check's error
message no longer navigates away, so the text can be copied. The copy
button was a second, larger answer to the same problem, and it brought a
reusable component, an HTTP clipboard fallback, four translation keys and
a timer to maintain.

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

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

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

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

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

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

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

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

Two changes:

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

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

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00
eegrok af465766c8 fix Start Time description to accurately reflect that the time zone is local time, not UTC 2026-09-03 09:13:32 +12:00
signalgridco c13f26a4c3 feat: add Signalgrid notification provider 2026-09-01 23:17:11 +02:00
53 changed files with 789 additions and 3005 deletions

No files matched your search

+58 -22
View File
@@ -9,6 +9,9 @@
"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",
@@ -93,7 +96,6 @@
"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",
@@ -363,6 +365,59 @@
"node": ">=14"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"license": "MIT",
@@ -409,7 +464,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +501,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1236,7 +1289,6 @@
"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",
@@ -1341,7 +1393,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2437,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2452,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2542,6 @@
"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",
@@ -2774,7 +2822,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3178,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3621,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +4020,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4832,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5643,6 @@
"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",
@@ -6382,7 +6424,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6435,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6638,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6788,6 @@
"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"
},
@@ -7591,7 +7629,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7766,6 @@
"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,6 +20,9 @@
},
"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,6 +81,21 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -102,7 +117,10 @@ export function DataTable<
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -256,7 +274,8 @@ export function DataTable<
}),
...(getRowSx?.(row) as object),
}}
onClick={() => {
onClick={(e) => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
+1 -18
View File
@@ -9,7 +9,6 @@ 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 = ({
@@ -26,8 +25,6 @@ export const DialogInput = ({
maxWidth,
fullWidth = false,
additionalButtons,
cancelSx,
onEscape,
}: {
open: boolean;
title?: string;
@@ -42,14 +39,6 @@ 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();
@@ -59,12 +48,7 @@ export const DialogInput = ({
maxWidth={maxWidth}
fullWidth={fullWidth}
onClose={(_event, reason) => {
if (reason === "backdropClick") return;
if (onEscape) {
onEscape();
return;
}
if (onCancel) onCancel(undefined);
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
}}
>
{title && (
@@ -110,7 +94,6 @@ export const DialogInput = ({
loading={loading}
variant="outlined"
onClick={onCancel}
sx={cancelSx}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
)}
<Button
variant="contained"
color="secondary"
+7
View File
@@ -1,4 +1,5 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { CardId } from "@/Pages/Dashboard/cards";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
@@ -35,6 +36,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardCards: CardId[] | null;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +76,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardCards: null,
};
const uiSlice = createSlice({
@@ -117,6 +120,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
state.dashboardCards = action.payload;
},
},
});
@@ -133,4 +139,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardCards,
} = uiSlice.actions;
@@ -1,78 +1,123 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { useMemo } from "react";
import { GripVertical } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
import { Icon } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { layoutCards } from "../layout";
import { useCardTransitions } from "../useCardTransitions";
import { CardReorderControls } from "./CardReorderControls";
import { CardSlotContext } from "./CardSlotContext";
export const CardGrid = ({
cards,
onMove,
}: {
cards: CardDefinition[];
onMove: (id: CardId, delta: -1 | 1) => void;
}) => {
const SortableCard = ({ card }: { card: CardDefinition }) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
const { registerCard, capturePositions } = useCardTransitions();
// Positions are measured before the state change so the cards can be
// animated from where they were to where they land.
const handleMove = (id: CardId, delta: -1 | 1) => {
capturePositions(id);
onMove(id, delta);
};
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id });
const Component = card.component;
return (
<Box
display="grid"
gap={theme.spacing(LAYOUT.MD)}
ref={setNodeRef}
minWidth={0}
sx={{
gridTemplateColumns: {
xs: "1fr",
md: `repeat(${GRID_COLUMNS}, 1fr)`,
width: {
xs: "100%",
md:
card.width >= GRID_COLUMNS
? "100%"
: `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
},
alignItems: "stretch",
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",
}}
>
{placed.map(({ card, renderedWidth }, index) => {
const Component = card.component;
return (
<CardSlotContext.Provider
value={
<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" }}
{...attributes}
{...listeners}
display="flex"
alignItems="center"
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
touchAction: "none",
}}
>
{/*
* 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>
<Icon
icon={GripVertical}
size={16}
color={theme.palette.text.secondary}
/>
</Box>
);
})}
}
>
<Component />
</CardSlotContext.Provider>
</Box>
);
};
export const CardGrid = ({
cards,
onReorder,
}: {
cards: CardDefinition[];
onReorder: (sourceIndex: number, destIndex: number) => void;
}) => {
const theme = useTheme();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const sourceIndex = cards.findIndex((c) => c.id === active.id);
const destIndex = cards.findIndex((c) => c.id === over.id);
if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
};
const cardIds = cards.map((c) => c.id);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cardIds}
strategy={rectSortingStrategy}
>
<Box
display="flex"
flexWrap="wrap"
gap={theme.spacing(LAYOUT.MD)}
alignItems="stretch"
>
{cards.map((card) => (
<SortableCard
key={card.id}
card={card}
/>
))}
</Box>
</SortableContext>
</DndContext>
);
};
@@ -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 } from "@/Components/inputs";
import { Dialog, Checkbox, Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
@@ -14,8 +14,11 @@ 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;
}
@@ -29,8 +32,11 @@ export const CardPicker = ({
open,
available,
selected,
allSelected,
onAdd,
onRemove,
onSelectAll,
onDeselectAll,
onReset,
onClose,
}: CardPickerProps) => {
@@ -45,6 +51,7 @@ export const CardPicker = ({
}, [available]);
const chosen = new Set(selected);
const someSelected = selected.length > 0 && !allSelected;
return (
<Dialog
@@ -52,27 +59,35 @@ export const CardPicker = ({
maxWidth="sm"
fullWidth
title={t("pages.dashboard.picker.title")}
// The dialog lays its actions out as cancel → additional → confirm, all
// right-aligned. Reset takes the cancel slot with an auto right margin
// so it sits alone on the left, away from Done — a destructive action
// Reset sits alone on the left, away from Done — a destructive action
// should not be adjacent to the one people reach for by reflex.
onCancel={onReset}
cancelText={t("pages.dashboard.picker.reset")}
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 behind a scrollbar. Groups flow
* down the columns and are kept whole, so a group is never split
* across the gap.
* which hides most of the catalogue. Groups flow down the columns and
* are kept whole so a group is never split across the gap.
*/}
<Box
pt={theme.spacing(LAYOUT.SM)}
@@ -92,11 +107,6 @@ 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,
})}
@@ -1,90 +0,0 @@
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,12 +51,8 @@ export const DashboardCard = ({
height="100%"
sx={{
overflow: "hidden",
"&:hover .dashboard-card-slot, &:has(:focus-visible) .dashboard-card-slot": {
"&:hover .dashboard-card-slot": {
opacity: 1,
pointerEvents: "auto",
},
"&:hover .dashboard-card-action, &:has(:focus-visible) .dashboard-card-action": {
opacity: 0,
},
}}
>
@@ -96,22 +92,12 @@ export const DashboardCard = ({
color="primary"
/>
)}
{action && (
<Box
className="dashboard-card-action"
sx={{ transition: "opacity 0.15s ease" }}
>
{action}
</Box>
)}
{action && <Box>{action}</Box>}
{slot && (
<Box
className="dashboard-card-slot"
position="absolute"
right={0}
sx={{
opacity: 0,
pointerEvents: "none",
opacity: 0.3,
transition: "opacity 0.15s ease",
}}
>
+16 -3
View File
@@ -16,8 +16,18 @@ const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const monitorsRequest = useMonitorsRequest();
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
useCardSelection();
const {
cards,
available,
selected,
addCard,
removeCard,
reorderCards,
resetCards,
selectAll,
deselectAll,
allSelected,
} = useCardSelection();
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
@@ -52,7 +62,7 @@ const Dashboard = () => {
) : (
<CardGrid
cards={cards}
onMove={moveCard}
onReorder={reorderCards}
/>
)}
</Stack>
@@ -60,8 +70,11 @@ const Dashboard = () => {
open={isPickerOpen}
available={available}
selected={selected}
allSelected={allSelected}
onAdd={addCard}
onRemove={removeCard}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
onReset={resetCards}
onClose={() => setIsPickerOpen(false)}
/>
+63 -93
View File
@@ -1,79 +1,33 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo } from "react";
import { useSelector, useDispatch } from "react-redux";
import { logger } from "@/Utils/logger";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import {
CardIds,
DASHBOARD_CARDS_STORAGE_KEY,
DEFAULT_CARD_IDS,
type CardId,
} from "./cards";
import { setDashboardCards } from "@/Features/UI/uiSlice";
import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
import { CARD_REGISTRY, getCard } from "./registry";
import type { CardDefinition } from "./cards";
import type { RootState } from "@/store";
const isCardId = (value: unknown): value is CardId =>
typeof value === "string" && (CardIds as readonly string[]).includes(value);
/** Stored ids are validated: a card removed from a release must not break the page. */
const readStored = (): CardId[] | null => {
try {
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return null;
}
// An empty array is a real choice and must not fall back to the defaults;
// only an absent or malformed key returns null. Unknown ids are dropped
// so a card removed in a later release cannot break the page.
return parsed.filter(isCardId);
} catch (error) {
logger.error(
"Could not read dashboard card selection",
error instanceof Error ? error : undefined
);
return null;
}
};
/**
* Which cards are on the page, persisted per browser. Admin-only cards are
* filtered out entirely for non-admins — hidden, not disabled.
*/
export const useCardSelection = () => {
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
const [selected, setSelected] = useState<CardId[]>(
() => readStored() ?? [...DEFAULT_CARD_IDS]
// 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]
);
// 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
@@ -85,55 +39,71 @@ export const useCardSelection = () => {
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
const addCard = useCallback((id: CardId) => {
setSelected((current) => (current.includes(id) ? current : [...current, id]));
}, []);
const addCard = useCallback(
(id: CardId) => {
if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
},
[dispatch, selected]
);
const removeCard = useCallback((id: CardId) => {
setSelected((current) => current.filter((cardId) => cardId !== id));
}, []);
const removeCard = useCallback(
(id: CardId) => {
dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
},
[dispatch, selected]
);
const resetCards = useCallback(() => setSelected([...DEFAULT_CARD_IDS]), []);
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]);
/**
* 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.
* Reorders cards based on DnD source/dest indices into the visible `cards` array.
* Hidden admin cards keep their stored positions unchanged.
*/
const moveCard = useCallback(
(id: CardId, delta: -1 | 1) => {
setSelected((current) => {
const visible = current.filter((cardId) => {
const card = getCard(cardId);
return card !== undefined && isVisible(card);
});
const from = visible.indexOf(id);
const to = from + delta;
if (from === -1 || to < 0 || to >= visible.length) {
return current;
}
// Swap in the visible order, then write that order back over the
// stored positions the visible cards occupy, leaving hidden cards
// exactly where they were.
const reordered = [...visible];
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
let next = 0;
return current.map((cardId) =>
visible.includes(cardId) ? reordered[next++] : cardId
);
});
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
)
)
);
},
[isVisible]
[dispatch, cards, selected]
);
const allSelected = available.every((card) => selected.includes(card.id));
return {
cards,
available,
selected,
addCard,
removeCard,
moveCard,
reorderCards,
resetCards,
selectAll,
deselectAll,
allSelected,
};
};
@@ -1,113 +0,0 @@
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,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
@@ -24,6 +25,7 @@ export const DockerContainerStatusBoxes = ({
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
@@ -33,6 +35,7 @@ export const DockerContainerStatusBoxes = ({
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
@@ -23,6 +23,7 @@ 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";
@@ -50,6 +51,7 @@ 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[]>([]);
@@ -59,7 +61,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
enabled && monitorId && containerName
isAdmin && enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
@@ -168,6 +170,15 @@ 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,6 +126,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
+1 -1
View File
@@ -93,7 +93,7 @@ const Routes = () => {
>
<Route
path="/"
element={<Navigate to="/uptime" />}
element={<Navigate to="/dashboard" />}
/>
<Route
path="/dashboard"
+1
View File
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
+8
View File
@@ -40,6 +40,14 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning";
};
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success",
+7
View File
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
signalgridSchema,
twilioSchema,
ntfySchema,
]);
-141
View File
@@ -182,7 +182,6 @@
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات",
"dashboard": "لوحة المعلومات",
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
@@ -230,146 +229,6 @@
}
},
"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,7 +190,6 @@
"maintenance": "Manteniment",
"logs": "Registres",
"settings": "Configuració",
"dashboard": "Tauler",
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení",
"dashboard": "Přehled",
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Wartung",
"logs": "Protokolle",
"settings": "Einstellungen",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
@@ -230,146 +229,6 @@
}
},
"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."
+16 -2
View File
@@ -206,6 +206,7 @@
},
"sidebar": {
"menu": {
"dashboard": "Dashboard",
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
@@ -219,7 +220,6 @@
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
@@ -376,6 +376,7 @@
"picker": {
"trigger": "Edit cards",
"title": "Dashboard cards",
"selectAll": "Select all",
"done": "Done",
"reset": "Reset to defaults",
"groups": {
@@ -1126,6 +1127,10 @@
"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."
}
}
},
@@ -1388,7 +1393,7 @@
},
"startTime": {
"title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"option": {
"duration": {
"label": "Duration"
@@ -1470,6 +1475,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1490,6 +1496,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
-141
View File
@@ -182,7 +182,6 @@
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración",
"dashboard": "Panel",
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Huolto",
"logs": "Lokit",
"settings": "Asetukset",
"dashboard": "Kojelauta",
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Maintenance",
"logs": "Journaux",
"settings": "Paramètres",
"dashboard": "Tableau de bord",
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
@@ -230,146 +229,6 @@
}
},
"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,7 +194,6 @@
"maintenance": "Manutenzione",
"logs": "Log",
"settings": "Impostazioni",
"dashboard": "Dashboard",
"groups": {
"monitoring": "Monitoraggio",
"alerting": "Avvisi",
@@ -234,146 +233,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "メンテナンス",
"logs": "ログ",
"settings": "設定",
"dashboard": "ダッシュボード",
"groups": {
"monitoring": "監視",
"alerting": "アラート",
@@ -230,146 +229,6 @@
}
},
"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,7 +215,6 @@
"statusPages": "Strony statusu",
"tags": "Tagi",
"uptime": "Czas działania",
"dashboard": "Pulpit",
"groups": {
"monitoring": "Monitorowanie",
"alerting": "Alerty",
@@ -234,146 +233,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Manutenção",
"logs": "Registros",
"settings": "Configurações",
"dashboard": "Painel",
"groups": {
"monitoring": "Monitoramento",
"alerting": "Alertas",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Обслуживание",
"logs": "Логи",
"settings": "Настройки",
"dashboard": "Панель",
"groups": {
"monitoring": "Мониторинг",
"alerting": "Оповещения",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "การบำรุงรักษา",
"logs": "บันทึก",
"settings": "การตั้งค่า",
"dashboard": "แดชบอร์ด",
"groups": {
"monitoring": "การตรวจสอบ",
"alerting": "การแจ้งเตือน",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Bakım",
"logs": "Günlükler",
"settings": "Ayarlar",
"dashboard": "Panel",
"groups": {
"monitoring": "İzleme",
"alerting": "Uyarılar",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "Обслуговування",
"logs": "Журнали",
"settings": "Налаштування",
"dashboard": "Панель",
"groups": {
"monitoring": "Моніторинг",
"alerting": "Сповіщення",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"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",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "维护",
"logs": "日志",
"settings": "设置",
"dashboard": "仪表板",
"groups": {
"monitoring": "监控",
"alerting": "告警",
@@ -230,146 +229,6 @@
}
},
"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,7 +182,6 @@
"maintenance": "維護",
"logs": "日誌",
"settings": "設定",
"dashboard": "儀表板",
"groups": {
"monitoring": "監控",
"alerting": "警示",
@@ -230,146 +229,6 @@
}
},
"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,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
signalgrid: {
component: "SignalgridNotification",
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
},
twilio: {
component: "TwilioNotification",
example: {
+1 -1
View File
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
router.delete("/:monitorId", checkController.deleteChecks);
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
return router;
};
@@ -1,15 +1,16 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
router.post("/", maintenanceWindowController.createMaintenanceWindows);
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
+5 -1
View File
@@ -21,7 +21,11 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
router.get(
"/docker/details/:monitorId/containers/:containerName/logs",
isAllowed(["admin", "superadmin"]),
monitorController.getDockerContainerLogs
);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+6 -5
View File
@@ -1,14 +1,15 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
router.post("/", notificationController.createNotification);
router.post("/test/all", notificationController.testAllNotifications);
router.post("/test", notificationController.testNotification);
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
router.delete("/:id", notificationController.deleteNotification);
router.patch("/:id", notificationController.editNotification);
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
return router;
};
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Signalgrid notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
+3
View File
@@ -33,6 +33,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -178,6 +179,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -192,6 +194,7 @@ 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<void>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
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<void> => {
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
@@ -191,6 +191,7 @@ export class MonitorService implements IMonitorService {
}
this.scheduler.addJob(monitor.id, monitor);
return monitor;
};
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"signalgrid",
"twilio",
"ntfy",
] as const;
@@ -0,0 +1,123 @@
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");
}
}
@@ -0,0 +1,79 @@
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();
});
});
@@ -0,0 +1,155 @@
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,16 +122,17 @@ const createService = (
describe("MonitorService", () => {
describe("createMonitor", () => {
it("creates a monitor and adds a job", async () => {
it("creates a monitor, adds a job and returns the created document", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service, jobQueue } = createService({ monitorsRepository });
await service.createMonitor(TEAM_ID, USER_ID, monitor as any);
const result = 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 () => {