Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2507f26480 | ||
|
|
34663ec5f9 | ||
|
|
26c0628f34 | ||
|
|
4bbc84ac48 | ||
|
|
7862b3c6f9 | ||
|
|
02b0daa37d | ||
|
|
1492569b73 | ||
|
|
a545b81569 | ||
|
|
ce9dce48b3 | ||
|
|
7fcdcc0568 | ||
|
|
9eea53aa5f | ||
|
|
bd7b6038f5 | ||
|
|
e2362ca809 | ||
|
|
9a541dac21 | ||
|
|
71fed33ecd | ||
|
|
074b94b061 | ||
|
|
c1af8995e4 | ||
|
|
431cada08e | ||
|
|
e012910332 | ||
|
|
7dbc7b3064 | ||
|
|
1b97539eae | ||
|
|
6e0afe223e | ||
|
|
37e1005479 | ||
|
|
795e1caca4 | ||
|
|
a5bb186814 | ||
|
|
bee3d4221e | ||
|
|
f7e1793bac | ||
|
|
9a8203b0d8 | ||
|
|
24d4595bb1 | ||
|
|
6587ddf389 | ||
|
|
9b939f2d94 | ||
|
|
530eeb0110 | ||
|
|
da0d2a47b4 | ||
|
|
700baf2061 | ||
|
|
6ea4ca1aa9 | ||
|
|
3e06cd39b1 | ||
|
|
efcc7829d4 | ||
|
|
77974682fb | ||
|
|
6a54fcd82b | ||
|
|
dad32bc475 | ||
|
|
e3a565d976 | ||
|
|
a0d91abcd0 | ||
|
|
729c2cb049 | ||
|
|
8398a0300d | ||
|
|
74d859b877 | ||
|
|
e4718b814f | ||
|
|
c46ead515b | ||
|
|
49af07009d | ||
|
|
f86ead0806 | ||
|
|
23b00401ca | ||
|
|
c58087c787 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
||||
</p>
|
||||
<br>
|
||||
|
||||
@@ -212,7 +211,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
<a id="contributing"></a>
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
||||
|
||||
|
||||
Generated
+22
-2
@@ -93,6 +93,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -408,6 +409,7 @@
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -445,6 +447,7 @@
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.14.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1233,6 +1236,7 @@
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.7",
|
||||
@@ -1337,6 +1341,7 @@
|
||||
"node_modules/@mui/system": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.7",
|
||||
@@ -2381,6 +2386,7 @@
|
||||
"version": "24.5.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
@@ -2396,6 +2402,7 @@
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2486,6 +2493,7 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -2766,6 +2774,7 @@
|
||||
"version": "8.15.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3122,6 +3131,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
@@ -3565,7 +3575,8 @@
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3964,6 +3975,7 @@
|
||||
"version": "8.57.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4776,6 +4788,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -5587,6 +5600,7 @@
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
@@ -6368,6 +6382,7 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6379,6 +6394,7 @@
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.71.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6582,7 +6598,8 @@
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-persist": {
|
||||
"version": "6.0.0",
|
||||
@@ -6732,6 +6749,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -7573,6 +7591,7 @@
|
||||
"version": "5.9.3",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7710,6 +7729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format-check": "prettier --check ."
|
||||
"format-check": "prettier --check .",
|
||||
"compile": "tsc -b"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
BaseBox.displayName = "BaseBox";
|
||||
@@ -8,9 +8,6 @@ interface IconProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// 16 is the house size: 17 call sites ask for it explicitly against 2 for 20,
|
||||
// and the sidebar clamps to 16 in CSS. A 20px default put icons above the cap
|
||||
// height of the 13px text they sit beside.
|
||||
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
|
||||
@@ -37,12 +37,6 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
// `error.dark` rather than `main`: in dark mode `main` is light
|
||||
// enough that MUI derives near-black contrastText, which reads as
|
||||
// a warning rather than an outage on the one surface where
|
||||
// urgency matters. `dark` keeps a solid red bar with white text
|
||||
// in both modes, and leaves `main` alone for the ~30 places that
|
||||
// use it as text or a chart mark on a dark ground.
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
color: "#FFFFFF",
|
||||
px: theme.spacing(8),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -110,9 +110,6 @@ export const PageHeader = ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
// Wide enough to hold a one-line page description at this
|
||||
// size; still a cap, so it wraps rather than running the
|
||||
// full width of a large monitor.
|
||||
maxWidth: "108ch",
|
||||
}}
|
||||
>
|
||||
@@ -146,7 +143,7 @@ export const PageHeader = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
color: theme.palette.text.secondary,
|
||||
borderRight:
|
||||
idx < linkItems.length - 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
@@ -45,7 +45,7 @@ export const SkeletonCard = ({
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
@@ -19,15 +19,8 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
// In light mode `paper` is white, and lightening white is a no-op - the
|
||||
// gradient collapses flat. Darken slightly towards the page ground instead.
|
||||
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||
const paperEnd = paper;
|
||||
// Both ends stay close to `main` so MUI's derived `contrastText` still
|
||||
// applies - that is white on the dark light-mode fills and near-black on
|
||||
// the lighter dark-mode ones, so a bigger lift would break one or other.
|
||||
// `light` is a mid-tone used for status dots and chart marks, too pale to
|
||||
// use here, so the lift comes off `main` instead.
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
@@ -33,12 +33,6 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
bottom={0}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
// Deliberately far fainter than `divider` - this is texture
|
||||
// behind the number, not a rule, and it must never compete
|
||||
// with the box's own border. Note alpha() *replaces* the
|
||||
// alpha channel, so this relies on `divider` being an opaque
|
||||
// hex; if it ever goes translucent this gets stronger, not
|
||||
// weaker.
|
||||
backgroundImage: `
|
||||
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||
|
||||
@@ -52,11 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
getRowSx?: (row: T) => SxProps<Theme>;
|
||||
/**
|
||||
* Marks a row selected so it gets the shared selected + hover treatment.
|
||||
* Desktop table only - the small-screen card branch does not style
|
||||
* selection yet, same as `getRowSx`.
|
||||
*/
|
||||
isRowSelected?: (row: T) => boolean;
|
||||
};
|
||||
|
||||
@@ -220,21 +215,6 @@ export function DataTable<
|
||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
// Cells paint `background.paper`, so a background set on the row
|
||||
// never shows. Selection and hover both have to be applied at
|
||||
// cell level, and selection has to come first so hover layers
|
||||
// on top of it rather than replacing it.
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.rowHover,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.selectedHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
@@ -255,18 +235,25 @@ export function DataTable<
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
const isExpanded = expanded === key;
|
||||
const selected = isRowSelected?.(row);
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
className={[
|
||||
isInteractive ? "is-clickable" : "",
|
||||
isRowSelected?.(row) ? "is-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
sx={{
|
||||
cursor: isInteractive ? "pointer" : "default",
|
||||
...(isInteractive && {
|
||||
"&:hover .MuiTableCell-root": {
|
||||
backgroundColor: selected
|
||||
? theme.palette.action.selectedHover
|
||||
: theme.palette.action.rowHover,
|
||||
},
|
||||
}),
|
||||
...(selected && {
|
||||
"& .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
}),
|
||||
...(getRowSx?.(row) as object),
|
||||
}}
|
||||
onClick={() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import MuiTabs from "@mui/material/Tabs";
|
||||
import type { TabsProps } from "@mui/material/Tabs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
interface CustomTabsProps extends TabsProps {}
|
||||
|
||||
export const Tabs = (props: CustomTabsProps) => {
|
||||
@@ -10,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
||||
return (
|
||||
<MuiTabs
|
||||
sx={{
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTabs-indicator": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
@@ -42,17 +42,13 @@ export const Tab = (props: CustomTabProps) => {
|
||||
textTransform: "none",
|
||||
fontSize: typographyLevels.m,
|
||||
fontWeight: 500,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
padding: theme.spacing(1, 0),
|
||||
paddingBottom: 0,
|
||||
minWidth: "auto",
|
||||
alignItems: "flex-start",
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-selected": {
|
||||
// Weight stays put, as on ToggleButton: bolding the active tab
|
||||
// widens it, shifting every tab to its right and dragging the
|
||||
// indicator to a moving target. Colour and the indicator are
|
||||
// already carrying the state.
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"&:hover": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme, darken } from "@mui/material/styles";
|
||||
import { CONTROL_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const PALETTE_COLORS = [
|
||||
"primary",
|
||||
@@ -43,10 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: CONTROL_HEIGHT,
|
||||
// MUI's button line-height is 1.75 (22.75px at 13px), which with the
|
||||
// 6px padding needs 34.75px - already over its own box at 34, and
|
||||
// clipping at 32. 1.2 leaves room for tall scripts too.
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
@@ -19,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -56,7 +56,7 @@ export const DatePickerComponent = ({
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const DialogInput = ({
|
||||
@@ -25,6 +26,8 @@ export const DialogInput = ({
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
additionalButtons,
|
||||
cancelSx,
|
||||
onEscape,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
@@ -39,6 +42,14 @@ export const DialogInput = ({
|
||||
maxWidth?: DialogProps["maxWidth"];
|
||||
fullWidth?: boolean;
|
||||
additionalButtons?: ReactNode;
|
||||
/** Styles the cancel button — e.g. an auto margin to push it left. */
|
||||
cancelSx?: SxProps;
|
||||
/**
|
||||
* Called when Escape is pressed. Defaults to `onCancel`, which is right when
|
||||
* cancel simply dismisses — but not when it does something else, so a dialog
|
||||
* whose cancel slot carries another action passes its own dismiss here.
|
||||
*/
|
||||
onEscape?(): void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
@@ -48,7 +59,12 @@ export const DialogInput = ({
|
||||
maxWidth={maxWidth}
|
||||
fullWidth={fullWidth}
|
||||
onClose={(_event, reason) => {
|
||||
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
|
||||
if (reason === "backdropClick") return;
|
||||
if (onEscape) {
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
if (onCancel) onCancel(undefined);
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
@@ -94,6 +110,7 @@ export const DialogInput = ({
|
||||
loading={loading}
|
||||
variant="outlined"
|
||||
onClick={onCancel}
|
||||
sx={cancelSx}
|
||||
>
|
||||
{cancelText ?? t("common.buttons.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -72,7 +72,7 @@ const SelectInputInner = <T,>(
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: `${CONTROL_HEIGHT}px`,
|
||||
height: `${INPUT_BASE_HEIGHT}px`,
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { CONTROL_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: props.multiline ? "auto" : CONTROL_HEIGHT,
|
||||
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -34,7 +34,7 @@ export const TimePickerComponent = ({
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& input": {
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -21,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
sx={{
|
||||
px: 8,
|
||||
textTransform: "none",
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontWeight: 400,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -34,10 +34,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: selectedBg,
|
||||
// Weight stays at 400 so the group sits level with Button,
|
||||
// which it is routinely placed beside. The fill and the
|
||||
// darker ink carry the selected state instead - a weight
|
||||
// change also reflows the label width as you switch.
|
||||
color: theme.palette.text.primary,
|
||||
"&:hover": {
|
||||
backgroundColor: selectedHoverBg,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MonitorListPage = ({
|
||||
headerKey,
|
||||
page,
|
||||
actionLink,
|
||||
controller: c,
|
||||
controller,
|
||||
bulkActions,
|
||||
showTypeFilter,
|
||||
summaryProps,
|
||||
@@ -48,14 +48,14 @@ export const MonitorListPage = ({
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isAdmin = useIsAdmin();
|
||||
const isLoading = c.isLoading || Boolean(extraLoading);
|
||||
const isLoading = controller.isLoading || Boolean(extraLoading);
|
||||
|
||||
return (
|
||||
<MonitorBasePageWithStates
|
||||
headerKey={headerKey}
|
||||
loading={isLoading}
|
||||
error={c.error || extraError}
|
||||
totalCount={c.effectiveTotalCount}
|
||||
error={controller.error || extraError}
|
||||
totalCount={controller.effectiveTotalCount}
|
||||
page={page}
|
||||
actionLink={actionLink}
|
||||
priorityFallback={priorityFallback}
|
||||
@@ -66,7 +66,7 @@ export const MonitorListPage = ({
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<HeaderMonitorsSummary
|
||||
summary={c.summary}
|
||||
summary={controller.summary}
|
||||
{...summaryProps}
|
||||
/>
|
||||
<Stack
|
||||
@@ -78,27 +78,27 @@ export const MonitorListPage = ({
|
||||
{...(showTypeFilter === false
|
||||
? { showTypes: false }
|
||||
: {
|
||||
selectedTypes: c.selectedTypes,
|
||||
setSelectedTypes: c.setSelectedTypes,
|
||||
selectedTypes: controller.selectedTypes,
|
||||
setSelectedTypes: controller.setSelectedTypes,
|
||||
})}
|
||||
selectedStatus={c.selectedStatus}
|
||||
setSelectedStatus={c.setSelectedStatus}
|
||||
selectedState={c.selectedState}
|
||||
setSelectedState={c.setSelectedState}
|
||||
tagOptions={c.tags ?? []}
|
||||
selectedTags={c.selectedTags}
|
||||
setSelectedTags={c.setSelectedTags}
|
||||
onClearFilters={c.handleClearFilters}
|
||||
selectedStatus={controller.selectedStatus}
|
||||
setSelectedStatus={controller.setSelectedStatus}
|
||||
selectedState={controller.selectedState}
|
||||
setSelectedState={controller.setSelectedState}
|
||||
tagOptions={controller.tags ?? []}
|
||||
selectedTags={controller.selectedTags}
|
||||
setSelectedTags={controller.setSelectedTags}
|
||||
onClearFilters={controller.handleClearFilters}
|
||||
/>
|
||||
<TextField
|
||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
||||
value={c.search}
|
||||
value={controller.search}
|
||||
onChange={(event) => {
|
||||
c.setSearch(event.target.value);
|
||||
controller.setSearch(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{c.selectedTags.length > 0 && (
|
||||
{controller.selectedTags.length > 0 && (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
alignItems={isSmall ? "flex-start" : "center"}
|
||||
@@ -108,8 +108,8 @@ export const MonitorListPage = ({
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.uptime.filters.activeTags")}
|
||||
</Typography>
|
||||
{c.selectedTags.map((tagId) => {
|
||||
const tag = c.tags?.find((t) => t.id === tagId);
|
||||
{controller.selectedTags.map((tagId) => {
|
||||
const tag = controller.tags?.find((t) => t.id === tagId);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<ColoredLabel
|
||||
@@ -123,20 +123,20 @@ export const MonitorListPage = ({
|
||||
)}
|
||||
{bulkActions && !isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={c.selectedRows.length}
|
||||
onCancel={c.handleCancelSelection}
|
||||
selectedCount={controller.selectedRows.length}
|
||||
onCancel={controller.handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={c.handleBulkResume}
|
||||
onClick={controller.handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={c.handleBulkPause}
|
||||
onClick={controller.handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
|
||||
)}
|
||||
{children}
|
||||
<Dialog
|
||||
open={c.isDialogOpen}
|
||||
open={controller.isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={c.handleConfirmDelete}
|
||||
onCancel={c.handleCancelDelete}
|
||||
loading={c.isDeleting}
|
||||
onConfirm={controller.handleConfirmDelete}
|
||||
onCancel={controller.handleCancelDelete}
|
||||
loading={controller.isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from "@/Components/design-elements";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Globe,
|
||||
Gauge,
|
||||
Link,
|
||||
@@ -26,15 +27,20 @@ export const getMenu = (t: Function) => {
|
||||
{
|
||||
group: t("components.sidebar.menu.groups.monitoring"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.dashboard"),
|
||||
path: "dashboard",
|
||||
icon: <Icon icon={LayoutDashboard} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
@@ -42,9 +48,9 @@ export const getMenu = (t: Function) => {
|
||||
icon: <Icon icon={Link} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,10 +25,6 @@ export const NavItem = ({
|
||||
const iconStroke = selected
|
||||
? theme.palette.sidebar.accent
|
||||
: theme.palette.text.secondary;
|
||||
|
||||
// Both states come from the same family so selected always reads stronger
|
||||
// than a hovered neighbour. MUI's default action.hover is lighter than
|
||||
// action.selected on a dark ground, which inverted the two.
|
||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||
const buttonBgHoverColor = selected
|
||||
? theme.palette.action.selectedHover
|
||||
|
||||
@@ -73,9 +73,6 @@ export const Sidebar = () => {
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
borderRight={`1px solid ${theme.palette.divider}`}
|
||||
width={width}
|
||||
// On `paper` rather than inheriting the page ground, so the nav
|
||||
// reads as a surface and the content area stays the darker
|
||||
// `default`. Opaque, so the blur it used to carry is gone.
|
||||
bgcolor={theme.palette.background.paper}
|
||||
sx={{
|
||||
touchAction: "none",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { logger } from "@/Utils/logger";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: T;
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { toast, type ToastOptions } from "react-toastify";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useToast = () => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
return useMemo(() => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
}, []);
|
||||
};
|
||||
@@ -77,6 +77,7 @@ export const getMonitorDefaults = (
|
||||
...base,
|
||||
type: "docker",
|
||||
url: data?.url || "",
|
||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||
};
|
||||
break;
|
||||
case "game":
|
||||
|
||||
@@ -118,8 +118,8 @@ const getGeneralSettingsConfig = (
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
|
||||
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
@@ -255,6 +255,7 @@ const CreateMonitorPage = () => {
|
||||
const firstSegment = pathSegments[0];
|
||||
if (firstSegment === "pagespeed") return "pagespeed";
|
||||
if (firstSegment === "infrastructure") return "hardware";
|
||||
if (firstSegment === "docker") return "docker";
|
||||
return "uptime";
|
||||
}, [location.pathname]);
|
||||
|
||||
@@ -264,7 +265,9 @@ const CreateMonitorPage = () => {
|
||||
? "pagespeed"
|
||||
: pageType === "hardware"
|
||||
? "hardware"
|
||||
: "http";
|
||||
: pageType === "docker"
|
||||
? "docker"
|
||||
: "http";
|
||||
|
||||
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
|
||||
isEditMode ? `/monitors/${monitorId}` : null
|
||||
@@ -373,6 +376,8 @@ const CreateMonitorPage = () => {
|
||||
navigate("/pagespeed");
|
||||
} else if (pageType === "hardware") {
|
||||
navigate("/infrastructure");
|
||||
} else if (pageType === "docker") {
|
||||
navigate("/docker");
|
||||
} else {
|
||||
navigate("/uptime");
|
||||
}
|
||||
@@ -823,6 +828,19 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(1) && watchedType === "docker" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||
rightContent={
|
||||
<FormSwitchField
|
||||
name="dockerLogsEnabled"
|
||||
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(2) &&
|
||||
(watchedType === "http" ||
|
||||
watchedType === "grpc" ||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
|
||||
import { layoutCards } from "../layout";
|
||||
import { useCardTransitions } from "../useCardTransitions";
|
||||
import { CardReorderControls } from "./CardReorderControls";
|
||||
import { CardSlotContext } from "./CardSlotContext";
|
||||
|
||||
export const CardGrid = ({
|
||||
cards,
|
||||
onMove,
|
||||
}: {
|
||||
cards: CardDefinition[];
|
||||
onMove: (id: CardId, delta: -1 | 1) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const placed = useMemo(() => layoutCards(cards), [cards]);
|
||||
const { registerCard, capturePositions } = useCardTransitions();
|
||||
|
||||
// Positions are measured before the state change so the cards can be
|
||||
// animated from where they were to where they land.
|
||||
const handleMove = (id: CardId, delta: -1 | 1) => {
|
||||
capturePositions(id);
|
||||
onMove(id, delta);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
sx={{
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
md: `repeat(${GRID_COLUMNS}, 1fr)`,
|
||||
},
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{placed.map(({ card, renderedWidth }, index) => {
|
||||
const Component = card.component;
|
||||
return (
|
||||
<Box
|
||||
key={card.id}
|
||||
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
|
||||
minWidth={0}
|
||||
// Positioned so the moving card can be lifted above its
|
||||
// neighbours with a z-index while it travels.
|
||||
position="relative"
|
||||
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
|
||||
// Hints the compositor before a transform is applied, so the
|
||||
// first frame of a move does not stutter.
|
||||
sx={{ willChange: "transform" }}
|
||||
>
|
||||
{/*
|
||||
* The controls go through context rather than a prop so the 17
|
||||
* card components stay unaware of their own position.
|
||||
*/}
|
||||
<CardSlotContext.Provider
|
||||
value={
|
||||
<CardReorderControls
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < placed.length - 1}
|
||||
onMoveUp={() => handleMove(card.id, -1)}
|
||||
onMoveDown={() => handleMove(card.id, 1)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Component />
|
||||
</CardSlotContext.Provider>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dialog, Checkbox } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
||||
|
||||
interface CardPickerProps {
|
||||
open: boolean;
|
||||
available: CardDefinition[];
|
||||
selected: CardId[];
|
||||
onAdd: (id: CardId) => void;
|
||||
onRemove: (id: CardId) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards are grouped by sidebar section, not by load cost: you look for a card
|
||||
* where you look for the feature it belongs to. Admin-only cards never reach
|
||||
* `available` for a non-admin, so nobody is offered a card they cannot load.
|
||||
*/
|
||||
export const CardPicker = ({
|
||||
open,
|
||||
available,
|
||||
selected,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onReset,
|
||||
onClose,
|
||||
}: CardPickerProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return CardGroups.map((group: CardGroup) => ({
|
||||
group,
|
||||
cards: available.filter((card) => card.group === group),
|
||||
})).filter((entry) => entry.cards.length > 0);
|
||||
}, [available]);
|
||||
|
||||
const chosen = new Set(selected);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
title={t("pages.dashboard.picker.title")}
|
||||
// The dialog lays its actions out as cancel → additional → confirm, all
|
||||
// right-aligned. Reset takes the cancel slot with an auto right margin
|
||||
// so it sits alone on the left, away from Done — a destructive action
|
||||
// should not be adjacent to the one people reach for by reflex.
|
||||
onCancel={onReset}
|
||||
cancelText={t("pages.dashboard.picker.reset")}
|
||||
cancelSx={{ marginRight: "auto" }}
|
||||
// Escape must dismiss, not reset. The dialog sends Escape to onCancel by
|
||||
// default, and this one's cancel slot resets the selection — so without
|
||||
// this, Escape would wipe the user's cards instead of closing.
|
||||
onEscape={onClose}
|
||||
// Changes apply immediately, so Done only dismisses; it is the primary
|
||||
// action here and carries the usual contained styling.
|
||||
onConfirm={onClose}
|
||||
confirmText={t("pages.dashboard.picker.done")}
|
||||
>
|
||||
{/*
|
||||
* Two columns: seventeen cards in one list makes the dialog scroll,
|
||||
* which hides most of the catalogue behind a scrollbar. Groups flow
|
||||
* down the columns and are kept whole, so a group is never split
|
||||
* across the gap.
|
||||
*/}
|
||||
<Box
|
||||
pt={theme.spacing(LAYOUT.SM)}
|
||||
sx={{
|
||||
columnCount: { xs: 1, sm: 2 },
|
||||
columnGap: theme.spacing(LAYOUT.XL),
|
||||
}}
|
||||
>
|
||||
{grouped.map(({ group, cards }) => (
|
||||
<Stack
|
||||
key={group}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
pb={theme.spacing(LAYOUT.MD)}
|
||||
sx={{ breakInside: "avoid" }}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.disabled}
|
||||
>
|
||||
{/*
|
||||
* The picker has its own group labels rather than reusing the
|
||||
* sidebar's: the Logs page hosts logs, queue and diagnostics
|
||||
* tabs, so "Logs" would misdescribe the three cards under it.
|
||||
*/}
|
||||
{t(`pages.dashboard.picker.groups.${group}`, {
|
||||
defaultValue: group,
|
||||
})}
|
||||
</Typography>
|
||||
{cards.map((card) => (
|
||||
<FormControlLabel
|
||||
key={card.id}
|
||||
label={
|
||||
<Typography color={theme.palette.text.primary}>
|
||||
{t(`pages.dashboard.cards.${card.key}.title`)}
|
||||
</Typography>
|
||||
}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={chosen.has(card.id)}
|
||||
onChange={() =>
|
||||
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { Tooltip } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* One thickness for every bar on the dashboard. Plain and segmented bars sit
|
||||
* side by side across cards, so differing weights read as an inconsistency
|
||||
* rather than as emphasis.
|
||||
*/
|
||||
const BAR_HEIGHT = 8;
|
||||
|
||||
export interface BarSegment {
|
||||
key: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Tooltip text, since the segments themselves carry no labels. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
||||
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
||||
*/
|
||||
export const CardBar = ({
|
||||
value,
|
||||
max = 100,
|
||||
color,
|
||||
}: {
|
||||
value: number;
|
||||
max?: number;
|
||||
color: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height={BAR_HEIGHT}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{ overflow: "hidden" }}
|
||||
>
|
||||
<Box
|
||||
width={`${ratio * 100}%`}
|
||||
height="100%"
|
||||
bgcolor={color}
|
||||
sx={{ transition: "width 0.3s ease" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* One bar divided into proportional segments — the shape of a whole split into
|
||||
* parts, where the relative sizes are the point rather than any single value.
|
||||
*
|
||||
* Zero-value segments are dropped so they cannot appear as hairlines, and each
|
||||
* segment carries a tooltip because the bar itself has no room for labels.
|
||||
*/
|
||||
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
|
||||
const theme = useTheme();
|
||||
const visible = segments.filter((segment) => segment.value > 0);
|
||||
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
width="100%"
|
||||
height={BAR_HEIGHT}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
gap="1px"
|
||||
sx={{ overflow: "hidden" }}
|
||||
>
|
||||
{total > 0 &&
|
||||
visible.map((segment) => (
|
||||
<Tooltip
|
||||
key={segment.key}
|
||||
title={segment.label}
|
||||
>
|
||||
<Box
|
||||
width={`${(segment.value / total) * 100}%`}
|
||||
height="100%"
|
||||
bgcolor={segment.color}
|
||||
sx={{ transition: "width 0.3s ease" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** A clickable list row. Rows link to the entity they describe. */
|
||||
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...(to ? { component: RouterLink, to } : {})}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
px={theme.spacing(LAYOUT.XS)}
|
||||
py={theme.spacing(LAYOUT.XS)}
|
||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
color="inherit"
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
...(to && {
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Name plus optional secondary line, truncating rather than wrapping. */
|
||||
export const CardRowLabel = ({
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
primary: string;
|
||||
secondary?: string | null;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
minWidth={0}
|
||||
flex={1}
|
||||
>
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{primary}
|
||||
</Typography>
|
||||
{secondary && (
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
noWrap
|
||||
>
|
||||
{secondary}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** A single large figure with a caption — the shape of the stat cards. */
|
||||
export const CardFigure = ({
|
||||
value,
|
||||
caption,
|
||||
color,
|
||||
}: {
|
||||
value: string;
|
||||
caption?: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={300}
|
||||
color={color ?? theme.palette.text.primary}
|
||||
lineHeight={1.1}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A label with its value on the right — the shape almost every stat card
|
||||
* repeats. `color` highlights the value alone, leaving the label quiet.
|
||||
*/
|
||||
export const CardStatLine = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||
<Typography
|
||||
color={color ?? theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A CardStatLine with a proportional bar beneath it — the ranking and
|
||||
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
||||
* in the set to make bars relative to each other instead.
|
||||
*
|
||||
* `label` may be a node so callers can put a "N down" flag beside the name.
|
||||
*/
|
||||
export const CardMeterRow = ({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
trailing,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: number;
|
||||
max?: number;
|
||||
color: string;
|
||||
trailing?: ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{typeof label === "string" ? (
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{trailing}
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={value}
|
||||
max={max}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
|
||||
import { Icon, Tooltip } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
// Snug around the 16px icon without inflating the card header.
|
||||
const CONTROL_SIZE = 24;
|
||||
const ICON_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Move-up / move-down controls, revealed on hover over the card they belong to.
|
||||
* Kept out of DashboardCard so the 17 card components stay unaware of their own
|
||||
* position — the grid owns ordering.
|
||||
*/
|
||||
export const CardReorderControls = ({
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttonSx = {
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
"&.Mui-disabled": { color: theme.palette.text.disabled },
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
p={theme.spacing(LAYOUT.XXS)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={`1px solid ${theme.palette.divider}`}
|
||||
>
|
||||
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
|
||||
{/* span keeps the tooltip working while the button is disabled */}
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!canMoveUp}
|
||||
onClick={onMoveUp}
|
||||
aria-label={t("pages.dashboard.reorder.moveUp")}
|
||||
sx={buttonSx}
|
||||
>
|
||||
<Icon
|
||||
icon={ChevronUp}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!canMoveDown}
|
||||
onClick={onMoveDown}
|
||||
aria-label={t("pages.dashboard.reorder.moveDown")}
|
||||
sx={buttonSx}
|
||||
>
|
||||
<Icon
|
||||
icon={ChevronDown}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const CardSlotContext = createContext<ReactNode>(null);
|
||||
|
||||
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
||||
@@ -0,0 +1,219 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
import type { QueueMetrics } from "@/Types/Queue";
|
||||
import type { Log } from "@/Types/Log";
|
||||
import type { User } from "@/Types/User";
|
||||
|
||||
const MAX_ERROR_ROWS = 5;
|
||||
|
||||
/** Reads as a single reassurance line while nothing is failing. */
|
||||
export const ChecksOnScheduleCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
|
||||
"/queue/metrics",
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const healthy = data?.failingJobs === 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.checksOnSchedule.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{healthy ? (
|
||||
<CardMessage
|
||||
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
|
||||
jobs: data.jobs,
|
||||
workers: data.workers.length,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CardFigure
|
||||
value={String(data.failingJobs)}
|
||||
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
|
||||
<CardRow key={String(job.monitorId)}>
|
||||
<CardRowLabel
|
||||
primary={job.monitorUrl ?? String(job.monitorId)}
|
||||
secondary={job.failReason}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{job.failCount}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
||||
value={String(data.jobs)}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
||||
value={String(data.activeJobs)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
|
||||
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
|
||||
* worded "recent" rather than "all".
|
||||
*/
|
||||
export const RecentErrorsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<Log[]>(
|
||||
"/logs",
|
||||
{},
|
||||
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const { errors, warnings, newest } = useMemo(() => {
|
||||
const relevant = (data ?? []).filter(
|
||||
(entry) => entry.level === "error" || entry.level === "warn"
|
||||
);
|
||||
const sorted = [...relevant].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
return {
|
||||
errors: relevant.filter((entry) => entry.level === "error").length,
|
||||
warnings: relevant.filter((entry) => entry.level === "warn").length,
|
||||
newest: sorted.slice(0, MAX_ERROR_ROWS),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.recentErrors.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
errors > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{newest.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
||||
value={String(warnings)}
|
||||
/>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{newest.map((entry, index) => (
|
||||
<CardRow key={`${entry.timestamp}-${index}`}>
|
||||
<CardRowLabel
|
||||
primary={entry.message}
|
||||
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
|
||||
/>
|
||||
<Typography
|
||||
color={
|
||||
entry.level === "error"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.warning.main
|
||||
}
|
||||
flexShrink={0}
|
||||
>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
/** Never empty — at minimum the current user exists. */
|
||||
export const TeamCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
|
||||
|
||||
const { total, byRole, unverified } = useMemo(() => {
|
||||
const users = data ?? [];
|
||||
const roles = new Map<string, number>();
|
||||
for (const user of users) {
|
||||
for (const role of user.role ?? []) {
|
||||
roles.set(role, (roles.get(role) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: users.length,
|
||||
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
|
||||
unverified: users.filter((user) => !user.isVerified).length,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.team.title")}
|
||||
to="/account/team"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={String(total)}
|
||||
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
||||
/>
|
||||
{byRole.map(([role, count]) => (
|
||||
<CardStatLine
|
||||
key={role}
|
||||
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
||||
value={String(count)}
|
||||
/>
|
||||
))}
|
||||
{unverified > 0 && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.team.unverified")}
|
||||
value={String(unverified)}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
||||
|
||||
export interface BreakdownBucket {
|
||||
/** Stable key and displayed label. */
|
||||
key: string;
|
||||
label: string;
|
||||
total: number;
|
||||
down: number;
|
||||
/** Where the row links to, if anywhere. */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
||||
* bucket with something down. Monitors by type and Monitors by group are the
|
||||
* same card over different keys, so they share this shell.
|
||||
*/
|
||||
export const BreakdownCard = ({
|
||||
titleKey,
|
||||
emptyKey,
|
||||
to,
|
||||
buckets,
|
||||
isLoading,
|
||||
isStale,
|
||||
error,
|
||||
}: {
|
||||
titleKey: string;
|
||||
emptyKey: string;
|
||||
to?: string;
|
||||
buckets: BreakdownBucket[];
|
||||
isLoading?: boolean;
|
||||
isStale?: boolean;
|
||||
error?: unknown;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t(titleKey)}
|
||||
to={to}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isStale={isStale}
|
||||
>
|
||||
{buckets.length === 0 ? (
|
||||
<CardMessage text={t(emptyKey)} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{buckets.map((bucket) => (
|
||||
<CardRow
|
||||
key={bucket.key}
|
||||
to={bucket.to}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={bucket.label}
|
||||
value={bucket.total}
|
||||
max={max}
|
||||
color={
|
||||
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
||||
}
|
||||
trailing={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{bucket.down > 0 && (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||
count: bucket.down,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{bucket.total}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import { latestCheck } from "../../checks";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
// <60% fine, 60-85% warm, above that hot.
|
||||
const WARM = 60;
|
||||
const HOT = 85;
|
||||
|
||||
interface ServerUsage {
|
||||
monitor: Monitor;
|
||||
cpu: number | null;
|
||||
memory: number | null;
|
||||
disk: number | null;
|
||||
peak: number;
|
||||
}
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | null }) => {
|
||||
const theme = useTheme();
|
||||
const color =
|
||||
value === null
|
||||
? theme.palette.text.secondary
|
||||
: value > HOT
|
||||
? theme.palette.error.main
|
||||
: value > WARM
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.primary.main;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||
<Typography color={color}>
|
||||
{/* A down server reports nothing — show a dash, never 0%. */}
|
||||
{value === null ? "—" : `${value.toFixed(1)}%`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={value ?? 0}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const BusiestServersCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const servers = useMemo<ServerUsage[]>(() => {
|
||||
return (data?.monitors ?? [])
|
||||
.filter((monitor) => monitor.type === "hardware")
|
||||
.map((monitor) => {
|
||||
const latest = latestCheck(monitor);
|
||||
// snake_case here, unlike camelCase in HardwareCheckStats, and
|
||||
// these are 0-1 fractions, so scale to percent.
|
||||
const toPercent = (value: number | undefined) =>
|
||||
typeof value === "number" ? value * 100 : null;
|
||||
const cpu = toPercent(latest?.cpu?.usage_percent);
|
||||
const memory = toPercent(latest?.memory?.usage_percent);
|
||||
const disks = (latest?.disk ?? [])
|
||||
.map((entry) => entry.usage_percent)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
|
||||
return {
|
||||
monitor,
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.peak - a.peak);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.busiestServers.title")}
|
||||
to="/infrastructure"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{servers.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{servers.map(({ monitor, cpu, memory, disk }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/infrastructure/${monitor.id}`}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<CardRowLabel primary={monitor.name} />
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.cpu")}
|
||||
value={cpu}
|
||||
/>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.memory")}
|
||||
value={memory}
|
||||
/>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.disk")}
|
||||
value={disk}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard } from "../DashboardCard";
|
||||
import { CardBar, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
|
||||
// heap warm past 80% of the V8 limit.
|
||||
const EVENT_LOOP_WARM_MS = 50;
|
||||
const EVENT_LOOP_HOT_MS = 200;
|
||||
const HEAP_WARM_RATIO = 0.8;
|
||||
|
||||
/**
|
||||
* The one card that is never empty — it reports on Checkmate itself, so it
|
||||
* works on a fresh install with zero monitors. Admin only.
|
||||
*/
|
||||
export const CheckmateServerCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
|
||||
"/diagnostic/system",
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
||||
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
|
||||
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
|
||||
const heapColor =
|
||||
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
|
||||
|
||||
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
|
||||
const eventLoopColor =
|
||||
eventLoopMs > EVENT_LOOP_HOT_MS
|
||||
? theme.palette.error.main
|
||||
: eventLoopMs > EVENT_LOOP_WARM_MS
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.text.primary;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.checkmateServer.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
||||
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
||||
heapLimit / BYTES_PER_MB
|
||||
)} MB`}
|
||||
color={heapColor}
|
||||
/>
|
||||
<CardBar
|
||||
value={heapRatio * 100}
|
||||
color={heapColor}
|
||||
/>
|
||||
</Stack>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
||||
value={`${eventLoopMs.toFixed(1)} ms`}
|
||||
color={eventLoopColor}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
||||
value={formatDuration(data.uptimeMs)}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.database")}
|
||||
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
||||
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
||||
writes: data.mongoStats.writesPerSecond.toFixed(1),
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardRow } from "../CardPrimitives";
|
||||
import { Sparkline } from "../Sparkline";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import { latestCheck } from "../../checks";
|
||||
|
||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||
|
||||
const SPARKLINE_WIDTH = 80;
|
||||
|
||||
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
||||
down: 1,
|
||||
breached: 2,
|
||||
};
|
||||
|
||||
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
|
||||
|
||||
/**
|
||||
* How long the monitor has been down, from the oldest consecutive failing
|
||||
* check. recentChecks is oldest-first, so the run is walked back from the end.
|
||||
*/
|
||||
const downSince = (monitor: Monitor): string | null => {
|
||||
const checks = monitor.recentChecks ?? [];
|
||||
let oldestFailing: string | null = null;
|
||||
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
||||
if (checks[index].status !== false) {
|
||||
break;
|
||||
}
|
||||
oldestFailing = checks[index].createdAt;
|
||||
}
|
||||
return oldestFailing;
|
||||
};
|
||||
|
||||
export const CurrentlyDownCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const problems = useMemo(() => {
|
||||
const monitors = data?.monitors ?? [];
|
||||
return monitors
|
||||
.filter(isProblem)
|
||||
.map((monitor) => ({ monitor, since: downSince(monitor) }))
|
||||
.sort((a, b) => {
|
||||
const rank =
|
||||
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
|
||||
if (rank !== 0) {
|
||||
return rank;
|
||||
}
|
||||
// Tie-break on longest downtime: the older timestamp ranks first.
|
||||
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.currentlyDown.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
problems.length > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.currentlyDown.count", {
|
||||
count: problems.length,
|
||||
})}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{problems.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{problems.map(({ monitor, since }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<Dot
|
||||
color={getStatusColor(monitor.status, theme)}
|
||||
size="md"
|
||||
/>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
{since && (
|
||||
<Typography
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
|
||||
focusable element inside the row's link is unreachable
|
||||
by keyboard. */}
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.type} ·{" "}
|
||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
||||
</Typography>
|
||||
<Box
|
||||
width={SPARKLINE_WIDTH}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Sparkline
|
||||
checks={monitor.recentChecks ?? []}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardFigure } from "../CardPrimitives";
|
||||
|
||||
import type { ChecksSummary } from "@/Types/Check";
|
||||
|
||||
// Day only. `dateRange=week` returns figures identical to `day`, so a range
|
||||
// selector here would silently lie.
|
||||
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
|
||||
|
||||
export const FailedChecksCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
|
||||
|
||||
const total = data?.totalChecks ?? 0;
|
||||
const failed = data?.downChecks ?? 0;
|
||||
const percentage = total > 0 ? (failed / total) * 100 : 0;
|
||||
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.failedChecks.title")}
|
||||
to="/checks"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{total === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={`${percentage.toFixed(1)}%`}
|
||||
caption={t("pages.dashboard.cards.failedChecks.ratio", {
|
||||
failed: failed.toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
})}
|
||||
color={color}
|
||||
/>
|
||||
<CardBar
|
||||
value={percentage}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { getStatusColor } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
|
||||
|
||||
// Every state the summary reports, ordered worst first so the bar reads
|
||||
// problems-to-healthy left to right and the chips below list what is wrong
|
||||
// before what is merely idle. `up` is handled separately as the headline.
|
||||
const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
|
||||
{ key: "down", status: "down", field: "downMonitors" },
|
||||
{ key: "breached", status: "breached", field: "breachedMonitors" },
|
||||
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
|
||||
{ key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
|
||||
{ key: "paused", status: "paused", field: "pausedMonitors" },
|
||||
];
|
||||
|
||||
export const MonitorStatusCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
const summary = data?.summary ?? null;
|
||||
|
||||
// Only states that actually have monitors are shown. Padding the card with
|
||||
// zeros says nothing, and a healthy fleet should read as calm rather than as
|
||||
// a row of empty counters.
|
||||
const present = summary
|
||||
? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
|
||||
(state) => state.count > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
const segments: BarSegment[] = summary
|
||||
? [
|
||||
{
|
||||
key: "up",
|
||||
value: summary.upMonitors,
|
||||
color: getStatusColor("up", theme),
|
||||
label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
|
||||
},
|
||||
...present.map((state) => ({
|
||||
key: state.key,
|
||||
value: state.count,
|
||||
color: getStatusColor(state.status, theme),
|
||||
label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorStatus.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !summary}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!summary || summary.totalMonitors === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={300}
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.1}
|
||||
>
|
||||
{summary.upMonitors}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.dashboard.cards.monitorStatus.upOfTotal", {
|
||||
total: summary.totalMonitors,
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardSegmentedBar segments={segments} />
|
||||
</Stack>
|
||||
{present.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{present.map((state) => (
|
||||
<Stack
|
||||
key={state.key}
|
||||
component={RouterLink}
|
||||
// Plain /uptime: the monitors list does not read query
|
||||
// params, so a ?status= filter would be silently dropped
|
||||
// and the link would lie about where it goes.
|
||||
to="/uptime"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
px={theme.spacing(LAYOUT.XS)}
|
||||
py={theme.spacing(LAYOUT.XXS)}
|
||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
}}
|
||||
>
|
||||
<Dot
|
||||
color={getStatusColor(state.status, theme)}
|
||||
size="md"
|
||||
/>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t(`pages.common.monitors.status.${state.key}`)}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.primary}>
|
||||
{state.count}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
export const MonitorsByGroupCard = () => {
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
// `group` is a plain string | null on the monitor — there is no groups
|
||||
// endpoint, so the set is whatever monitors actually declare.
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byGroup = new Map<string, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
if (!monitor.group) {
|
||||
continue;
|
||||
}
|
||||
const bucket = byGroup.get(monitor.group) ?? {
|
||||
key: monitor.group,
|
||||
label: monitor.group,
|
||||
total: 0,
|
||||
down: 0,
|
||||
};
|
||||
bucket.total += 1;
|
||||
if (monitor.status === "down" || monitor.status === "breached") {
|
||||
bucket.down += 1;
|
||||
}
|
||||
byGroup.set(monitor.group, bucket);
|
||||
}
|
||||
return [...byGroup.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByGroup.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { MonitorType } from "@/Types/Monitor";
|
||||
|
||||
export const MonitorsByTypeCard = () => {
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byType = new Map<MonitorType, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
const bucket = byType.get(monitor.type) ?? {
|
||||
key: monitor.type,
|
||||
label: monitor.type,
|
||||
total: 0,
|
||||
down: 0,
|
||||
to: `/${getMonitorPath(monitor.type)}`,
|
||||
};
|
||||
bucket.total += 1;
|
||||
if (monitor.status === "down" || monitor.status === "breached") {
|
||||
bucket.down += 1;
|
||||
}
|
||||
byType.set(monitor.type, bucket);
|
||||
}
|
||||
return [...byType.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByType.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByType.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
const TOP_N = 5;
|
||||
// A ranking of one is not a ranking.
|
||||
const MIN_RANKABLE = 2;
|
||||
|
||||
const averageResponseTime = (monitor: Monitor): number | null => {
|
||||
const times = (monitor.recentChecks ?? [])
|
||||
.map((check) => check.responseTime)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
if (times.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return times.reduce((sum, value) => sum + value, 0) / times.length;
|
||||
};
|
||||
|
||||
/** Header reads "top 5 of 12" so a truncated ranking says so. */
|
||||
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlowestMonitorsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
return (data?.monitors ?? [])
|
||||
.filter((monitor) => monitor.status !== "paused")
|
||||
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
|
||||
.filter(
|
||||
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
|
||||
)
|
||||
.sort((a, b) => b.average - a.average);
|
||||
}, [data]);
|
||||
|
||||
const slowest = ranked[0]?.average ?? 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.slowestMonitors.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
ranked.length >= MIN_RANKABLE ? (
|
||||
<RankingCount
|
||||
shown={Math.min(TOP_N, ranked.length)}
|
||||
total={ranked.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{ranked.length < MIN_RANKABLE ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
value={average}
|
||||
max={slowest}
|
||||
color={theme.palette.primary.main}
|
||||
trailing={
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
{Math.round(average)} ms
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const LowestUptimeCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
return (
|
||||
(data?.monitors ?? [])
|
||||
// Paused monitors keep whatever uptime they had, or zero — a paused
|
||||
// ping monitor reads 0% and would rank beside a genuinely down one.
|
||||
// Neither is a live problem, so neither belongs in a worst-uptime list.
|
||||
.filter((monitor) => monitor.status !== "paused")
|
||||
.filter(
|
||||
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||
typeof monitor.uptimePercentage === "number"
|
||||
)
|
||||
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.lowestUptime.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
ranked.length >= MIN_RANKABLE ? (
|
||||
<RankingCount
|
||||
shown={Math.min(TOP_N, ranked.length)}
|
||||
total={ranked.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{ranked.length < MIN_RANKABLE ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{ranked.slice(0, TOP_N).map((monitor) => {
|
||||
// uptimePercentage is a 0-1 fraction, not 0-100.
|
||||
const percentage = monitor.uptimePercentage * 100;
|
||||
const palette = getUptimePercentageColor(monitor.uptimePercentage);
|
||||
return (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
// The card ranks worst uptime first, so the bar shows the
|
||||
// shortfall - the worst monitor gets the longest bar.
|
||||
value={100 - percentage}
|
||||
color={theme.palette[palette].main}
|
||||
trailing={
|
||||
<Typography
|
||||
color={theme.palette[palette].main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{percentage.toFixed(1)}%
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
|
||||
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
|
||||
|
||||
const MAX_ROWS = 5;
|
||||
|
||||
/** Open incidents have no end time; closed ones report how long they ran. */
|
||||
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
|
||||
if (!incident.endTime) {
|
||||
return null;
|
||||
}
|
||||
const ms =
|
||||
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
|
||||
return ms > 0 ? formatDuration(ms) : null;
|
||||
};
|
||||
|
||||
export const RecentIncidentsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||
"/incidents/team/summary"
|
||||
);
|
||||
|
||||
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.recentIncidents.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
data && data.totalActive > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.recentIncidents.active", {
|
||||
count: data.totalActive,
|
||||
})}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{incidents.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{incidents.map((incident) => {
|
||||
const isOpen = incident.status === true;
|
||||
const duration = incidentDuration(incident);
|
||||
return (
|
||||
<CardRow
|
||||
key={incident.id}
|
||||
to="/incidents"
|
||||
>
|
||||
<Dot
|
||||
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
|
||||
size="md"
|
||||
/>
|
||||
<CardRowLabel
|
||||
primary={
|
||||
incident.monitorName ??
|
||||
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
|
||||
}
|
||||
secondary={formatTimestamp(incident.startTime)}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{isOpen
|
||||
? t("pages.dashboard.cards.recentIncidents.ongoing")
|
||||
: (duration ?? "—")}
|
||||
</Typography>
|
||||
{incident.resolutionType === "manual" && (
|
||||
<Typography
|
||||
color={theme.palette.warning.main}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("pages.dashboard.cards.recentIncidents.manual")}
|
||||
</Typography>
|
||||
)}
|
||||
</CardRow>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import {
|
||||
CardFigure,
|
||||
CardRow,
|
||||
CardRowLabel,
|
||||
CardSegmentedBar,
|
||||
CardStatLine,
|
||||
} from "../CardPrimitives";
|
||||
|
||||
import type { IncidentSummary } from "@/Types/Incident";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
/** Free when Recent incidents is present — same summary call, shared by SWR. */
|
||||
export const IncidentStatsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||
"/incidents/team/summary"
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.incidentStats.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{/* Open now leads, paired with the all-time total for context: two
|
||||
open means something different against 12 incidents than 183. */}
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.XXL)}
|
||||
>
|
||||
<CardFigure
|
||||
value={String(data.totalActive)}
|
||||
caption={t("pages.dashboard.cards.incidentStats.openNow")}
|
||||
color={
|
||||
data.totalActive > 0
|
||||
? theme.palette.error.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
/>
|
||||
<CardFigure
|
||||
value={data.total.toLocaleString()}
|
||||
caption={t("pages.dashboard.cards.incidentStats.total")}
|
||||
/>
|
||||
</Stack>
|
||||
{/* How much of the fleet heals itself — the automatic/manual split
|
||||
is already computed server-side and said nothing on the card
|
||||
before. A high manual share means people are doing the work. */}
|
||||
{data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<CardSegmentedBar
|
||||
segments={[
|
||||
{
|
||||
key: "automatic",
|
||||
value: data.totalAutomaticResolutions,
|
||||
color: theme.palette.success.main,
|
||||
label: `${t("pages.dashboard.cards.incidentStats.automatic")} — ${data.totalAutomaticResolutions}`,
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
value: data.totalManualResolutions,
|
||||
color: theme.palette.warning.main,
|
||||
label: `${t("pages.dashboard.cards.incidentStats.manual")} — ${data.totalManualResolutions}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.resolved")}
|
||||
value={t("pages.dashboard.cards.incidentStats.autoManual", {
|
||||
automatic: data.totalAutomaticResolutions,
|
||||
manual: data.totalManualResolutions,
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
|
||||
value={t("pages.dashboard.cards.incidentStats.hours", {
|
||||
hours: data.avgResolutionTimeHours.toFixed(1),
|
||||
})}
|
||||
/>
|
||||
{data.topMonitor?.monitorName && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
|
||||
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationChannelsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } =
|
||||
useGet<Notification[]>("/notifications/team");
|
||||
|
||||
// The response also carries accessToken and accountSid. Only `type` is read
|
||||
// here — credential fields must never reach the DOM.
|
||||
const byType = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const notification of data ?? []) {
|
||||
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.notificationChannels.title")}
|
||||
to="/notifications"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{byType.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{byType.map(([type, count]) => (
|
||||
<CardStatLine
|
||||
key={type}
|
||||
label={type}
|
||||
value={String(count)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const MaintenanceCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<{
|
||||
maintenanceWindows: MaintenanceWindow[];
|
||||
maintenanceWindowCount: number;
|
||||
}>("/maintenance-window/team?page=0&rowsPerPage=0");
|
||||
|
||||
// `active` is a user on/off flag, not "running now" — compare the window
|
||||
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
|
||||
// a window that is currently live.
|
||||
const { running, next } = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
|
||||
const live = windows.filter((entry) => {
|
||||
const start = new Date(entry.start).getTime();
|
||||
const end = new Date(entry.end).getTime();
|
||||
return start <= now && (now <= end || entry.repeat > 0);
|
||||
});
|
||||
const upcoming = windows
|
||||
.filter((entry) => new Date(entry.start).getTime() > now)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
return { running: live.length, next: upcoming[0] ?? null };
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.maintenance.title")}
|
||||
to="/maintenance"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!data || data.maintenanceWindows.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={String(running)}
|
||||
caption={t("pages.dashboard.cards.maintenance.runningNow")}
|
||||
/>
|
||||
{next && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.maintenance.next")}
|
||||
value={formatTimestamp(next.start)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusPagesCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } =
|
||||
useGet<StatusPage[]>("/status-page/team");
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.statusPages.title")}
|
||||
to="/status"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!data || data.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{data.map((page) => (
|
||||
<CardRow
|
||||
key={page.id}
|
||||
to={`/status/${page.url}`}
|
||||
>
|
||||
<Dot
|
||||
color={
|
||||
page.isPublished
|
||||
? theme.palette.success.light
|
||||
: theme.palette.text.secondary
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
<CardRowLabel
|
||||
primary={page.companyName}
|
||||
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
|
||||
count: page.monitors.length,
|
||||
})}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
{page.isPublished
|
||||
? t("pages.dashboard.cards.statusPages.published")
|
||||
: t("pages.dashboard.cards.statusPages.draft")}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useCardSlot } from "./CardSlotContext";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Control height plus the header's vertical padding, so a card with no action
|
||||
// still lines up with one that has a button in its header.
|
||||
const HEADER_MIN_HEIGHT = 44;
|
||||
const SKELETON_ROW_HEIGHT = 20;
|
||||
|
||||
interface DashboardCardProps {
|
||||
title: string;
|
||||
/** Where the title links to — the page this card belongs to. */
|
||||
to?: string;
|
||||
/** Rendered at the right of the header: a count, an average, a filter. */
|
||||
action?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
|
||||
isStale?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DashboardCard = ({
|
||||
title,
|
||||
to,
|
||||
action,
|
||||
isLoading,
|
||||
error,
|
||||
isStale,
|
||||
children,
|
||||
}: DashboardCardProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const slot = useCardSlot();
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
height="100%"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
"&:hover .dashboard-card-slot, &:has(:focus-visible) .dashboard-card-slot": {
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
"&:hover .dashboard-card-action, &:has(:focus-visible) .dashboard-card-action": {
|
||||
opacity: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
px={theme.spacing(LAYOUT.MD)}
|
||||
py={theme.spacing(LAYOUT.SM)}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
minHeight={HEADER_MIN_HEIGHT}
|
||||
>
|
||||
<Typography
|
||||
{...(to ? { component: RouterLink, to } : {})}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
...(to && {
|
||||
"&:hover": { color: theme.palette.primary.main },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isStale && (
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
{action && (
|
||||
<Box
|
||||
className="dashboard-card-action"
|
||||
sx={{ transition: "opacity 0.15s ease" }}
|
||||
>
|
||||
{action}
|
||||
</Box>
|
||||
)}
|
||||
{slot && (
|
||||
<Box
|
||||
className="dashboard-card-slot"
|
||||
position="absolute"
|
||||
right={0}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{slot}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
flex={1}
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
sx={{
|
||||
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : error ? (
|
||||
<CardMessage text={t("pages.dashboard.card.error")} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Box>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
/** Placeholder rows, sized to the row list most cards render. */
|
||||
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
height={SKELETON_ROW_HEIGHT}
|
||||
width={index === rows - 1 ? "60%" : "100%"}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** The quiet one-line state a healthy card collapses to. */
|
||||
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{text}</Typography>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
|
||||
const VIEWBOX_WIDTH = 100;
|
||||
// The svg renders at its viewBox height, so one constant serves both.
|
||||
const VIEWBOX_HEIGHT = 24;
|
||||
// Matches the 1.5 stroke the shared Icon component uses.
|
||||
const STROKE_WIDTH = 1.5;
|
||||
|
||||
/**
|
||||
* Response times as a bare polyline. Deliberately axis-free and label-free —
|
||||
* at this size the shape is the only readable signal.
|
||||
*
|
||||
* `checks` arrives oldest-first, which is already left-to-right reading order.
|
||||
*/
|
||||
export const Sparkline = ({
|
||||
checks,
|
||||
color,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
color: string;
|
||||
}) => {
|
||||
const points = useMemo(() => {
|
||||
const values = checks
|
||||
.map((check) => check.responseTime)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const span = max - min || 1;
|
||||
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
|
||||
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}, [checks]);
|
||||
|
||||
if (!points) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="svg"
|
||||
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
width="100%"
|
||||
height={VIEWBOX_HEIGHT}
|
||||
display="block"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// The 12-column grid the dashboard lays cards out on.
|
||||
//
|
||||
// Every card is currently half-width, so they pair up down the page and the
|
||||
// grid reads as one system. Full width stays in the type for a card whose
|
||||
// content genuinely needs it. Width is declared per card type in code and is
|
||||
// never a user setting.
|
||||
export const CardWidths = [6, 12] as const;
|
||||
export type CardWidth = (typeof CardWidths)[number];
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
|
||||
// Picker groups mirror the sidebar sections: you look for a card where you
|
||||
// look for the feature it belongs to.
|
||||
export const CardGroups = [
|
||||
"uptime",
|
||||
"infrastructure",
|
||||
"incidents",
|
||||
"notifications",
|
||||
"maintenance",
|
||||
"statusPages",
|
||||
"checks",
|
||||
"logs",
|
||||
"settings",
|
||||
] as const;
|
||||
export type CardGroup = (typeof CardGroups)[number];
|
||||
|
||||
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
|
||||
// `containerSummary` on the check snapshot, which arrives with the Docker
|
||||
// monitoring work. Add it here once that lands on develop.
|
||||
export const CardIds = [
|
||||
"monitorStatus",
|
||||
"currentlyDown",
|
||||
"slowestMonitors",
|
||||
"lowestUptime",
|
||||
"monitorsByType",
|
||||
"monitorsByGroup",
|
||||
"recentIncidents",
|
||||
"incidentStats",
|
||||
"notificationChannels",
|
||||
"maintenance",
|
||||
"statusPages",
|
||||
"failedChecks",
|
||||
"busiestServers",
|
||||
"checkmateServer",
|
||||
"checksOnSchedule",
|
||||
"recentErrors",
|
||||
"team",
|
||||
] as const;
|
||||
export type CardId = (typeof CardIds)[number];
|
||||
|
||||
export interface CardDefinition {
|
||||
id: CardId;
|
||||
/** i18n key fragment under pages.dashboard.cards.<key>. */
|
||||
key: string;
|
||||
group: CardGroup;
|
||||
width: CardWidth;
|
||||
/** Hidden from the page and absent from the picker for non-admins. */
|
||||
adminOnly?: boolean;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
// The cards a fresh dashboard starts with. Chosen so a brand new install still
|
||||
// shows something real: Checkmate server works with zero monitors.
|
||||
// Ordered so the declared widths pair into full rows: a card left alone in a
|
||||
// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
|
||||
export const DEFAULT_CARD_IDS: readonly CardId[] = [
|
||||
"monitorStatus",
|
||||
"currentlyDown",
|
||||
"failedChecks",
|
||||
"monitorsByType",
|
||||
"recentIncidents",
|
||||
"checkmateServer",
|
||||
];
|
||||
|
||||
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
|
||||
|
||||
/**
|
||||
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
|
||||
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
|
||||
*/
|
||||
export const REFRESH_INTERVAL_MS = 30000;
|
||||
export const SLOW_REFRESH_INTERVAL_MS = 60000;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
/**
|
||||
* `recentChecks` is oldest-first — the server appends each snapshot with
|
||||
* `$push … $slice: -N` — so the newest retained check is the last element.
|
||||
*/
|
||||
export const latestCheck = (monitor: Monitor) =>
|
||||
monitor.recentChecks?.[monitor.recentChecks.length - 1];
|
||||
@@ -0,0 +1,73 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CardGrid } from "./Components/CardGrid";
|
||||
import { CardPicker } from "./Components/CardPicker";
|
||||
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
|
||||
import { useCardSelection } from "./useCardSelection";
|
||||
|
||||
const Dashboard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorsRequest = useMonitorsRequest();
|
||||
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
|
||||
useCardSelection();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<MonitorsContext.Provider value={monitorsRequest}>
|
||||
<BasePage headerKey="dashboard">
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setIsPickerOpen(true)}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={LayoutGrid}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("pages.dashboard.picker.trigger")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{cards.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("pages.dashboard.empty.title")}
|
||||
description={t("pages.dashboard.empty.description")}
|
||||
actionText={t("pages.dashboard.picker.trigger")}
|
||||
onAction={() => setIsPickerOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<CardGrid
|
||||
cards={cards}
|
||||
onMove={moveCard}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<CardPicker
|
||||
open={isPickerOpen}
|
||||
available={available}
|
||||
selected={selected}
|
||||
onAdd={addCard}
|
||||
onRemove={removeCard}
|
||||
onReset={resetCards}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
/>
|
||||
</BasePage>
|
||||
</MonitorsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CardDefinition } from "./cards";
|
||||
|
||||
export interface PlacedCard {
|
||||
card: CardDefinition;
|
||||
/** Columns rendered — currently always the declared width. */
|
||||
renderedWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs cards into rows of GRID_COLUMNS.
|
||||
*
|
||||
* With a single body width this is a straight pass-through: two halves fill a
|
||||
* row exactly, so no card is ever stretched to close a gap.
|
||||
*
|
||||
* An odd number of cards leaves the last one alone on its row. It keeps its
|
||||
* declared width rather than being promoted to full: widening one card to fill
|
||||
* the row makes it look more important than it is, and an odd count is a
|
||||
* property of the selection, not something the layout should hide.
|
||||
*/
|
||||
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
|
||||
cards.map((card) => ({ card, renderedWidth: card.width }));
|
||||
@@ -0,0 +1,161 @@
|
||||
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
|
||||
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
|
||||
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
|
||||
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
|
||||
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
|
||||
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
|
||||
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
|
||||
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
|
||||
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
|
||||
import {
|
||||
IncidentStatsCard,
|
||||
NotificationChannelsCard,
|
||||
MaintenanceCard,
|
||||
StatusPagesCard,
|
||||
} from "./Components/Cards/SummaryCards";
|
||||
import {
|
||||
ChecksOnScheduleCard,
|
||||
RecentErrorsCard,
|
||||
TeamCard,
|
||||
} from "./Components/Cards/AdminCards";
|
||||
|
||||
import type { CardDefinition, CardId } from "./cards";
|
||||
|
||||
// Declared width is the minimum number of columns a card needs. The grid may
|
||||
// stretch the last card of a short row; nothing here is a user setting.
|
||||
//
|
||||
// Order is the render order, and the order the picker lists them in.
|
||||
//
|
||||
// Every card is half-width and pairs up down the page. The grid promotes a
|
||||
// lone trailing card to full width so the page does not end on a ragged half.
|
||||
export const CARD_REGISTRY: CardDefinition[] = [
|
||||
{
|
||||
id: "monitorStatus",
|
||||
key: "monitorStatus",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorStatusCard,
|
||||
},
|
||||
{
|
||||
id: "currentlyDown",
|
||||
key: "currentlyDown",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: CurrentlyDownCard,
|
||||
},
|
||||
{
|
||||
id: "failedChecks",
|
||||
key: "failedChecks",
|
||||
group: "checks",
|
||||
width: 6,
|
||||
component: FailedChecksCard,
|
||||
},
|
||||
{
|
||||
id: "slowestMonitors",
|
||||
key: "slowestMonitors",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: SlowestMonitorsCard,
|
||||
},
|
||||
{
|
||||
id: "lowestUptime",
|
||||
key: "lowestUptime",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: LowestUptimeCard,
|
||||
},
|
||||
{
|
||||
id: "monitorsByType",
|
||||
key: "monitorsByType",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorsByTypeCard,
|
||||
},
|
||||
{
|
||||
id: "monitorsByGroup",
|
||||
key: "monitorsByGroup",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorsByGroupCard,
|
||||
},
|
||||
{
|
||||
id: "busiestServers",
|
||||
key: "busiestServers",
|
||||
group: "infrastructure",
|
||||
width: 6,
|
||||
component: BusiestServersCard,
|
||||
},
|
||||
{
|
||||
id: "recentIncidents",
|
||||
key: "recentIncidents",
|
||||
group: "incidents",
|
||||
width: 6,
|
||||
component: RecentIncidentsCard,
|
||||
},
|
||||
{
|
||||
id: "incidentStats",
|
||||
key: "incidentStats",
|
||||
group: "incidents",
|
||||
width: 6,
|
||||
component: IncidentStatsCard,
|
||||
},
|
||||
{
|
||||
id: "notificationChannels",
|
||||
key: "notificationChannels",
|
||||
group: "notifications",
|
||||
width: 6,
|
||||
component: NotificationChannelsCard,
|
||||
},
|
||||
{
|
||||
id: "maintenance",
|
||||
key: "maintenance",
|
||||
group: "maintenance",
|
||||
width: 6,
|
||||
component: MaintenanceCard,
|
||||
},
|
||||
{
|
||||
id: "statusPages",
|
||||
key: "statusPages",
|
||||
group: "statusPages",
|
||||
width: 6,
|
||||
component: StatusPagesCard,
|
||||
},
|
||||
{
|
||||
id: "checkmateServer",
|
||||
key: "checkmateServer",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: CheckmateServerCard,
|
||||
},
|
||||
{
|
||||
id: "checksOnSchedule",
|
||||
key: "checksOnSchedule",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: ChecksOnScheduleCard,
|
||||
},
|
||||
{
|
||||
id: "recentErrors",
|
||||
key: "recentErrors",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: RecentErrorsCard,
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
key: "team",
|
||||
group: "settings",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: TeamCard,
|
||||
},
|
||||
];
|
||||
|
||||
const BY_ID = new Map<CardId, CardDefinition>(
|
||||
CARD_REGISTRY.map((card) => [card.id, card])
|
||||
);
|
||||
|
||||
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import {
|
||||
CardIds,
|
||||
DASHBOARD_CARDS_STORAGE_KEY,
|
||||
DEFAULT_CARD_IDS,
|
||||
type CardId,
|
||||
} from "./cards";
|
||||
import { CARD_REGISTRY, getCard } from "./registry";
|
||||
|
||||
import type { CardDefinition } from "./cards";
|
||||
|
||||
const isCardId = (value: unknown): value is CardId =>
|
||||
typeof value === "string" && (CardIds as readonly string[]).includes(value);
|
||||
|
||||
/** Stored ids are validated: a card removed from a release must not break the page. */
|
||||
const readStored = (): CardId[] | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
// An empty array is a real choice and must not fall back to the defaults;
|
||||
// only an absent or malformed key returns null. Unknown ids are dropped
|
||||
// so a card removed in a later release cannot break the page.
|
||||
return parsed.filter(isCardId);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Could not read dashboard card selection",
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Which cards are on the page, persisted per browser. Admin-only cards are
|
||||
* filtered out entirely for non-admins — hidden, not disabled.
|
||||
*/
|
||||
export const useCardSelection = () => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const [selected, setSelected] = useState<CardId[]>(
|
||||
() => readStored() ?? [...DEFAULT_CARD_IDS]
|
||||
);
|
||||
|
||||
// Skip the write on mount: if the read failed and fell back to the defaults,
|
||||
// writing would overwrite the user's real selection permanently.
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMounted.current) {
|
||||
hasMounted.current = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Could not persist dashboard card selection",
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(card: CardDefinition) => !card.adminOnly || isAdmin,
|
||||
[isAdmin]
|
||||
);
|
||||
|
||||
// Selection order is the render order.
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
selected
|
||||
.map(getCard)
|
||||
.filter((card): card is CardDefinition => card !== undefined)
|
||||
.filter(isVisible),
|
||||
[selected, isVisible]
|
||||
);
|
||||
|
||||
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
|
||||
|
||||
const addCard = useCallback((id: CardId) => {
|
||||
setSelected((current) => (current.includes(id) ? current : [...current, id]));
|
||||
}, []);
|
||||
|
||||
const removeCard = useCallback((id: CardId) => {
|
||||
setSelected((current) => current.filter((cardId) => cardId !== id));
|
||||
}, []);
|
||||
|
||||
const resetCards = useCallback(() => setSelected([...DEFAULT_CARD_IDS]), []);
|
||||
|
||||
/**
|
||||
* Swaps a card with its neighbour; `delta` is -1 for up, +1 for down.
|
||||
* Resolved against the visible order, so moving past a card hidden from a
|
||||
* non-admin does not appear to do nothing.
|
||||
*/
|
||||
const moveCard = useCallback(
|
||||
(id: CardId, delta: -1 | 1) => {
|
||||
setSelected((current) => {
|
||||
const visible = current.filter((cardId) => {
|
||||
const card = getCard(cardId);
|
||||
return card !== undefined && isVisible(card);
|
||||
});
|
||||
const from = visible.indexOf(id);
|
||||
const to = from + delta;
|
||||
if (from === -1 || to < 0 || to >= visible.length) {
|
||||
return current;
|
||||
}
|
||||
// Swap in the visible order, then write that order back over the
|
||||
// stored positions the visible cards occupy, leaving hidden cards
|
||||
// exactly where they were.
|
||||
const reordered = [...visible];
|
||||
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
|
||||
|
||||
let next = 0;
|
||||
return current.map((cardId) =>
|
||||
visible.includes(cardId) ? reordered[next++] : cardId
|
||||
);
|
||||
});
|
||||
},
|
||||
[isVisible]
|
||||
);
|
||||
|
||||
return {
|
||||
cards,
|
||||
available,
|
||||
selected,
|
||||
addCard,
|
||||
removeCard,
|
||||
moveCard,
|
||||
resetCards,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
import type { CardId } from "./cards";
|
||||
|
||||
/** Long enough to follow a card across a row, short enough not to feel slow. */
|
||||
const DURATION_MS = 260;
|
||||
/** Decelerating: quick to leave, gentle to settle. */
|
||||
const EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
||||
|
||||
export const useCardTransitions = () => {
|
||||
const nodes = useRef(new Map<CardId, HTMLElement>());
|
||||
const positions = useRef(new Map<CardId, DOMRect>());
|
||||
/** Set only for the render that follows a reorder, so nothing animates on data refresh. */
|
||||
const isAnimating = useRef(false);
|
||||
/** The card the user moved, lifted above the rest while it travels. */
|
||||
const movedCard = useRef<CardId | null>(null);
|
||||
/** Pending frame and settle handles, cancelled on unmount and on a new move. */
|
||||
const frames = useRef<number[]>([]);
|
||||
const timers = useRef<number[]>([]);
|
||||
|
||||
const cancelPending = useCallback(() => {
|
||||
frames.current.forEach(cancelAnimationFrame);
|
||||
timers.current.forEach(clearTimeout);
|
||||
frames.current = [];
|
||||
timers.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancelPending, [cancelPending]);
|
||||
|
||||
const registerCard = useCallback((id: CardId, node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
nodes.current.set(id, node);
|
||||
} else {
|
||||
nodes.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Call immediately before the state change that reorders the cards.
|
||||
* `movedId` is the card the user acted on; it rides above the others so the
|
||||
* swap reads as one card moving rather than two sliding through each other.
|
||||
*/
|
||||
const capturePositions = useCallback(
|
||||
(movedId: CardId) => {
|
||||
// A move while a previous one is still settling would otherwise have
|
||||
// its inversion wiped by the earlier timeout, and the card would jump.
|
||||
cancelPending();
|
||||
positions.current.clear();
|
||||
for (const [id, node] of nodes.current) {
|
||||
positions.current.set(id, node.getBoundingClientRect());
|
||||
}
|
||||
movedCard.current = movedId;
|
||||
isAnimating.current = true;
|
||||
},
|
||||
[cancelPending]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isAnimating.current) {
|
||||
return;
|
||||
}
|
||||
isAnimating.current = false;
|
||||
|
||||
const reducedMotion = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
)?.matches;
|
||||
if (reducedMotion) {
|
||||
positions.current.clear();
|
||||
movedCard.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, node] of nodes.current) {
|
||||
const previous = positions.current.get(id);
|
||||
if (!previous) {
|
||||
continue;
|
||||
}
|
||||
const current = node.getBoundingClientRect();
|
||||
const deltaX = previous.left - current.left;
|
||||
const deltaY = previous.top - current.top;
|
||||
if (deltaX === 0 && deltaY === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invert: jump back to the old position with no transition...
|
||||
node.style.transition = "none";
|
||||
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
if (id === movedCard.current) {
|
||||
node.style.zIndex = "1";
|
||||
}
|
||||
|
||||
// ...then play: on the next frame, release to the new one.
|
||||
frames.current.push(
|
||||
requestAnimationFrame(() => {
|
||||
node.style.transition = `transform ${DURATION_MS}ms ${EASING}`;
|
||||
node.style.transform = "";
|
||||
// Clear the inline styles once settled. Left in place they would
|
||||
// animate the card again on any later reflow it did not ask for.
|
||||
timers.current.push(
|
||||
window.setTimeout(() => {
|
||||
node.style.transition = "";
|
||||
node.style.zIndex = "";
|
||||
}, DURATION_MS)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
positions.current.clear();
|
||||
movedCard.current = null;
|
||||
});
|
||||
|
||||
return { registerCard, capturePositions };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||
import { REFRESH_INTERVAL_MS } from "./cards";
|
||||
|
||||
// One request feeds every monitor-derived card, read from context so adding a
|
||||
// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
|
||||
// fleet, and the repository treats its absence as "no limit".
|
||||
const buildMonitorsUrl = () => {
|
||||
const params = new URLSearchParams();
|
||||
MonitorTypes.forEach((type) => params.append("type", type));
|
||||
return `/monitors/team/with-checks?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const useMonitorsRequest = () =>
|
||||
useGet<MonitorsWithChecksResponse>(
|
||||
buildMonitorsUrl(),
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
|
||||
);
|
||||
|
||||
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
|
||||
|
||||
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
|
||||
|
||||
export const useMonitors = (): MonitorsRequest => {
|
||||
const context = useContext(MonitorsContext);
|
||||
if (!context) {
|
||||
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
|
||||
// Hooks
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
|
||||
|
||||
interface HeaderLogsProps {
|
||||
lines: number;
|
||||
loading: boolean;
|
||||
lastUpdatedAt: number | null;
|
||||
}
|
||||
|
||||
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (lastUpdatedAt === null) return;
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
|
||||
return () => window.clearInterval(id);
|
||||
}, [lastUpdatedAt]);
|
||||
|
||||
const renderStatus = () => {
|
||||
if (loading) return t("pages.docker.container.logs.loading");
|
||||
if (lastUpdatedAt === null) return null;
|
||||
const ageMs = Math.max(0, now - lastUpdatedAt);
|
||||
return ageMs < MS_PER_MINUTE
|
||||
? t("pages.docker.container.logs.updatedSecondsAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_SECOND),
|
||||
})
|
||||
: t("pages.docker.container.logs.updatedMinutesAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_MINUTE),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.MD}
|
||||
>
|
||||
<Typography>
|
||||
{t("pages.docker.container.logs.recentLines", { count: lines })}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={SPACING.SM}
|
||||
>
|
||||
{lastUpdatedAt !== null && !loading && (
|
||||
<Dot
|
||||
color={theme.palette.success.main}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import type { DockerLogRow } from "@/Utils/MonitorUtils";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/store";
|
||||
|
||||
export const RowLog = ({ row }: { row: DockerLogRow }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid
|
||||
container
|
||||
spacing={{ xs: 0, md: LAYOUT.MD }}
|
||||
py={{ xs: LAYOUT.LG, md: 0 }}
|
||||
>
|
||||
<Grid size={{ xs: 12, md: "auto" }}>
|
||||
<Typography
|
||||
fontFamily="monospace"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: "grow" }}>
|
||||
<Typography
|
||||
fontFamily={"monospace"}
|
||||
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
|
||||
sx={{
|
||||
overflowWrap: { xs: "anywhere", md: "normal" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={"span"}
|
||||
color={
|
||||
row.stream === "stderr"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
>
|
||||
{row.stream}
|
||||
</Box>
|
||||
: {row.text}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{row.gapBefore ? (
|
||||
<Stack
|
||||
width="100%"
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
py={SPACING.MD}
|
||||
>
|
||||
<Typography pr={SPACING.MD}>
|
||||
{t("pages.docker.container.logs.skipped")}
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<Divider sx={{ display: { xs: "block", md: "none" } }} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,241 @@
|
||||
export const TabLogs = () => {
|
||||
return "This is the logs tab";
|
||||
// Components
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, EmptyState } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
|
||||
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
|
||||
import type { DockerLog } from "@/Types/Check";
|
||||
import type { ApiResponse } from "@/Hooks/UseApi";
|
||||
|
||||
// Hooks
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
|
||||
// Utils
|
||||
import { get } from "@/Utils/ApiClient";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
|
||||
|
||||
interface TabLogsProps {
|
||||
monitorId?: string;
|
||||
containerName?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const SCROLL_PIN_THRESHOLD_PX = 8;
|
||||
const TAIL_POLL_MS = 10_000;
|
||||
|
||||
const isScrolledToBottom = (el: HTMLElement) =>
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
|
||||
|
||||
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
|
||||
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { toastError } = useToast();
|
||||
|
||||
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
||||
const [logs, setLogs] = useState<DockerLog[]>([]);
|
||||
const [olderExhausted, setOlderExhausted] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
||||
|
||||
const logsUrl =
|
||||
enabled && monitorId && containerName
|
||||
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
||||
: null;
|
||||
|
||||
const newestCursor = logs[0]?.checkedAt;
|
||||
const oldestCursor = logs[logs.length - 1]?.checkedAt;
|
||||
|
||||
// Initial page. Resets everything when the container changes.
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
setOlderExhausted(false);
|
||||
if (!logsUrl) return;
|
||||
let cancelled = false;
|
||||
setLoadingInitial(true);
|
||||
fetchLogs(logsUrl, {})
|
||||
.then((page) => {
|
||||
if (cancelled) return;
|
||||
setLogs(page.logs);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
setLastUpdatedAt(Date.now());
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
toastError(t("pages.docker.container.logs.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingInitial(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logsUrl, t, toastError]);
|
||||
|
||||
const newestCursorRef = useRef(newestCursor);
|
||||
newestCursorRef.current = newestCursor;
|
||||
|
||||
useEffect(() => {
|
||||
if (!logsUrl) return;
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight || document.hidden) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const cursor = newestCursorRef.current;
|
||||
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
|
||||
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
|
||||
setLastUpdatedAt(Date.now());
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const id = window.setInterval(tick, TAIL_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [logsUrl]);
|
||||
|
||||
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
|
||||
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedToBottom = useRef(true);
|
||||
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
|
||||
// Always pin to bottom when container/monitor changes
|
||||
useLayoutEffect(() => {
|
||||
pinnedToBottom.current = true;
|
||||
}, [monitorId, containerName]);
|
||||
|
||||
// Rows changed: restore position after a prepend, otherwise honour the pin
|
||||
useLayoutEffect(() => {
|
||||
const el = logBoxRef.current;
|
||||
if (!el) return;
|
||||
const anchor = prependAnchor.current;
|
||||
if (anchor) {
|
||||
prependAnchor.current = null;
|
||||
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
|
||||
return;
|
||||
}
|
||||
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
|
||||
}, [rows]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!logsUrl || !oldestCursor || loadingOlder) return;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const page = await fetchLogs(logsUrl, { before: oldestCursor });
|
||||
const el = logBoxRef.current;
|
||||
if (el)
|
||||
prependAnchor.current = {
|
||||
scrollHeight: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
setLogs((prev) => [...prev, ...page.logs]);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
} catch {
|
||||
toastError(t("pages.docker.container.logs.loadOlderError"));
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.disabled.title")}
|
||||
description={t("pages.docker.container.logs.disabled.description")}
|
||||
actionText={t("pages.docker.container.logs.disabled.action")}
|
||||
actionTo={`/docker/configure/${monitorId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastUpdatedAt !== null && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.empty.title")}
|
||||
description={t("pages.docker.container.logs.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={LAYOUT.MD}>
|
||||
<HeaderLogs
|
||||
lines={rows.length}
|
||||
loading={loadingInitial}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
<BaseBox
|
||||
ref={logBoxRef}
|
||||
onScroll={handleScroll}
|
||||
minHeight={240}
|
||||
maxHeight="50vh"
|
||||
overflow="auto"
|
||||
padding={LAYOUT.MD}
|
||||
sx={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{rows.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
pb={LAYOUT.MD}
|
||||
>
|
||||
{olderExhausted ? (
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.docker.container.logs.noOlder")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={loadingOlder}
|
||||
onClick={loadOlder}
|
||||
>
|
||||
{loadingOlder
|
||||
? t("pages.docker.container.logs.loadingOlder")
|
||||
: t("pages.docker.container.logs.loadOlder")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
return (
|
||||
<RowLog
|
||||
key={row.key}
|
||||
row={row}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BaseBox>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,7 @@ const TAB_MAP = {
|
||||
logs: 1,
|
||||
} as const;
|
||||
|
||||
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
|
||||
const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const { monitorId, containerName } = useParams<{
|
||||
@@ -70,7 +70,13 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
|
||||
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||
</Tabs>
|
||||
{activeTab === 0 && <TabOverview stats={stats} />}
|
||||
{activeTab === 1 && <TabLogs />}
|
||||
{activeTab === 1 && (
|
||||
<TabLogs
|
||||
monitorId={monitorId}
|
||||
containerName={containerName}
|
||||
enabled={monitor.dockerLogsEnabled ?? false}
|
||||
/>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,9 +30,6 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
// The icons are supporting marks beside the label, not content,
|
||||
// so they take secondary ink rather than lucide's currentColor
|
||||
// default, which renders them heavier than the text.
|
||||
sx={{ "& svg": { color: theme.palette.text.secondary } }}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// No "Inter" here: its webfont was never self-hosted, so once the Google Fonts
|
||||
// import went it resolved to a fallback anyway. Listing it only made the stack
|
||||
// look intentional on machines that happen to have Inter installed.
|
||||
export const SANS_STACK =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const c = useMonitorListController({
|
||||
const monitorListController = useMonitorListController({
|
||||
types: "selectable",
|
||||
checksLimit: 25,
|
||||
refreshInterval: 5000,
|
||||
@@ -17,25 +17,25 @@ const UptimeMonitorsPage = () => {
|
||||
headerKey="uptime"
|
||||
page="uptime"
|
||||
actionLink="/uptime/create"
|
||||
controller={c}
|
||||
controller={monitorListController}
|
||||
bulkActions
|
||||
>
|
||||
<MonitorTable
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
setSelectedMonitor={c.setSelectedMonitor}
|
||||
count={c.count || 0}
|
||||
page={c.page}
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
selectedRows={c.selectedRows}
|
||||
onSelectionChange={c.setSelectedRows}
|
||||
monitors={monitorListController.monitors || []}
|
||||
tags={monitorListController.tags || []}
|
||||
refetch={monitorListController.refetch}
|
||||
setSelectedMonitor={monitorListController.setSelectedMonitor}
|
||||
count={monitorListController.count || 0}
|
||||
page={monitorListController.page}
|
||||
setPage={monitorListController.setPage}
|
||||
rowsPerPage={monitorListController.rowsPerPage}
|
||||
sortField={monitorListController.sortField}
|
||||
setSortField={monitorListController.setSortField}
|
||||
sortOrder={monitorListController.sortOrder}
|
||||
setSortOrder={monitorListController.setSortOrder}
|
||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||
selectedRows={monitorListController.selectedRows}
|
||||
onSelectionChange={monitorListController.setSelectedRows}
|
||||
/>
|
||||
</MonitorListPage>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
|
||||
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
||||
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
|
||||
|
||||
// Dashboard
|
||||
import Dashboard from "@/Pages/Dashboard";
|
||||
|
||||
// Uptime
|
||||
import Uptime from "@/Pages/Uptime/Monitors";
|
||||
import UptimeDetails from "@/Pages/Uptime/Details";
|
||||
@@ -92,6 +95,10 @@ const Routes = () => {
|
||||
path="/"
|
||||
element={<Navigate to="/uptime" />}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<Dashboard />}
|
||||
/>
|
||||
<Route
|
||||
path="/uptime"
|
||||
element={<Uptime />}
|
||||
|
||||
@@ -161,6 +161,31 @@ export interface DockerContainerMount {
|
||||
rw: boolean;
|
||||
}
|
||||
|
||||
export const DockerLogStreams = ["stdout", "stderr"] as const;
|
||||
export type DockerLogStream = (typeof DockerLogStreams)[number];
|
||||
|
||||
export interface DockerLogLine {
|
||||
ts: string;
|
||||
stream: DockerLogStream;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DockerLog {
|
||||
id: string;
|
||||
metadata: {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
};
|
||||
lines: DockerLogLine[];
|
||||
gap: boolean;
|
||||
checkedAt: string;
|
||||
expiry: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DailyCheckBucket,
|
||||
DockerContainerInfo,
|
||||
DockerContainerSummary,
|
||||
DockerLog,
|
||||
} from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
@@ -158,6 +159,7 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dockerLogsEnabled?: boolean;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
@@ -322,6 +324,10 @@ export interface DockerContainerResponse {
|
||||
stats: DockerContainerStats;
|
||||
}
|
||||
|
||||
export interface DockerContainerLogsResponse {
|
||||
logs: DockerLog[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
export interface Game {
|
||||
name: string;
|
||||
options?: {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import type {
|
||||
DockerContainerMount,
|
||||
DockerContainerPort,
|
||||
DockerContainerState,
|
||||
import {
|
||||
DockerLogStreams,
|
||||
type DockerContainerMount,
|
||||
type DockerContainerPort,
|
||||
type DockerContainerState,
|
||||
type DockerLog,
|
||||
type DockerLogLine,
|
||||
} from "@/Types/Check";
|
||||
|
||||
export const getMonitorPath = (type: MonitorType): string => {
|
||||
@@ -144,3 +147,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => {
|
||||
if (mount.source) return mount.source;
|
||||
return mount.type;
|
||||
};
|
||||
|
||||
export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const;
|
||||
export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number];
|
||||
|
||||
export interface DockerLogRow extends DockerLogLine {
|
||||
key: string;
|
||||
gapBefore: boolean; // This signifies lines skipped
|
||||
}
|
||||
|
||||
export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => {
|
||||
const rows: DockerLogRow[] = [];
|
||||
for (const log of [...logs].reverse()) {
|
||||
log.lines.forEach((line: DockerLogLine, idx: number) => {
|
||||
rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap });
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const filterDockerLogRows = (
|
||||
rows: DockerLogRow[],
|
||||
stream: DockerLogStreamFilter,
|
||||
query: string
|
||||
): DockerLogRow[] => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return (
|
||||
(stream === "all" || row.stream === stream) &&
|
||||
(normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery))
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -18,14 +18,11 @@ export const colors = {
|
||||
gray700: "#313131",
|
||||
gray900: "#151518",
|
||||
gray850: "#1c1c21",
|
||||
// Neutral ramp, biased slightly green so it sits with the brand rather than
|
||||
// against it. Opaque values - alpha-black over white reads washed out.
|
||||
ink900: "#101917",
|
||||
ink600: "#55635C",
|
||||
ink400: "#7C8983",
|
||||
surface50: "#F6F8F7",
|
||||
rule200: "#C7D2CC",
|
||||
// Semantic colours derived from the brand rather than Material's defaults
|
||||
successMain: "#0D6049",
|
||||
errorMain: "#A02B24",
|
||||
warningMain: "#8C5806",
|
||||
@@ -60,8 +57,6 @@ export const lightPalette = {
|
||||
action: {
|
||||
rowHover: alpha(colors.gray900, HOVER.ROW),
|
||||
controlHover: alpha(colors.gray900, HOVER.CONTROL),
|
||||
// Tinted with the brand so a selected row reads as chosen rather than
|
||||
// merely dirty, and the hover step stays clearly above the resting one.
|
||||
selected: alpha(colors.brandGreen, 0.06),
|
||||
selectedHover: alpha(colors.brandGreen, 0.1),
|
||||
},
|
||||
@@ -78,9 +73,6 @@ export const lightPalette = {
|
||||
disabled: colors.ink400,
|
||||
},
|
||||
divider: colors.rule200,
|
||||
// Only `main` is set. MUI derives `light`/`dark` from it, and `light` is
|
||||
// consumed as a saturated colour - status dots, histogram bars, heatmap
|
||||
// cells - so it must not be overridden with a pale tint.
|
||||
success: {
|
||||
main: colors.successMain,
|
||||
},
|
||||
@@ -112,10 +104,6 @@ export const lightPalette = {
|
||||
export const darkPalette = {
|
||||
primary: {
|
||||
main: colors.brandGreen,
|
||||
// The dark ground needs a lighter green for anything drawn as a line
|
||||
// rather than a fill - focus rings especially, which were dimmer than
|
||||
// their own hover state. Leaving `main` alone keeps contained buttons
|
||||
// white-on-green instead of flipping them to dark text.
|
||||
light: colors.brandGreenLight,
|
||||
},
|
||||
action: {
|
||||
|
||||
@@ -134,9 +134,6 @@ export const theme = (mode: string, palette: any) =>
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
"&.Mui-focusVisible": {
|
||||
// Same green as the input focus ring - `main` is only
|
||||
// 2.86:1 on the dark ground, so keyboard focus was
|
||||
// barely visible there.
|
||||
outline: `1px solid ${
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.primary.light
|
||||
@@ -195,24 +192,14 @@ export const theme = (mode: string, palette: any) =>
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
// One border story for every outlined input - text fields, selects,
|
||||
// autocompletes, date and colour pickers all route through this.
|
||||
// Resting: divider. Hover: a step darker, so there is a cue without
|
||||
// jumping to near-black. Focus: the brand, at 2px.
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
// MUI pads the inner input 8.5px top and bottom, which with an
|
||||
// 18.7px line-height needs 35.7px - taller than the control
|
||||
// itself, so the text was being clipped by overflow:hidden.
|
||||
input: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "100%",
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
// The Select renders a div rather than an input, so it needs
|
||||
// the same treatment to sit inside the control height.
|
||||
"& .MuiSelect-select": {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
|
||||
@@ -18,12 +18,7 @@ export const LAYOUT = {
|
||||
XXL: 16,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared height for anything that can sit in a row with something else -
|
||||
* buttons, inputs, selects, pickers, toggles, tabs, icon buttons. One number so
|
||||
* they share a baseline instead of drifting 2px apart.
|
||||
*/
|
||||
export const CONTROL_HEIGHT = 32;
|
||||
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
||||
|
||||
export const HOVER = {
|
||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||
|
||||
@@ -111,7 +111,8 @@ const portSchema = baseSchema.extend({
|
||||
// Docker monitor schema
|
||||
const dockerSchema = baseSchema.extend({
|
||||
type: z.literal("docker"),
|
||||
url: z.string().min(1, "Container ID is required"),
|
||||
url: z.string().min(1, "Docker host URL is required"),
|
||||
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||
});
|
||||
|
||||
// Game server monitor schema
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "الصيانة",
|
||||
"logs": "السجلات",
|
||||
"settings": "الإعدادات",
|
||||
"dashboard": "لوحة المعلومات",
|
||||
"groups": {
|
||||
"monitoring": "المراقبة",
|
||||
"alerting": "التنبيهات",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "لوحة المعلومات",
|
||||
"description": "اطّلع على كل أنواع المراقبات في مكان واحد ولاحظ ما يحتاج إلى انتباه."
|
||||
},
|
||||
"card": {
|
||||
"error": "تعذّر تحميل هذه البطاقة."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "حالة المراقبات",
|
||||
"empty": "لا توجد مراقبات بعد.",
|
||||
"upOfTotal": "من {{total}} تعمل"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "متوقفة حاليًا",
|
||||
"empty": "جميع المراقبات تعمل. لا توجد حوادث مفتوحة.",
|
||||
"count": "{{count}} تحتاج إلى انتباه"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "المراقبات حسب النوع",
|
||||
"empty": "لا توجد مراقبات بعد.",
|
||||
"down": "{{count}} متوقفة"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "الحوادث الأخيرة",
|
||||
"empty": "لا توجد حوادث — جميع الأنظمة سليمة.",
|
||||
"active": "{{count}} مفتوحة",
|
||||
"ongoing": "جارية",
|
||||
"manual": "يدوي",
|
||||
"unknownMonitor": "مراقبة غير معروفة"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "الفحوصات الفاشلة",
|
||||
"empty": "لا توجد فحوصات بعد.",
|
||||
"ratio": "{{failed}} من {{total}} في آخر يوم"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "خادم Checkmate",
|
||||
"memory": "الذاكرة",
|
||||
"eventLoop": "تأخير حلقة الأحداث",
|
||||
"uptime": "مدة التشغيل",
|
||||
"database": "قاعدة البيانات",
|
||||
"readsWrites": "{{reads}} قراءة/ث · {{writes}} كتابة/ث"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "أعلى {{shown}} من {{total}}",
|
||||
"notEnough": "لا توجد مراقبات كافية للترتيب."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "أبطأ المراقبات"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "أدنى وقت تشغيل"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "المراقبات حسب المجموعة",
|
||||
"empty": "لا توجد مجموعات بعد. تُحدَّد المجموعات لكل مراقبة."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "أكثر الخوادم انشغالًا",
|
||||
"empty": "لا توجد مراقبات بنية تحتية بعد.",
|
||||
"cpu": "المعالج",
|
||||
"memory": "الذاكرة",
|
||||
"disk": "القرص"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "إحصائيات الحوادث",
|
||||
"openNow": "مفتوحة الآن",
|
||||
"avgResolution": "متوسط وقت الحل",
|
||||
"hours": "{{hours}} س",
|
||||
"mostIncidents": "الأكثر حوادث",
|
||||
"total": "الإجمالي",
|
||||
"resolved": "تم حلها",
|
||||
"automatic": "تلقائي",
|
||||
"manual": "يدوي",
|
||||
"autoManual": "{{automatic}} تلقائي · {{manual}} يدوي"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "قنوات الإشعارات",
|
||||
"empty": "لا توجد قنوات بعد."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "نوافذ الصيانة",
|
||||
"empty": "لا توجد صيانة مجدولة.",
|
||||
"runningNow": "قيد التشغيل",
|
||||
"next": "النافذة التالية"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "صفحات الحالة",
|
||||
"empty": "لا توجد صفحات حالة بعد.",
|
||||
"monitorCount": "{{count}} مراقبة",
|
||||
"published": "منشورة",
|
||||
"draft": "مسودة"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "طابور الفحوصات",
|
||||
"healthy": "جميع المهام البالغ عددها {{jobs}} تسير وفق الجدول على {{workers}} عامل.",
|
||||
"failing": "المهام الفاشلة",
|
||||
"scheduled": "المهام المجدولة",
|
||||
"runningNow": "قيد التشغيل"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "الأخطاء الأخيرة",
|
||||
"empty": "لم تُسجَّل أخطاء.",
|
||||
"count": "{{count}} أخطاء",
|
||||
"warnings": "التحذيرات"
|
||||
},
|
||||
"team": {
|
||||
"title": "أعضاء الفريق",
|
||||
"members": "{{count}} عضو",
|
||||
"unverified": "غير مُوثَّقين"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "تحرير البطاقات",
|
||||
"title": "بطاقات لوحة المعلومات",
|
||||
"done": "تم",
|
||||
"reset": "إعادة التعيين إلى الافتراضي",
|
||||
"groups": {
|
||||
"uptime": "وقت التشغيل",
|
||||
"infrastructure": "البنية التحتية",
|
||||
"incidents": "الحوادث",
|
||||
"notifications": "الإشعارات",
|
||||
"maintenance": "الصيانة",
|
||||
"statusPages": "صفحات الحالة",
|
||||
"checks": "الفحوصات",
|
||||
"logs": "النظام",
|
||||
"settings": "الإعدادات"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "لا توجد بطاقات في لوحة المعلومات",
|
||||
"description": "اختر البطاقات التي تريد رؤيتها. يُحفظ اختيارك في هذا المتصفح."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "نقل لأعلى",
|
||||
"moveDown": "نقل لأسفل"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "يا إلهي! لقد أسقطت السوشي!",
|
||||
"subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
"maintenance": "Manteniment",
|
||||
"logs": "Registres",
|
||||
"settings": "Configuració",
|
||||
"dashboard": "Tauler",
|
||||
"groups": {
|
||||
"monitoring": "Monitoratge",
|
||||
"alerting": "Alertes",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Tauler",
|
||||
"description": "Vegeu cada tipus de monitor en un sol lloc i detecteu què necessita atenció."
|
||||
},
|
||||
"card": {
|
||||
"error": "No s'ha pogut carregar aquesta targeta."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Estat dels monitors",
|
||||
"empty": "Encara no hi ha monitors.",
|
||||
"upOfTotal": "de {{total}} actius"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Actualment caiguts",
|
||||
"empty": "Tots els monitors estan actius. Cap incident obert.",
|
||||
"count": "{{count}} requereixen atenció"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitors per tipus",
|
||||
"empty": "Encara no hi ha monitors.",
|
||||
"down": "{{count}} caiguts"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Incidents recents",
|
||||
"empty": "Cap incident — tots els sistemes sans.",
|
||||
"active": "{{count}} oberts",
|
||||
"ongoing": "En curs",
|
||||
"manual": "Manual",
|
||||
"unknownMonitor": "Monitor desconegut"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Comprovacions fallides",
|
||||
"empty": "Encara no hi ha comprovacions.",
|
||||
"ratio": "{{failed}} de {{total}} el darrer dia"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Servidor de Checkmate",
|
||||
"memory": "Memòria",
|
||||
"eventLoop": "Retard del bucle d'esdeveniments",
|
||||
"uptime": "Temps actiu",
|
||||
"database": "Base de dades",
|
||||
"readsWrites": "{{reads}} lectures/s · {{writes}} escriptures/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} de {{total}}",
|
||||
"notEnough": "No hi ha prou monitors per fer una classificació."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Monitors més lents"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Temps en línia més baix"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitors per grup",
|
||||
"empty": "Encara no hi ha grups. Els grups es defineixen per monitor."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Servidors més carregats",
|
||||
"empty": "Encara no hi ha monitors d'infraestructura.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memòria",
|
||||
"disk": "Disc"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Estadístiques d'incidents",
|
||||
"openNow": "Oberts ara",
|
||||
"avgResolution": "Temps mitjà de resolució",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Més incidents",
|
||||
"total": "total",
|
||||
"resolved": "Resolts",
|
||||
"automatic": "Automàtica",
|
||||
"manual": "Manual",
|
||||
"autoManual": "{{automatic}} automàtiques · {{manual}} manuals"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Canals de notificació",
|
||||
"empty": "Encara no hi ha canals."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Finestres de manteniment",
|
||||
"empty": "Cap manteniment programat.",
|
||||
"runningNow": "En execució",
|
||||
"next": "Propera finestra"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Pàgines d'estat",
|
||||
"empty": "Encara no hi ha pàgines d'estat.",
|
||||
"monitorCount": "{{count}} monitors",
|
||||
"published": "Publicada",
|
||||
"draft": "Esborrany"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Cua de comprovacions",
|
||||
"healthy": "Les {{jobs}} tasques van segons el previst en {{workers}} worker(s).",
|
||||
"failing": "Tasques fallides",
|
||||
"scheduled": "Tasques programades",
|
||||
"runningNow": "En execució"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Errors recents",
|
||||
"empty": "Cap error registrat.",
|
||||
"count": "{{count}} errors",
|
||||
"warnings": "Advertiments"
|
||||
},
|
||||
"team": {
|
||||
"title": "Membres de l'equip",
|
||||
"members": "{{count}} membres",
|
||||
"unverified": "Sense verificar"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Edita les targetes",
|
||||
"title": "Targetes del tauler",
|
||||
"done": "Fet",
|
||||
"reset": "Restableix els valors predeterminats",
|
||||
"groups": {
|
||||
"uptime": "Temps en línia",
|
||||
"infrastructure": "Infraestructura",
|
||||
"incidents": "Incidents",
|
||||
"notifications": "Notificacions",
|
||||
"maintenance": "Manteniment",
|
||||
"statusPages": "Pàgines d'estat",
|
||||
"checks": "Comprovacions",
|
||||
"logs": "Sistema",
|
||||
"settings": "Configuració"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "No hi ha targetes al vostre tauler",
|
||||
"description": "Trieu les targetes que voleu veure. La vostra selecció es recorda en aquest navegador."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Mou amunt",
|
||||
"moveDown": "Mou avall"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh no! Has deixat caure el teu sushi!",
|
||||
"subtitle": "O bé la URL no existeix, o bé no tens accés."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Údržba",
|
||||
"logs": "Záznamy",
|
||||
"settings": "Nastavení",
|
||||
"dashboard": "Přehled",
|
||||
"groups": {
|
||||
"monitoring": "Monitorování",
|
||||
"alerting": "Upozornění",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Přehled",
|
||||
"description": "Zobrazte každý typ monitoru na jednom místě a poznejte, co vyžaduje pozornost."
|
||||
},
|
||||
"card": {
|
||||
"error": "Tuto kartu se nepodařilo načíst."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Stav monitorů",
|
||||
"empty": "Zatím žádné monitory.",
|
||||
"upOfTotal": "z {{total}} běží"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Aktuálně nedostupné",
|
||||
"empty": "Všechny monitory běží. Žádné otevřené incidenty.",
|
||||
"count": "{{count}} vyžaduje pozornost"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitory podle typu",
|
||||
"empty": "Zatím žádné monitory.",
|
||||
"down": "{{count}} nedostupných"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Nedávné incidenty",
|
||||
"empty": "Žádné incidenty — všechny systémy v pořádku.",
|
||||
"active": "{{count}} otevřených",
|
||||
"ongoing": "Probíhá",
|
||||
"manual": "Ručně",
|
||||
"unknownMonitor": "Neznámý monitor"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Neúspěšné kontroly",
|
||||
"empty": "Zatím žádné kontroly.",
|
||||
"ratio": "{{failed}} z {{total}} za poslední den"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Server Checkmate",
|
||||
"memory": "Paměť",
|
||||
"eventLoop": "Zpoždění event loop",
|
||||
"uptime": "Doba běhu",
|
||||
"database": "Databáze",
|
||||
"readsWrites": "{{reads}} čtení/s · {{writes}} zápisů/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} z {{total}}",
|
||||
"notEnough": "Zatím není dost monitorů pro žebříček."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Nejpomalejší monitory"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Nejnižší dostupnost"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitory podle skupiny",
|
||||
"empty": "Zatím žádné skupiny. Skupiny se nastavují u monitoru."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Nejvytíženější servery",
|
||||
"empty": "Zatím žádné monitory infrastruktury.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Paměť",
|
||||
"disk": "Disk"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Statistiky incidentů",
|
||||
"openNow": "Nyní otevřené",
|
||||
"avgResolution": "Průměrná doba vyřešení",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Nejvíce incidentů",
|
||||
"total": "celkem",
|
||||
"resolved": "Vyřešeno",
|
||||
"automatic": "Automaticky",
|
||||
"manual": "Ručně",
|
||||
"autoManual": "{{automatic}} automaticky · {{manual}} ručně"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Kanály oznámení",
|
||||
"empty": "Zatím žádné kanály."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Okna údržby",
|
||||
"empty": "Žádná naplánovaná údržba.",
|
||||
"runningNow": "Právě probíhá",
|
||||
"next": "Další okno"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Stavové stránky",
|
||||
"empty": "Zatím žádné stavové stránky.",
|
||||
"monitorCount": "{{count}} monitorů",
|
||||
"published": "Publikováno",
|
||||
"draft": "Koncept"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Fronta kontrol",
|
||||
"healthy": "Všech {{jobs}} úloh běží podle plánu na {{workers}} workerech.",
|
||||
"failing": "Selhávající úlohy",
|
||||
"scheduled": "Naplánované úlohy",
|
||||
"runningNow": "Právě probíhá"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Nedávné chyby",
|
||||
"empty": "Žádné zaznamenané chyby.",
|
||||
"count": "{{count}} chyb",
|
||||
"warnings": "Varování"
|
||||
},
|
||||
"team": {
|
||||
"title": "Členové týmu",
|
||||
"members": "{{count}} členů",
|
||||
"unverified": "Neověření"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Upravit karty",
|
||||
"title": "Karty přehledu",
|
||||
"done": "Hotovo",
|
||||
"reset": "Obnovit výchozí",
|
||||
"groups": {
|
||||
"uptime": "Dostupnost",
|
||||
"infrastructure": "Infrastruktura",
|
||||
"incidents": "Incidenty",
|
||||
"notifications": "Notifikace",
|
||||
"maintenance": "Údržba",
|
||||
"statusPages": "Stavové stránky",
|
||||
"checks": "Kontroly",
|
||||
"logs": "Systém",
|
||||
"settings": "Nastavení"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ve vašem přehledu nejsou žádné karty",
|
||||
"description": "Vyberte karty, které chcete zobrazit. Váš výběr se uchová v tomto prohlížeči."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Posunout nahoru",
|
||||
"moveDown": "Posunout dolů"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Ale ne! Upustili jste své sushi!",
|
||||
"subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Wartung",
|
||||
"logs": "Protokolle",
|
||||
"settings": "Einstellungen",
|
||||
"dashboard": "Dashboard",
|
||||
"groups": {
|
||||
"monitoring": "Überwachung",
|
||||
"alerting": "Benachrichtigungen",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Dashboard",
|
||||
"description": "Sehen Sie jeden Monitortyp an einem Ort und erkennen Sie, was Aufmerksamkeit braucht."
|
||||
},
|
||||
"card": {
|
||||
"error": "Diese Karte konnte nicht geladen werden."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Monitor-Status",
|
||||
"empty": "Noch keine Monitore.",
|
||||
"upOfTotal": "von {{total}} online"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Derzeit offline",
|
||||
"empty": "Alle Monitore sind online. Keine offenen Vorfälle.",
|
||||
"count": "{{count}} benötigen Aufmerksamkeit"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitore nach Typ",
|
||||
"empty": "Noch keine Monitore.",
|
||||
"down": "{{count}} offline"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Letzte Vorfälle",
|
||||
"empty": "Keine Vorfälle — alle Systeme gesund.",
|
||||
"active": "{{count}} offen",
|
||||
"ongoing": "Laufend",
|
||||
"manual": "Manuell",
|
||||
"unknownMonitor": "Unbekannter Monitor"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Fehlgeschlagene Prüfungen",
|
||||
"empty": "Noch keine Prüfungen.",
|
||||
"ratio": "{{failed}} von {{total}} am letzten Tag"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate-Server",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"eventLoop": "Event-Loop-Verzögerung",
|
||||
"uptime": "Laufzeit",
|
||||
"database": "Datenbank",
|
||||
"readsWrites": "{{reads}} Lesevorgänge/s · {{writes}} Schreibvorgänge/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} von {{total}}",
|
||||
"notEnough": "Noch nicht genug Monitore für ein Ranking."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Langsamste Monitore"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Niedrigste Uptime"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitore nach Gruppe",
|
||||
"empty": "Noch keine Gruppen. Gruppen werden pro Monitor gesetzt."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Ausgelastetste Server",
|
||||
"empty": "Noch keine Infrastruktur-Monitore.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"disk": "Festplatte"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Vorfallstatistik",
|
||||
"openNow": "Jetzt offen",
|
||||
"avgResolution": "Durchschnittliche Lösungszeit",
|
||||
"hours": "{{hours}} Std.",
|
||||
"mostIncidents": "Meiste Vorfälle",
|
||||
"total": "gesamt",
|
||||
"resolved": "Gelöst",
|
||||
"automatic": "Automatisch",
|
||||
"manual": "Manuell",
|
||||
"autoManual": "{{automatic}} automatisch · {{manual}} manuell"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Benachrichtigungskanäle",
|
||||
"empty": "Noch keine Kanäle."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Wartungsfenster",
|
||||
"empty": "Keine Wartung geplant.",
|
||||
"runningNow": "Läuft gerade",
|
||||
"next": "Nächstes Fenster"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Statusseiten",
|
||||
"empty": "Noch keine Statusseiten.",
|
||||
"monitorCount": "{{count}} Monitore",
|
||||
"published": "Veröffentlicht",
|
||||
"draft": "Entwurf"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Prüfungs-Warteschlange",
|
||||
"healthy": "Alle {{jobs}} Jobs planmäßig auf {{workers}} Worker(n).",
|
||||
"failing": "Fehlgeschlagene Jobs",
|
||||
"scheduled": "Geplante Jobs",
|
||||
"runningNow": "Läuft gerade"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Letzte Fehler",
|
||||
"empty": "Keine Fehler protokolliert.",
|
||||
"count": "{{count}} Fehler",
|
||||
"warnings": "Warnungen"
|
||||
},
|
||||
"team": {
|
||||
"title": "Teammitglieder",
|
||||
"members": "{{count}} Mitglieder",
|
||||
"unverified": "Nicht verifiziert"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Karten bearbeiten",
|
||||
"title": "Dashboard-Karten",
|
||||
"done": "Fertig",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infrastruktur",
|
||||
"incidents": "Vorfälle",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"maintenance": "Wartung",
|
||||
"statusPages": "Statusseiten",
|
||||
"checks": "Prüfungen",
|
||||
"logs": "System",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Keine Karten auf Ihrem Dashboard",
|
||||
"description": "Wählen Sie die Karten, die Sie sehen möchten. Ihre Auswahl wird in diesem Browser gespeichert."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh nein! Sie haben Ihr Sushi fallen lassen!",
|
||||
"subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf."
|
||||
|
||||
+176
-5
@@ -219,6 +219,7 @@
|
||||
"maintenance": "Maintenance",
|
||||
"logs": "Logs",
|
||||
"settings": "Settings",
|
||||
"dashboard": "Dashboard",
|
||||
"groups": {
|
||||
"monitoring": "Monitoring",
|
||||
"alerting": "Alerting",
|
||||
@@ -258,6 +259,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Dashboard",
|
||||
"description": "See every monitor type in one place and spot what needs attention."
|
||||
},
|
||||
"card": {
|
||||
"error": "Could not load this card."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Monitor status",
|
||||
"empty": "No monitors yet.",
|
||||
"upOfTotal": "of {{total}} up"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Currently down",
|
||||
"empty": "All monitors are up. No incidents open.",
|
||||
"count": "{{count}} needing attention"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitors by type",
|
||||
"empty": "No monitors yet.",
|
||||
"down": "{{count}} down"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Recent incidents",
|
||||
"empty": "No incidents — all systems healthy.",
|
||||
"active": "{{count}} open",
|
||||
"ongoing": "Ongoing",
|
||||
"manual": "Manual",
|
||||
"unknownMonitor": "Unknown monitor"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Failed checks",
|
||||
"empty": "No checks yet.",
|
||||
"ratio": "{{failed}} of {{total}} in the last day"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate server",
|
||||
"memory": "Memory",
|
||||
"eventLoop": "Event loop delay",
|
||||
"uptime": "Uptime",
|
||||
"database": "Database",
|
||||
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} of {{total}}",
|
||||
"notEnough": "Not enough monitors to rank yet."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Slowest monitors"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Lowest uptime"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitors by group",
|
||||
"empty": "No groups yet. Groups are set per monitor."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Busiest servers",
|
||||
"empty": "No infrastructure monitors yet.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Incident stats",
|
||||
"openNow": "Open now",
|
||||
"avgResolution": "Average time to resolve",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Most incidents",
|
||||
"total": "total",
|
||||
"resolved": "Resolved",
|
||||
"automatic": "Automatic",
|
||||
"manual": "Manual",
|
||||
"autoManual": "{{automatic}} automatic · {{manual}} manual"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Notification channels",
|
||||
"empty": "No channels yet."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance windows",
|
||||
"empty": "No maintenance scheduled.",
|
||||
"runningNow": "Running now",
|
||||
"next": "Next window"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Status pages",
|
||||
"empty": "No status pages yet.",
|
||||
"monitorCount": "{{count}} monitors",
|
||||
"published": "Published",
|
||||
"draft": "Draft"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Check queue",
|
||||
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
|
||||
"failing": "Failing jobs",
|
||||
"scheduled": "Scheduled jobs",
|
||||
"runningNow": "Running now"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Recent errors",
|
||||
"empty": "No errors logged.",
|
||||
"count": "{{count}} errors",
|
||||
"warnings": "Warnings"
|
||||
},
|
||||
"team": {
|
||||
"title": "Team members",
|
||||
"members": "{{count}} members",
|
||||
"unverified": "Unverified"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Edit cards",
|
||||
"title": "Dashboard cards",
|
||||
"done": "Done",
|
||||
"reset": "Reset to defaults",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infrastructure",
|
||||
"incidents": "Incidents",
|
||||
"notifications": "Notifications",
|
||||
"maintenance": "Maintenance",
|
||||
"statusPages": "Status pages",
|
||||
"checks": "Checks",
|
||||
"logs": "System",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "No cards on your dashboard",
|
||||
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
}
|
||||
},
|
||||
"proxies": {
|
||||
"header": {
|
||||
"title": "Proxies",
|
||||
@@ -632,9 +773,9 @@
|
||||
},
|
||||
"general": {
|
||||
"option": {
|
||||
"container": {
|
||||
"label": "Container name/ID",
|
||||
"placeholder": "my-app or abcd1234"
|
||||
"dockerHost": {
|
||||
"label": "Docker host",
|
||||
"placeholder": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"host": {
|
||||
"label": "Host",
|
||||
@@ -716,7 +857,7 @@
|
||||
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
|
||||
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
|
||||
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
|
||||
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
|
||||
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.",
|
||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
@@ -756,7 +897,7 @@
|
||||
},
|
||||
"type": {
|
||||
"description": "Select the type of check to perform",
|
||||
"optionDockerDescription": "Use Docker to monitor if a container is running.",
|
||||
"optionDockerDescription": "Monitor a Docker host and every container running on it.",
|
||||
"optionGameDescription": "Monitor if a specific game server is online.",
|
||||
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
|
||||
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
|
||||
@@ -814,6 +955,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerLogs": {
|
||||
"title": "Container logs",
|
||||
"description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.",
|
||||
"option": {
|
||||
"enabled": {
|
||||
"label": "Collect container logs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Monitor history",
|
||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||
@@ -956,6 +1106,27 @@
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"logs": {
|
||||
"recentLines": "{{count}} recent lines",
|
||||
"loading": "Loading...",
|
||||
"updatedSecondsAgo": "Updated {{count}}s ago",
|
||||
"updatedMinutesAgo": "Updated {{count}}m ago",
|
||||
"skipped": "Some log entries were skipped",
|
||||
"loadOlder": "Load older logs",
|
||||
"loadingOlder": "Loading...",
|
||||
"noOlder": "Beginning of retained logs",
|
||||
"loadError": "Could not load logs",
|
||||
"loadOlderError": "Could not load older logs",
|
||||
"empty": {
|
||||
"title": "No logs collected yet",
|
||||
"description": "Lines will appear here after the next check. Only output written since collection was turned on is stored."
|
||||
},
|
||||
"disabled": {
|
||||
"title": "Log collection is off for this host",
|
||||
"description": "Turn on container logs in the monitor settings to start collecting.",
|
||||
"action": "Configure monitor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Mantenimiento",
|
||||
"logs": "Registros",
|
||||
"settings": "Configuración",
|
||||
"dashboard": "Panel",
|
||||
"groups": {
|
||||
"monitoring": "Monitorización",
|
||||
"alerting": "Alertas",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Panel",
|
||||
"description": "Vea cada tipo de monitor en un solo lugar y detecte lo que necesita atención."
|
||||
},
|
||||
"card": {
|
||||
"error": "No se pudo cargar esta tarjeta."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Estado de los monitores",
|
||||
"empty": "Aún no hay monitores.",
|
||||
"upOfTotal": "de {{total}} activos"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Caídos ahora",
|
||||
"empty": "Todos los monitores están activos. Sin incidentes abiertos.",
|
||||
"count": "{{count}} requieren atención"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitores por tipo",
|
||||
"empty": "Aún no hay monitores.",
|
||||
"down": "{{count}} caídos"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Incidentes recientes",
|
||||
"empty": "Sin incidentes: todos los sistemas están sanos.",
|
||||
"active": "{{count}} abiertos",
|
||||
"ongoing": "En curso",
|
||||
"manual": "Manual",
|
||||
"unknownMonitor": "Monitor desconocido"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Comprobaciones fallidas",
|
||||
"empty": "Aún no hay comprobaciones.",
|
||||
"ratio": "{{failed}} de {{total}} en el último día"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Servidor de Checkmate",
|
||||
"memory": "Memoria",
|
||||
"eventLoop": "Retardo del bucle de eventos",
|
||||
"uptime": "Tiempo activo",
|
||||
"database": "Base de datos",
|
||||
"readsWrites": "{{reads}} lecturas/s · {{writes}} escrituras/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} de {{total}}",
|
||||
"notEnough": "No hay suficientes monitores para clasificar."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Monitores más lentos"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Menor uptime"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitores por grupo",
|
||||
"empty": "Aún no hay grupos. Los grupos se definen por monitor."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Servidores más cargados",
|
||||
"empty": "Aún no hay monitores de infraestructura.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"disk": "Disco"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Estadísticas de incidentes",
|
||||
"openNow": "Abiertos ahora",
|
||||
"avgResolution": "Tiempo medio de resolución",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Más incidentes",
|
||||
"total": "total",
|
||||
"resolved": "Resueltos",
|
||||
"automatic": "Automática",
|
||||
"manual": "Manual",
|
||||
"autoManual": "{{automatic}} automáticas · {{manual}} manuales"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Canales de notificación",
|
||||
"empty": "Aún no hay canales."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Ventanas de mantenimiento",
|
||||
"empty": "Sin mantenimiento programado.",
|
||||
"runningNow": "En ejecución",
|
||||
"next": "Próxima ventana"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Páginas de estado",
|
||||
"empty": "Aún no hay páginas de estado.",
|
||||
"monitorCount": "{{count}} monitores",
|
||||
"published": "Publicada",
|
||||
"draft": "Borrador"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Cola de comprobaciones",
|
||||
"healthy": "Los {{jobs}} trabajos van según lo previsto en {{workers}} worker(s).",
|
||||
"failing": "Trabajos fallidos",
|
||||
"scheduled": "Trabajos programados",
|
||||
"runningNow": "En ejecución"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Errores recientes",
|
||||
"empty": "No se registraron errores.",
|
||||
"count": "{{count}} errores",
|
||||
"warnings": "Advertencias"
|
||||
},
|
||||
"team": {
|
||||
"title": "Miembros del equipo",
|
||||
"members": "{{count}} miembros",
|
||||
"unverified": "Sin verificar"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Editar tarjetas",
|
||||
"title": "Tarjetas del panel",
|
||||
"done": "Listo",
|
||||
"reset": "Restablecer valores predeterminados",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infraestructura",
|
||||
"incidents": "Incidentes",
|
||||
"notifications": "Notificaciones",
|
||||
"maintenance": "Mantenimiento",
|
||||
"statusPages": "Páginas de estado",
|
||||
"checks": "Comprobaciones",
|
||||
"logs": "Sistema",
|
||||
"settings": "Configuración"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "No hay tarjetas en tu panel",
|
||||
"description": "Elige las tarjetas que quieres ver. Tu selección se recuerda en este navegador."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Mover arriba",
|
||||
"moveDown": "Mover abajo"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "¡Oh no! ¡Se le cayó el sushi!",
|
||||
"subtitle": "La URL no existe o no tiene acceso a ella."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Huolto",
|
||||
"logs": "Lokit",
|
||||
"settings": "Asetukset",
|
||||
"dashboard": "Kojelauta",
|
||||
"groups": {
|
||||
"monitoring": "Valvonta",
|
||||
"alerting": "Hälytykset",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Kojelauta",
|
||||
"description": "Näe kaikki monitorityypit yhdessä paikassa ja huomaa huomiota vaativat asiat."
|
||||
},
|
||||
"card": {
|
||||
"error": "Tätä korttia ei voitu ladata."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Monitorien tila",
|
||||
"empty": "Ei vielä monitoreja.",
|
||||
"upOfTotal": "/ {{total}} käynnissä"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Tällä hetkellä alhaalla",
|
||||
"empty": "Kaikki monitorit ovat käynnissä. Ei avoimia tapauksia.",
|
||||
"count": "{{count}} vaatii huomiota"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitorit tyypeittäin",
|
||||
"empty": "Ei vielä monitoreja.",
|
||||
"down": "{{count}} alhaalla"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Viimeisimmät tapaukset",
|
||||
"empty": "Ei tapauksia — kaikki järjestelmät kunnossa.",
|
||||
"active": "{{count}} avoinna",
|
||||
"ongoing": "Käynnissä",
|
||||
"manual": "Manuaalinen",
|
||||
"unknownMonitor": "Tuntematon monitori"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Epäonnistuneet tarkistukset",
|
||||
"empty": "Ei vielä tarkistuksia.",
|
||||
"ratio": "{{failed}} / {{total}} viimeisen vuorokauden aikana"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate-palvelin",
|
||||
"memory": "Muisti",
|
||||
"eventLoop": "Tapahtumasilmukan viive",
|
||||
"uptime": "Käyntiaika",
|
||||
"database": "Tietokanta",
|
||||
"readsWrites": "{{reads}} lukua/s · {{writes}} kirjoitusta/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} / {{total}}",
|
||||
"notEnough": "Ei tarpeeksi monitoreja järjestykseen."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Hitaimmat monitorit"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Alhaisin käytettävyys"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitorit ryhmittäin",
|
||||
"empty": "Ei vielä ryhmiä. Ryhmät asetetaan monitorikohtaisesti."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Kuormitetuimmat palvelimet",
|
||||
"empty": "Ei vielä infrastruktuurimonitoreja.",
|
||||
"cpu": "Suoritin",
|
||||
"memory": "Muisti",
|
||||
"disk": "Levy"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Tapaustilastot",
|
||||
"openNow": "Avoinna nyt",
|
||||
"avgResolution": "Keskimääräinen ratkaisuaika",
|
||||
"hours": "{{hours}} t",
|
||||
"mostIncidents": "Eniten tapauksia",
|
||||
"total": "yhteensä",
|
||||
"resolved": "Ratkaistu",
|
||||
"automatic": "Automaattinen",
|
||||
"manual": "Manuaalinen",
|
||||
"autoManual": "{{automatic}} automaattista · {{manual}} manuaalista"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Ilmoituskanavat",
|
||||
"empty": "Ei vielä kanavia."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Huoltoikkunat",
|
||||
"empty": "Ei suunniteltua huoltoa.",
|
||||
"runningNow": "Käynnissä nyt",
|
||||
"next": "Seuraava ikkuna"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Tilasivut",
|
||||
"empty": "Ei vielä tilasivuja.",
|
||||
"monitorCount": "{{count}} monitoria",
|
||||
"published": "Julkaistu",
|
||||
"draft": "Luonnos"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Tarkistusjono",
|
||||
"healthy": "Kaikki {{jobs}} työtä aikataulussa {{workers}} työntekijällä.",
|
||||
"failing": "Epäonnistuvat työt",
|
||||
"scheduled": "Ajastetut työt",
|
||||
"runningNow": "Käynnissä nyt"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Viimeisimmät virheet",
|
||||
"empty": "Ei kirjattuja virheitä.",
|
||||
"count": "{{count}} virhettä",
|
||||
"warnings": "Varoitukset"
|
||||
},
|
||||
"team": {
|
||||
"title": "Tiimin jäsenet",
|
||||
"members": "{{count}} jäsentä",
|
||||
"unverified": "Vahvistamattomat"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Muokkaa kortteja",
|
||||
"title": "Kojelaudan kortit",
|
||||
"done": "Valmis",
|
||||
"reset": "Palauta oletukset",
|
||||
"groups": {
|
||||
"uptime": "Käytettävyys",
|
||||
"infrastructure": "Infrastruktuuri",
|
||||
"incidents": "Tapaukset",
|
||||
"notifications": "Ilmoitukset",
|
||||
"maintenance": "Huolto",
|
||||
"statusPages": "Tilasivut",
|
||||
"checks": "Tarkistukset",
|
||||
"logs": "Järjestelmä",
|
||||
"settings": "Asetukset"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Kojelaudallasi ei ole kortteja",
|
||||
"description": "Valitse näytettävät kortit. Valintasi muistetaan tässä selaimessa."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Siirrä ylös",
|
||||
"moveDown": "Siirrä alas"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Voi ei! Sushisi putosi!",
|
||||
"subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Maintenance",
|
||||
"logs": "Journaux",
|
||||
"settings": "Paramètres",
|
||||
"dashboard": "Tableau de bord",
|
||||
"groups": {
|
||||
"monitoring": "Surveillance",
|
||||
"alerting": "Alertes",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Tableau de bord",
|
||||
"description": "Voyez chaque type de moniteur au même endroit et repérez ce qui demande votre attention."
|
||||
},
|
||||
"card": {
|
||||
"error": "Impossible de charger cette carte."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "État des moniteurs",
|
||||
"empty": "Aucun moniteur pour l'instant.",
|
||||
"upOfTotal": "sur {{total}} en ligne"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Actuellement hors ligne",
|
||||
"empty": "Tous les moniteurs sont en ligne. Aucun incident ouvert.",
|
||||
"count": "{{count}} à surveiller"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Moniteurs par type",
|
||||
"empty": "Aucun moniteur pour l'instant.",
|
||||
"down": "{{count}} hors ligne"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Incidents récents",
|
||||
"empty": "Aucun incident — tous les systèmes sont sains.",
|
||||
"active": "{{count}} ouverts",
|
||||
"ongoing": "En cours",
|
||||
"manual": "Manuel",
|
||||
"unknownMonitor": "Moniteur inconnu"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Vérifications échouées",
|
||||
"empty": "Aucune vérification pour l'instant.",
|
||||
"ratio": "{{failed}} sur {{total}} le dernier jour"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Serveur Checkmate",
|
||||
"memory": "Mémoire",
|
||||
"eventLoop": "Latence de la boucle d'événements",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"database": "Base de données",
|
||||
"readsWrites": "{{reads}} lectures/s · {{writes}} écritures/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} sur {{total}}",
|
||||
"notEnough": "Pas assez de moniteurs pour établir un classement."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Moniteurs les plus lents"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Disponibilité la plus faible"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Moniteurs par groupe",
|
||||
"empty": "Aucun groupe pour l'instant. Les groupes se définissent par moniteur."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Serveurs les plus chargés",
|
||||
"empty": "Aucun moniteur d'infrastructure pour l'instant.",
|
||||
"cpu": "Processeur",
|
||||
"memory": "Mémoire",
|
||||
"disk": "Disque"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Statistiques d'incidents",
|
||||
"openNow": "Ouverts maintenant",
|
||||
"avgResolution": "Temps moyen de résolution",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Le plus d'incidents",
|
||||
"total": "total",
|
||||
"resolved": "Résolus",
|
||||
"automatic": "Automatique",
|
||||
"manual": "Manuel",
|
||||
"autoManual": "{{automatic}} automatiques · {{manual}} manuels"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Canaux de notification",
|
||||
"empty": "Aucun canal pour l'instant."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Fenêtres de maintenance",
|
||||
"empty": "Aucune maintenance planifiée.",
|
||||
"runningNow": "En cours",
|
||||
"next": "Prochaine fenêtre"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Pages de statut",
|
||||
"empty": "Aucune page de statut pour l'instant.",
|
||||
"monitorCount": "{{count}} moniteurs",
|
||||
"published": "Publiée",
|
||||
"draft": "Brouillon"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "File de vérifications",
|
||||
"healthy": "Les {{jobs}} tâches sont à l'heure sur {{workers}} worker(s).",
|
||||
"failing": "Tâches en échec",
|
||||
"scheduled": "Tâches planifiées",
|
||||
"runningNow": "En cours"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Erreurs récentes",
|
||||
"empty": "Aucune erreur enregistrée.",
|
||||
"count": "{{count}} erreurs",
|
||||
"warnings": "Avertissements"
|
||||
},
|
||||
"team": {
|
||||
"title": "Membres de l'équipe",
|
||||
"members": "{{count}} membres",
|
||||
"unverified": "Non vérifiés"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Modifier les cartes",
|
||||
"title": "Cartes du tableau de bord",
|
||||
"done": "Terminé",
|
||||
"reset": "Réinitialiser par défaut",
|
||||
"groups": {
|
||||
"uptime": "Disponibilité",
|
||||
"infrastructure": "Infrastructure",
|
||||
"incidents": "Incidents",
|
||||
"notifications": "Notifications",
|
||||
"maintenance": "Maintenance",
|
||||
"statusPages": "Pages de statut",
|
||||
"checks": "Vérifications",
|
||||
"logs": "Système",
|
||||
"settings": "Paramètres"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune carte sur votre tableau de bord",
|
||||
"description": "Choisissez les cartes à afficher. Votre sélection est mémorisée dans ce navigateur."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh non ! Vous avez fait tomber vos sushis !",
|
||||
"subtitle": "Soit l'URL n'existe pas, soit vous n'y avez pas accès."
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
"maintenance": "Manutenzione",
|
||||
"logs": "Log",
|
||||
"settings": "Impostazioni",
|
||||
"dashboard": "Dashboard",
|
||||
"groups": {
|
||||
"monitoring": "Monitoraggio",
|
||||
"alerting": "Avvisi",
|
||||
@@ -233,6 +234,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Dashboard",
|
||||
"description": "Vedi ogni tipo di monitor in un unico posto e individua ciò che richiede attenzione."
|
||||
},
|
||||
"card": {
|
||||
"error": "Impossibile caricare questa scheda."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Stato dei monitor",
|
||||
"empty": "Nessun monitor ancora.",
|
||||
"upOfTotal": "su {{total}} attivi"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Attualmente offline",
|
||||
"empty": "Tutti i monitor sono attivi. Nessun incidente aperto.",
|
||||
"count": "{{count}} richiedono attenzione"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitor per tipo",
|
||||
"empty": "Nessun monitor ancora.",
|
||||
"down": "{{count}} offline"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Incidenti recenti",
|
||||
"empty": "Nessun incidente — tutti i sistemi sono sani.",
|
||||
"active": "{{count}} aperti",
|
||||
"ongoing": "In corso",
|
||||
"manual": "Manuale",
|
||||
"unknownMonitor": "Monitor sconosciuto"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Verifiche fallite",
|
||||
"empty": "Nessuna verifica ancora.",
|
||||
"ratio": "{{failed}} su {{total}} nell'ultimo giorno"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Server Checkmate",
|
||||
"memory": "Memoria",
|
||||
"eventLoop": "Ritardo dell'event loop",
|
||||
"uptime": "Tempo di attività",
|
||||
"database": "Database",
|
||||
"readsWrites": "{{reads}} letture/s · {{writes}} scritture/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} su {{total}}",
|
||||
"notEnough": "Non ci sono abbastanza monitor per una classifica."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Monitor più lenti"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Uptime più basso"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitor per gruppo",
|
||||
"empty": "Nessun gruppo ancora. I gruppi si impostano per monitor."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Server più carichi",
|
||||
"empty": "Nessun monitor infrastruttura ancora.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"disk": "Disco"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Statistiche incidenti",
|
||||
"openNow": "Aperti ora",
|
||||
"avgResolution": "Tempo medio di risoluzione",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Più incidenti",
|
||||
"total": "totale",
|
||||
"resolved": "Risolti",
|
||||
"automatic": "Automatica",
|
||||
"manual": "Manuale",
|
||||
"autoManual": "{{automatic}} automatiche · {{manual}} manuali"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Canali di notifica",
|
||||
"empty": "Nessun canale ancora."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Finestre di manutenzione",
|
||||
"empty": "Nessuna manutenzione pianificata.",
|
||||
"runningNow": "In esecuzione",
|
||||
"next": "Prossima finestra"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Pagine di stato",
|
||||
"empty": "Nessuna pagina di stato ancora.",
|
||||
"monitorCount": "{{count}} monitor",
|
||||
"published": "Pubblicata",
|
||||
"draft": "Bozza"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Coda delle verifiche",
|
||||
"healthy": "Tutti i {{jobs}} job sono in orario su {{workers}} worker.",
|
||||
"failing": "Job falliti",
|
||||
"scheduled": "Job pianificati",
|
||||
"runningNow": "In esecuzione"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Errori recenti",
|
||||
"empty": "Nessun errore registrato.",
|
||||
"count": "{{count}} errori",
|
||||
"warnings": "Avvisi"
|
||||
},
|
||||
"team": {
|
||||
"title": "Membri del team",
|
||||
"members": "{{count}} membri",
|
||||
"unverified": "Non verificati"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Modifica schede",
|
||||
"title": "Schede della dashboard",
|
||||
"done": "Fatto",
|
||||
"reset": "Ripristina predefiniti",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infrastruttura",
|
||||
"incidents": "Incidenti",
|
||||
"notifications": "Notifiche",
|
||||
"maintenance": "Manutenzione",
|
||||
"statusPages": "Pagine di stato",
|
||||
"checks": "Verifiche",
|
||||
"logs": "Sistema",
|
||||
"settings": "Impostazioni"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nessuna scheda nella tua dashboard",
|
||||
"description": "Scegli le schede da mostrare. La tua selezione viene ricordata in questo browser."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Sposta su",
|
||||
"moveDown": "Sposta giù"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh no! Hai fatto cadere il tuo sushi!",
|
||||
"subtitle": "L'URL non esiste, oppure non hai accesso."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "メンテナンス",
|
||||
"logs": "ログ",
|
||||
"settings": "設定",
|
||||
"dashboard": "ダッシュボード",
|
||||
"groups": {
|
||||
"monitoring": "監視",
|
||||
"alerting": "アラート",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "ダッシュボード",
|
||||
"description": "すべての監視タイプを一か所で確認し、対応が必要な箇所を把握できます。"
|
||||
},
|
||||
"card": {
|
||||
"error": "このカードを読み込めませんでした。"
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "監視の状態",
|
||||
"empty": "監視はまだありません。",
|
||||
"upOfTotal": "/ {{total}} が稼働中"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "現在ダウン中",
|
||||
"empty": "すべての監視が稼働中です。未解決のインシデントはありません。",
|
||||
"count": "{{count}} 件が要対応"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "種類別の監視",
|
||||
"empty": "監視はまだありません。",
|
||||
"down": "{{count}} 件ダウン"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "最近のインシデント",
|
||||
"empty": "インシデントなし — すべて正常です。",
|
||||
"active": "{{count}} 件未解決",
|
||||
"ongoing": "進行中",
|
||||
"manual": "手動",
|
||||
"unknownMonitor": "不明な監視"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "失敗したチェック",
|
||||
"empty": "チェックはまだありません。",
|
||||
"ratio": "直近1日で {{total}} 件中 {{failed}} 件"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate サーバー",
|
||||
"memory": "メモリ",
|
||||
"eventLoop": "イベントループ遅延",
|
||||
"uptime": "稼働時間",
|
||||
"database": "データベース",
|
||||
"readsWrites": "{{reads}} 読み取り/秒 · {{writes}} 書き込み/秒"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "{{total}} 件中の上位 {{shown}} 件",
|
||||
"notEnough": "ランキングに十分な監視がありません。"
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "最も遅い監視"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "稼働率が最も低い監視"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "グループ別の監視",
|
||||
"empty": "グループはまだありません。グループは監視ごとに設定します。"
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "最も負荷の高いサーバー",
|
||||
"empty": "インフラ監視はまだありません。",
|
||||
"cpu": "CPU",
|
||||
"memory": "メモリ",
|
||||
"disk": "ディスク"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "インシデント統計",
|
||||
"openNow": "現在未解決",
|
||||
"avgResolution": "平均解決時間",
|
||||
"hours": "{{hours}} 時間",
|
||||
"mostIncidents": "インシデント最多",
|
||||
"total": "合計",
|
||||
"resolved": "解決済み",
|
||||
"automatic": "自動",
|
||||
"manual": "手動",
|
||||
"autoManual": "自動 {{automatic}} 件 · 手動 {{manual}} 件"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "通知チャンネル",
|
||||
"empty": "チャンネルはまだありません。"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "メンテナンスウィンドウ",
|
||||
"empty": "予定されたメンテナンスはありません。",
|
||||
"runningNow": "実行中",
|
||||
"next": "次のウィンドウ"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "ステータスページ",
|
||||
"empty": "ステータスページはまだありません。",
|
||||
"monitorCount": "{{count}} 件の監視",
|
||||
"published": "公開中",
|
||||
"draft": "下書き"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "チェックキュー",
|
||||
"healthy": "{{workers}} 個のワーカーで {{jobs}} 件のジョブがすべて予定どおりです。",
|
||||
"failing": "失敗中のジョブ",
|
||||
"scheduled": "予定されたジョブ",
|
||||
"runningNow": "実行中"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "最近のエラー",
|
||||
"empty": "エラーは記録されていません。",
|
||||
"count": "{{count}} 件のエラー",
|
||||
"warnings": "警告"
|
||||
},
|
||||
"team": {
|
||||
"title": "チームメンバー",
|
||||
"members": "{{count}} 人のメンバー",
|
||||
"unverified": "未確認"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "カードを編集",
|
||||
"title": "ダッシュボードのカード",
|
||||
"done": "完了",
|
||||
"reset": "既定に戻す",
|
||||
"groups": {
|
||||
"uptime": "稼働監視",
|
||||
"infrastructure": "インフラ",
|
||||
"incidents": "インシデント",
|
||||
"notifications": "通知",
|
||||
"maintenance": "メンテナンス",
|
||||
"statusPages": "ステータスページ",
|
||||
"checks": "チェック",
|
||||
"logs": "システム",
|
||||
"settings": "設定"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "ダッシュボードにカードがありません",
|
||||
"description": "表示したいカードを選択してください。選択内容はこのブラウザーに保存されます。"
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "上へ移動",
|
||||
"moveDown": "下へ移動"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "おっと!お寿司を落としてしまいました!",
|
||||
"subtitle": "URLが存在しないか、アクセス権限がありません。"
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
"statusPages": "Strony statusu",
|
||||
"tags": "Tagi",
|
||||
"uptime": "Czas działania",
|
||||
"dashboard": "Pulpit",
|
||||
"groups": {
|
||||
"monitoring": "Monitorowanie",
|
||||
"alerting": "Alerty",
|
||||
@@ -233,6 +234,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Pulpit",
|
||||
"description": "Zobacz każdy typ monitora w jednym miejscu i dostrzeż, co wymaga uwagi."
|
||||
},
|
||||
"card": {
|
||||
"error": "Nie udało się załadować tej karty."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Stan monitorów",
|
||||
"empty": "Brak monitorów.",
|
||||
"upOfTotal": "z {{total}} działa"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Obecnie niedostępne",
|
||||
"empty": "Wszystkie monitory działają. Brak otwartych incydentów.",
|
||||
"count": "{{count}} wymaga uwagi"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitory według typu",
|
||||
"empty": "Brak monitorów.",
|
||||
"down": "{{count}} niedostępnych"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Ostatnie incydenty",
|
||||
"empty": "Brak incydentów — wszystkie systemy sprawne.",
|
||||
"active": "{{count}} otwartych",
|
||||
"ongoing": "Trwa",
|
||||
"manual": "Ręcznie",
|
||||
"unknownMonitor": "Nieznany monitor"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Nieudane checki",
|
||||
"empty": "Brak checków.",
|
||||
"ratio": "{{failed}} z {{total}} w ostatnim dniu"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Serwer Checkmate",
|
||||
"memory": "Pamięć",
|
||||
"eventLoop": "Opóźnienie pętli zdarzeń",
|
||||
"uptime": "Czas działania",
|
||||
"database": "Baza danych",
|
||||
"readsWrites": "{{reads}} odczytów/s · {{writes}} zapisów/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} z {{total}}",
|
||||
"notEnough": "Za mało monitorów, aby utworzyć ranking."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Najwolniejsze monitory"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Najniższa dostępność"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitory według grupy",
|
||||
"empty": "Brak grup. Grupy ustawia się przy monitorze."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Najbardziej obciążone serwery",
|
||||
"empty": "Brak monitorów infrastruktury.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Pamięć",
|
||||
"disk": "Dysk"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Statystyki incydentów",
|
||||
"openNow": "Otwarte teraz",
|
||||
"avgResolution": "Średni czas rozwiązania",
|
||||
"hours": "{{hours}} godz.",
|
||||
"mostIncidents": "Najwięcej incydentów",
|
||||
"total": "łącznie",
|
||||
"resolved": "Rozwiązane",
|
||||
"automatic": "Automatycznie",
|
||||
"manual": "Ręcznie",
|
||||
"autoManual": "{{automatic}} automatycznie · {{manual}} ręcznie"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Kanały powiadomień",
|
||||
"empty": "Brak kanałów."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Przerwy techniczne",
|
||||
"empty": "Brak zaplanowanych przerw.",
|
||||
"runningNow": "Trwa teraz",
|
||||
"next": "Następna przerwa"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Strony statusu",
|
||||
"empty": "Brak stron statusu.",
|
||||
"monitorCount": "{{count}} monitorów",
|
||||
"published": "Opublikowana",
|
||||
"draft": "Wersja robocza"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Kolejka checków",
|
||||
"healthy": "Wszystkie {{jobs}} zadania zgodnie z planem na {{workers}} workerach.",
|
||||
"failing": "Nieudane zadania",
|
||||
"scheduled": "Zaplanowane zadania",
|
||||
"runningNow": "Trwa teraz"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Ostatnie błędy",
|
||||
"empty": "Nie zarejestrowano błędów.",
|
||||
"count": "{{count}} błędów",
|
||||
"warnings": "Ostrzeżenia"
|
||||
},
|
||||
"team": {
|
||||
"title": "Członkowie zespołu",
|
||||
"members": "{{count}} członków",
|
||||
"unverified": "Niezweryfikowani"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Edytuj karty",
|
||||
"title": "Karty pulpitu",
|
||||
"done": "Gotowe",
|
||||
"reset": "Przywróć domyślne",
|
||||
"groups": {
|
||||
"uptime": "Dostępność",
|
||||
"infrastructure": "Infrastruktura",
|
||||
"incidents": "Incydenty",
|
||||
"notifications": "Powiadomienia",
|
||||
"maintenance": "Przerwy techniczne",
|
||||
"statusPages": "Strony statusu",
|
||||
"checks": "Checki",
|
||||
"logs": "System",
|
||||
"settings": "Ustawienia"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Brak kart na pulpicie",
|
||||
"description": "Wybierz karty, które chcesz widzieć. Twój wybór zostanie zapamiętany w tej przeglądarce."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Przenieś w górę",
|
||||
"moveDown": "Przenieś w dół"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"form": {
|
||||
"currentPassword": {
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Manutenção",
|
||||
"logs": "Registros",
|
||||
"settings": "Configurações",
|
||||
"dashboard": "Painel",
|
||||
"groups": {
|
||||
"monitoring": "Monitoramento",
|
||||
"alerting": "Alertas",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Painel",
|
||||
"description": "Veja cada tipo de monitor em um só lugar e identifique o que precisa de atenção."
|
||||
},
|
||||
"card": {
|
||||
"error": "Não foi possível carregar este cartão."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Status dos monitores",
|
||||
"empty": "Ainda não há monitores.",
|
||||
"upOfTotal": "de {{total}} no ar"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Fora do ar agora",
|
||||
"empty": "Todos os monitores estão no ar. Nenhum incidente aberto.",
|
||||
"count": "{{count}} precisam de atenção"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Monitores por tipo",
|
||||
"empty": "Ainda não há monitores.",
|
||||
"down": "{{count}} fora do ar"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Incidentes recentes",
|
||||
"empty": "Nenhum incidente — todos os sistemas saudáveis.",
|
||||
"active": "{{count}} abertos",
|
||||
"ongoing": "Em andamento",
|
||||
"manual": "Manual",
|
||||
"unknownMonitor": "Monitor desconhecido"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Verificações com falha",
|
||||
"empty": "Ainda não há verificações.",
|
||||
"ratio": "{{failed}} de {{total}} no último dia"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Servidor Checkmate",
|
||||
"memory": "Memória",
|
||||
"eventLoop": "Atraso do event loop",
|
||||
"uptime": "Tempo no ar",
|
||||
"database": "Banco de dados",
|
||||
"readsWrites": "{{reads}} leituras/s · {{writes}} gravações/s"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} de {{total}}",
|
||||
"notEnough": "Não há monitores suficientes para classificar."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Monitores mais lentos"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Menor uptime"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Monitores por grupo",
|
||||
"empty": "Ainda não há grupos. Grupos são definidos por monitor."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Servidores mais ocupados",
|
||||
"empty": "Ainda não há monitores de infraestrutura.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"disk": "Disco"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Estatísticas de incidentes",
|
||||
"openNow": "Abertos agora",
|
||||
"avgResolution": "Tempo médio de resolução",
|
||||
"hours": "{{hours}} h",
|
||||
"mostIncidents": "Mais incidentes",
|
||||
"total": "total",
|
||||
"resolved": "Resolvidos",
|
||||
"automatic": "Automática",
|
||||
"manual": "Manual",
|
||||
"autoManual": "{{automatic}} automáticas · {{manual}} manuais"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Canais de notificação",
|
||||
"empty": "Ainda não há canais."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Janelas de manutenção",
|
||||
"empty": "Nenhuma manutenção agendada.",
|
||||
"runningNow": "Em execução",
|
||||
"next": "Próxima janela"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Páginas de status",
|
||||
"empty": "Ainda não há páginas de status.",
|
||||
"monitorCount": "{{count}} monitores",
|
||||
"published": "Publicada",
|
||||
"draft": "Rascunho"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Fila de verificações",
|
||||
"healthy": "Todos os {{jobs}} jobs em dia em {{workers}} worker(s).",
|
||||
"failing": "Jobs com falha",
|
||||
"scheduled": "Jobs agendados",
|
||||
"runningNow": "Em execução"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Erros recentes",
|
||||
"empty": "Nenhum erro registrado.",
|
||||
"count": "{{count}} erros",
|
||||
"warnings": "Avisos"
|
||||
},
|
||||
"team": {
|
||||
"title": "Membros da equipe",
|
||||
"members": "{{count}} membros",
|
||||
"unverified": "Não verificados"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Editar cartões",
|
||||
"title": "Cartões do painel",
|
||||
"done": "Concluído",
|
||||
"reset": "Restaurar padrões",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Infraestrutura",
|
||||
"incidents": "Incidentes",
|
||||
"notifications": "Notificações",
|
||||
"maintenance": "Manutenção",
|
||||
"statusPages": "Páginas de status",
|
||||
"checks": "Verificações",
|
||||
"logs": "Sistema",
|
||||
"settings": "Configurações"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nenhum cartão no seu painel",
|
||||
"description": "Escolha os cartões que deseja ver. Sua seleção é lembrada neste navegador."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Oh não! Você derrubou seu sushi!",
|
||||
"subtitle": "A URL não existe ou você não tem acesso a ela."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Обслуживание",
|
||||
"logs": "Логи",
|
||||
"settings": "Настройки",
|
||||
"dashboard": "Панель",
|
||||
"groups": {
|
||||
"monitoring": "Мониторинг",
|
||||
"alerting": "Оповещения",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Панель",
|
||||
"description": "Смотрите все типы мониторов в одном месте и замечайте требующее внимания."
|
||||
},
|
||||
"card": {
|
||||
"error": "Не удалось загрузить эту карточку."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Состояние мониторов",
|
||||
"empty": "Мониторов пока нет.",
|
||||
"upOfTotal": "из {{total}} работают"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Сейчас недоступны",
|
||||
"empty": "Все мониторы работают. Открытых инцидентов нет.",
|
||||
"count": "{{count}} требуют внимания"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Мониторы по типу",
|
||||
"empty": "Мониторов пока нет.",
|
||||
"down": "{{count}} недоступны"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Последние инциденты",
|
||||
"empty": "Инцидентов нет — все системы в порядке.",
|
||||
"active": "{{count}} открыто",
|
||||
"ongoing": "В процессе",
|
||||
"manual": "Вручную",
|
||||
"unknownMonitor": "Неизвестный монитор"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Неудачные проверки",
|
||||
"empty": "Проверок пока нет.",
|
||||
"ratio": "{{failed}} из {{total}} за последние сутки"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Сервер Checkmate",
|
||||
"memory": "Память",
|
||||
"eventLoop": "Задержка event loop",
|
||||
"uptime": "Время работы",
|
||||
"database": "База данных",
|
||||
"readsWrites": "{{reads}} чтений/с · {{writes}} записей/с"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Топ {{shown}} из {{total}}",
|
||||
"notEnough": "Недостаточно мониторов для рейтинга."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Самые медленные мониторы"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Наименьшая работоспособность"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Мониторы по группе",
|
||||
"empty": "Групп пока нет. Группы задаются у монитора."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Самые загруженные серверы",
|
||||
"empty": "Инфраструктурных мониторов пока нет.",
|
||||
"cpu": "ЦП",
|
||||
"memory": "Память",
|
||||
"disk": "Диск"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Статистика инцидентов",
|
||||
"openNow": "Открыто сейчас",
|
||||
"avgResolution": "Среднее время решения",
|
||||
"hours": "{{hours}} ч",
|
||||
"mostIncidents": "Больше всего инцидентов",
|
||||
"total": "всего",
|
||||
"resolved": "Решено",
|
||||
"automatic": "Автоматически",
|
||||
"manual": "Вручную",
|
||||
"autoManual": "{{automatic}} автоматически · {{manual}} вручную"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Каналы уведомлений",
|
||||
"empty": "Каналов пока нет."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Окна обслуживания",
|
||||
"empty": "Обслуживание не запланировано.",
|
||||
"runningNow": "Выполняется",
|
||||
"next": "Следующее окно"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Страницы состояния",
|
||||
"empty": "Страниц состояния пока нет.",
|
||||
"monitorCount": "{{count}} мониторов",
|
||||
"published": "Опубликована",
|
||||
"draft": "Черновик"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Очередь проверок",
|
||||
"healthy": "Все {{jobs}} задач идут по расписанию на {{workers}} воркерах.",
|
||||
"failing": "Сбойные задачи",
|
||||
"scheduled": "Запланированные задачи",
|
||||
"runningNow": "Выполняется"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Последние ошибки",
|
||||
"empty": "Ошибок не зафиксировано.",
|
||||
"count": "{{count}} ошибок",
|
||||
"warnings": "Предупреждения"
|
||||
},
|
||||
"team": {
|
||||
"title": "Участники команды",
|
||||
"members": "{{count}} участников",
|
||||
"unverified": "Не подтверждены"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Изменить карточки",
|
||||
"title": "Карточки панели",
|
||||
"done": "Готово",
|
||||
"reset": "Сбросить по умолчанию",
|
||||
"groups": {
|
||||
"uptime": "Работоспособность",
|
||||
"infrastructure": "Инфраструктура",
|
||||
"incidents": "Инциденты",
|
||||
"notifications": "Уведомления",
|
||||
"maintenance": "Обслуживание",
|
||||
"statusPages": "Страницы состояния",
|
||||
"checks": "Проверки",
|
||||
"logs": "Система",
|
||||
"settings": "Настройки"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "На вашей панели нет карточек",
|
||||
"description": "Выберите карточки, которые хотите видеть. Ваш выбор сохраняется в этом браузере."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Переместить вверх",
|
||||
"moveDown": "Переместить вниз"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Ой! Вы уронили суши!",
|
||||
"subtitle": "Либо URL не существует, либо у Вас нет к нему доступа."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "การบำรุงรักษา",
|
||||
"logs": "บันทึก",
|
||||
"settings": "การตั้งค่า",
|
||||
"dashboard": "แดชบอร์ด",
|
||||
"groups": {
|
||||
"monitoring": "การตรวจสอบ",
|
||||
"alerting": "การแจ้งเตือน",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "แดชบอร์ด",
|
||||
"description": "ดูตัวตรวจสอบทุกประเภทในที่เดียว และสังเกตสิ่งที่ต้องดูแล"
|
||||
},
|
||||
"card": {
|
||||
"error": "ไม่สามารถโหลดการ์ดนี้ได้"
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "สถานะตัวตรวจสอบ",
|
||||
"empty": "ยังไม่มีตัวตรวจสอบ",
|
||||
"upOfTotal": "จาก {{total}} ที่ทำงาน"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "ขณะนี้ล่ม",
|
||||
"empty": "ตัวตรวจสอบทั้งหมดทำงานอยู่ ไม่มีเหตุการณ์ที่เปิดค้าง",
|
||||
"count": "{{count}} รายการต้องดูแล"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "ตัวตรวจสอบตามประเภท",
|
||||
"empty": "ยังไม่มีตัวตรวจสอบ",
|
||||
"down": "ล่ม {{count}} รายการ"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "เหตุการณ์ล่าสุด",
|
||||
"empty": "ไม่มีเหตุการณ์ — ทุกระบบปกติ",
|
||||
"active": "เปิดอยู่ {{count}} รายการ",
|
||||
"ongoing": "กำลังดำเนินอยู่",
|
||||
"manual": "ด้วยตนเอง",
|
||||
"unknownMonitor": "ตัวตรวจสอบที่ไม่รู้จัก"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "การตรวจสอบที่ล้มเหลว",
|
||||
"empty": "ยังไม่มีการตรวจสอบ",
|
||||
"ratio": "{{failed}} จาก {{total}} ในวันที่ผ่านมา"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "เซิร์ฟเวอร์ Checkmate",
|
||||
"memory": "หน่วยความจำ",
|
||||
"eventLoop": "ความหน่วงของ event loop",
|
||||
"uptime": "เวลาทำงาน",
|
||||
"database": "ฐานข้อมูล",
|
||||
"readsWrites": "อ่าน {{reads}}/วิ · เขียน {{writes}}/วิ"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "อันดับต้น {{shown}} จาก {{total}}",
|
||||
"notEnough": "มีตัวตรวจสอบไม่พอสำหรับจัดอันดับ"
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "ตัวตรวจสอบที่ช้าที่สุด"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "uptime ต่ำที่สุด"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "ตัวตรวจสอบตามกลุ่ม",
|
||||
"empty": "ยังไม่มีกลุ่ม กลุ่มถูกกำหนดในแต่ละตัวตรวจสอบ"
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "เซิร์ฟเวอร์ที่ใช้งานหนักที่สุด",
|
||||
"empty": "ยังไม่มีตัวตรวจสอบโครงสร้างพื้นฐาน",
|
||||
"cpu": "CPU",
|
||||
"memory": "หน่วยความจำ",
|
||||
"disk": "ดิสก์"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "สถิติเหตุการณ์",
|
||||
"openNow": "เปิดอยู่ตอนนี้",
|
||||
"avgResolution": "เวลาแก้ไขโดยเฉลี่ย",
|
||||
"hours": "{{hours}} ชม.",
|
||||
"mostIncidents": "เหตุการณ์มากที่สุด",
|
||||
"total": "ทั้งหมด",
|
||||
"resolved": "แก้ไขแล้ว",
|
||||
"automatic": "อัตโนมัติ",
|
||||
"manual": "ด้วยตนเอง",
|
||||
"autoManual": "อัตโนมัติ {{automatic}} · ด้วยตนเอง {{manual}}"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "ช่องทางการแจ้งเตือน",
|
||||
"empty": "ยังไม่มีช่องทาง"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "ช่วงเวลาบำรุงรักษา",
|
||||
"empty": "ไม่มีการบำรุงรักษาที่กำหนดไว้",
|
||||
"runningNow": "กำลังดำเนินการ",
|
||||
"next": "ช่วงถัดไป"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "หน้าสถานะ",
|
||||
"empty": "ยังไม่มีหน้าสถานะ",
|
||||
"monitorCount": "{{count}} ตัวตรวจสอบ",
|
||||
"published": "เผยแพร่แล้ว",
|
||||
"draft": "ฉบับร่าง"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "คิวการตรวจสอบ",
|
||||
"healthy": "งานทั้ง {{jobs}} รายการเป็นไปตามกำหนดบน {{workers}} worker",
|
||||
"failing": "งานที่ล้มเหลว",
|
||||
"scheduled": "งานที่กำหนดเวลาไว้",
|
||||
"runningNow": "กำลังดำเนินการ"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "ข้อผิดพลาดล่าสุด",
|
||||
"empty": "ไม่มีข้อผิดพลาดที่บันทึกไว้",
|
||||
"count": "{{count}} ข้อผิดพลาด",
|
||||
"warnings": "คำเตือน"
|
||||
},
|
||||
"team": {
|
||||
"title": "สมาชิกทีม",
|
||||
"members": "{{count}} สมาชิก",
|
||||
"unverified": "ยังไม่ยืนยัน"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "แก้ไขการ์ด",
|
||||
"title": "การ์ดแดชบอร์ด",
|
||||
"done": "เสร็จสิ้น",
|
||||
"reset": "รีเซ็ตเป็นค่าเริ่มต้น",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "โครงสร้างพื้นฐาน",
|
||||
"incidents": "เหตุการณ์",
|
||||
"notifications": "การแจ้งเตือน",
|
||||
"maintenance": "การบำรุงรักษา",
|
||||
"statusPages": "หน้าสถานะ",
|
||||
"checks": "การตรวจสอบ",
|
||||
"logs": "ระบบ",
|
||||
"settings": "การตั้งค่า"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "ไม่มีการ์ดบนแดชบอร์ดของคุณ",
|
||||
"description": "เลือกการ์ดที่ต้องการเห็น การเลือกของคุณจะถูกจดจำในเบราว์เซอร์นี้"
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "เลื่อนขึ้น",
|
||||
"moveDown": "เลื่อนลง"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "โอ้ไม่! ซูชิของคุณหล่นแล้ว!",
|
||||
"subtitle": "URL ไม่มีอยู่ หรือคุณไม่มีสิทธิ์เข้าถึง"
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Bakım",
|
||||
"logs": "Günlükler",
|
||||
"settings": "Ayarlar",
|
||||
"dashboard": "Panel",
|
||||
"groups": {
|
||||
"monitoring": "İzleme",
|
||||
"alerting": "Uyarılar",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Panel",
|
||||
"description": "Her izleyici türünü tek yerde görün ve neyin ilgi gerektirdiğini fark edin."
|
||||
},
|
||||
"card": {
|
||||
"error": "Bu kart yüklenemedi."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "İzleyici durumu",
|
||||
"empty": "Henüz izleyici yok.",
|
||||
"upOfTotal": "/ {{total}} çalışıyor"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Şu anda kapalı",
|
||||
"empty": "Tüm izleyiciler çalışıyor. Açık olay yok.",
|
||||
"count": "{{count}} ilgi bekliyor"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Türe göre izleyiciler",
|
||||
"empty": "Henüz izleyici yok.",
|
||||
"down": "{{count}} kapalı"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Son olaylar",
|
||||
"empty": "Olay yok — tüm sistemler sağlıklı.",
|
||||
"active": "{{count}} açık",
|
||||
"ongoing": "Devam ediyor",
|
||||
"manual": "Manuel",
|
||||
"unknownMonitor": "Bilinmeyen izleyici"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Başarısız kontroller",
|
||||
"empty": "Henüz kontrol yok.",
|
||||
"ratio": "son günde {{total}} kontrolün {{failed}} tanesi"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate sunucusu",
|
||||
"memory": "Bellek",
|
||||
"eventLoop": "Olay döngüsü gecikmesi",
|
||||
"uptime": "Çalışma süresi",
|
||||
"database": "Veritabanı",
|
||||
"readsWrites": "{{reads}} okuma/sn · {{writes}} yazma/sn"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "{{total}} içinden ilk {{shown}}",
|
||||
"notEnough": "Sıralama için yeterli izleyici yok."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "En yavaş izleyiciler"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "En düşük çalışma süresi"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Gruba göre izleyiciler",
|
||||
"empty": "Henüz grup yok. Gruplar izleyici başına ayarlanır."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "En yoğun sunucular",
|
||||
"empty": "Henüz altyapı izleyicisi yok.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Bellek",
|
||||
"disk": "Disk"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Olay istatistikleri",
|
||||
"openNow": "Şu anda açık",
|
||||
"avgResolution": "Ortalama çözüm süresi",
|
||||
"hours": "{{hours}} sa",
|
||||
"mostIncidents": "En çok olay",
|
||||
"total": "toplam",
|
||||
"resolved": "Çözüldü",
|
||||
"automatic": "Otomatik",
|
||||
"manual": "Manuel",
|
||||
"autoManual": "{{automatic}} otomatik · {{manual}} manuel"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Bildirim kanalları",
|
||||
"empty": "Henüz kanal yok."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Bakım pencereleri",
|
||||
"empty": "Planlanmış bakım yok.",
|
||||
"runningNow": "Şu anda çalışıyor",
|
||||
"next": "Sonraki pencere"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Durum sayfaları",
|
||||
"empty": "Henüz durum sayfası yok.",
|
||||
"monitorCount": "{{count}} izleyici",
|
||||
"published": "Yayında",
|
||||
"draft": "Taslak"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Kontrol kuyruğu",
|
||||
"healthy": "{{workers}} işçide {{jobs}} işin tümü planlandığı gibi.",
|
||||
"failing": "Başarısız işler",
|
||||
"scheduled": "Planlanmış işler",
|
||||
"runningNow": "Şu anda çalışıyor"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Son hatalar",
|
||||
"empty": "Kaydedilmiş hata yok.",
|
||||
"count": "{{count}} hata",
|
||||
"warnings": "Uyarılar"
|
||||
},
|
||||
"team": {
|
||||
"title": "Ekip üyeleri",
|
||||
"members": "{{count}} üye",
|
||||
"unverified": "Doğrulanmamış"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Kartları düzenle",
|
||||
"title": "Panel kartları",
|
||||
"done": "Tamam",
|
||||
"reset": "Varsayılanlara sıfırla",
|
||||
"groups": {
|
||||
"uptime": "Çalışma süresi",
|
||||
"infrastructure": "Altyapı",
|
||||
"incidents": "Olaylar",
|
||||
"notifications": "Bildirimler",
|
||||
"maintenance": "Bakım",
|
||||
"statusPages": "Durum sayfaları",
|
||||
"checks": "Kontroller",
|
||||
"logs": "Sistem",
|
||||
"settings": "Ayarlar"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Panelinizde kart yok",
|
||||
"description": "Görmek istediğiniz kartları seçin. Seçiminiz bu tarayıcıda hatırlanır."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Yukarı taşı",
|
||||
"moveDown": "Aşağı taşı"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Eyvah! Sushini düşürdün!",
|
||||
"subtitle": "URL mevcut değil veya erişim yetkiniz yok."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Обслуговування",
|
||||
"logs": "Журнали",
|
||||
"settings": "Налаштування",
|
||||
"dashboard": "Панель",
|
||||
"groups": {
|
||||
"monitoring": "Моніторинг",
|
||||
"alerting": "Сповіщення",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Панель",
|
||||
"description": "Переглядайте всі типи моніторів в одному місці та помічайте те, що потребує уваги."
|
||||
},
|
||||
"card": {
|
||||
"error": "Не вдалося завантажити цю картку."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Стан моніторів",
|
||||
"empty": "Моніторів поки немає.",
|
||||
"upOfTotal": "з {{total}} працюють"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Зараз недоступні",
|
||||
"empty": "Усі монітори працюють. Відкритих інцидентів немає.",
|
||||
"count": "{{count}} потребують уваги"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Монітори за типом",
|
||||
"empty": "Моніторів поки немає.",
|
||||
"down": "{{count}} недоступні"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Останні інциденти",
|
||||
"empty": "Інцидентів немає — усі системи справні.",
|
||||
"active": "{{count}} відкрито",
|
||||
"ongoing": "Триває",
|
||||
"manual": "Вручну",
|
||||
"unknownMonitor": "Невідомий монітор"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Невдалі перевірки",
|
||||
"empty": "Перевірок поки немає.",
|
||||
"ratio": "{{failed}} з {{total}} за останню добу"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Сервер Checkmate",
|
||||
"memory": "Пам'ять",
|
||||
"eventLoop": "Затримка event loop",
|
||||
"uptime": "Час роботи",
|
||||
"database": "База даних",
|
||||
"readsWrites": "{{reads}} читань/с · {{writes}} записів/с"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Топ {{shown}} з {{total}}",
|
||||
"notEnough": "Недостатньо моніторів для рейтингу."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Найповільніші монітори"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Найнижча працездатність"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Монітори за групою",
|
||||
"empty": "Груп поки немає. Групи задаються в моніторі."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Найзавантаженіші сервери",
|
||||
"empty": "Інфраструктурних моніторів поки немає.",
|
||||
"cpu": "ЦП",
|
||||
"memory": "Пам'ять",
|
||||
"disk": "Диск"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Статистика інцидентів",
|
||||
"openNow": "Відкрито зараз",
|
||||
"avgResolution": "Середній час вирішення",
|
||||
"hours": "{{hours}} год",
|
||||
"mostIncidents": "Найбільше інцидентів",
|
||||
"total": "усього",
|
||||
"resolved": "Вирішено",
|
||||
"automatic": "Автоматично",
|
||||
"manual": "Вручну",
|
||||
"autoManual": "{{automatic}} автоматично · {{manual}} вручну"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Канали сповіщень",
|
||||
"empty": "Каналів поки немає."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Вікна обслуговування",
|
||||
"empty": "Обслуговування не заплановано.",
|
||||
"runningNow": "Виконується",
|
||||
"next": "Наступне вікно"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Сторінки статусу",
|
||||
"empty": "Сторінок статусу поки немає.",
|
||||
"monitorCount": "{{count}} моніторів",
|
||||
"published": "Опубліковано",
|
||||
"draft": "Чернетка"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Черга перевірок",
|
||||
"healthy": "Усі {{jobs}} завдань за розкладом на {{workers}} воркерах.",
|
||||
"failing": "Невдалі завдання",
|
||||
"scheduled": "Заплановані завдання",
|
||||
"runningNow": "Виконується"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Останні помилки",
|
||||
"empty": "Помилок не зафіксовано.",
|
||||
"count": "{{count}} помилок",
|
||||
"warnings": "Попередження"
|
||||
},
|
||||
"team": {
|
||||
"title": "Учасники команди",
|
||||
"members": "{{count}} учасників",
|
||||
"unverified": "Не підтверджені"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Редагувати картки",
|
||||
"title": "Картки панелі",
|
||||
"done": "Готово",
|
||||
"reset": "Скинути до типових",
|
||||
"groups": {
|
||||
"uptime": "Працездатність",
|
||||
"infrastructure": "Інфраструктура",
|
||||
"incidents": "Інциденти",
|
||||
"notifications": "Сповіщення",
|
||||
"maintenance": "Обслуговування",
|
||||
"statusPages": "Сторінки статусу",
|
||||
"checks": "Перевірки",
|
||||
"logs": "Система",
|
||||
"settings": "Налаштування"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "На вашій панелі немає карток",
|
||||
"description": "Виберіть картки, які хочете бачити. Ваш вибір зберігається в цьому браузері."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Перемістити вгору",
|
||||
"moveDown": "Перемістити вниз"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Ой ні! Ви впустили суші!",
|
||||
"subtitle": "Або URL не існує, або у Вас немає доступу до нього."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "Bảo trì",
|
||||
"logs": "Nhật ký",
|
||||
"settings": "Cài đặt",
|
||||
"dashboard": "Bảng điều khiển",
|
||||
"groups": {
|
||||
"monitoring": "Giám sát",
|
||||
"alerting": "Cảnh báo",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Bảng điều khiển",
|
||||
"description": "Xem mọi loại trình giám sát ở cùng một nơi và nhận ra điều cần chú ý."
|
||||
},
|
||||
"card": {
|
||||
"error": "Không thể tải thẻ này."
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "Trạng thái giám sát",
|
||||
"empty": "Chưa có trình giám sát nào.",
|
||||
"upOfTotal": "trên {{total}} đang hoạt động"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "Hiện đang ngừng",
|
||||
"empty": "Tất cả trình giám sát đang hoạt động. Không có sự cố nào đang mở.",
|
||||
"count": "{{count}} cần chú ý"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "Trình giám sát theo loại",
|
||||
"empty": "Chưa có trình giám sát nào.",
|
||||
"down": "{{count}} đang ngừng"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "Sự cố gần đây",
|
||||
"empty": "Không có sự cố — mọi hệ thống đều ổn.",
|
||||
"active": "{{count}} đang mở",
|
||||
"ongoing": "Đang diễn ra",
|
||||
"manual": "Thủ công",
|
||||
"unknownMonitor": "Trình giám sát không xác định"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "Kiểm tra thất bại",
|
||||
"empty": "Chưa có lần kiểm tra nào.",
|
||||
"ratio": "{{failed}} trên {{total}} trong ngày qua"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Máy chủ Checkmate",
|
||||
"memory": "Bộ nhớ",
|
||||
"eventLoop": "Độ trễ vòng lặp sự kiện",
|
||||
"uptime": "Thời gian hoạt động",
|
||||
"database": "Cơ sở dữ liệu",
|
||||
"readsWrites": "{{reads}} lượt đọc/giây · {{writes}} lượt ghi/giây"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "Top {{shown}} trên {{total}}",
|
||||
"notEnough": "Chưa đủ trình giám sát để xếp hạng."
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "Trình giám sát chậm nhất"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "Uptime thấp nhất"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "Trình giám sát theo nhóm",
|
||||
"empty": "Chưa có nhóm nào. Nhóm được đặt cho từng trình giám sát."
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "Máy chủ bận nhất",
|
||||
"empty": "Chưa có trình giám sát hạ tầng nào.",
|
||||
"cpu": "CPU",
|
||||
"memory": "Bộ nhớ",
|
||||
"disk": "Ổ đĩa"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "Thống kê sự cố",
|
||||
"openNow": "Đang mở",
|
||||
"avgResolution": "Thời gian xử lý trung bình",
|
||||
"hours": "{{hours}} giờ",
|
||||
"mostIncidents": "Nhiều sự cố nhất",
|
||||
"total": "tổng cộng",
|
||||
"resolved": "Đã xử lý",
|
||||
"automatic": "Tự động",
|
||||
"manual": "Thủ công",
|
||||
"autoManual": "{{automatic}} tự động · {{manual}} thủ công"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "Kênh thông báo",
|
||||
"empty": "Chưa có kênh nào."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Cửa sổ bảo trì",
|
||||
"empty": "Không có bảo trì nào được lên lịch.",
|
||||
"runningNow": "Đang chạy",
|
||||
"next": "Cửa sổ tiếp theo"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "Trang trạng thái",
|
||||
"empty": "Chưa có trang trạng thái nào.",
|
||||
"monitorCount": "{{count}} trình giám sát",
|
||||
"published": "Đã xuất bản",
|
||||
"draft": "Bản nháp"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "Hàng đợi kiểm tra",
|
||||
"healthy": "Tất cả {{jobs}} tác vụ đúng lịch trên {{workers}} worker.",
|
||||
"failing": "Tác vụ thất bại",
|
||||
"scheduled": "Tác vụ đã lên lịch",
|
||||
"runningNow": "Đang chạy"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "Lỗi gần đây",
|
||||
"empty": "Không có lỗi nào được ghi.",
|
||||
"count": "{{count}} lỗi",
|
||||
"warnings": "Cảnh báo"
|
||||
},
|
||||
"team": {
|
||||
"title": "Thành viên nhóm",
|
||||
"members": "{{count}} thành viên",
|
||||
"unverified": "Chưa xác minh"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "Chỉnh sửa thẻ",
|
||||
"title": "Thẻ bảng điều khiển",
|
||||
"done": "Xong",
|
||||
"reset": "Đặt lại mặc định",
|
||||
"groups": {
|
||||
"uptime": "Uptime",
|
||||
"infrastructure": "Hạ tầng",
|
||||
"incidents": "Sự cố",
|
||||
"notifications": "Thông báo",
|
||||
"maintenance": "Bảo trì",
|
||||
"statusPages": "Trang trạng thái",
|
||||
"checks": "Kiểm tra",
|
||||
"logs": "Hệ thống",
|
||||
"settings": "Cài đặt"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "Không có thẻ nào trên bảng điều khiển",
|
||||
"description": "Chọn các thẻ bạn muốn xem. Lựa chọn của bạn được ghi nhớ trong trình duyệt này."
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "Di chuyển lên",
|
||||
"moveDown": "Di chuyển xuống"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Ôi không! Bạn đã làm rơi sushi!",
|
||||
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "维护",
|
||||
"logs": "日志",
|
||||
"settings": "设置",
|
||||
"dashboard": "仪表板",
|
||||
"groups": {
|
||||
"monitoring": "监控",
|
||||
"alerting": "告警",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "仪表板",
|
||||
"description": "在一个页面查看所有类型的监控器,快速发现需要关注的问题。"
|
||||
},
|
||||
"card": {
|
||||
"error": "无法加载此卡片。"
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "监控状态",
|
||||
"empty": "暂无监控器。",
|
||||
"upOfTotal": "/ {{total}} 正常"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "当前离线",
|
||||
"empty": "所有监控器均正常。没有未处理的事件。",
|
||||
"count": "{{count}} 项需要关注"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "按类型分组的监控器",
|
||||
"empty": "暂无监控器。",
|
||||
"down": "{{count}} 个离线"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "最近事件",
|
||||
"empty": "没有事件 — 所有系统正常。",
|
||||
"active": "{{count}} 个未处理",
|
||||
"ongoing": "进行中",
|
||||
"manual": "手动",
|
||||
"unknownMonitor": "未知监控器"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "失败的检查",
|
||||
"empty": "暂无检查。",
|
||||
"ratio": "最近一天 {{total}} 次中的 {{failed}} 次"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate 服务器",
|
||||
"memory": "内存",
|
||||
"eventLoop": "事件循环延迟",
|
||||
"uptime": "运行时间",
|
||||
"database": "数据库",
|
||||
"readsWrites": "{{reads}} 读/秒 · {{writes}} 写/秒"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "{{total}} 个中的前 {{shown}} 个",
|
||||
"notEnough": "监控器数量不足,无法排名。"
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "最慢的监控器"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "运行时间最低"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "按分组的监控器",
|
||||
"empty": "暂无分组。分组在每个监控器上设置。"
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "最繁忙的服务器",
|
||||
"empty": "暂无基础设施监控器。",
|
||||
"cpu": "CPU",
|
||||
"memory": "内存",
|
||||
"disk": "磁盘"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "事件统计",
|
||||
"openNow": "当前未处理",
|
||||
"avgResolution": "平均解决时间",
|
||||
"hours": "{{hours}} 小时",
|
||||
"mostIncidents": "事件最多",
|
||||
"total": "总计",
|
||||
"resolved": "已解决",
|
||||
"automatic": "自动",
|
||||
"manual": "手动",
|
||||
"autoManual": "{{automatic}} 个自动 · {{manual}} 个手动"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "通知渠道",
|
||||
"empty": "暂无渠道。"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "维护窗口",
|
||||
"empty": "没有计划的维护。",
|
||||
"runningNow": "正在进行",
|
||||
"next": "下一个窗口"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "状态页面",
|
||||
"empty": "暂无状态页面。",
|
||||
"monitorCount": "{{count}} 个监控器",
|
||||
"published": "已发布",
|
||||
"draft": "草稿"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "检查队列",
|
||||
"healthy": "{{workers}} 个工作进程上的 {{jobs}} 个任务均按计划运行。",
|
||||
"failing": "失败的任务",
|
||||
"scheduled": "计划任务",
|
||||
"runningNow": "正在进行"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "最近错误",
|
||||
"empty": "没有记录到错误。",
|
||||
"count": "{{count}} 个错误",
|
||||
"warnings": "警告"
|
||||
},
|
||||
"team": {
|
||||
"title": "团队成员",
|
||||
"members": "{{count}} 名成员",
|
||||
"unverified": "未验证"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "编辑卡片",
|
||||
"title": "仪表板卡片",
|
||||
"done": "完成",
|
||||
"reset": "恢复默认",
|
||||
"groups": {
|
||||
"uptime": "运行时间",
|
||||
"infrastructure": "基础设施",
|
||||
"incidents": "事件",
|
||||
"notifications": "通知",
|
||||
"maintenance": "维护",
|
||||
"statusPages": "状态页面",
|
||||
"checks": "检查",
|
||||
"logs": "系统",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "您的仪表板上没有卡片",
|
||||
"description": "选择您想查看的卡片。您的选择会记录在此浏览器中。"
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "哦不!你的寿司掉了!",
|
||||
"subtitle": "该 URL 不存在,或者您没有访问权限。"
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"maintenance": "維護",
|
||||
"logs": "日誌",
|
||||
"settings": "設定",
|
||||
"dashboard": "儀表板",
|
||||
"groups": {
|
||||
"monitoring": "監控",
|
||||
"alerting": "警示",
|
||||
@@ -229,6 +230,146 @@
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "儀表板",
|
||||
"description": "在同一個頁面檢視所有類型的監視器,快速發現需要留意的問題。"
|
||||
},
|
||||
"card": {
|
||||
"error": "無法載入此卡片。"
|
||||
},
|
||||
"cards": {
|
||||
"monitorStatus": {
|
||||
"title": "監視狀態",
|
||||
"empty": "尚無監視器。",
|
||||
"upOfTotal": "/ {{total}} 正常"
|
||||
},
|
||||
"currentlyDown": {
|
||||
"title": "目前離線",
|
||||
"empty": "所有監視器均正常。沒有未處理的事件。",
|
||||
"count": "{{count}} 項需要留意"
|
||||
},
|
||||
"monitorsByType": {
|
||||
"title": "依類型分類的監視器",
|
||||
"empty": "尚無監視器。",
|
||||
"down": "{{count}} 個離線"
|
||||
},
|
||||
"recentIncidents": {
|
||||
"title": "最近事件",
|
||||
"empty": "沒有事件 — 所有系統正常。",
|
||||
"active": "{{count}} 個未處理",
|
||||
"ongoing": "進行中",
|
||||
"manual": "手動",
|
||||
"unknownMonitor": "未知監視器"
|
||||
},
|
||||
"failedChecks": {
|
||||
"title": "失敗的檢查",
|
||||
"empty": "尚無檢查。",
|
||||
"ratio": "最近一天 {{total}} 次中的 {{failed}} 次"
|
||||
},
|
||||
"checkmateServer": {
|
||||
"title": "Checkmate 伺服器",
|
||||
"memory": "記憶體",
|
||||
"eventLoop": "事件迴圈延遲",
|
||||
"uptime": "運行時間",
|
||||
"database": "資料庫",
|
||||
"readsWrites": "{{reads}} 讀取/秒 · {{writes}} 寫入/秒"
|
||||
},
|
||||
"ranking": {
|
||||
"topOf": "{{total}} 個中的前 {{shown}} 個",
|
||||
"notEnough": "監視器數量不足,無法排名。"
|
||||
},
|
||||
"slowestMonitors": {
|
||||
"title": "最慢的監視器"
|
||||
},
|
||||
"lowestUptime": {
|
||||
"title": "運行時間最低"
|
||||
},
|
||||
"monitorsByGroup": {
|
||||
"title": "依群組分類的監視器",
|
||||
"empty": "尚無群組。群組在每個監視器上設定。"
|
||||
},
|
||||
"busiestServers": {
|
||||
"title": "最繁忙的伺服器",
|
||||
"empty": "尚無基礎設施監視器。",
|
||||
"cpu": "CPU",
|
||||
"memory": "記憶體",
|
||||
"disk": "磁碟"
|
||||
},
|
||||
"incidentStats": {
|
||||
"title": "事件統計",
|
||||
"openNow": "目前未處理",
|
||||
"avgResolution": "平均解決時間",
|
||||
"hours": "{{hours}} 小時",
|
||||
"mostIncidents": "事件最多",
|
||||
"total": "總計",
|
||||
"resolved": "已解決",
|
||||
"automatic": "自動",
|
||||
"manual": "手動",
|
||||
"autoManual": "{{automatic}} 個自動 · {{manual}} 個手動"
|
||||
},
|
||||
"notificationChannels": {
|
||||
"title": "通知管道",
|
||||
"empty": "尚無管道。"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "維護時段",
|
||||
"empty": "沒有排定的維護。",
|
||||
"runningNow": "進行中",
|
||||
"next": "下一個時段"
|
||||
},
|
||||
"statusPages": {
|
||||
"title": "狀態頁面",
|
||||
"empty": "尚無狀態頁面。",
|
||||
"monitorCount": "{{count}} 個監視器",
|
||||
"published": "已發布",
|
||||
"draft": "草稿"
|
||||
},
|
||||
"checksOnSchedule": {
|
||||
"title": "檢查佇列",
|
||||
"healthy": "{{workers}} 個工作處理程序上的 {{jobs}} 個工作均按計畫執行。",
|
||||
"failing": "失敗的工作",
|
||||
"scheduled": "排定的工作",
|
||||
"runningNow": "進行中"
|
||||
},
|
||||
"recentErrors": {
|
||||
"title": "最近錯誤",
|
||||
"empty": "沒有記錄到錯誤。",
|
||||
"count": "{{count}} 個錯誤",
|
||||
"warnings": "警告"
|
||||
},
|
||||
"team": {
|
||||
"title": "團隊成員",
|
||||
"members": "{{count}} 位成員",
|
||||
"unverified": "未驗證"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"trigger": "編輯卡片",
|
||||
"title": "儀表板卡片",
|
||||
"done": "完成",
|
||||
"reset": "還原預設值",
|
||||
"groups": {
|
||||
"uptime": "運行時間",
|
||||
"infrastructure": "基礎設施",
|
||||
"incidents": "事件",
|
||||
"notifications": "通知",
|
||||
"maintenance": "維護",
|
||||
"statusPages": "狀態頁面",
|
||||
"checks": "檢查",
|
||||
"logs": "系統",
|
||||
"settings": "設定"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"title": "您的儀表板上沒有卡片",
|
||||
"description": "選擇您想檢視的卡片。您的選擇會記錄在此瀏覽器中。"
|
||||
},
|
||||
"reorder": {
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"title": "哎呀!您的壽司掉了!",
|
||||
"subtitle": "該 URL 不存在,或您沒有存取權限。"
|
||||
|
||||
@@ -24,6 +24,8 @@ services:
|
||||
checkmate-demo:
|
||||
image: ghcr.io/bluewave-labs/checkmate:master
|
||||
container_name: checkmate-demo
|
||||
group_add:
|
||||
- "989"
|
||||
restart: always
|
||||
env_file:
|
||||
- server-prod.env
|
||||
@@ -68,6 +70,8 @@ services:
|
||||
checkmate-staging:
|
||||
image: ghcr.io/bluewave-labs/checkmate:develop
|
||||
container_name: checkmate-staging
|
||||
group_add:
|
||||
- "989"
|
||||
restart: always
|
||||
env_file:
|
||||
- server-staging.env
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
dockerContainerDetailsResponseSchema,
|
||||
monitorResponseSchema,
|
||||
uptimeDetailsResponseSchema,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
dockerContainerLogsResponseSchema,
|
||||
} from "@/api/validation/monitorValidation.js";
|
||||
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
|
||||
|
||||
@@ -131,6 +133,16 @@ registry.registerPath({
|
||||
responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs",
|
||||
tags,
|
||||
summary: "Get stored log lines for one container on a Docker host monitor, newest first",
|
||||
security: bearer,
|
||||
request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation },
|
||||
responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/pagespeed/details/{monitorId}",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getDockerDetailsByIdQueryValidation,
|
||||
getDockerContainerNameParamValidation,
|
||||
getDockerContainerByNameQueryValidation,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
} from "@/api/validation/monitorValidation.js";
|
||||
import sslChecker from "ssl-checker";
|
||||
import * as whoiser from "whoiser";
|
||||
@@ -37,6 +38,7 @@ export interface IMonitorController {
|
||||
getPageSpeedDetailsById: RequestHandler;
|
||||
getDockerDetailsById: RequestHandler;
|
||||
getDockerContainerByName: RequestHandler;
|
||||
getDockerContainerLogs: RequestHandler;
|
||||
getGeoChecksByMonitorId: RequestHandler;
|
||||
getMonitorById: RequestHandler;
|
||||
createMonitor: RequestHandler;
|
||||
@@ -198,6 +200,30 @@ class MonitorController implements IMonitorController {
|
||||
});
|
||||
});
|
||||
|
||||
getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => {
|
||||
const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
|
||||
const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query);
|
||||
|
||||
const { monitorId, containerName } = validatedParams;
|
||||
const { before, after, limit } = validatedQuery;
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const data = await this.monitorService.getDockerContainerLogs({
|
||||
teamId,
|
||||
monitorId,
|
||||
containerName,
|
||||
before: before ? new Date(before) : undefined,
|
||||
after: after ? new Date(after) : undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Docker container logs retrieved successfully",
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => {
|
||||
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
|
||||
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
|
||||
|
||||
@@ -21,6 +21,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
|
||||
// Docker routes
|
||||
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
|
||||
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
|
||||
router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs);
|
||||
|
||||
// Geo checks routes
|
||||
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
ProxyModes,
|
||||
} from "@/domain/monitors/monitor.type.js";
|
||||
import { DateRanges, SortOrders } from "@/types/query.js";
|
||||
import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js";
|
||||
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
|
||||
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
|
||||
|
||||
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
|
||||
|
||||
@@ -119,22 +120,15 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
|
||||
}
|
||||
};
|
||||
|
||||
const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/;
|
||||
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
||||
|
||||
const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => {
|
||||
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
||||
if (data.type !== "docker" || data.url === undefined) return;
|
||||
if (!dockerUrlRegex.test(data.url)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["url"],
|
||||
message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path",
|
||||
});
|
||||
}
|
||||
if (data.url.startsWith("ssh://") && !data.sshPrivateKey) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["sshPrivateKey"],
|
||||
message: "SSH Docker hosts require a private key",
|
||||
message: "Docker host must be unix:///path or an absolute socket path",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -163,7 +157,6 @@ export const createMonitorBodyValidation = z
|
||||
tags: z.array(z.string()).optional(),
|
||||
customUpCodes: z.array(httpStatusCode).default([]),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
@@ -176,6 +169,7 @@ export const createMonitorBodyValidation = z
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dockerLogsEnabled: z.boolean().optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
@@ -199,7 +193,6 @@ export const editMonitorBodyValidation = z
|
||||
tags: z.array(z.string()).optional(),
|
||||
customUpCodes: z.array(httpStatusCode).optional(),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
proxyMode: z.enum(ProxyModes).optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
@@ -221,6 +214,7 @@ export const editMonitorBodyValidation = z
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dockerLogsEnabled: z.boolean().optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
@@ -280,7 +274,6 @@ const importedMonitorSchema = z
|
||||
tags: z.array(z.string()).default([]),
|
||||
customUpCodes: z.array(httpStatusCode).default([]),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
cpuAlertThreshold: z.number().default(100),
|
||||
cpuAlertCounter: z.number().default(5),
|
||||
memoryAlertThreshold: z.number().default(100),
|
||||
@@ -297,6 +290,8 @@ const importedMonitorSchema = z
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
dockerLogsEnabled: z.boolean().default(false),
|
||||
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
createdAt: z.string().optional(),
|
||||
@@ -332,6 +327,21 @@ export const getDockerContainerNameParamValidation = z.object({
|
||||
});
|
||||
export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
|
||||
|
||||
export const getDockerContainerLogsQueryValidation = z
|
||||
.object({
|
||||
before: z.iso.datetime().optional(),
|
||||
after: z.iso.datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT),
|
||||
})
|
||||
.superRefine((query, ctx) => {
|
||||
if (query.before && query.after) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Specify either before or after, not both",
|
||||
path: ["after"],
|
||||
});
|
||||
}
|
||||
});
|
||||
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
|
||||
// what the controllers actually serialize.
|
||||
export const monitorResponseSchema = z
|
||||
@@ -359,7 +369,6 @@ export const monitorResponseSchema = z
|
||||
tags: z.array(z.string()),
|
||||
customUpCodes: z.array(httpStatusCode).optional(),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
cpuAlertThreshold: z.number(),
|
||||
memoryAlertThreshold: z.number(),
|
||||
diskAlertThreshold: z.number(),
|
||||
@@ -371,6 +380,7 @@ export const monitorResponseSchema = z
|
||||
geoCheckEnabled: z.boolean(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||
geoCheckInterval: z.number(),
|
||||
dockerLogsEnabled: z.boolean(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
teamId: z.string(),
|
||||
@@ -529,3 +539,26 @@ export const dockerContainerDetailsResponseSchema = z.object({
|
||||
.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const dockerLogLineResponseSchema = z.object({
|
||||
ts: z.string(),
|
||||
stream: z.enum(DockerLogStreams),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const dockerLogResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }),
|
||||
lines: z.array(dockerLogLineResponseSchema),
|
||||
gap: z.boolean(),
|
||||
checkedAt: z.string(),
|
||||
expiry: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs.
|
||||
export const dockerContainerLogsResponseSchema = z.object({
|
||||
logs: z.array(dockerLogResponseSchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
usersRepository,
|
||||
@@ -75,6 +76,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IDb } from "@/db/db.interface.js";
|
||||
import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ICheckService, CheckService } from "@/domain/checks/check.service.js";
|
||||
import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
|
||||
import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js";
|
||||
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
|
||||
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
|
||||
@@ -58,6 +59,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.
|
||||
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
|
||||
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
|
||||
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
|
||||
import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js";
|
||||
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
|
||||
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
|
||||
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
|
||||
@@ -72,6 +74,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
|
||||
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
|
||||
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
|
||||
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
|
||||
// Shared infrastructure + business services that both the API and the worker process construct.
|
||||
export interface SharedServices {
|
||||
@@ -83,6 +86,7 @@ export interface SharedServices {
|
||||
incidentService: IIncidentService;
|
||||
checkService: ICheckService;
|
||||
geoChecksService: IGeoChecksService;
|
||||
dockerLogsService: IDockerLogsService;
|
||||
notificationsService: INotificationsService;
|
||||
|
||||
// Queue identity (one per process; the worker reuses these)
|
||||
@@ -94,6 +98,7 @@ export interface SharedServices {
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
checksRepository: IChecksRepository;
|
||||
geoChecksRepository: IGeoChecksRepository;
|
||||
dockerLogsRepository: IDockerLogsRepository;
|
||||
monitorStatsRepository: IMonitorStatsRepository;
|
||||
statusPagesRepository: IStatusPagesRepository;
|
||||
usersRepository: IUsersRepository;
|
||||
@@ -131,6 +136,7 @@ export const buildShared = async ({
|
||||
const incidentsRepository = new MongoIncidentsRepository();
|
||||
const usersRepository = new MongoUsersRepository();
|
||||
const geoChecksRepository = new MongoGeoChecksRepository();
|
||||
const dockerLogsRepository = new MongoDockerLogsRepository();
|
||||
const notificationsRepository = new MongoNotificationsRepository();
|
||||
const jobsRepository = new MongoJobsRepository(workerId);
|
||||
const queueWorkersRepository = new MongoQueueWorkersRepository();
|
||||
@@ -161,6 +167,7 @@ export const buildShared = async ({
|
||||
monitorsRepository,
|
||||
});
|
||||
|
||||
const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository });
|
||||
const webhookProvider = new WebhookProvider(logger);
|
||||
const rocketChatProvider = new RocketChatProvider(logger);
|
||||
const slackProvider = new SlackProvider(logger);
|
||||
@@ -207,6 +214,7 @@ export const buildShared = async ({
|
||||
incidentService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
dockerLogsService,
|
||||
notificationsService,
|
||||
workerId,
|
||||
jobsRepository,
|
||||
@@ -214,6 +222,7 @@ export const buildShared = async ({
|
||||
queueWorkersRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
usersRepository,
|
||||
|
||||
@@ -52,6 +52,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
settingsService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
dockerLogsService,
|
||||
notificationsService,
|
||||
incidentService,
|
||||
workerId,
|
||||
@@ -60,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
incidentsRepository,
|
||||
teamsRepository,
|
||||
@@ -95,7 +97,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
]);
|
||||
|
||||
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
|
||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
||||
|
||||
@@ -119,6 +121,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
networkService,
|
||||
proxyResolver,
|
||||
bufferService,
|
||||
dockerLogsService,
|
||||
logger
|
||||
);
|
||||
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
|
||||
@@ -138,7 +141,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
monitorStatsRepository,
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
geoChecksRepository
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository
|
||||
);
|
||||
|
||||
const worker = await DBQueueWorker.create({
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js";
|
||||
* The old docker provider interpreted monitor.url as a container name/ID and only
|
||||
* ever talked to the local socket, so every pre-existing docker monitor's host is
|
||||
* definitionally unix:///var/run/docker.sock. The container name is preserved in
|
||||
* the description. Urls already in host form (unix://, ssh://, or an absolute
|
||||
* socket path) are left untouched, which makes the migration idempotent.
|
||||
* the description. Urls already in host form (unix:// or an absolute socket
|
||||
* path) are left untouched, which makes the migration idempotent.
|
||||
*/
|
||||
export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
|
||||
const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls";
|
||||
@@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
|
||||
try {
|
||||
logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" });
|
||||
|
||||
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [
|
||||
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [
|
||||
{
|
||||
$set: {
|
||||
description: {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
DockerContainerSummary,
|
||||
DockerHealthStatuses,
|
||||
DockerPortProtocols,
|
||||
} from "@/types/network.js";
|
||||
} from "@/domain/docker/docker.type.js";
|
||||
|
||||
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user