mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 23:02:49 +03:00
Notices and status bar improvements (#24459)
* Notice and status bar improvements Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side. Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case. * move camera CPU warnings to notices High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice. * fix mypy and handle missing cpu stats in notices
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
type StatusMessageItemProps = {
|
||||
message: StatusMessage;
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** One status bar message, a link when it has one. */
|
||||
export function StatusMessageItem({
|
||||
message,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
message.link && "cursor-pointer hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={cn("size-5 shrink-0", SEVERITY_COLOR[message.severity])}
|
||||
/>
|
||||
{message.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!message.link) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={message.link} onClick={onNavigate}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessageListProps = {
|
||||
messages: StatusMessage[];
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** Status bar messages stacked one per line. */
|
||||
export default function StatusMessageList({
|
||||
messages,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-testid="status-message-list"
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// ids are unique only within a message key
|
||||
<StatusMessageItem
|
||||
key={`${index}:${message.id}`}
|
||||
message={message}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList, {
|
||||
StatusMessageItem,
|
||||
} from "@/components/StatusMessageList";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -22,9 +26,7 @@ export default function Statusbar() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
@@ -38,21 +40,6 @@ export default function Statusbar() {
|
||||
return parseInt(systemCpu);
|
||||
}, [stats]);
|
||||
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const activeProfile = useMemo(() => {
|
||||
@@ -68,28 +55,6 @@ export default function Statusbar() {
|
||||
};
|
||||
}, [profilesData]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
@@ -187,42 +152,57 @@ export default function Statusbar() {
|
||||
))}
|
||||
</div>
|
||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||
{!isAdmin ? null : Object.entries(messages).length === 0 ? (
|
||||
{!isAdmin ? null : messages.length === 0 ? (
|
||||
<Link to="/system#health" className="flex items-center gap-2 text-sm">
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</Link>
|
||||
) : messages.length === 1 ? (
|
||||
<StatusMessageItem
|
||||
message={messages[0]}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
) : (
|
||||
Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex h-full items-center gap-2">
|
||||
{messageArray.map(({ text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div
|
||||
key={text}
|
||||
className={`flex items-center gap-2 whitespace-nowrap text-sm ${link ? "cursor-pointer hover:underline" : ""}`}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={text} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
<StatusMessagesPopover messages={messages} />
|
||||
)}
|
||||
{isAdmin && <StatusBarNotices />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessagesPopoverProps = {
|
||||
messages: StatusMessage[];
|
||||
};
|
||||
|
||||
/** The most severe message and a count of the rest, which open the full list. */
|
||||
function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [first, ...rest] = messages;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm hover:underline"
|
||||
>
|
||||
<StatusMessageItem
|
||||
message={{ ...first, link: undefined }}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<span className="shrink-0 rounded-full bg-secondary px-1.5 text-xs text-secondary-foreground">
|
||||
{t("stats.moreMessages", { count: rest.length })}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-auto max-w-md">
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import {
|
||||
LuBell,
|
||||
LuBellOff,
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuEye,
|
||||
LuInfo,
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
@@ -51,7 +55,13 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
problem.link ||
|
||||
problem.docLink ||
|
||||
problem.externalLink ||
|
||||
problem.onDismiss;
|
||||
problem.onAcknowledge ||
|
||||
problem.onMute ||
|
||||
problem.onUnhide;
|
||||
const unhideLabel =
|
||||
problem.hidden === "muted"
|
||||
? t("health.notices.unmute")
|
||||
: t("health.notices.showAgain");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -150,16 +160,46 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onDismiss && (
|
||||
<RowAction label={t("health.notices.dismiss")}>
|
||||
{problem.onAcknowledge && (
|
||||
<RowAction label={t("health.notices.acknowledgeHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.acknowledge")}
|
||||
onClick={problem.onAcknowledge}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
<LuCheck className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onMute && (
|
||||
<RowAction label={t("health.notices.muteHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.mute")}
|
||||
onClick={problem.onMute}
|
||||
>
|
||||
<LuBellOff className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onUnhide && (
|
||||
<RowAction label={unhideLabel}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={unhideLabel}
|
||||
onClick={problem.onUnhide}
|
||||
>
|
||||
{problem.hidden === "muted" ? (
|
||||
<LuBell className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function NoticeFilterButton({
|
||||
const { t } = useTranslation(["views/system", "components/filter"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const active =
|
||||
filter.showDismissed ||
|
||||
filter.showHidden ||
|
||||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
|
||||
|
||||
const severityLabels: Record<HealthSeverity, string> = {
|
||||
@@ -59,10 +59,10 @@ export default function NoticeFilterButton({
|
||||
const content = (
|
||||
<div className="space-y-3 p-4">
|
||||
<FilterSwitch
|
||||
label={t("health.notices.filter.showDismissed")}
|
||||
isChecked={filter.showDismissed}
|
||||
onCheckedChange={(showDismissed) =>
|
||||
onFilterChange({ ...filter, showDismissed })
|
||||
label={t("health.notices.filter.showHidden")}
|
||||
isChecked={filter.showHidden}
|
||||
onCheckedChange={(showHidden) =>
|
||||
onFilterChange({ ...filter, showHidden })
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -23,9 +23,9 @@ type NoticesPaneProps = {
|
||||
|
||||
export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
const { t } = useTranslation(["views/system", "views/settings", "common"]);
|
||||
const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
|
||||
const { problems, hidden, loading, unhideAll } = useHealthProblems(
|
||||
t,
|
||||
filter.showDismissed,
|
||||
filter.showHidden,
|
||||
);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
@@ -37,12 +37,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
[problems, filter.severities],
|
||||
);
|
||||
|
||||
const shownDismissed = useMemo(
|
||||
const shownHidden = useMemo(
|
||||
() =>
|
||||
dismissed?.filter((problem) =>
|
||||
filter.severities.includes(problem.severity),
|
||||
),
|
||||
[dismissed, filter.severities],
|
||||
hidden?.filter((problem) => filter.severities.includes(problem.severity)),
|
||||
[hidden, filter.severities],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -70,32 +68,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filter.showDismissed && (
|
||||
{filter.showHidden && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("health.notices.dismissedTitle")}
|
||||
{t("health.notices.hiddenTitle")}
|
||||
</div>
|
||||
{dismissed && dismissed.length > 0 && (
|
||||
{hidden && hidden.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{shownDismissed === undefined ? (
|
||||
{shownHidden === undefined ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : shownDismissed.length === 0 ? (
|
||||
) : shownHidden.length === 0 ? (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
{t("health.notices.noneDismissed")}
|
||||
{t("health.notices.noneHidden")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{shownDismissed.map((problem) => (
|
||||
{shownHidden.map((problem) => (
|
||||
<HealthProblemRow key={problem.id} problem={problem} />
|
||||
))}
|
||||
</div>
|
||||
@@ -107,10 +105,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("health.notices.clearDismissedTitle")}
|
||||
{t("health.notices.showAllTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("health.notices.clearDismissedDesc")}
|
||||
{t("health.notices.showAllDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -119,9 +117,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={clearDismissed}
|
||||
onClick={unhideAll}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useHealthProblems } from "@/hooks/use-health-problems";
|
||||
|
||||
/** The count of undismissed Notices rows, shown before the status bar's health text. */
|
||||
/** The count of shown Notices rows, shown before the status bar's health text. */
|
||||
export default function StatusBarNotices() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { problems, loading } = useHealthProblems(t);
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import NavItem from "./NavItem";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList from "../StatusMessageList";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Bottombar() {
|
||||
const navItems = useNavigation("secondary");
|
||||
@@ -98,64 +83,12 @@ type StatusAlertNavProps = {
|
||||
large?: boolean;
|
||||
};
|
||||
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const latestStats = useFrigateStats();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (latestStats) {
|
||||
return latestStats;
|
||||
}
|
||||
|
||||
return initialStats;
|
||||
}, [initialStats, latestStats]);
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// problems link to admin-only pages
|
||||
if (!isAdmin || !messages || Object.keys(messages).length === 0) {
|
||||
if (!isAdmin || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,6 +96,7 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
data-testid="status-alert-trigger"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2",
|
||||
large && "size-12",
|
||||
@@ -182,32 +116,10 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="scrollbar-container flex h-auto w-full flex-col items-center gap-2 overflow-y-auto overflow-x-hidden px-2 py-4">
|
||||
{Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex w-full items-center gap-2">
|
||||
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div key={id} className="flex items-center gap-2 text-xs">
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={id} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
className="scrollbar-container w-full overflow-y-auto overflow-x-hidden px-4 py-4"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
export type StatusMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
severity: ProblemSeverity;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
@@ -16,7 +17,7 @@ type StatusBarMessagesContextValue = {
|
||||
addMessage: (
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity?: ProblemSeverity,
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => string | undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessagesState,
|
||||
} from "@/context/statusbar-context";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
type StatusBarMessagesProviderProps = {
|
||||
children: ReactNode;
|
||||
@@ -19,21 +20,21 @@ export function StatusBarMessagesProvider({
|
||||
(
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity: ProblemSeverity = "error",
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => {
|
||||
if (!key || !message) return;
|
||||
|
||||
const id = messageId ?? Date.now().toString();
|
||||
const msgColor = color ?? "text-danger";
|
||||
// the text is the fallback id, so repeating a message replaces it
|
||||
const id = messageId ?? message;
|
||||
|
||||
setMessagesState((prevMessages) => {
|
||||
const existingMessages = prevMessages[key] || [];
|
||||
// Check if a message with the same ID already exists
|
||||
const messageIndex = existingMessages.findIndex((msg) => msg.id === id);
|
||||
|
||||
const newMessage = { id, text: message, color: msgColor, link };
|
||||
const newMessage = { id, text: message, severity, link };
|
||||
|
||||
// If the message exists, replace it, otherwise add the new message
|
||||
let updatedMessages;
|
||||
|
||||
@@ -5,25 +5,25 @@ import useSWR from "swr";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useHealthChecks } from "@/hooks/use-health-checks";
|
||||
import { useNotices } from "@/hooks/use-notices";
|
||||
import { hiddenAt, useNotices } from "@/hooks/use-notices";
|
||||
import { evaluateConfigHealth } from "@/utils/configHealth";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { sortHealthProblems } from "@/utils/healthSort";
|
||||
import { streamHealth } from "@/utils/streamHealth";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { DismissedCheck, Notice } from "@/types/notice";
|
||||
import type { MutedCheck, Notice } from "@/types/notice";
|
||||
|
||||
const EXTERNAL_LINK = /^https?:\/\//;
|
||||
|
||||
type HealthProblems = {
|
||||
/** undismissed rows, most severe first */
|
||||
/** shown rows, most severe first */
|
||||
problems: HealthProblem[];
|
||||
/** dismissed rows, most recently dismissed first; undefined until loaded */
|
||||
dismissed?: HealthProblem[];
|
||||
/** acknowledged and muted rows, most recently hidden first; undefined until loaded */
|
||||
hidden?: HealthProblem[];
|
||||
loading: boolean;
|
||||
/** delete every dismissed row, so each can show again */
|
||||
clearDismissed: () => Promise<void>;
|
||||
/** show every hidden row again */
|
||||
unhideAll: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ type HealthProblems = {
|
||||
*/
|
||||
export function useHealthProblems(
|
||||
t: TFunction,
|
||||
showDismissed = false,
|
||||
showHidden = false,
|
||||
): HealthProblems {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -43,30 +43,40 @@ export function useHealthProblems(
|
||||
const locale = useDateLocale();
|
||||
const {
|
||||
notices,
|
||||
dismissed: dismissedNotices,
|
||||
dismiss,
|
||||
mutateDismissed,
|
||||
} = useNotices(showDismissed);
|
||||
const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR<
|
||||
DismissedCheck[]
|
||||
>("notices/dismissed_checks");
|
||||
hidden: hiddenNotices,
|
||||
acknowledge,
|
||||
mute,
|
||||
unhide,
|
||||
mutateHidden,
|
||||
} = useNotices(showHidden);
|
||||
const { data: mutedChecks, mutate: mutateMutedChecks } = useSWR<MutedCheck[]>(
|
||||
"notices/muted_checks",
|
||||
);
|
||||
const {
|
||||
stream: { results },
|
||||
} = useHealthChecks();
|
||||
|
||||
const dismissCheck = useCallback(
|
||||
const muteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
mutateDismissedChecks();
|
||||
await axios.post(`notices/${id}/mute`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateDismissedChecks],
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const clearDismissed = useCallback(async () => {
|
||||
await axios.delete("notices/dismissed");
|
||||
mutateDismissed();
|
||||
mutateDismissedChecks();
|
||||
}, [mutateDismissed, mutateDismissedChecks]);
|
||||
const unmuteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.delete(`notices/${id}/hidden`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const unhideAll = useCallback(async () => {
|
||||
await axios.delete("notices/hidden");
|
||||
mutateHidden();
|
||||
mutateMutedChecks();
|
||||
}, [mutateHidden, mutateMutedChecks]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(timestamp: number) =>
|
||||
@@ -98,23 +108,37 @@ export function useHealthProblems(
|
||||
count: notice.count,
|
||||
}),
|
||||
meta:
|
||||
notice.dismissed_at === null
|
||||
? t("health.notices.firstSeen", {
|
||||
notice.muted_at !== null
|
||||
? t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
time: formatTime(notice.muted_at),
|
||||
})
|
||||
: t("health.notices.dismissedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.dismissed_at),
|
||||
}),
|
||||
: notice.acknowledged_at !== null
|
||||
? t("health.notices.acknowledgedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.acknowledged_at),
|
||||
})
|
||||
: t("health.notices.firstSeen", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
}),
|
||||
link: external ? undefined : link,
|
||||
externalLink: external ? link : undefined,
|
||||
onDismiss:
|
||||
notice.dismissed_at === null ? () => dismiss(notice.id) : undefined,
|
||||
...(hiddenAt(notice) > 0
|
||||
? {
|
||||
hidden: notice.muted_at !== null ? "muted" : "acknowledged",
|
||||
onUnhide: () => unhide(notice.id),
|
||||
}
|
||||
: {
|
||||
onAcknowledge: notice.acknowledgeable
|
||||
? () => acknowledge(notice.id)
|
||||
: undefined,
|
||||
onMute: () => mute(notice.id),
|
||||
}),
|
||||
};
|
||||
},
|
||||
[dismiss, formatTime, t],
|
||||
[acknowledge, mute, unhide, formatTime, t],
|
||||
);
|
||||
|
||||
const checks = useMemo<HealthProblem[]>(
|
||||
@@ -128,12 +152,10 @@ export function useHealthProblems(
|
||||
[config, results, t],
|
||||
);
|
||||
|
||||
const dismissedAt = useMemo(
|
||||
const mutedAt = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]),
|
||||
),
|
||||
[dismissedChecks],
|
||||
new Map((mutedChecks ?? []).map((check) => [check.id, check.muted_at])),
|
||||
[mutedChecks],
|
||||
);
|
||||
|
||||
const problems = useMemo(
|
||||
@@ -141,27 +163,27 @@ export function useHealthProblems(
|
||||
sortHealthProblems([
|
||||
...(notices ?? []).map(noticeRow),
|
||||
...checks
|
||||
.filter((check) => !dismissedAt.has(check.id))
|
||||
.filter((check) => !mutedAt.has(check.id))
|
||||
.map((check) => ({
|
||||
...check,
|
||||
onDismiss: () => dismissCheck(check.id),
|
||||
onMute: () => muteCheck(check.id),
|
||||
})),
|
||||
]),
|
||||
[notices, noticeRow, checks, dismissedAt, dismissCheck],
|
||||
[notices, noticeRow, checks, mutedAt, muteCheck],
|
||||
);
|
||||
|
||||
const dismissed = useMemo(() => {
|
||||
if (!showDismissed || dismissedNotices === undefined) {
|
||||
const hidden = useMemo(() => {
|
||||
if (!showHidden || hiddenNotices === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
...dismissedNotices.map((notice) => ({
|
||||
at: notice.dismissed_at ?? 0,
|
||||
...hiddenNotices.map((notice) => ({
|
||||
at: hiddenAt(notice),
|
||||
row: noticeRow(notice),
|
||||
})),
|
||||
...checks.flatMap((check) => {
|
||||
const at = dismissedAt.get(check.id);
|
||||
const at = mutedAt.get(check.id);
|
||||
|
||||
return at === undefined
|
||||
? []
|
||||
@@ -170,10 +192,12 @@ export function useHealthProblems(
|
||||
at,
|
||||
row: {
|
||||
...check,
|
||||
meta: t("health.notices.dismissedAt", {
|
||||
meta: t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(at),
|
||||
}),
|
||||
hidden: "muted" as const,
|
||||
onUnhide: () => unmuteCheck(check.id),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -182,17 +206,17 @@ export function useHealthProblems(
|
||||
|
||||
return rows.sort((a, b) => b.at - a.at).map(({ row }) => row);
|
||||
}, [
|
||||
showDismissed,
|
||||
dismissedNotices,
|
||||
showHidden,
|
||||
hiddenNotices,
|
||||
noticeRow,
|
||||
checks,
|
||||
dismissedAt,
|
||||
mutedAt,
|
||||
formatTime,
|
||||
unmuteCheck,
|
||||
t,
|
||||
]);
|
||||
|
||||
const loading =
|
||||
notices === undefined || dismissedChecks === undefined || !config;
|
||||
const loading = notices === undefined || mutedChecks === undefined || !config;
|
||||
|
||||
return { problems, dismissed, loading, clearDismissed };
|
||||
return { problems, hidden, loading, unhideAll };
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@ import useSWR from "swr";
|
||||
import { useWs } from "@/api/ws";
|
||||
import type { Notice } from "@/types/notice";
|
||||
|
||||
/** When a hidden notice was acknowledged or muted. */
|
||||
export function hiddenAt(notice: Notice): number {
|
||||
return notice.muted_at ?? notice.acknowledged_at ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active notices come from a REST snapshot, then from every `notices`
|
||||
* websocket payload. Dismissed notices are fetched only while the history is
|
||||
* shown, and again when the active list changes or the tab regains focus. A
|
||||
* purge in another tab leaves the active list unchanged, so only focus
|
||||
* catches it.
|
||||
* websocket payload. Hidden notices are fetched only while the hidden list is
|
||||
* shown, and again when the active list changes or the tab regains focus.
|
||||
*/
|
||||
export function useNotices(showDismissed: boolean) {
|
||||
export function useNotices(showHidden: boolean) {
|
||||
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: history, mutate: mutateHistory } = useSWR<Notice[]>(
|
||||
showDismissed ? ["notices", { include_dismissed: true }] : null,
|
||||
const { data: all, mutate: mutateHidden } = useSWR<Notice[]>(
|
||||
showHidden ? ["notices", { include_hidden: true }] : null,
|
||||
);
|
||||
const {
|
||||
value: { payload },
|
||||
@@ -30,31 +33,44 @@ export function useNotices(showDismissed: boolean) {
|
||||
[payload],
|
||||
);
|
||||
|
||||
// once a websocket frame has arrived it is the source of truth; a dismiss
|
||||
// still shows up because the registry publishes a new frame after it
|
||||
// once a websocket frame has arrived it is the source of truth; every
|
||||
// acknowledge, mute, and unhide publishes a new frame
|
||||
const notices = live ?? initial;
|
||||
|
||||
// refetch the history whenever the active list changes; SWR ignores the
|
||||
// call while the history is hidden
|
||||
// refetch the hidden list whenever the active list changes; SWR ignores the
|
||||
// call while the hidden list is not shown
|
||||
useEffect(() => {
|
||||
mutateHistory();
|
||||
}, [live, mutateHistory]);
|
||||
mutateHidden();
|
||||
}, [live, mutateHidden]);
|
||||
|
||||
const dismissed = useMemo(
|
||||
const hidden = useMemo(
|
||||
() =>
|
||||
history
|
||||
?.filter((notice) => notice.dismissed_at !== null)
|
||||
.sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)),
|
||||
[history],
|
||||
all
|
||||
?.filter((notice) => hiddenAt(notice) > 0)
|
||||
.sort((a, b) => hiddenAt(b) - hiddenAt(a)),
|
||||
[all],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
const act = useCallback(
|
||||
async (request: Promise<unknown>) => {
|
||||
await request;
|
||||
mutate();
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return { notices, dismissed, dismiss, mutateDismissed: mutateHistory };
|
||||
const acknowledge = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/acknowledge`)),
|
||||
[act],
|
||||
);
|
||||
const mute = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/mute`)),
|
||||
[act],
|
||||
);
|
||||
const unhide = useCallback(
|
||||
(id: string) => act(axios.delete(`notices/${id}/hidden`)),
|
||||
[act],
|
||||
);
|
||||
|
||||
return { notices, hidden, acknowledge, mute, unhide, mutateHidden };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
CameraDetectThreshold,
|
||||
CameraFfmpegThreshold,
|
||||
InferenceThreshold,
|
||||
} from "@/types/graph";
|
||||
import { InferenceThreshold } from "@/types/graph";
|
||||
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
@@ -15,20 +11,12 @@ import { useIsAdmin } from "./use-is-admin";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// the status bar has always rendered these exact classes; keep them byte for
|
||||
// byte so its output does not change
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
function problem(
|
||||
severity: ProblemSeverity,
|
||||
text: string,
|
||||
relevantLink?: string,
|
||||
): PotentialProblem {
|
||||
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
|
||||
return { text, severity, relevantLink };
|
||||
}
|
||||
|
||||
// matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py
|
||||
@@ -122,20 +110,13 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
});
|
||||
|
||||
// check camera cpu usages
|
||||
// check for skipped detections
|
||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
||||
);
|
||||
const detectAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
|
||||
);
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
if (
|
||||
@@ -153,32 +134,6 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
detectAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SEVERITY_ORDER: Record<ProblemSeverity, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the stats problems and reindex progress to the status bar, then
|
||||
* returns every status bar message, most severe first.
|
||||
*/
|
||||
export default function useStatusMessages(): StatusMessage[] {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.severity,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(messages)
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
}
|
||||
+10
-3
@@ -27,16 +27,23 @@ export type HealthProblem = {
|
||||
externalLink?: string;
|
||||
/** render with a spinner instead of the severity icon (stream check running) */
|
||||
pending?: boolean;
|
||||
onDismiss?: () => void;
|
||||
/** why a hidden row is hidden */
|
||||
hidden?: "acknowledged" | "muted";
|
||||
/** hide until the next occurrence; only for kinds that repeat */
|
||||
onAcknowledge?: () => void;
|
||||
/** hide for good */
|
||||
onMute?: () => void;
|
||||
/** show a hidden row again */
|
||||
onUnhide?: () => void;
|
||||
};
|
||||
|
||||
/** What the Health tab's filter shows. */
|
||||
export type NoticeFilter = {
|
||||
showDismissed: boolean;
|
||||
showHidden: boolean;
|
||||
severities: HealthSeverity[];
|
||||
};
|
||||
|
||||
export const DEFAULT_NOTICE_FILTER: NoticeFilter = {
|
||||
showDismissed: false,
|
||||
showHidden: false,
|
||||
severities: ["error", "warning", "info"],
|
||||
};
|
||||
|
||||
@@ -13,11 +13,16 @@ export type Notice = {
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
count: number;
|
||||
dismissed_at: number | null;
|
||||
/** whether the kind repeats, so acknowledging it can hide it until next time */
|
||||
acknowledgeable: boolean;
|
||||
/** hidden until the next occurrence */
|
||||
acknowledged_at: number | null;
|
||||
/** hidden for good */
|
||||
muted_at: number | null;
|
||||
};
|
||||
|
||||
/** a config or stream check row an admin dismissed */
|
||||
export type DismissedCheck = {
|
||||
/** a config or stream check row an admin muted */
|
||||
export type MutedCheck = {
|
||||
id: string;
|
||||
dismissed_at: number;
|
||||
muted_at: number;
|
||||
};
|
||||
|
||||
@@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info";
|
||||
export type PotentialProblem = {
|
||||
text: string;
|
||||
severity: ProblemSeverity;
|
||||
color: string;
|
||||
relevantLink?: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user