Merge branch 'feature/centralized-alarm-report' into main

This commit is contained in:
Yinyin Liu 2026-06-10 10:52:03 +02:00
commit 879d848ed9
10 changed files with 1046 additions and 3 deletions

View File

@ -155,6 +155,119 @@ public class Controller : ControllerBase
.ToList();
}
// Admin-only centralized alarm log across all accessible installations.
// Server-side filtering (product/device/severity/date/installation); the frontend
// does the grouping (Installation+Device+Alarm+Severity) and sorting.
// NOTE: the underlying Error/Warning tables keep only the last ~100 rows per
// installation (see Db.HandleError/HandleWarning), so this report reflects
// currently-retained alarms, not a guaranteed full history.
[HttpGet(nameof(GetAlarmReport))]
public ActionResult<IEnumerable<AlarmReportRow>> GetAlarmReport(Token authToken,
String? products = null,
String? devices = null,
String severity = "both",
String? from = null,
String? to = null,
Int64? installationId = null)
{
var user = Db.GetSession(authToken)?.User;
if (user is null || user.UserType != 2) // admins only
return Unauthorized();
var productSet = ParseIntCsv(products);
var deviceSet = ParseIntCsv(devices);
// This is a battery alarm report for the Sodistore series only:
// products Sodistore Home + Pro, devices Growatt (3) + Sinexcel (4).
var allowedProducts = new HashSet<int> { (int)ProductType.SodioHome, (int)ProductType.SodistorePro };
var allowedDevices = new HashSet<int> { 3, 4 };
var installations = user
.AccessibleInstallations()
.Where(i => allowedProducts.Contains(i.Product))
.Where(i => allowedDevices.Contains(i.Device))
.Where(i => productSet is null || productSet.Contains(i.Product))
.Where(i => deviceSet is null || deviceSet.Contains(i.Device))
.Where(i => installationId is null || i.Id == installationId)
.ToList();
// GroupBy (not a plain ToDictionary): AccessibleInstallations() concats direct +
// folder-derived installations and .Distinct()s by reference, so the same Id can
// appear as two instances. GroupBy collapses them and avoids a duplicate-key throw.
var byId = installations
.GroupBy(i => i.Id)
.ToDictionary(g => g.Key, g => g.First());
var ids = byId.Keys.ToHashSet();
if (ids.Count == 0)
return new List<AlarmReportRow>();
var rows = new List<AlarmReportRow>();
var wantErrors = severity is "both" or "error";
var wantWarnings = severity is "both" or "warning";
// Query per accessible installation so sqlite-net pushes "InstallationId == x"
// down to SQL (HashSet.Contains can't be translated and would scan the whole table).
foreach (var instId in ids)
{
if (wantErrors)
rows.AddRange(Db.Errors
.Where(e => e.InstallationId == instId)
.AsEnumerable()
.Select(e => ToReportRow(e, "error", byId)));
if (wantWarnings)
rows.AddRange(Db.Warnings
.Where(w => w.InstallationId == instId)
.AsEnumerable()
.Select(w => ToReportRow(w, "warning", byId)));
}
// Date stored as "yyyy-MM-dd": lexical compare == chronological, no parsing needed.
if (!String.IsNullOrWhiteSpace(from))
rows = rows.Where(r => String.Compare(r.Date, from, StringComparison.Ordinal) >= 0).ToList();
if (!String.IsNullOrWhiteSpace(to))
rows = rows.Where(r => String.Compare(r.Date, to, StringComparison.Ordinal) <= 0).ToList();
return rows
.OrderByDescending(r => r.Date)
.ThenByDescending(r => r.Time)
.ToList();
}
private static AlarmReportRow ToReportRow(LogEntry entry, String severity, IReadOnlyDictionary<Int64, Installation> byId)
{
var installation = byId.TryGetValue(entry.InstallationId, out var inst) ? inst : null;
return new AlarmReportRow
{
Id = entry.Id,
InstallationId = entry.InstallationId,
InstallationName = installation?.Name ?? "",
Product = installation?.Product ?? -1,
Device = installation?.Device ?? -1,
Severity = severity,
Description = entry.Description,
Date = entry.Date,
Time = entry.Time,
DeviceCreatedTheMessage = entry.DeviceCreatedTheMessage,
Seen = entry.Seen,
};
}
private static HashSet<int>? ParseIntCsv(String? csv)
{
if (String.IsNullOrWhiteSpace(csv))
return null;
return csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? (int?)v : null)
.Where(v => v.HasValue)
.Select(v => v!.Value)
.ToHashSet();
}
[HttpGet(nameof(GetCsvTimestampsForInstallation))]

