diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx
index d50058378..c3fd089d7 100644
--- a/client/src/Components/design-elements/StatusLabel.tsx
+++ b/client/src/Components/design-elements/StatusLabel.tsx
@@ -93,3 +93,20 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string })
);
};
+
+export const ColoredLabel = ({ text, color }: { text: string; color: string }) => {
+ const theme = useTheme();
+
+ return (
+
+ {text}
+
+ );
+};
diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx
index c98e8ff32..4822a805f 100644
--- a/client/src/Components/inputs/AutoComplete.tsx
+++ b/client/src/Components/inputs/AutoComplete.tsx
@@ -14,17 +14,19 @@ type AutoCompleteInputProps = Omit<
renderInput?: AutocompleteProps["renderInput"];
fieldLabel?: string;
required?: boolean;
+ renderOptionContent?: (option: any) => React.ReactNode;
};
export const AutoCompleteInput = ({
fieldLabel,
required,
renderInput,
+ renderOptionContent,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const multiple = props.multiple;
-
+ const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => (
option.id}
renderTags={() => null}
- renderOption={(props, option, { selected }) => {
- const { key, ...optionProps } = props;
- return (
-
- {
+ const { key, ...optionProps } = props;
+ return (
+
- {multiple && }
- {option.name}
-
-
- );
- }}
+
+ {multiple && }
+ {renderOptionContent ? renderOptionContent(option) : option.name}
+
+
+ );
+ })
+ }
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx
index a617d454c..196ef2042 100644
--- a/client/src/Components/monitors/ControlsFilter.tsx
+++ b/client/src/Components/monitors/ControlsFilter.tsx
@@ -1,9 +1,11 @@
import Stack from "@mui/material/Stack";
-import { Select, Button } from "@/Components/inputs";
+import { Select, Autocomplete, Button, TextField } from "@/Components/inputs";
+import { ColoredLabel } from "@/Components/design-elements";
import MenuItem from "@mui/material/MenuItem";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -28,22 +30,34 @@ export const ControlsFilter = ({
setSelectedStatus,
selectedState,
setSelectedState,
+ tagOptions,
+ selectedTags,
+ setSelectedTags,
onClearFilters,
}: {
showTypes?: boolean;
selectedTypes?: MonitorType[];
setSelectedTypes?: React.Dispatch>;
- selectedStatus: string;
- setSelectedStatus: React.Dispatch>;
- selectedState: string;
- setSelectedState: React.Dispatch>;
+ selectedStatus?: string;
+ setSelectedStatus?: React.Dispatch>;
+ selectedState?: string;
+ setSelectedState?: React.Dispatch>;
+ tagOptions?: Tag[];
+ selectedTags?: string[];
+ setSelectedTags?: React.Dispatch>;
onClearFilters: () => void;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
- (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
+ (selectedTypes?.length ?? 0) > 0 ||
+ Boolean(selectedStatus) ||
+ Boolean(selectedState) ||
+ (selectedTags?.length ?? 0) > 0;
+ const selectedTagObjects = (tagOptions ?? []).filter((tag) =>
+ (selectedTags ?? []).includes(tag.id)
+ );
return (
)}
-
-
+ {setSelectedStatus && (
+
+ )}
+ {setSelectedState && (
+
+ )}
+ {tagOptions && setSelectedTags && (
+ option.name}
+ onChange={(_: unknown, newValue: Tag[]) => {
+ setSelectedTags(newValue.map((tag) => tag.id));
+ }}
+ isOptionEqualToValue={(option, value) => option.id === value.id}
+ renderOptionContent={(option) => (
+
+ )}
+ renderInput={(params) => (
+
+ )}
+ slotProps={{
+ popper: {
+ sx: { width: "fit-content !important", minWidth: 280 },
+ },
+ }}
+ sx={{ minWidth: 180 }}
+ />
+ )}
{isFilterActive && (
+ {selectedTags.length > 0 && (
+
+
+ {t("pages.uptime.filters.activeTags")}
+
+ {selectedTags.map((tagId) => {
+ const tag = tags?.find((t) => t.id === tagId);
+ if (!tag) return null;
+ return (
+
+ );
+ })}
+
+ )}
+
{!isLoading && (
{
{
+ if (row.tags.length === 0) return "-";
+ return (
+
+ {row.tags.map((tagId) => {
+ const fullTag = tags.find((tag) => tag.id === tagId);
+ if (!fullTag) return null;
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
{
id: "actions",
diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx
index 4cb28b51e..b9d0b49b1 100644
--- a/client/src/Pages/PageSpeed/Monitors/index.tsx
+++ b/client/src/Pages/PageSpeed/Monitors/index.tsx
@@ -1,22 +1,32 @@
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
+ ColoredLabel,
} from "@/Components/design-elements";
-import { Dialog } from "@/Components/inputs";
+import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material";
+import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet, useDelete } from "@/Hooks/UseApi";
+import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
-import { HeaderMonitorsSummary } from "@/Components/monitors";
+import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
@@ -33,17 +43,50 @@ const PageSpeedMonitorsPage = () => {
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [selectedStatus, setSelectedStatus] = useState("");
+ const [selectedState, setSelectedState] = useState("");
+ const [search, setSearch] = useState("");
+
+ const debouncedSearch = useDebounce(search, 300);
+
+ // Status: pass "up"/"down" directly to the API
+ // State: "active" -> true, "paused" -> false
+ const toFilterStatus = useMemo(() => {
+ if (selectedStatus === "up") return "up";
+ if (selectedStatus === "down") return "down";
+ return undefined;
+ }, [selectedStatus]);
+
+ const toFilterActive = useMemo(() => {
+ if (selectedState === "active") return "true";
+ if (selectedState === "paused") return "false";
+ return undefined;
+ }, [selectedState]);
+
+ // Priority: status > isActive > search > sort
+ const filterLookup = new Map([
+ [toFilterStatus, "status"],
+ [toFilterActive, "isActive"],
+ ]);
+ const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
+ const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
+ const filter = activeFilter?.[0] || debouncedSearch;
+
+ const { data: tags } = useGet("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
+ selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (sortField) params.append("field", sortField);
+ if (filter) params.append("filter", filter);
+ if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
- }, [page, rowsPerPage, sortField, sortOrder]);
+ }, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
@@ -75,12 +118,28 @@ const PageSpeedMonitorsPage = () => {
setSelectedMonitor(null);
};
+ const handleClearFilters = useCallback(() => {
+ setSelectedStatus("");
+ setSelectedState("");
+ setSelectedTags([]);
+ setSearch("");
+ }, []);
+
+ const hasActiveFilters = Boolean(
+ selectedStatus || selectedState || selectedTags.length > 0 || search
+ );
+
+ const effectiveTotalCount =
+ hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
+ ? 1
+ : (summary?.totalMonitors ?? 0);
+
return (
: undefined}
@@ -91,8 +150,59 @@ const PageSpeedMonitorsPage = () => {
isAdmin={isAdmin}
/>
+
+
+
+ {
+ setSearch(event.target.value);
+ }}
+ />
+
+
+ {selectedTags.length > 0 && (
+
+
+ {t("pages.uptime.filters.activeTags")}
+
+ {selectedTags.map((tagId) => {
+ const tag = tags?.find((t) => t.id === tagId);
+ if (!tag) return null;
+ return (
+
+ );
+ })}
+
+ )}
+
{
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const { pagedRows, paginationProps } = useClientPagination(tags);
+
+ const getActions = (tag: Tag): ActionMenuItem[] => {
+ return [
+ {
+ id: 1,
+ label: t("pages.common.monitors.actions.configure"),
+ action: () => {
+ navigate(`/tags/configure/${tag.id}`);
+ },
+ closeMenu: true,
+ },
+
+ {
+ id: 2,
+ label: (
+
+ {t("pages.common.monitors.actions.delete")}
+
+ ),
+ action: async () => {
+ setSelectedTag(tag);
+ },
+ closeMenu: true,
+ },
+ ];
+ };
+
+ const getHeaders = () => {
+ const headers: Header[] = [
+ {
+ id: "name",
+ content: t("common.table.headers.name"),
+ render: (row) => {
+ return {row?.name};
+ },
+ },
+
+ {
+ id: "color",
+ content: t("pages.tags.table.headers.color"),
+ render: (row) => {
+ return {row?.color};
+ },
+ },
+ {
+ id: "chip",
+ content: t("pages.tags.table.headers.appearance"),
+ render: (row) => {
+ return (
+
+ );
+ },
+ },
+
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ return (
+ <>
+ {
+ navigate(`/tags/configure/${row.id}`);
+ }}
+ />
+ {tags.length > 0 && }
+ >
+ );
+};
diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx
new file mode 100644
index 000000000..5e29096d0
--- /dev/null
+++ b/client/src/Pages/Tags/create/index.tsx
@@ -0,0 +1,118 @@
+import { BasePage, ConfigBox } from "@/Components/design-elements";
+import { TextField, Button, ColorInput } from "@/Components/inputs";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+
+import { useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
+import { Controller, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { mutate } from "swr";
+import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
+import type { TagFormData } from "@/Validation/tag";
+import type { Tag } from "@/Types/Tag";
+import { useTranslation } from "react-i18next";
+import { useTagsForm } from "@/Hooks/useTagsForm";
+
+const TagsCreatePage = () => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const { tagId } = useParams<{ tagId: string }>();
+ const isEditMode = Boolean(tagId);
+
+ const { data: existingTag } = useGet(isEditMode ? `/tags/${tagId}` : null);
+
+ const { post, loading: isSubmitting } = usePost();
+ const { patch, loading: isPatching } = usePatch();
+
+ const { schema, defaults } = useTagsForm({ data: existingTag });
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ });
+
+ const { control, reset, handleSubmit } = form;
+
+ useEffect(() => {
+ reset(defaults);
+ }, [defaults, reset]);
+
+ const onSubmit = async (data: TagFormData) => {
+ const result = isEditMode
+ ? await patch(`/tags/${tagId}`, data)
+ : await post("/tags", data);
+ if (result?.success) {
+ await mutate((key) => typeof key === "string" && key.startsWith("/tags"));
+ navigate("/tags");
+ }
+ };
+
+ return (
+
+ (
+
+ )}
+ />
+ }
+ />
+ (
+
+ )}
+ />
+ }
+ />
+
+
+
+
+
+ );
+};
+
+export default TagsCreatePage;
diff --git a/client/src/Pages/Tags/index.tsx b/client/src/Pages/Tags/index.tsx
new file mode 100644
index 000000000..9509cfc4b
--- /dev/null
+++ b/client/src/Pages/Tags/index.tsx
@@ -0,0 +1,72 @@
+import { BasePageWithStates } from "@/Components/design-elements";
+import { TagsTable } from "@/Pages/Tags/components/TagsTable";
+import { Dialog } from "@/Components/inputs";
+import { HeaderCreate } from "@/Components/common";
+
+import { useState } from "react";
+import { useGet, useDelete } from "@/Hooks/UseApi";
+import { useTranslation } from "react-i18next";
+import type { Tag } from "@/Types/Tag";
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+
+const TagsPage = () => {
+ const { t } = useTranslation();
+ const isAdmin = useIsAdmin();
+
+ const [selectedTag, setSelectedTag] = useState(null);
+ const isDialogOpen = Boolean(selectedTag);
+
+ const {
+ data: tags,
+ isLoading,
+ isValidating,
+ error,
+ refetch,
+ } = useGet("/tags/team", {}, { keepPreviousData: true });
+
+ const { deleteFn, loading: isDeleting } = useDelete();
+
+ const handleConfirm = async () => {
+ if (!selectedTag) return;
+ await deleteFn(`/tags/${selectedTag.id}`);
+ setSelectedTag(null);
+ refetch();
+ };
+
+ const handleCancel = () => {
+ setSelectedTag(null);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default TagsPage;
diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
index 048998776..2c026c35d 100644
--- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
+++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
@@ -6,6 +6,7 @@ import {
type Header,
Pagination,
StatusLabel,
+ ColoredLabel,
} from "@/Components/design-elements";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import { ActionsMenu } from "@/Components/actions-menu";
@@ -16,14 +17,18 @@ import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useSelector } from "react-redux";
+import useMediaQuery from "@mui/material/useMediaQuery";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
+import type { Tag } from "@/Types/Tag";
+import { SPACING } from "@/Utils/Theme/constants";
interface MonitorTableProps {
monitors: Monitor[];
+ tags: Tag[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
@@ -41,6 +46,7 @@ interface MonitorTableProps {
export const MonitorTable = ({
monitors,
+ tags,
refetch,
setSelectedMonitor,
sortField,
@@ -60,6 +66,7 @@ export const MonitorTable = ({
const navigate = useNavigate();
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
const { post } = usePost, Monitor>();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const selectedSet = new Set(selectedRows);
const isAllSelected =
@@ -252,6 +259,33 @@ export const MonitorTable = ({
}
},
},
+ {
+ id: "tags",
+ content: t("common.table.headers.tags"),
+ render: (row) => {
+ if (row.tags.length === 0) return "-";
+
+ return (
+
+ {row.tags.map((tag) => {
+ const fullTag = tags.find((t) => t.id === tag);
+ if (!fullTag) return null;
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
{
id: "type",
mobileLabel: t("common.table.headers.type"),
diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx
index 85db2cb11..021e02ac5 100644
--- a/client/src/Pages/Uptime/Monitors/index.tsx
+++ b/client/src/Pages/Uptime/Monitors/index.tsx
@@ -3,17 +3,19 @@ import {
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
-import { MonitorBasePageWithStates } from "@/Components/design-elements";
+import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
+import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
@@ -22,6 +24,7 @@ import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
+import { SPACING } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
@@ -38,6 +41,7 @@ const UptimeMonitorsPage = () => {
const [selectedTypes, setSelectedTypes] = useState([]);
const [selectedStatus, setSelectedStatus] = useState("");
const [selectedState, setSelectedState] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
const [page, setPage] = useState(0);
const [search, setSearch] = useState("");
const [sortField, setSortField] = useState("");
@@ -83,6 +87,7 @@ const UptimeMonitorsPage = () => {
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
+ selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
@@ -90,7 +95,7 @@ const UptimeMonitorsPage = () => {
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
- }, [effectiveTypes, page, rowsPerPage, filter, field, sortOrder]);
+ }, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
@@ -103,6 +108,8 @@ const UptimeMonitorsPage = () => {
{ refreshInterval: 5000, keepPreviousData: true }
);
+ const { data: tags } = useGet("/tags/team");
+
const {
monitors: monitorsWithChecks,
summary,
@@ -126,6 +133,7 @@ const UptimeMonitorsPage = () => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
+ setSelectedTags([]);
setSearch("");
}, []);
@@ -144,7 +152,11 @@ const UptimeMonitorsPage = () => {
// Check if any filters are active
const hasActiveFilters = Boolean(
- selectedTypes.length > 0 || selectedStatus || selectedState || search
+ selectedTypes.length > 0 ||
+ selectedStatus ||
+ selectedState ||
+ selectedTags.length > 0 ||
+ search
);
// Show empty state only when there are truly no monitors (not just filtered out)
@@ -183,6 +195,9 @@ const UptimeMonitorsPage = () => {
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
+ tagOptions={tags ?? []}
+ selectedTags={selectedTags}
+ setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
{
/>
+ {selectedTags.length > 0 && (
+
+
+ {t("pages.uptime.filters.activeTags")}
+
+ {selectedTags.map((tagId) => {
+ const tag = tags?.find((t) => t.id === tagId);
+ if (!tag) return null;
+ return (
+
+ );
+ })}
+
+ )}
+
{!isLoading && (
{
{
path="notifications/configure/:notificationId"
element={}
/>
+ }
+ />
+ }
+ />
+
+ }
+ />
;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index b964eec84..14b19851b 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -131,7 +131,8 @@
"url": "Url",
"interval": "Interval",
"active": "Active",
- "responseTime": "Response time"
+ "responseTime": "Response time",
+ "tags": "Tags"
}
}
},
@@ -184,6 +185,7 @@
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
"notifications": "Notifications",
+ "tags": "Tags",
"checks": "Checks",
"incidents": "Incidents",
"statusPages": "Status pages",
@@ -627,6 +629,10 @@
"description": "Select the notification channels you want to use",
"title": "Notifications"
},
+ "tags": {
+ "description": "Select the tags you want to apply to this monitor",
+ "title": "Tags"
+ },
"type": {
"description": "Select the type of check to perform",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
@@ -876,6 +882,37 @@
"description": "Track server performance, identify bottlenecks, and ensure reliability with real-time infrastructure monitoring."
}
},
+ "tags": {
+ "header": {
+ "title": "Tags",
+ "description": "Organize your monitors with tags"
+ },
+ "fallback": {
+ "title": "No tags yet",
+ "description": "Organize your monitors with tags. Once you create a tag and assign it to a monitor, you'll be able to filter by that tag across the app and see tag details here.",
+ "actionButton": "Create a tag"
+ },
+ "table": {
+ "headers": {
+ "color": "Color",
+ "appearance": "Appearance"
+ }
+ },
+ "form": {
+ "name": {
+ "title": "Tag name",
+ "description": "Choose a name for your tag",
+ "optionName": "Tag name",
+ "placeholder": "e.g. Group 1"
+ },
+ "color": {
+ "title": "Tag color",
+ "description": "Choose a color for your tag",
+ "optionName": "Tag color",
+ "placeholder": "e.g. #13715B"
+ }
+ }
+ },
"logs": {
"fallback": {
"title": "No logs yet",
@@ -1523,7 +1560,8 @@
"filters": {
"search": {
"placeholder": "Search monitors..."
- }
+ },
+ "activeTags": "Active tags"
},
"table": {
"headers": {
diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts
index 6103132f8..c52ba55c0 100644
--- a/server/src/repositories/monitors/IMonitorsRepository.ts
+++ b/server/src/repositories/monitors/IMonitorsRepository.ts
@@ -13,6 +13,7 @@ export interface TeamQueryConfig {
export interface SummaryConfig {
type?: MonitorType | MonitorType[];
+ tags?: string | string[];
}
export interface IMonitorsRepository {
diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts
index 24a3cb7d8..10bd57d56 100644
--- a/server/src/repositories/monitors/MongoMonitorsRepository.ts
+++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts
@@ -292,6 +292,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
if (config?.type !== undefined) {
match.type = Array.isArray(config.type) ? { $in: config.type } : config.type;
}
+ if (config?.tags !== undefined) {
+ const tagIds = (Array.isArray(config.tags) ? config.tags : [config.tags]).map((tag) => new mongoose.Types.ObjectId(tag));
+ match.tags = { $in: tagIds };
+ }
const pipeline = [
{ $match: match },
{
diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts
index fc08200c5..2023a1d7a 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -391,7 +391,7 @@ export class MonitorService implements IMonitorService {
field?: string;
order?: "asc" | "desc";
}): Promise => {
- const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type });
+ const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type, tags });
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter });
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
limit,