From 3e3623ea8c5349221cff631dd4eaf96a419373da Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 27 Oct 2026 14:31:16 -0600 Subject: [PATCH 1/2] Dashboard: warn about records that won't reach a radio An orange triangle on a stat card opens a list of the problem records: DMR channels with no talkgroup (the same test the programmers warn on at program time), zones/groups and scan lists not in any codeplug, and codeplugs with no radio profile. Clicking a channel opens it via a new ?open= link on the Channels page. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/dashboard.rs | 71 ++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/models.rs | 24 ++++ src/lib/api.ts | 2 + src/lib/types.ts | 12 ++ src/pages/Channels.tsx | 10 ++ src/pages/Dashboard.tsx | 178 +++++++++++++++++++++++++++- 7 files changed, 292 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/dashboard.rs b/src-tauri/src/commands/dashboard.rs index 629ed07..2a7e748 100644 --- a/src-tauri/src/commands/dashboard.rs +++ b/src-tauri/src/commands/dashboard.rs @@ -2,7 +2,7 @@ use tauri::State; use crate::db::AppState; use crate::error::MapErrString; -use crate::models::{CodeplugSummary, DashboardStats}; +use crate::models::{CodeplugSummary, DashboardStats, DashboardWarningItem, DashboardWarnings}; #[tauri::command] pub async fn get_dashboard_stats(state: State<'_, AppState>) -> Result { @@ -41,6 +41,75 @@ pub async fn get_dashboard_stats(state: State<'_, AppState>) -> Result, +) -> Result { + let items = |sql: &'static str| { + sqlx::query_as::<_, DashboardWarningItem>(sql).fetch_all(&state.pool) + }; + + // Matches `expand_for_export`: a DMR channel expands to nothing when it has + // neither curated talkgroups nor an inline one. + let dmr_channels_without_talkgroup = items( + r#" + SELECT c.id, + COALESCE(NULLIF(c.name_long, ''), NULLIF(c.name_short, ''), + NULLIF(c.rb_name, ''), NULLIF(c.callsign, ''), 'Unnamed') + || ' · ' || printf('%.4f', c.rx_freq) AS label + FROM channels c + WHERE c.mode = 'DMR' + AND c.dmr_talkgroup IS NULL + AND NOT EXISTS (SELECT 1 FROM repeater_talkgroups r WHERE r.channel_id = c.id) + ORDER BY label + "#, + ) + .await + .estr()?; + + let unused_channel_lists = items( + r#" + SELECT l.id, l.name AS label + FROM channel_lists l + WHERE NOT EXISTS (SELECT 1 FROM codeplug_channel_lists x WHERE x.channel_list_id = l.id) + ORDER BY l.name + "#, + ) + .await + .estr()?; + + let unused_scan_lists = items( + r#" + SELECT s.id, s.name AS label + FROM scan_lists s + WHERE NOT EXISTS (SELECT 1 FROM codeplug_scan_lists x WHERE x.scan_list_id = s.id) + AND NOT EXISTS (SELECT 1 FROM codeplug_channel_scan_lists y WHERE y.scan_list_id = s.id) + ORDER BY s.name + "#, + ) + .await + .estr()?; + + let codeplugs_without_profile = items( + r#" + SELECT cp.id, cp.name AS label + FROM codeplugs cp + WHERE cp.radio_profile_id IS NULL + OR NOT EXISTS (SELECT 1 FROM radio_profiles rp WHERE rp.id = cp.radio_profile_id) + ORDER BY cp.name + "#, + ) + .await + .estr()?; + + Ok(DashboardWarnings { + dmr_channels_without_talkgroup, + unused_channel_lists, + unused_scan_lists, + codeplugs_without_profile, + }) +} + #[tauri::command] pub async fn get_recent_codeplugs( state: State<'_, AppState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48e8ae0..bdef575 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ // dashboard commands::dashboard::get_dashboard_stats, + commands::dashboard::get_dashboard_warnings, commands::dashboard::get_recent_codeplugs, // channels commands::channels::list_channels, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 3fc9510..07860fb 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -402,6 +402,30 @@ pub struct DashboardStats { pub total_talkgroups: i64, } +/// One record a dashboard warning points at: enough to name it and open it. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DashboardWarningItem { + pub id: i64, + pub label: String, +} + +/// Problems worth seeing before program time, grouped by the stat card they +/// belong to. An empty list means that card shows no warning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardWarnings { + /// DMR channels that would program with no talkgroup — the same test the + /// direct programmers warn on (no `repeater_talkgroups` row and no inline + /// `dmr_talkgroup`). + pub dmr_channels_without_talkgroup: Vec, + /// Zones/groups not included in any codeplug. + pub unused_channel_lists: Vec, + /// Scan lists not included in any codeplug, nor assigned to any channel + /// within one. + pub unused_scan_lists: Vec, + /// Codeplugs with no radio profile, which cannot be programmed. + pub codeplugs_without_profile: Vec, +} + // ============================================================ // export // ============================================================ diff --git a/src/lib/api.ts b/src/lib/api.ts index a2419eb..9a09f1e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -17,6 +17,7 @@ import type { BackupTable, TalkgroupRefreshSummary, DashboardStats, + DashboardWarnings, GeoPoint, ExportPreview, ImportPreview, @@ -77,6 +78,7 @@ import type { export const api = { // ---- dashboard ---- getDashboardStats: () => invoke("get_dashboard_stats"), + getDashboardWarnings: () => invoke("get_dashboard_warnings"), getRecentCodeplugs: () => invoke("get_recent_codeplugs"), // ---- channels ---- diff --git a/src/lib/types.ts b/src/lib/types.ts index d28b1c8..7b58986 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -471,6 +471,18 @@ export interface DashboardStats { total_talkgroups: number; } +export interface DashboardWarningItem { + id: number; + label: string; +} + +export interface DashboardWarnings { + dmr_channels_without_talkgroup: DashboardWarningItem[]; + unused_channel_lists: DashboardWarningItem[]; + unused_scan_lists: DashboardWarningItem[]; + codeplugs_without_profile: DashboardWarningItem[]; +} + export interface ExportPreviewRow { channel_id: number; name: string; diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index fea9243..5f939ab 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -287,6 +287,16 @@ export function Channels() { } }, [searchParams, setSearchParams]); + // Open one channel when navigated from a dashboard warning (?open=). + useEffect(() => { + const id = Number(searchParams.get("open")); + if (!id || allChannels.length === 0) return; + const c = allChannels.find((ch) => ch.id === id); + if (c) openEdit(c); + searchParams.delete("open"); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams, allChannels]); + // Filter dropdown options — only values actually present in the data. const bands = useMemo( () => presentFacet(allChannels.map((c) => c.band), BANDS), diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7776825..9e23e3b 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,8 +1,24 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Radio, List, Package, ScanLine, Users, Upload, Plus, LayoutDashboard } from "lucide-react"; +import { + Radio, + List, + Package, + ScanLine, + Users, + Upload, + Plus, + LayoutDashboard, + TriangleAlert, +} from "lucide-react"; +import clsx from "clsx"; import { api } from "../lib/api"; -import type { CodeplugSummary, DashboardStats } from "../lib/types"; +import type { + CodeplugSummary, + DashboardStats, + DashboardWarningItem, + DashboardWarnings, +} from "../lib/types"; import { Button, Card, PageHeader, Spinner } from "../components/ui"; import { HandheldRadio } from "../components/icons/HandheldRadio"; @@ -15,10 +31,154 @@ const STAT_CARDS = [ { key: "total_radio_profiles", label: "Radio Profiles", icon: HandheldRadio }, ] as const; +type StatKey = (typeof STAT_CARDS)[number]["key"]; + +/// What a card's warning says and where each listed record opens. +interface CardWarning { + title: string; + detail: string; + items: DashboardWarningItem[]; + linkLabel: string; + page: string; + itemPath: (id: number) => string; +} + +function cardWarnings(w: DashboardWarnings): Partial> { + const out: Partial> = {}; + const n = (items: DashboardWarningItem[], one: string, many: string) => + `${items.length} ${items.length === 1 ? one : many}`; + if (w.dmr_channels_without_talkgroup.length > 0) { + out.total_channels = { + title: `${n(w.dmr_channels_without_talkgroup, "DMR channel has", "DMR channels have")} no talkgroup`, + detail: + "They will receive but cannot transmit a call. Open one to add its talkgroups under “Talkgroups on this repeater”.", + items: w.dmr_channels_without_talkgroup, + linkLabel: "Open Channels", + page: "/channels", + itemPath: (id) => `/channels?open=${id}`, + }; + } + if (w.unused_channel_lists.length > 0) { + out.total_channel_lists = { + title: `${n(w.unused_channel_lists, "zone/group is", "zones/groups are")} not in any codeplug`, + detail: "They will not reach a radio until a codeplug includes them.", + items: w.unused_channel_lists, + linkLabel: "Open Zones/Groups", + page: "/channel-lists", + itemPath: () => "/channel-lists", + }; + } + if (w.unused_scan_lists.length > 0) { + out.total_scan_lists = { + title: `${n(w.unused_scan_lists, "scan list is", "scan lists are")} not in any codeplug`, + detail: "They will not reach a radio until a codeplug includes them.", + items: w.unused_scan_lists, + linkLabel: "Open Scan Lists", + page: "/scan-lists", + itemPath: () => "/scan-lists", + }; + } + if (w.codeplugs_without_profile.length > 0) { + out.total_codeplugs = { + title: `${n(w.codeplugs_without_profile, "codeplug has", "codeplugs have")} no radio profile`, + detail: "A codeplug needs a radio profile before it can be exported or programmed.", + items: w.codeplugs_without_profile, + linkLabel: "Open Codeplugs", + page: "/codeplugs", + itemPath: (id) => `/codeplugs/${id}`, + }; + } + return out; +} + +/// The warning triangle on a stat card, and the popover it opens. +function CardWarningButton({ warning }: { warning: CardWarning }) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + // Open toward the roomier side: the triangle sits at a card's right edge, so + // a popover anchored there on the leftmost card would run off the page. + const [alignLeft, setAlignLeft] = useState(true); + const ref = useRef(null); + + const toggle = () => { + const r = ref.current?.getBoundingClientRect(); + if (r) setAlignLeft(r.left < window.innerWidth / 2); + setOpen((o) => !o); + }; + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + {open && ( +
+
+ +
+
+ {warning.title} +
+
+ {warning.detail} +
+
+
+
    + {warning.items.map((item) => ( +
  • + +
  • + ))} +
+
+ +
+
+ )} +
+ ); +} + export function Dashboard() { const navigate = useNavigate(); const [stats, setStats] = useState(null); const [recent, setRecent] = useState([]); + const [warnings, setWarnings] = useState>>({}); const [loading, setLoading] = useState(true); useEffect(() => { @@ -28,6 +188,11 @@ export function Dashboard() { setRecent(r); }) .finally(() => setLoading(false)); + // Warnings are advisory: a failure here must not blank the dashboard. + api + .getDashboardWarnings() + .then((w) => setWarnings(cardWarnings(w))) + .catch(() => setWarnings({})); }, []); return ( @@ -50,8 +215,11 @@ export function Dashboard() { -
- {stats ? stats[key] : 0} +
+
+ {stats ? stats[key] : 0} +
+ {warnings[key] && }
))} From 5fd86133ae81a762a9e3b85e37ec5c3ee651909a Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 27 Oct 2026 15:09:27 -0600 Subject: [PATCH 2/2] Dashboard: more pre-program warnings, from the drivers' own preview Each stat card's warning popover now holds several sections: - Channels: DMR channels in a codeplug with no talkgroup or no color code, Off Air channels in a codeplug, year-old RepeaterBook records, and (as a grey note) DMR channels no codeplug uses yet. - Zones/Groups and Scan Lists: empty ones, and scan lists naming channels their codeplug leaves out. - Codeplugs: no zones, changed since last programmed, and what programming would drop or change. Radios with a codeplug programmer answer through their own pure preview(), so the dashboard matches program time exactly (the MD-380 splits a long list where an AnyTone truncates it). "Changed since last programmed" compares a digest of the resolved payload, profile settings and memory group, stamped at program/export time (migration 0021). A digest rather than edit timestamps, because a restore re-inserts every row and an edit put back is no change. Co-Authored-By: Claude Opus 5.5 --- .../0021_codeplug_programmed_fingerprint.sql | 6 + src-tauri/src/commands/dashboard.rs | 263 +++++++++++++++--- src-tauri/src/commands/export.rs | 161 ++++++++++- src-tauri/src/commands/program.rs | 9 +- src-tauri/src/commands/rb_api.rs | 2 +- src-tauri/src/models.rs | 32 ++- src/lib/types.ts | 12 +- src/pages/Dashboard.tsx | 252 ++++++++++++----- 8 files changed, 618 insertions(+), 119 deletions(-) create mode 100644 src-tauri/migrations/0021_codeplug_programmed_fingerprint.sql diff --git a/src-tauri/migrations/0021_codeplug_programmed_fingerprint.sql b/src-tauri/migrations/0021_codeplug_programmed_fingerprint.sql new file mode 100644 index 0000000..a7d0ea6 --- /dev/null +++ b/src-tauri/migrations/0021_codeplug_programmed_fingerprint.sql @@ -0,0 +1,6 @@ +-- A digest of what a codeplug resolved to when it was last programmed or +-- exported, so the dashboard can tell when the radio has fallen behind the +-- database. A digest rather than change timestamps: a restore re-inserts every +-- row, and an edit that is put back is no change at all. NULL for a codeplug +-- not programmed since this column existed. +ALTER TABLE codeplugs ADD COLUMN programmed_fingerprint TEXT; diff --git a/src-tauri/src/commands/dashboard.rs b/src-tauri/src/commands/dashboard.rs index 2a7e748..0bb2aba 100644 --- a/src-tauri/src/commands/dashboard.rs +++ b/src-tauri/src/commands/dashboard.rs @@ -1,6 +1,8 @@ use tauri::State; +use crate::commands::{export, rb_api}; use crate::db::AppState; +use crate::radios::registry::driver_for_model; use crate::error::MapErrString; use crate::models::{CodeplugSummary, DashboardStats, DashboardWarningItem, DashboardWarnings}; @@ -41,72 +43,242 @@ pub async fn get_dashboard_stats(state: State<'_, AppState>) -> Result Result, String> { + sqlx::query_as::<_, DashboardWarningItem>(sql).fetch_all(pool).await.estr() +} + +async fn channel_items( + pool: &sqlx::SqlitePool, + filter: &str, +) -> Result, String> { + warning_items( + pool, + &format!("SELECT c.id, {CHANNEL_LABEL} AS label FROM channels c WHERE {filter} ORDER BY label"), + ) + .await +} + +/// Driver preview warnings that describe what the write will do rather than +/// something lost: a long MD-380 list split into numbered zones, the TM-D710's +/// one-memory-at-a-time write, AM channels routed to the air-band memory. +/// Every other preview warning drops, truncates, or substitutes something. +const INFORMATIONAL_PROGRAM_NOTES: &[&str] = &[ + "so it becomes", + "this write is not atomic", + "separate air-band memory", +]; + +/// What programming this codeplug would complain about, found without a radio. +/// +/// A radio with a codeplug programmer answers for itself: its `preview` is the +/// same planner `program` runs, so the dashboard says exactly what the write +/// would warn about, skip, or refuse — the MD-380 splits a long list into more +/// zones where an AnyTone truncates it, and only the driver knows which. Other +/// radios are checked against what the resolver decides: the memory count, and +/// a fixed-zone layout's refusals. +async fn codeplug_program_problems( + pool: &sqlx::SqlitePool, + codeplug_id: i64, +) -> Result, String> { + let resolved = export::resolve_codeplug_payload(pool, codeplug_id).await?; + let model = &resolved.model; + + if let Some(programmer) = + driver_for_model(model).and_then(|d| d.as_codeplug_programmer()) + { + // Skipped channels are left out: they are the per-model exclusions + // (band, mode) of a radio-agnostic library, by design, and the + // codeplug's own preview lists them. + return Ok(match programmer.preview(&resolved.payload()) { + Err(e) => vec![e], + Ok(p) => p + .warnings + .into_iter() + .filter(|w| !INFORMATIONAL_PROGRAM_NOTES.iter().any(|n| w.contains(n))) + .collect(), + }); + } + + let mut problems = Vec::new(); + if let Some(layout) = export::fixed_zone_layout(model) { + let zones = layout.zones; + let (_, zoned) = export::resolve_codeplug_zone_slots(pool, codeplug_id, layout).await?; + if !zoned.refused.is_empty() { + problems.push(format!( + "{} channels do not fit in the radio's {zones} zones", + zoned.refused.len() + )); + } + return Ok(problems); + } + let included = resolved + .channels + .iter() + .filter(|ec| export::exclusion_reason(&ec.channel, model).is_none()) + .count(); + if let Some(max) = model.memory_channels { + if included as i64 > max { + problems.push(format!("{included} channels; the radio holds {max}")); + } + } + Ok(problems) +} + #[tauri::command] pub async fn get_dashboard_warnings( state: State<'_, AppState>, ) -> Result { - let items = |sql: &'static str| { - sqlx::query_as::<_, DashboardWarningItem>(sql).fetch_all(&state.pool) - }; + dashboard_warnings(&state.pool).await +} - // Matches `expand_for_export`: a DMR channel expands to nothing when it has - // neither curated talkgroups nor an inline one. - let dmr_channels_without_talkgroup = items( - r#" - SELECT c.id, - COALESCE(NULLIF(c.name_long, ''), NULLIF(c.name_short, ''), - NULLIF(c.rb_name, ''), NULLIF(c.callsign, ''), 'Unnamed') - || ' · ' || printf('%.4f', c.rx_freq) AS label - FROM channels c - WHERE c.mode = 'DMR' - AND c.dmr_talkgroup IS NULL - AND NOT EXISTS (SELECT 1 FROM repeater_talkgroups r WHERE r.channel_id = c.id) - ORDER BY label - "#, +async fn dashboard_warnings(pool: &sqlx::SqlitePool) -> Result { + + let dmr_without_talkgroup_in_codeplug = + channel_items(pool, &format!("{DMR_WITHOUT_TALKGROUP} AND {IN_A_CODEPLUG}")).await?; + let dmr_without_talkgroup_unused = + channel_items(pool, &format!("{DMR_WITHOUT_TALKGROUP} AND NOT {IN_A_CODEPLUG}")).await?; + let dmr_without_color_code = channel_items( + pool, + &format!("c.mode = 'DMR' AND c.dmr_color_code IS NULL AND {IN_A_CODEPLUG}"), ) - .await - .estr()?; + .await?; + let off_air_in_codeplug = + channel_items(pool, &format!("c.operational_status = 'Off Air' AND {IN_A_CODEPLUG}")) + .await?; + let stale_repeaterbook = channel_items(pool, rb_api::STALE_WHERE).await?; - let unused_channel_lists = items( - r#" - SELECT l.id, l.name AS label - FROM channel_lists l - WHERE NOT EXISTS (SELECT 1 FROM codeplug_channel_lists x WHERE x.channel_list_id = l.id) - ORDER BY l.name - "#, + let unused_channel_lists = warning_items( + pool, + "SELECT l.id, l.name AS label FROM channel_lists l \ + WHERE NOT EXISTS (SELECT 1 FROM codeplug_channel_lists x WHERE x.channel_list_id = l.id) \ + ORDER BY l.name", ) - .await - .estr()?; + .await?; + let empty_channel_lists = warning_items( + pool, + "SELECT l.id, l.name AS label FROM channel_lists l \ + WHERE NOT EXISTS (SELECT 1 FROM channel_list_entries e WHERE e.channel_list_id = l.id) \ + ORDER BY l.name", + ) + .await?; - let unused_scan_lists = items( + let unused_scan_lists = warning_items( + pool, + "SELECT s.id, s.name AS label FROM scan_lists s \ + WHERE NOT EXISTS (SELECT 1 FROM codeplug_scan_lists x WHERE x.scan_list_id = s.id) \ + AND NOT EXISTS (SELECT 1 FROM codeplug_channel_scan_lists y WHERE y.scan_list_id = s.id) \ + ORDER BY s.name", + ) + .await?; + let empty_scan_lists = warning_items( + pool, + "SELECT s.id, s.name AS label FROM scan_lists s \ + WHERE NOT EXISTS (SELECT 1 FROM scan_list_entries e WHERE e.scan_list_id = s.id) \ + ORDER BY s.name", + ) + .await?; + // A scan list member the codeplug does not program has nothing to scan. + let scan_list_channels_outside_zones = warning_items( + pool, r#" - SELECT s.id, s.name AS label - FROM scan_lists s - WHERE NOT EXISTS (SELECT 1 FROM codeplug_scan_lists x WHERE x.scan_list_id = s.id) - AND NOT EXISTS (SELECT 1 FROM codeplug_channel_scan_lists y WHERE y.scan_list_id = s.id) - ORDER BY s.name + SELECT s.id, + s.name || ' in ' || cp.name || ': ' || COUNT(*) || ' channel' + || CASE COUNT(*) WHEN 1 THEN '' ELSE 's' END + || ' not in its zones' AS label + FROM codeplug_scan_lists x + JOIN scan_lists s ON s.id = x.scan_list_id + JOIN codeplugs cp ON cp.id = x.codeplug_id + JOIN scan_list_entries se ON se.scan_list_id = s.id + WHERE NOT EXISTS ( + SELECT 1 FROM codeplug_channel_lists ccl + JOIN channel_list_entries ce ON ce.channel_list_id = ccl.channel_list_id + WHERE ccl.codeplug_id = x.codeplug_id AND ce.channel_id = se.channel_id) + GROUP BY x.codeplug_id, s.id + ORDER BY label "#, ) - .await - .estr()?; + .await?; - let codeplugs_without_profile = items( - r#" - SELECT cp.id, cp.name AS label - FROM codeplugs cp - WHERE cp.radio_profile_id IS NULL - OR NOT EXISTS (SELECT 1 FROM radio_profiles rp WHERE rp.id = cp.radio_profile_id) - ORDER BY cp.name - "#, + let codeplugs_without_profile = warning_items( + pool, + "SELECT cp.id, cp.name AS label FROM codeplugs cp \ + WHERE cp.radio_profile_id IS NULL \ + OR NOT EXISTS (SELECT 1 FROM radio_profiles rp WHERE rp.id = cp.radio_profile_id) \ + ORDER BY cp.name", ) + .await?; + let codeplugs_without_zones = warning_items( + pool, + "SELECT cp.id, cp.name AS label FROM codeplugs cp \ + WHERE NOT EXISTS (SELECT 1 FROM codeplug_channel_lists x WHERE x.codeplug_id = cp.id) \ + ORDER BY cp.name", + ) + .await?; + + // The per-codeplug checks resolve each codeplug the way programming does. + // A codeplug that cannot resolve (no profile) is already warned about. + let resolvable: Vec<(i64, String, Option)> = sqlx::query_as( + "SELECT cp.id, cp.name, cp.programmed_fingerprint FROM codeplugs cp \ + JOIN radio_profiles rp ON rp.id = cp.radio_profile_id ORDER BY cp.name", + ) + .fetch_all(pool) .await .estr()?; + let mut codeplugs_changed_since_programmed = Vec::new(); + let mut codeplugs_program_problems = Vec::new(); + for (id, name, stamped) in resolvable { + if let Some(stamped) = stamped { + if export::codeplug_fingerprint(pool, id).await.ok().as_ref() != Some(&stamped) { + codeplugs_changed_since_programmed.push(DashboardWarningItem { + id, + label: name.clone(), + }); + } + } + if let Ok(problems) = codeplug_program_problems(pool, id).await { + for p in problems { + codeplugs_program_problems.push(DashboardWarningItem { + id, + label: format!("{name}: {p}"), + }); + } + } + } Ok(DashboardWarnings { - dmr_channels_without_talkgroup, + dmr_without_talkgroup_in_codeplug, + dmr_without_talkgroup_unused, + dmr_without_color_code, + off_air_in_codeplug, + stale_repeaterbook, unused_channel_lists, + empty_channel_lists, unused_scan_lists, + empty_scan_lists, + scan_list_channels_outside_zones, codeplugs_without_profile, + codeplugs_without_zones, + codeplugs_changed_since_programmed, + codeplugs_program_problems, }) } @@ -135,3 +307,4 @@ pub async fn get_recent_codeplugs( .await .estr() } + diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index bbf2e75..2c327c0 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -376,6 +376,108 @@ pub(crate) async fn resolve_codeplug_payload( }) } +/// Channel columns that never reach a radio and change without the operator +/// touching the channel — a RepeaterBook refresh, an edit stamp. Left out of +/// [`codeplug_fingerprint`] so they cannot mark a codeplug as changed. +const FINGERPRINT_SKIPPED_CHANNEL_FIELDS: &[&str] = &[ + "operational_status", + "operational_status_overridden", + "rb_dcs_code", + "rb_dcs_rx_code", + "rb_ctcss_uplink", + "rb_ctcss_downlink", + "rb_operational_status", + "rb_notes", + "ctcss_uplink_overridden", + "ctcss_downlink_overridden", + "notes_overridden", + "has_overrides", + "last_rb_update", + "last_user_edit", + "created_at", + "updated_at", +]; + +/// A digest of everything programming a codeplug hands a driver: the payload +/// from [`resolve_codeplug_payload`], the profile's radio settings, and the +/// memory group. Stamped when a codeplug is programmed or exported; the +/// dashboard recomputes it to say the radio is out of date. +pub(crate) async fn codeplug_fingerprint( + pool: &sqlx::SqlitePool, + codeplug_id: i64, +) -> Result { + use md5::{Digest, Md5}; + + let resolved = resolve_codeplug_payload(pool, codeplug_id).await?; + let settings: Option = sqlx::query_scalar( + "SELECT rp.non_channel_settings FROM codeplugs cp \ + JOIN radio_profiles rp ON rp.id = cp.radio_profile_id WHERE cp.id = ?1", + ) + .bind(codeplug_id) + .fetch_optional(pool) + .await + .estr()? + .flatten(); + let memory_group: Vec = sqlx::query_scalar( + "SELECT channel_id FROM codeplug_memory_group WHERE codeplug_id = ?1 ORDER BY channel_id", + ) + .bind(codeplug_id) + .fetch_all(pool) + .await + .estr()?; + + let channel = |c: &Channel| { + let mut v = serde_json::to_value(c).unwrap_or_default(); + if let Some(m) = v.as_object_mut() { + for k in FINGERPRINT_SKIPPED_CHANNEL_FIELDS { + m.remove(*k); + } + } + v + }; + let mut overrides: Vec<(i64, i64)> = resolved + .scan_list_overrides + .iter() + .map(|o| (o.channel_id, o.scan_list_id)) + .collect(); + overrides.sort_unstable(); + + let doc = serde_json::json!({ + "model": resolved.model.id, + "settings": settings, + "groups": resolved.groups.iter().map(|g| serde_json::json!([ + g.list_id, + g.list_name, + g.channels.iter().map(|c| c.id).collect::>(), + ])).collect::>(), + "channels": resolved.channels.iter().map(|ec| serde_json::json!([ + channel(&ec.channel), + ec.tg_label, + ec.timeslot, + ec.tg_number, + ec.tg_call_type, + ec.tg_inline, + ])).collect::>(), + "scan_lists": resolved.scan_lists.iter().map(|s| serde_json::json!([ + s.id, + s.name, + s.priority_channel_id, + s.priority_channel_2_id, + s.priority_select, + s.look_back_a, + s.look_back_b, + s.dropout_delay, + s.dwell_time, + s.revert_channel, + s.member_channel_ids, + ])).collect::>(), + "scan_list_overrides": overrides, + "memory_group": memory_group, + }); + let digest = Md5::digest(doc.to_string().as_bytes()); + Ok(digest.iter().map(|b| format!("{b:02x}")).collect()) +} + /// How a channel fits a radio: programmed, programmed but muted on transmit, or /// not programmed at all. #[derive(Debug, Clone, PartialEq, Eq)] @@ -1130,10 +1232,15 @@ pub async fn generate_codeplug( } }; + // Best-effort: a digest that fails to compute leaves the dashboard unable + // to say "changed since", never fails an export that already happened. + let fingerprint = codeplug_fingerprint(&state.pool, codeplug_id).await.ok(); sqlx::query( - "UPDATE codeplugs SET last_exported = CURRENT_TIMESTAMP, last_export_kind = 'file' WHERE id = ?1", + "UPDATE codeplugs SET last_exported = CURRENT_TIMESTAMP, last_export_kind = 'file', \ + programmed_fingerprint = ?2 WHERE id = ?1", ) .bind(codeplug_id) + .bind(fingerprint) .execute(&state.pool) .await .estr()?; @@ -2732,4 +2839,56 @@ mod tests { assert!(exclusion_reason(&fm_at(600.0), &model).is_some()); assert!(exclusion_reason(&fm_at(224.840), &model).is_some()); } + + /// The programmed-fingerprint is what the dashboard's "changed since last + /// programmed" compares against, so it must be stable when nothing changed, + /// move when a programmed field does, ignore a RepeaterBook refresh stamp, + /// and come back when an edit is put back. + #[tokio::test] + async fn codeplug_fingerprint_tracks_programmed_content_only() { + let dir = std::env::temp_dir().join(format!("cpm_fp_{}", std::process::id())); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + for sql in [ + "INSERT INTO channels (id, name_long, rx_freq, mode, source) \ + VALUES (1, 'K0FM Boulder', 146.94, 'FM', 'manual')", + "INSERT INTO channel_lists (id, name) VALUES (1, 'Local')", + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) VALUES (1, 1, 0)", + "INSERT INTO radio_profiles (id, display_name, radio_model_id) \ + SELECT 1, 'Test', id FROM radio_models ORDER BY id LIMIT 1", + "INSERT INTO codeplugs (id, name, radio_profile_id) VALUES (1, 'Truck', 1)", + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) VALUES (1, 1, 0)", + ] { + sqlx::query(sql).execute(&pool).await.unwrap(); + } + + let original = codeplug_fingerprint(&pool, 1).await.unwrap(); + assert_eq!(codeplug_fingerprint(&pool, 1).await.unwrap(), original); + + sqlx::query("UPDATE channels SET last_rb_update = '2026-01-01 00:00:00' WHERE id = 1") + .execute(&pool) + .await + .unwrap(); + assert_eq!(codeplug_fingerprint(&pool, 1).await.unwrap(), original); + + sqlx::query("UPDATE channels SET rx_freq = 146.97 WHERE id = 1") + .execute(&pool) + .await + .unwrap(); + assert_ne!(codeplug_fingerprint(&pool, 1).await.unwrap(), original); + + sqlx::query("UPDATE channels SET rx_freq = 146.94 WHERE id = 1") + .execute(&pool) + .await + .unwrap(); + assert_eq!(codeplug_fingerprint(&pool, 1).await.unwrap(), original); + + sqlx::query("UPDATE channel_lists SET name = 'Home' WHERE id = 1") + .execute(&pool) + .await + .unwrap(); + assert_ne!(codeplug_fingerprint(&pool, 1).await.unwrap(), original); + } } diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index 493c3bf..f11886e 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -1214,10 +1214,17 @@ pub async fn program_radio( // Every block ack'd, so record this as the codeplug's last program time (the // Codeplugs screen shows "Programmed "). Verification is best-effort // and deliberately does not gate the stamp. + // The digest lets the dashboard say when the radio has fallen behind; a + // failure to compute it must not fail a write that already succeeded. + let fingerprint = crate::commands::export::codeplug_fingerprint(&state.pool, codeplug_id) + .await + .ok(); sqlx::query( - "UPDATE codeplugs SET last_exported = CURRENT_TIMESTAMP, last_export_kind = 'radio' WHERE id = ?1", + "UPDATE codeplugs SET last_exported = CURRENT_TIMESTAMP, last_export_kind = 'radio', \ + programmed_fingerprint = ?2 WHERE id = ?1", ) .bind(codeplug_id) + .bind(fingerprint) .execute(&state.pool) .await .estr()?; diff --git a/src-tauri/src/commands/rb_api.rs b/src-tauri/src/commands/rb_api.rs index ce31cc9..18193f4 100644 --- a/src-tauri/src/commands/rb_api.rs +++ b/src-tauri/src/commands/rb_api.rs @@ -933,7 +933,7 @@ async fn library_count(pool: &SqlitePool) -> Result { } /// API records not refreshed within 12 months. -const STALE_WHERE: &str = "source = 'repeaterbook_api' \ +pub(crate) const STALE_WHERE: &str = "source = 'repeaterbook_api' \ AND (last_rb_update IS NULL OR last_rb_update < datetime('now', '-12 months'))"; // ============================================================ diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 07860fb..d848d4d 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -410,20 +410,42 @@ pub struct DashboardWarningItem { } /// Problems worth seeing before program time, grouped by the stat card they -/// belong to. An empty list means that card shows no warning. +/// belong to. An empty list means that check has nothing to say. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardWarnings { - /// DMR channels that would program with no talkgroup — the same test the - /// direct programmers warn on (no `repeater_talkgroups` row and no inline - /// `dmr_talkgroup`). - pub dmr_channels_without_talkgroup: Vec, + /// DMR channels a codeplug will program with no talkgroup — the same test + /// the direct programmers warn on at program time. + pub dmr_without_talkgroup_in_codeplug: Vec, + /// The same, for channels no codeplug includes yet. + pub dmr_without_talkgroup_unused: Vec, + /// DMR channels in a codeplug with no color code. + pub dmr_without_color_code: Vec, + /// Channels in a codeplug that are marked Off Air. + pub off_air_in_codeplug: Vec, + /// RepeaterBook records not refreshed within a year (#136). + pub stale_repeaterbook: Vec, /// Zones/groups not included in any codeplug. pub unused_channel_lists: Vec, + /// Zones/groups with no channels. + pub empty_channel_lists: Vec, /// Scan lists not included in any codeplug, nor assigned to any channel /// within one. pub unused_scan_lists: Vec, + /// Scan lists with no channels. + pub empty_scan_lists: Vec, + /// A codeplug's scan list naming channels that codeplug does not program; + /// one item per (codeplug, scan list). + pub scan_list_channels_outside_zones: Vec, /// Codeplugs with no radio profile, which cannot be programmed. pub codeplugs_without_profile: Vec, + /// Codeplugs with no zones/groups, so nothing to program. + pub codeplugs_without_zones: Vec, + /// Codeplugs whose content differs from what was last programmed or + /// exported. Only codeplugs stamped since migration 0021 can say. + pub codeplugs_changed_since_programmed: Vec, + /// What programming each codeplug would warn about, skip, or refuse — + /// one item per message, from the driver's own preview where it has one. + pub codeplugs_program_problems: Vec, } // ============================================================ diff --git a/src/lib/types.ts b/src/lib/types.ts index 7b58986..81ebb93 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -477,10 +477,20 @@ export interface DashboardWarningItem { } export interface DashboardWarnings { - dmr_channels_without_talkgroup: DashboardWarningItem[]; + dmr_without_talkgroup_in_codeplug: DashboardWarningItem[]; + dmr_without_talkgroup_unused: DashboardWarningItem[]; + dmr_without_color_code: DashboardWarningItem[]; + off_air_in_codeplug: DashboardWarningItem[]; + stale_repeaterbook: DashboardWarningItem[]; unused_channel_lists: DashboardWarningItem[]; + empty_channel_lists: DashboardWarningItem[]; unused_scan_lists: DashboardWarningItem[]; + empty_scan_lists: DashboardWarningItem[]; + scan_list_channels_outside_zones: DashboardWarningItem[]; codeplugs_without_profile: DashboardWarningItem[]; + codeplugs_without_zones: DashboardWarningItem[]; + codeplugs_changed_since_programmed: DashboardWarningItem[]; + codeplugs_program_problems: DashboardWarningItem[]; } export interface ExportPreviewRow { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 9e23e3b..129770f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,6 +10,7 @@ import { Plus, LayoutDashboard, TriangleAlert, + Info, } from "lucide-react"; import clsx from "clsx"; import { api } from "../lib/api"; @@ -33,65 +34,170 @@ const STAT_CARDS = [ type StatKey = (typeof STAT_CARDS)[number]["key"]; -/// What a card's warning says and where each listed record opens. -interface CardWarning { +/// One problem on a card: what it is and the records it applies to. +interface WarningSection { + /// "warn" will cost something at program time; "note" is worth knowing but + /// harmless today (library records no codeplug uses yet). + severity: "warn" | "note"; title: string; detail: string; items: DashboardWarningItem[]; + itemPath: (id: number) => string; +} + +/// Everything a card's triangle opens, and where its button goes. +interface CardWarning { + sections: WarningSection[]; linkLabel: string; page: string; - itemPath: (id: number) => string; } function cardWarnings(w: DashboardWarnings): Partial> { - const out: Partial> = {}; const n = (items: DashboardWarningItem[], one: string, many: string) => `${items.length} ${items.length === 1 ? one : many}`; - if (w.dmr_channels_without_talkgroup.length > 0) { - out.total_channels = { - title: `${n(w.dmr_channels_without_talkgroup, "DMR channel has", "DMR channels have")} no talkgroup`, - detail: - "They will receive but cannot transmit a call. Open one to add its talkgroups under “Talkgroups on this repeater”.", - items: w.dmr_channels_without_talkgroup, + const channel = (id: number) => `/channels?open=${id}`; + const codeplug = (id: number) => `/codeplugs/${id}`; + const lists = () => "/channel-lists"; + const scans = () => "/scan-lists"; + + const cards: Partial> = { + total_channels: { linkLabel: "Open Channels", page: "/channels", - itemPath: (id) => `/channels?open=${id}`, - }; - } - if (w.unused_channel_lists.length > 0) { - out.total_channel_lists = { - title: `${n(w.unused_channel_lists, "zone/group is", "zones/groups are")} not in any codeplug`, - detail: "They will not reach a radio until a codeplug includes them.", - items: w.unused_channel_lists, + sections: [ + { + severity: "warn", + title: `${n(w.dmr_without_talkgroup_in_codeplug, "DMR channel in a codeplug has", "DMR channels in a codeplug have")} no talkgroup`, + detail: + "They will receive but cannot transmit a call. Open one to add its talkgroups under “Talkgroups on this repeater”.", + items: w.dmr_without_talkgroup_in_codeplug, + itemPath: channel, + }, + { + severity: "warn", + title: `${n(w.dmr_without_color_code, "DMR channel in a codeplug has", "DMR channels in a codeplug have")} no color code`, + detail: "Without the repeater's color code the radio will not open squelch on it.", + items: w.dmr_without_color_code, + itemPath: channel, + }, + { + severity: "warn", + title: `${n(w.off_air_in_codeplug, "channel in a codeplug is", "channels in a codeplug are")} marked Off Air`, + detail: "These repeaters are listed as off the air, but a codeplug still programs them.", + items: w.off_air_in_codeplug, + itemPath: channel, + }, + { + severity: "note", + title: `${n(w.stale_repeaterbook, "RepeaterBook record is", "RepeaterBook records are")} over a year old`, + detail: "Refresh or delete them from the Channels page.", + items: w.stale_repeaterbook, + itemPath: channel, + }, + { + severity: "note", + title: `${n(w.dmr_without_talkgroup_unused, "other DMR channel has", "other DMR channels have")} no talkgroup`, + detail: "No codeplug includes these yet, so nothing is programmed wrong today.", + items: w.dmr_without_talkgroup_unused, + itemPath: channel, + }, + ], + }, + total_channel_lists: { linkLabel: "Open Zones/Groups", page: "/channel-lists", - itemPath: () => "/channel-lists", - }; - } - if (w.unused_scan_lists.length > 0) { - out.total_scan_lists = { - title: `${n(w.unused_scan_lists, "scan list is", "scan lists are")} not in any codeplug`, - detail: "They will not reach a radio until a codeplug includes them.", - items: w.unused_scan_lists, + sections: [ + { + severity: "warn", + title: `${n(w.empty_channel_lists, "zone/group is", "zones/groups are")} empty`, + detail: "A codeplug that includes one programs an empty zone.", + items: w.empty_channel_lists, + itemPath: lists, + }, + { + severity: "warn", + title: `${n(w.unused_channel_lists, "zone/group is", "zones/groups are")} not in any codeplug`, + detail: "They will not reach a radio until a codeplug includes them.", + items: w.unused_channel_lists, + itemPath: lists, + }, + ], + }, + total_scan_lists: { linkLabel: "Open Scan Lists", page: "/scan-lists", - itemPath: () => "/scan-lists", - }; - } - if (w.codeplugs_without_profile.length > 0) { - out.total_codeplugs = { - title: `${n(w.codeplugs_without_profile, "codeplug has", "codeplugs have")} no radio profile`, - detail: "A codeplug needs a radio profile before it can be exported or programmed.", - items: w.codeplugs_without_profile, + sections: [ + { + severity: "warn", + title: `${n(w.scan_list_channels_outside_zones, "scan list scans", "scan lists scan")} channels its codeplug leaves out`, + detail: "A scan list can only scan channels that are in one of the codeplug's zones/groups.", + items: w.scan_list_channels_outside_zones, + itemPath: scans, + }, + { + severity: "warn", + title: `${n(w.empty_scan_lists, "scan list is", "scan lists are")} empty`, + detail: "A scan list with no channels has nothing to scan.", + items: w.empty_scan_lists, + itemPath: scans, + }, + { + severity: "warn", + title: `${n(w.unused_scan_lists, "scan list is", "scan lists are")} not in any codeplug`, + detail: "They will not reach a radio until a codeplug includes them.", + items: w.unused_scan_lists, + itemPath: scans, + }, + ], + }, + total_codeplugs: { linkLabel: "Open Codeplugs", page: "/codeplugs", - itemPath: (id) => `/codeplugs/${id}`, - }; + sections: [ + { + severity: "warn", + title: `${n(w.codeplugs_without_profile, "codeplug has", "codeplugs have")} no radio profile`, + detail: "A codeplug needs a radio profile before it can be exported or programmed.", + items: w.codeplugs_without_profile, + itemPath: codeplug, + }, + { + severity: "warn", + title: `${n(w.codeplugs_without_zones, "codeplug has", "codeplugs have")} no zones/groups`, + detail: "With no zones/groups there are no channels to program.", + items: w.codeplugs_without_zones, + itemPath: codeplug, + }, + { + severity: "warn", + title: `Programming will drop or change ${n(w.codeplugs_program_problems, "thing", "things")}`, + detail: + "What the radio's own programmer reports for these codeplugs, before you connect it: anything it cannot hold, truncates, or substitutes.", + items: w.codeplugs_program_problems, + itemPath: codeplug, + }, + { + severity: "warn", + title: `${n(w.codeplugs_changed_since_programmed, "codeplug has", "codeplugs have")} changed since last programmed`, + detail: "The radio no longer matches. Program it again to bring it up to date.", + items: w.codeplugs_changed_since_programmed, + itemPath: codeplug, + }, + ], + }, + }; + + const out: Partial> = {}; + for (const [key, card] of Object.entries(cards) as [StatKey, CardWarning][]) { + const sections = card.sections.filter((sec) => sec.items.length > 0); + if (sections.length > 0) out[key] = { ...card, sections }; } return out; } -/// The warning triangle on a stat card, and the popover it opens. +/// The warning triangle on a stat card, and the popover it opens. A card with +/// only notes gets a quieter info icon, so the triangle keeps meaning "this +/// will go wrong at program time". function CardWarningButton({ warning }: { warning: CardWarning }) { const navigate = useNavigate(); const [open, setOpen] = useState(false); @@ -100,6 +206,9 @@ function CardWarningButton({ warning }: { warning: CardWarning }) { const [alignLeft, setAlignLeft] = useState(true); const ref = useRef(null); + const warns = warning.sections.some((sec) => sec.severity === "warn"); + const label = warning.sections.map((sec) => sec.title).join("; "); + const toggle = () => { const r = ref.current?.getBoundingClientRect(); if (r) setAlignLeft(r.left < window.innerWidth / 2); @@ -127,45 +236,58 @@ function CardWarningButton({ warning }: { warning: CardWarning }) { {open && (
-
- -
-
- {warning.title} -
-
- {warning.detail} + {warning.sections.map((sec) => ( +
+
+ {sec.severity === "warn" ? ( + + ) : ( + + )} +
+
+ {sec.title} +
+
+ {sec.detail} +
+
+
    + {sec.items.map((item, i) => ( +
  • + +
  • + ))} +
-
-
    - {warning.items.map((item) => ( -
  • - -
  • - ))} -
-
+ ))} +