diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx
new file mode 100644
index 000000000..caa317598
--- /dev/null
+++ b/client/src/Components/design-elements/CopyButton.tsx
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import IconButton from "@mui/material/IconButton";
+import Tooltip from "@mui/material/Tooltip";
+import { useTheme } from "@mui/material/styles";
+import { Check, Copy } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useToast } from "@/Hooks/UseToast";
+
+const RESET_DELAY_MS = 1500;
+
+/**
+ * Copies `value` to the clipboard. Shows a tick for a moment so the click is
+ * acknowledged without moving the user anywhere.
+ */
+const CopyButton = ({ value, label }: { value: string; label?: string }) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { toastError } = useToast();
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ // The button usually sits inside a clickable row.
+ e.stopPropagation();
+
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), RESET_DELAY_MS);
+ } catch {
+ toastError(t("common.copyFailed"));
+ }
+ };
+
+ const title = copied ? t("common.copied") : (label ?? t("common.copy"));
+
+ return (
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default CopyButton;
diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx
index df0d52b16..c7bdad9f8 100644
--- a/client/src/Components/design-elements/Table.tsx
+++ b/client/src/Components/design-elements/Table.tsx
@@ -79,6 +79,14 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+
+ // Selecting text inside a row ends in a mouseup on that row, which would
+ // otherwise navigate away and take the text with it. Ignore the click when
+ // the user has actually selected something.
+ const hasTextSelection = () => {
+ const selection = window.getSelection();
+ return Boolean(selection && !selection.isCollapsed && selection.toString().trim());
+ };
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
@@ -100,7 +108,10 @@ export function DataTable<
keys.push(key);
return (
(onRowClick ? onRowClick(row) : null)}
+ onClick={() => {
+ if (hasTextSelection()) return;
+ if (onRowClick) onRowClick(row);
+ }}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -247,6 +258,7 @@ export function DataTable<
...(getRowSx?.(row) as object),
}}
onClick={() => {
+ if (hasTextSelection()) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx
index d7d38d014..e35d95823 100644
--- a/client/src/Components/design-elements/index.tsx
+++ b/client/src/Components/design-elements/index.tsx
@@ -28,3 +28,4 @@ export * from "./Avatar";
export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
export * from "./StepProgress";
+export { default as CopyButton } from "./CopyButton";
diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx
index 31737f4bb..0e75116dc 100644
--- a/client/src/Pages/Checks/Components/ChecksTable.tsx
+++ b/client/src/Pages/Checks/Components/ChecksTable.tsx
@@ -3,8 +3,12 @@ import {
Pagination,
StatusLabel,
StatusCodeLabel,
+ CopyButton,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import { LAYOUT } from "@/Utils/Theme/constants";
import type { Header } from "@/Components/design-elements/Table";
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
@@ -31,6 +35,7 @@ export const ChecksTable = ({
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
+ const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const getHeaders = () => {
@@ -79,7 +84,21 @@ export const ChecksTable = ({
id: "message",
content: t("common.table.headers.message"),
render: (row) => {
- return row.message || "N/A";
+ if (!row.message) return "N/A";
+ return (
+
+ {row.message}
+
+
+ );
},
},
];
diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
index b100c28c4..91b4ce588 100644
--- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
+++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
@@ -3,8 +3,12 @@ import {
Pagination,
StatusLabel,
StatusCodeLabel,
+ CopyButton,
} from "@/Components/design-elements";
import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+import { LAYOUT } from "@/Utils/Theme/constants";
import type { Header } from "@/Components/design-elements";
import type { Check } from "@/Types/Check";
@@ -31,6 +35,7 @@ export const ChecksTable = ({
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
+ const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers: Header[] = [
@@ -52,7 +57,21 @@ export const ChecksTable = ({
id: "message",
content: t("common.table.headers.message"),
render: (row) => {
- return row.message || "N/A";
+ if (!row.message) return "N/A";
+ return (
+
+ {row.message}
+
+
+ );
},
},
{
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index cbebadded..9b193ef1f 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -14,6 +14,10 @@
}
},
"appName": "Checkmate",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyMessage": "Copy message",
+ "copyFailed": "Could not copy to clipboard",
"statusCode": {
"down": "Down",
"networkError": {