From aefeb756414a400725756ccf3dce55687b68eb2f Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 9 Jun 2026 10:22:48 +0200 Subject: [PATCH 1/2] warning overview page for sodistore home and pro --- csharp/App/Backend/Controller.cs | 117 ++- .../App/Backend/DataTypes/AlarmReportRow.cs | 19 + typescript/frontend-marios2/src/App.tsx | 2 + .../src/Resources/routes.json | 3 +- .../src/content/dashboards/Report/index.tsx | 719 ++++++++++++++++++ typescript/frontend-marios2/src/lang/de.json | 38 + typescript/frontend-marios2/src/lang/en.json | 38 + typescript/frontend-marios2/src/lang/fr.json | 38 + typescript/frontend-marios2/src/lang/it.json | 38 + .../Sidebar/SidebarMenu/index.tsx | 14 + 10 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/AlarmReportRow.cs create mode 100644 typescript/frontend-marios2/src/content/dashboards/Report/index.tsx diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 04a08e632..79f8e228b 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -154,8 +154,121 @@ public class Controller : ControllerBase .ThenByDescending(error => error.Time) .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> 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)ProductType.SodioHome, (int)ProductType.SodistorePro }; + var allowedDevices = new HashSet { 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(); + + var rows = new List(); + + 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 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? 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))] public ActionResult> GetCsvTimestampsForInstallation(Int64 id, Int32 start, Int32 end, Token authToken) diff --git a/csharp/App/Backend/DataTypes/AlarmReportRow.cs b/csharp/App/Backend/DataTypes/AlarmReportRow.cs new file mode 100644 index 000000000..0cfa064f8 --- /dev/null +++ b/csharp/App/Backend/DataTypes/AlarmReportRow.cs @@ -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; } +} diff --git a/typescript/frontend-marios2/src/App.tsx b/typescript/frontend-marios2/src/App.tsx index 2c78cc770..d85e55854 100644 --- a/typescript/frontend-marios2/src/App.tsx +++ b/typescript/frontend-marios2/src/App.tsx @@ -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() { /> } /> + } /> } /> = { + 0: 'Salimax', + 1: 'Salidomo', + 2: 'Sodistore Home', + 3: 'Sodistore Max', + 4: 'Sodistore Grid', + 5: 'Sodistore Pro' +}; +const DEVICE_LABELS: Record = { + 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([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + + const [severity, setSeverity] = useState('both'); + const [selectedProducts, setSelectedProducts] = useState([]); + const [selectedDevices, setSelectedDevices] = useState([]); + const [installationFilter, setInstallationFilter] = useState('all'); + const [rangePreset, setRangePreset] = useState('all'); + const [customFrom, setCustomFrom] = useState(''); + const [customTo, setCustomTo] = useState(''); + const [search, setSearch] = useState(''); + const [viewMode, setViewMode] = useState('grouped'); + const [expanded, setExpanded] = useState>(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) => { + 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(); + 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(); + 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; + }); + }; + + const severityIcon = (sev: 'error' | 'warning') => + sev === 'error' ? ( + + ) : ( + + ); + + 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 + ) => ( + + {label} + + + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {renderMultiSelect( + 'product-label', + , + selectedProducts, + availableProducts, + productLabel, + setSelectedProducts + )} + + + + {renderMultiSelect( + 'device-label', + , + selectedDevices, + availableDevices, + deviceLabel, + setSelectedDevices + )} + + + + + + + + + + + + + + + + + + + + + {rangePreset === 'custom' && ( + <> + + setCustomFrom(e.target.value)} + /> + + + setCustomTo(e.target.value)} + /> + + + )} + + + setSearch(e.target.value)} + /> + + + + + + + + + + + + + + {loading ? ( + + + + ) : loadError ? ( + + + + ) : ( + + + + {viewMode === 'grouped' ? ( + + ) : ( + + )} + + + + + + + {viewMode === 'grouped' && } + + + + + + + + + + + + + {viewMode === 'grouped' ? ( + <> + + + + + + + + + + + ) : ( + <> + + + + + + + + )} + + + + {viewMode === 'grouped' + ? groups.map((g) => ( + + + + toggleExpand(g.key)}> + {expanded.has(g.key) ? ( + + ) : ( + + )} + + + + {g.installationName} + + {productLabel(g.product)} + + + {deviceLabel(g.device)} + {alarmDisplayName(g.description)} + {severityIcon(g.severity)} + + + + {g.firstSeen} + {g.lastSeen} + + + + + +
+ + {[...g.events] + .sort((a, b) => + `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`) + ) + .map((ev) => ( + + {ev.deviceCreatedTheMessage} + {ev.date} + {ev.time} + + ))} + +
+ + + + + + )) + : rawSorted.map((r) => ( + + + {r.installationName} + + {productLabel(r.product)} + + + {deviceLabel(r.device)} + {alarmDisplayName(r.description)} + {severityIcon(r.severity)} + {r.date} + {r.time} + + ))} + {filteredRows.length === 0 && ( + + + + + + + + )} + + +
+
+ )} +
+ ); +} + +export default Report; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 9390e9273..b5f93aa47 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -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", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index d5831aee2..41edece41 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -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", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index d0fefd0c2..e871aa54b 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -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", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 8f191897b..f505c03d3 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -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", diff --git a/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx b/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx index 61304f5ad..12d904ef6 100644 --- a/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx +++ b/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx @@ -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() { + + + + + )} From bf0aa4d95962bfeca9e2471da7f2fd9a98c0c23d Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 9 Jun 2026 10:51:18 +0200 Subject: [PATCH 2/2] add link from alarm summary to installation itself --- .../src/content/dashboards/Report/index.tsx | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/typescript/frontend-marios2/src/content/dashboards/Report/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Report/index.tsx index 170debd52..7669defef 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Report/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Report/index.tsx @@ -270,6 +270,39 @@ function Report() { }); }; + // Deep-link to an installation's Log tab (mirrors the Ticket detail navigation). + const openInstallationLog = (product: number, installationId: number) => { + const productRoutes: Record = { + 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) => ( + + openInstallationLog(product, installationId)} + sx={{ + color: 'primary.main', + cursor: 'pointer', + '&:hover': { textDecoration: 'underline' } + }} + > + {name} + + + {productLabel(product)} + + + ); + const severityIcon = (sev: 'error' | 'warning') => sev === 'error' ? ( @@ -640,12 +673,7 @@ function Report() { )} - - {g.installationName} - - {productLabel(g.product)} - - + {installationCell(g.installationName, g.product, g.installationId)} {deviceLabel(g.device)} {alarmDisplayName(g.description)} {severityIcon(g.severity)} @@ -682,12 +710,7 @@ function Report() { )) : rawSorted.map((r) => ( - - {r.installationName} - - {productLabel(r.product)} - - + {installationCell(r.installationName, r.product, r.installationId)} {deviceLabel(r.device)} {alarmDisplayName(r.description)} {severityIcon(r.severity)}