View File

@ -0,0 +1,19 @@
namespace InnovEnergy.App.Backend.DataTypes;
// Read-only DTO returned by GetAlarmReport. Not a database table.
// One row per raw alarm/warning occurrence, enriched with installation metadata
// so the frontend can group/filter across installations without extra round-trips.
public class AlarmReportRow
{
public Int64 Id { get; set; }
public Int64 InstallationId { get; set; }
public String InstallationName { get; set; } = "";
public int Product { get; set; }
public int Device { get; set; }
public String Severity { get; set; } = ""; // "error" | "warning"
public String Description { get; set; } = ""; // camelCase alarm code, e.g. "Battery2Undervoltage"
public String Date { get; set; } = ""; // "yyyy-MM-dd" (Swiss local time, as stamped by the device)
public String Time { get; set; } = ""; // "HH:mm:ss" (Swiss local time)
public String DeviceCreatedTheMessage { get; set; } = ""; // e.g. "Inverter 1"
public Boolean Seen { get; set; }
}

View File

@ -119,6 +119,7 @@ function App() {
const Login = Loader(lazy(() => import('src/components/login')));
const Users = Loader(lazy(() => import('src/content/dashboards/Users')));
const Report = Loader(lazy(() => import('src/content/dashboards/Report')));
useEffect(() => {
if (!username || token) return;
@ -273,6 +274,7 @@ function App() {
/>
<Route path={routes.tickets + '*'} element={<Tickets />} />
<Route path={routes.alarmReport + '*'} element={<Report />} />
<Route path={routes.users + '*'} element={<Users />} />
<Route
path={'*'}

View File

@ -28,5 +28,6 @@
"documents": "documents",
"checklist": "checklist",
"onSiteChecklist": "onsiteChecklist",
"tickets": "/tickets/"
"tickets": "/tickets/",
"alarmReport": "/alarm_report/"
}

View File

@ -0,0 +1,742 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
Box,
Card,
Chip,
CircularProgress,
Collapse,
Container,
FormControl,
Grid,
IconButton,
InputLabel,
ListItemText,
MenuItem,
OutlinedInput,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography
} from '@mui/material';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import ErrorIcon from '@mui/icons-material/Error';
import WarningIcon from '@mui/icons-material/Warning';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import DownloadIcon from '@mui/icons-material/Download';
import RefreshIcon from '@mui/icons-material/Refresh';
import { FormattedMessage, useIntl } from 'react-intl';
import { AxiosError, AxiosResponse } from 'axios';
import axiosConfig from '../../../Resources/axiosConfig';
import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
import { TokenContext } from '../../../contexts/tokenContext';
interface AlarmReportRow {
id: number;
installationId: number;
installationName: string;
product: number;
device: number;
severity: 'error' | 'warning';
description: string;
date: string; // yyyy-MM-dd
time: string; // HH:mm:ss
deviceCreatedTheMessage: string;
seen: boolean;
}
interface AlarmGroup {
key: string;
installationId: number;
installationName: string;
product: number;
device: number;
severity: 'error' | 'warning';
description: string;
count: number;
firstSeen: string; // "yyyy-MM-dd HH:mm:ss"
lastSeen: string;
events: AlarmReportRow[];
}
// Product / device names are brand names — not translated (see i18n convention).
const PRODUCT_LABELS: Record<number, string> = {
0: 'Salimax',
1: 'Salidomo',
2: 'Sodistore Home',
3: 'Sodistore Max',
4: 'Sodistore Grid',
5: 'Sodistore Pro'
};
const DEVICE_LABELS: Record<number, string> = {
1: 'Cerbo',
2: 'Venus',
3: 'Growatt',
4: 'Sinexcel'
};
const productLabel = (p: number) => PRODUCT_LABELS[p] ?? `Product ${p}`;
const deviceLabel = (d: number) => DEVICE_LABELS[d] ?? `Device ${d}`;
type RangePreset = 'all' | 'today' | 'last7' | 'thisMonth' | 'lastMonth' | 'custom';
type SeverityFilter = 'both' | 'error' | 'warning';
type ViewMode = 'grouped' | 'raw';
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const toIsoDate = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
/**
* Resolve a preset into an inclusive [from, to] yyyy-MM-dd window, or null for "all".
* NOTE: uses the viewer's local timezone. Alarm dates are stamped in Swiss time; an
* admin viewing from a far-off timezone near midnight could see a day-boundary skew.
* Acceptable for v1 (almost all admins are in CH); revisit with Europe/Zurich math if needed.
*/
const resolveRange = (
preset: RangePreset,
customFrom: string,
customTo: string
): { from: string | null; to: string | null } => {
const now = new Date();
const today = toIsoDate(now);
switch (preset) {
case 'today':
return { from: today, to: today };
case 'last7': {
const d = new Date(now);
d.setDate(d.getDate() - 6);
return { from: toIsoDate(d), to: today };
}
case 'thisMonth': {
const first = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: toIsoDate(first), to: today };
}
case 'lastMonth': {
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const last = new Date(now.getFullYear(), now.getMonth(), 0);
return { from: toIsoDate(first), to: toIsoDate(last) };
}
case 'custom':
return { from: customFrom || null, to: customTo || null };
case 'all':
default:
return { from: null, to: null };
}
};
function Report() {
const intl = useIntl();
const navigate = useNavigate();
const { removeToken } = useContext(TokenContext);
const [rows, setRows] = useState<AlarmReportRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [severity, setSeverity] = useState<SeverityFilter>('both');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDevices, setSelectedDevices] = useState<number[]>([]);
const [installationFilter, setInstallationFilter] = useState<number | 'all'>('all');
const [rangePreset, setRangePreset] = useState<RangePreset>('all');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
/** "AbnormalGridVoltage" → "Abnormal Grid Voltage", then i18n lookup. */
const splitCamelCase = (s: string) =>
s.replace(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' ').trim();
const alarmDisplayName = (description: string) =>
intl.formatMessage({
id: `alarm_${description}`,
defaultMessage: splitCamelCase(description)
});
const loadReport = () => {
setLoading(true);
setLoadError(false);
axiosConfig
.get('/GetAlarmReport?severity=both')
.then((res: AxiosResponse<AlarmReportRow[]>) => {
setRows(res.data);
setLoading(false);
})
.catch((err: AxiosError) => {
if (err.response && err.response.status === 401) {
removeToken();
navigate(routes.login);
return;
}
setLoadError(true);
setLoading(false);
});
};
useEffect(() => {
loadReport();
}, []);
// Filter option lists derived from the data itself.
const availableProducts = useMemo(
() => Array.from(new Set(rows.map((r) => r.product))).sort((a, b) => a - b),
[rows]
);
const availableDevices = useMemo(
() => Array.from(new Set(rows.map((r) => r.device))).sort((a, b) => a - b),
[rows]
);
const availableInstallations = useMemo(() => {
const map = new Map<number, string>();
rows.forEach((r) => map.set(r.installationId, r.installationName));
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [rows]);
const range = resolveRange(rangePreset, customFrom, customTo);
const filteredRows = useMemo(() => {
const term = search.trim().toLowerCase();
return rows.filter((r) => {
if (severity !== 'both' && r.severity !== severity) return false;
if (selectedProducts.length && !selectedProducts.includes(r.product)) return false;
if (selectedDevices.length && !selectedDevices.includes(r.device)) return false;
if (installationFilter !== 'all' && r.installationId !== installationFilter) return false;
if (range.from && r.date < range.from) return false;
if (range.to && r.date > range.to) return false;
if (term) {
const hay = `${r.installationName} ${alarmDisplayName(r.description)} ${r.deviceCreatedTheMessage}`.toLowerCase();
if (!hay.includes(term)) return false;
}
return true;
});
}, [rows, severity, selectedProducts, selectedDevices, installationFilter, range.from, range.to, search]);
const groups = useMemo(() => {
const map = new Map<string, AlarmGroup>();
filteredRows.forEach((r) => {
const key = `${r.installationId}|${r.device}|${r.description}|${r.severity}`;
const stamp = `${r.date} ${r.time}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
key,
installationId: r.installationId,
installationName: r.installationName,
product: r.product,
device: r.device,
severity: r.severity,
description: r.description,
count: 1,
firstSeen: stamp,
lastSeen: stamp,
events: [r]
});
} else {
existing.count += 1;
existing.events.push(r);
if (stamp < existing.firstSeen) existing.firstSeen = stamp;
if (stamp > existing.lastSeen) existing.lastSeen = stamp;
}
});
return Array.from(map.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
}, [filteredRows]);
const rawSorted = useMemo(
() =>
[...filteredRows].sort((a, b) =>
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
),
[filteredRows]
);
const toggleExpand = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// Deep-link to an installation's Log tab (mirrors the Ticket detail navigation).
const openInstallationLog = (product: number, installationId: number) => {
const productRoutes: Record<number, string> = {
0: routes.installations,
1: routes.salidomo_installations,
2: routes.sodiohome_installations,
3: routes.sodistore_installations,
4: routes.sodistoregrid_installations,
5: routes.sodistorepro_installations
};
const prefix = productRoutes[product] ?? routes.installations;
navigate(prefix + routes.list + routes.installation + installationId + '/' + routes.log);
};
const installationCell = (name: string, product: number, installationId: number) => (
<TableCell>
<Typography
variant="body2"
onClick={() => openInstallationLog(product, installationId)}
sx={{
color: 'primary.main',
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' }
}}
>
{name}
</Typography>
<Typography variant="caption" color="text.secondary">
{productLabel(product)}
</Typography>
</TableCell>
);
const severityIcon = (sev: 'error' | 'warning') =>
sev === 'error' ? (
<ErrorIcon color="error" />
) : (
<WarningIcon color="warning" />
);
const exportCsv = () => {
const header = [
'Installation',
'Product',
'Device',
'Severity',
'Alarm',
'Count',
'First seen (CH)',
'Last seen (CH)'
];
const lines =
viewMode === 'grouped'
? groups.map((g) => [
g.installationName,
productLabel(g.product),
deviceLabel(g.device),
g.severity,
alarmDisplayName(g.description),
String(g.count),
g.firstSeen,
g.lastSeen
])
: rawSorted.map((r) => [
r.installationName,
productLabel(r.product),
deviceLabel(r.device),
r.severity,
alarmDisplayName(r.description),
'1',
`${r.date} ${r.time}`,
`${r.date} ${r.time}`
]);
// Neutralize CSV formula injection (installation names are user-editable):
// a leading =,+,-,@ would be executed as a formula by Excel/LibreOffice.
const sanitize = (v: string) => (/^[=+\-@]/.test(v) ? `'${v}` : v);
const escape = (v: string) => `"${sanitize(v).replace(/"/g, '""')}"`;
const csv = [header, ...lines].map((row) => row.map(escape).join(',')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alarm_report_${toIsoDate(new Date())}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const renderMultiSelect = (
labelId: string,
label: React.ReactNode,
values: number[],
options: number[],
optionLabel: (n: number) => string,
onChange: (next: number[]) => void
) => (
<FormControl size="small" fullWidth>
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
multiple
value={values}
input={<OutlinedInput label={label} />}
renderValue={(selected) =>
(selected as number[]).map(optionLabel).join(', ')
}
onChange={(e) => onChange(e.target.value as number[])}
>
{options.map((opt) => (
<MenuItem key={opt} value={opt}>
<Checkbox checked={values.includes(opt)} />
<ListItemText primary={optionLabel(opt)} />
</MenuItem>
))}
</Select>
</FormControl>
);
return (
<Container maxWidth="xl" sx={{ mt: 3, mb: 4 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1} flexWrap="wrap">
<Typography variant="h3">
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
</Typography>
<Box display="flex" gap={1}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadReport}
disabled={loading}
>
<FormattedMessage id="refresh" defaultMessage="Refresh" />
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={exportCsv}
disabled={filteredRows.length === 0}
>
<FormattedMessage id="exportCsv" defaultMessage="Export CSV" />
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary" mb={2}>
<FormattedMessage
id="alarmReportRetentionNote"
defaultMessage="Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation."
/>
</Typography>
<Card sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="severity-label">
<FormattedMessage id="filterSeverity" defaultMessage="Severity" />
</InputLabel>
<Select
labelId="severity-label"
value={severity}
label={intl.formatMessage({ id: 'filterSeverity', defaultMessage: 'Severity' })}
onChange={(e) => setSeverity(e.target.value as SeverityFilter)}
>
<MenuItem value="both">
<FormattedMessage id="severityBoth" defaultMessage="Errors & Warnings" />
</MenuItem>
<MenuItem value="error">
<FormattedMessage id="severityErrors" defaultMessage="Errors only" />
</MenuItem>
<MenuItem value="warning">
<FormattedMessage id="severityWarnings" defaultMessage="Warnings only" />
</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6} md={3}>
{renderMultiSelect(
'product-label',
<FormattedMessage id="filterProduct" defaultMessage="Product" />,
selectedProducts,
availableProducts,
productLabel,
setSelectedProducts
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
{renderMultiSelect(
'device-label',
<FormattedMessage id="filterDevice" defaultMessage="Device" />,
selectedDevices,
availableDevices,
deviceLabel,
setSelectedDevices
)}
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="installation-label">
<FormattedMessage id="filterInstallation" defaultMessage="Installation" />
</InputLabel>
<Select
labelId="installation-label"
value={installationFilter}
label={intl.formatMessage({ id: 'filterInstallation', defaultMessage: 'Installation' })}
onChange={(e) =>
setInstallationFilter(
e.target.value === 'all' ? 'all' : Number(e.target.value)
)
}
>
<MenuItem value="all">
<FormattedMessage id="filterAllInstallations" defaultMessage="All installations" />
</MenuItem>
{availableInstallations.map((i) => (
<MenuItem key={i.id} value={i.id}>
{i.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="range-label">
<FormattedMessage id="filterTimeRange" defaultMessage="Time range" />
</InputLabel>
<Select
labelId="range-label"
value={rangePreset}
label={intl.formatMessage({ id: 'filterTimeRange', defaultMessage: 'Time range' })}
onChange={(e) => setRangePreset(e.target.value as RangePreset)}
>
<MenuItem value="all">
<FormattedMessage id="rangeAll" defaultMessage="All retained" />
</MenuItem>
<MenuItem value="today">
<FormattedMessage id="rangeToday" defaultMessage="Today" />
</MenuItem>
<MenuItem value="last7">
<FormattedMessage id="rangeLast7" defaultMessage="Past 7 days" />
</MenuItem>
<MenuItem value="thisMonth">
<FormattedMessage id="rangeThisMonth" defaultMessage="This month" />
</MenuItem>
<MenuItem value="lastMonth">
<FormattedMessage id="rangeLastMonth" defaultMessage="Last month" />
</MenuItem>
<MenuItem value="custom">
<FormattedMessage id="rangeCustom" defaultMessage="Custom…" />
</MenuItem>
</Select>
</FormControl>
</Grid>
{rangePreset === 'custom' && (
<>
<Grid item xs={6} sm={3} md={2}>
<TextField
size="small"
fullWidth
type="date"
label={intl.formatMessage({ id: 'rangeFrom', defaultMessage: 'From' })}
InputLabelProps={{ shrink: true }}
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
</Grid>
<Grid item xs={6} sm={3} md={2}>
<TextField
size="small"
fullWidth
type="date"
label={intl.formatMessage({ id: 'rangeTo', defaultMessage: 'To' })}
InputLabelProps={{ shrink: true }}
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</Grid>
</>
)}
<Grid item xs={12} sm={6} md={3}>
<TextField
size="small"
fullWidth
label={intl.formatMessage({ id: 'filterSearch', defaultMessage: 'Search' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<FormControl size="small" fullWidth>
<InputLabel id="view-label">
<FormattedMessage id="filterView" defaultMessage="View" />
</InputLabel>
<Select
labelId="view-label"
value={viewMode}
label={intl.formatMessage({ id: 'filterView', defaultMessage: 'View' })}
onChange={(e) => setViewMode(e.target.value as ViewMode)}
>
<MenuItem value="grouped">
<FormattedMessage id="viewGrouped" defaultMessage="Grouped" />
</MenuItem>
<MenuItem value="raw">
<FormattedMessage id="viewRaw" defaultMessage="Raw events" />
</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Card>
{loading ? (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
) : loadError ? (
<Typography color="error">
<FormattedMessage
id="alarmReportLoadError"
defaultMessage="Failed to load the alarm report."
/>
</Typography>
) : (
<Card>
<Box px={2} py={1}>
<Typography variant="body2" color="text.secondary">
{viewMode === 'grouped' ? (
<FormattedMessage
id="alarmReportGroupCount"
defaultMessage="{groups} distinct alarms · {events} events"
values={{ groups: groups.length, events: filteredRows.length }}
/>
) : (
<FormattedMessage
id="alarmReportEventCount"
defaultMessage="{events} events"
values={{ events: filteredRows.length }}
/>
)}
</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
{viewMode === 'grouped' && <TableCell />}
<TableCell>
<FormattedMessage id="colInstallation" defaultMessage="Installation" />
</TableCell>
<TableCell>
<FormattedMessage id="colDevice" defaultMessage="Device" />
</TableCell>
<TableCell>
<FormattedMessage id="colAlarm" defaultMessage="Alarm" />
</TableCell>
<TableCell align="center">
<FormattedMessage id="colSeverity" defaultMessage="Type" />
</TableCell>
{viewMode === 'grouped' ? (
<>
<TableCell align="center">
<FormattedMessage id="colCount" defaultMessage="Count" />
</TableCell>
<TableCell>
<FormattedMessage id="colFirstSeen" defaultMessage="First seen (CH)" />
</TableCell>
<TableCell>
<FormattedMessage id="colLastSeen" defaultMessage="Last seen (CH)" />
</TableCell>
</>
) : (
<>
<TableCell>
<FormattedMessage id="colDate" defaultMessage="Date" />
</TableCell>
<TableCell>
<FormattedMessage id="colTime" defaultMessage="Time (CH)" />
</TableCell>
</>
)}
</TableRow>
</TableHead>
<TableBody>
{viewMode === 'grouped'
? groups.map((g) => (
<React.Fragment key={g.key}>
<TableRow hover>
<TableCell padding="checkbox">
<IconButton size="small" onClick={() => toggleExpand(g.key)}>
{expanded.has(g.key) ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton>
</TableCell>
{installationCell(g.installationName, g.product, g.installationId)}
<TableCell>{deviceLabel(g.device)}</TableCell>
<TableCell>{alarmDisplayName(g.description)}</TableCell>
<TableCell align="center">{severityIcon(g.severity)}</TableCell>
<TableCell align="center">
<Chip size="small" label={`${g.count}×`} />
</TableCell>
<TableCell>{g.firstSeen}</TableCell>
<TableCell>{g.lastSeen}</TableCell>
</TableRow>
<TableRow>
<TableCell sx={{ py: 0, borderBottom: 'none' }} colSpan={8}>
<Collapse in={expanded.has(g.key)} timeout="auto" unmountOnExit>
<Box my={1} ml={6}>
<Table size="small">
<TableBody>
{[...g.events]
.sort((a, b) =>
`${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
)
.map((ev) => (
<TableRow key={ev.id}>
<TableCell>{ev.deviceCreatedTheMessage}</TableCell>
<TableCell>{ev.date}</TableCell>
<TableCell>{ev.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
))
: rawSorted.map((r) => (
<TableRow hover key={r.id}>
{installationCell(r.installationName, r.product, r.installationId)}
<TableCell>{deviceLabel(r.device)}</TableCell>
<TableCell>{alarmDisplayName(r.description)}</TableCell>
<TableCell align="center">{severityIcon(r.severity)}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.time}</TableCell>
</TableRow>
))}
{filteredRows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 3 }}>
<Typography color="text.secondary">
<FormattedMessage
id="alarmReportNoResults"
defaultMessage="No alarms match the selected filters."
/>
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
</Container>
);
}
export default Report;

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Alarmbericht",
"exportCsv": "CSV exportieren",
"refresh": "Aktualisieren",
"alarmReportRetentionNote": "Zeiten in Schweizer Zeit (MEZ/MESZ). Es werden die aktuell gespeicherten Alarme angezeigt bis zu den letzten 100 Ereignissen pro Anlage.",
"filterSeverity": "Schweregrad",
"severityBoth": "Fehler & Warnungen",
"severityErrors": "Nur Fehler",
"severityWarnings": "Nur Warnungen",
"filterProduct": "Produkt",
"filterDevice": "Gerät",
"filterInstallation": "Anlage",
"filterAllInstallations": "Alle Anlagen",
"filterTimeRange": "Zeitraum",
"rangeAll": "Alle gespeicherten",
"rangeToday": "Heute",
"rangeLast7": "Letzte 7 Tage",
"rangeThisMonth": "Dieser Monat",
"rangeLastMonth": "Letzter Monat",
"rangeCustom": "Benutzerdefiniert…",
"rangeFrom": "Von",
"rangeTo": "Bis",
"filterSearch": "Suche",
"filterView": "Ansicht",
"viewGrouped": "Gruppiert",
"viewRaw": "Einzelereignisse",
"alarmReportLoadError": "Alarmbericht konnte nicht geladen werden.",
"alarmReportGroupCount": "{groups} verschiedene Alarme · {events} Ereignisse",
"alarmReportEventCount": "{events} Ereignisse",
"colInstallation": "Anlage",
"colDevice": "Gerät",
"colAlarm": "Alarm",
"colSeverity": "Typ",
"colCount": "Anzahl",
"colFirstSeen": "Zuerst gesehen (CH)",
"colLastSeen": "Zuletzt gesehen (CH)",
"colDate": "Datum",
"colTime": "Zeit (CH)",
"alarmReportNoResults": "Keine Alarme entsprechen den ausgewählten Filtern.",
"information": "Information",
"addNewChild": "Neues Kind hinzufügen",
"addNewDialogButton": "Neue Dialogschaltfläche hinzufügen",

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Alarm Report",
"exportCsv": "Export CSV",
"refresh": "Refresh",
"alarmReportRetentionNote": "Times shown in Swiss time (CET/CEST). Showing currently retained alarms — up to the last 100 events per installation.",
"filterSeverity": "Severity",
"severityBoth": "Errors & Warnings",
"severityErrors": "Errors only",
"severityWarnings": "Warnings only",
"filterProduct": "Product",
"filterDevice": "Device",
"filterInstallation": "Installation",
"filterAllInstallations": "All installations",
"filterTimeRange": "Time range",
"rangeAll": "All retained",
"rangeToday": "Today",
"rangeLast7": "Past 7 days",
"rangeThisMonth": "This month",
"rangeLastMonth": "Last month",
"rangeCustom": "Custom…",
"rangeFrom": "From",
"rangeTo": "To",
"filterSearch": "Search",
"filterView": "View",
"viewGrouped": "Grouped",
"viewRaw": "Raw events",
"alarmReportLoadError": "Failed to load the alarm report.",
"alarmReportGroupCount": "{groups} distinct alarms · {events} events",
"alarmReportEventCount": "{events} events",
"colInstallation": "Installation",
"colDevice": "Device",
"colAlarm": "Alarm",
"colSeverity": "Type",
"colCount": "Count",
"colFirstSeen": "First seen (CH)",
"colLastSeen": "Last seen (CH)",
"colDate": "Date",
"colTime": "Time (CH)",
"alarmReportNoResults": "No alarms match the selected filters.",
"allInstallations": "All installations",
"applyChanges": "Apply changes",
"country": "Country",

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Rapport d'alarmes",
"exportCsv": "Exporter CSV",
"refresh": "Actualiser",
"alarmReportRetentionNote": "Heures affichées en heure suisse (CET/CEST). Affiche les alarmes actuellement conservées — jusqu'aux 100 derniers événements par installation.",
"filterSeverity": "Gravité",
"severityBoth": "Erreurs et avertissements",
"severityErrors": "Erreurs uniquement",
"severityWarnings": "Avertissements uniquement",
"filterProduct": "Produit",
"filterDevice": "Appareil",
"filterInstallation": "Installation",
"filterAllInstallations": "Toutes les installations",
"filterTimeRange": "Période",
"rangeAll": "Tout le conservé",
"rangeToday": "Aujourd'hui",
"rangeLast7": "7 derniers jours",
"rangeThisMonth": "Ce mois-ci",
"rangeLastMonth": "Mois dernier",
"rangeCustom": "Personnalisé…",
"rangeFrom": "Du",
"rangeTo": "Au",
"filterSearch": "Recherche",
"filterView": "Affichage",
"viewGrouped": "Groupé",
"viewRaw": "Événements bruts",
"alarmReportLoadError": "Échec du chargement du rapport d'alarmes.",
"alarmReportGroupCount": "{groups} alarmes distinctes · {events} événements",
"alarmReportEventCount": "{events} événements",
"colInstallation": "Installation",
"colDevice": "Appareil",
"colAlarm": "Alarme",
"colSeverity": "Type",
"colCount": "Nombre",
"colFirstSeen": "Première occurrence (CH)",
"colLastSeen": "Dernière occurrence (CH)",
"colDate": "Date",
"colTime": "Heure (CH)",
"alarmReportNoResults": "Aucune alarme ne correspond aux filtres sélectionnés.",
"information": "Information",
"addUser": "Créer un utilisateur",
"alarms": "Alarmes",

View File

@ -1,4 +1,42 @@
{
"alarmReportTitle": "Rapporto allarmi",
"exportCsv": "Esporta CSV",
"refresh": "Aggiorna",
"alarmReportRetentionNote": "Orari mostrati in ora svizzera (CET/CEST). Vengono mostrati gli allarmi attualmente conservati — fino agli ultimi 100 eventi per installazione.",
"filterSeverity": "Gravità",
"severityBoth": "Errori e avvisi",
"severityErrors": "Solo errori",
"severityWarnings": "Solo avvisi",
"filterProduct": "Prodotto",
"filterDevice": "Dispositivo",
"filterInstallation": "Installazione",
"filterAllInstallations": "Tutte le installazioni",
"filterTimeRange": "Intervallo di tempo",
"rangeAll": "Tutti i conservati",
"rangeToday": "Oggi",
"rangeLast7": "Ultimi 7 giorni",
"rangeThisMonth": "Questo mese",
"rangeLastMonth": "Mese scorso",
"rangeCustom": "Personalizzato…",
"rangeFrom": "Da",
"rangeTo": "A",
"filterSearch": "Cerca",
"filterView": "Vista",
"viewGrouped": "Raggruppato",
"viewRaw": "Eventi singoli",
"alarmReportLoadError": "Impossibile caricare il rapporto allarmi.",
"alarmReportGroupCount": "{groups} allarmi distinti · {events} eventi",
"alarmReportEventCount": "{events} eventi",
"colInstallation": "Installazione",
"colDevice": "Dispositivo",
"colAlarm": "Allarme",
"colSeverity": "Tipo",
"colCount": "Conteggio",
"colFirstSeen": "Primo rilevamento (CH)",
"colLastSeen": "Ultimo rilevamento (CH)",
"colDate": "Data",
"colTime": "Ora (CH)",
"alarmReportNoResults": "Nessun allarme corrisponde ai filtri selezionati.",
"allInstallations": "Tutte le installazioni",
"applyChanges": "Applica modifiche",
"country": "Paese",

View File

@ -14,6 +14,7 @@ import { SidebarContext } from 'src/contexts/SidebarContext';
import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone';
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone';
import AssessmentTwoToneIcon from '@mui/icons-material/AssessmentTwoTone';
import { FormattedMessage } from 'react-intl';
import { UserContext } from '../../../../contexts/userContext';
import { UserType } from '../../../../interfaces/UserTypes';
@ -347,6 +348,19 @@ function SidebarMenu() {
</Button>
</ListItem>
</List>
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/alarm_report"
startIcon={<AssessmentTwoToneIcon />}
>
<FormattedMessage id="alarmReportTitle" defaultMessage="Alarm Report" />
</Button>
</ListItem>
</List>
</SubMenuWrapper>
</List>
)}