From c5d414ccd3d094471716fe6857fd385c087d5a56 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 20 Jan 2026 12:53:07 +1100 Subject: [PATCH 1/3] Bug Fix - Horrible UI impact from bad batching code for asset to team matching (#277) --- src/pages/tasking/main.js | 57 ++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index e6f34dd..6f47234 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -688,13 +688,16 @@ function VM() { if (!teamJson || teamJson.Id == null) return null; let team = self.teamsById.get(teamJson.Id); - if (team) { + if (team) { //existing team //if the team is from tasking, ignore as dont want to overwrite with old data if (source === 'tasking') { return team; } + const prevCallsign = team.callsign?.(); team.updateFromJson(teamJson); - self._refreshTeamTrackableAssets(team); + if (team.callsign?.() !== prevCallsign) { //only remap assets if the callsign changes + self._refreshTeamTrackableAssets(team); + } return team; } @@ -972,14 +975,12 @@ function VM() { } let asset = self.assetsById.get(assetJson.properties.id); - if (asset) { + if (asset) { //existing asset - update values asset.updateFromJson(assetJson); - self._attachAssetToMatchingTeams(asset); return asset; - } else { + } else { //new asset - create, store, attach to teams asset = new Asset(assetJson); self.trackableAssets.push(asset); - self._attachAssetToMatchingTeams(asset); self.assetsById.set(asset.id(), asset); } return asset; @@ -1169,31 +1170,35 @@ function VM() { // recompute one team's asset list :contentReference[oaicite:2]{index=2} self._refreshTeamTrackableAssets = function (team) { + console.log("Refreshing trackable assets for team:", team?.callsign?.()); if (!team || typeof team.trackableAssets !== 'function') return; - const all = self.trackableAssets?.() || []; - const desired = _computeMatchedAssetsForTeam(team, all); // Set + // Defer execution to avoid blocking UI thread + setTimeout(() => { + const all = self.trackableAssets?.() || []; + const desired = _computeMatchedAssetsForTeam(team, all); // Set - // Remove no-longer-matching - (team.trackableAssets() || []).slice().forEach(a => { - if (!desired.has(a)) { - a?.matchingTeams?.remove?.(team); - team.trackableAssets.remove(a); - } - }); + // Remove no-longer-matching + (team.trackableAssets() || []).slice().forEach(a => { + if (!desired.has(a)) { + a?.matchingTeams?.remove?.(team); + team.trackableAssets.remove(a); + } + }); - // Add new matches - for (const a of desired) { - const has = (team.trackableAssets() || []).includes(a); - if (!has) { - team.trackableAssets.push(a); - a?.matchingTeams?.push?.(team); + // Add new matches + for (const a of desired) { + const has = (team.trackableAssets() || []).includes(a); + if (!has) { + team.trackableAssets.push(a); + a?.matchingTeams?.push?.(team); + } } - } + }, 0); }; - // when a single asset changes/arrives, patch all teams that match :contentReference[oaicite:3]{index=3} - self._attachAssetToMatchingTeams = function (_asset) { + // redo matching assets after an asset update + self._attachAssetsToMatchingTeams = function () { // For correctness (token consumption + exact-first), reconcile per-team against full asset set (self.teams?.() || []).forEach(team => self._refreshTeamTrackableAssets(team)); }; @@ -1644,6 +1649,10 @@ function VM() { // Remove from observable array and registry self.trackableAssets.remove(asset); self.assetsById.delete(id); + + //Update Asset/Team mappings only once after all changes + self._attachAssetsToMatchingTeams(); + }); myViewModel._markInitialFetchDone(); assetDataRefreshInterlock = false; From 7ee5e8b8ffd1441b3ab4e89feabfc34ded8a25c1 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 20 Jan 2026 14:29:44 +1100 Subject: [PATCH 2/3] font size Added incident images to LAD --- src/pages/tasking/main.js | 15 + .../viewmodels/IncidentImagesModalVM.js | 284 ++++++++++++++++++ src/shared/BeaconClient.js | 5 +- src/shared/BeaconClient/images.js | 40 +++ static/pages/tasking.html | 98 +++++- styles/pages/tasking.css | 139 ++++----- 6 files changed, 496 insertions(+), 85 deletions(-) create mode 100644 src/pages/tasking/viewmodels/IncidentImagesModalVM.js create mode 100644 src/shared/BeaconClient/images.js diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 6f47234..2a90a9a 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -24,6 +24,7 @@ import { CreateRadioLogModalVM } from "./viewmodels/RadioLogModalVM.js"; import { SendSMSModalVM } from "./viewmodels/SMSTeamModalVM.js"; import { JobStatusConfirmModalVM } from "./viewmodels/JobStatusConfirmModalVM.js"; import { TrackableAssetsModalVM } from "./viewmodels/TrackableAssetsModalVM.js"; +import IncidentImagesModalVM from "./viewmodels/IncidentImagesModalVM"; import { installAlerts } from './components/alerts.js'; import { LegendControl } from './components/legend.js'; @@ -198,6 +199,20 @@ function VM() { self.jobStatusConfirmVM = new JobStatusConfirmModalVM(self); + self.incidentImagesVM = new IncidentImagesModalVM({ + getToken, + apiHost, + userId: params.userId, + BeaconClient + }); + + self.openIncidentImages = function (job, e) { + if (e) { e.stopPropagation?.(); e.preventDefault?.(); } + if (!job || typeof job.id !== "function") return false; + self.incidentImagesVM.openForJob(job); + return false; + }; + self.attachJobStatusConfirmModal = function (job, newStatus) { const modalEl = document.getElementById("JobStatusConfirmModal"); const modal = new bootstrap.Modal(modalEl); diff --git a/src/pages/tasking/viewmodels/IncidentImagesModalVM.js b/src/pages/tasking/viewmodels/IncidentImagesModalVM.js new file mode 100644 index 0000000..284b67a --- /dev/null +++ b/src/pages/tasking/viewmodels/IncidentImagesModalVM.js @@ -0,0 +1,284 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +import ko from "knockout"; +import * as bootstrap from 'bootstrap5'; // gives you Modal, Tooltip, etc. + + +export default function IncidentImagesModalVM({ getToken, apiHost, userId, BeaconClient }) { + const vm = this; + + vm.modalInstance = null; + + vm.job = ko.observable(null); + vm.title = ko.pureComputed(() => { + const j = vm.job(); + if (!j) return "Incident images"; + const ident = (typeof j.identifier === "function") ? j.identifier() : ""; + const id = (typeof j.id === "function") ? j.id() : ""; + return `Incident ${ident || id} images`; + }); + + vm.images = ko.observableArray([]); // ImageVM[] + vm.selectedImage = ko.observable(null); + + vm.loadingList = ko.observable(false); + vm.loadingFull = ko.observable(false); + vm.errorText = ko.observable(""); + + vm.errorText = ko.observable(""); + vm.hasError = ko.pureComputed(() => !!vm.errorText()); + + vm.showNoImages = ko.pureComputed(() => { + return !vm.loadingList() && vm.images().length === 0 && !vm.hasError(); + }); + + vm.selectedFullSrc = ko.observable(""); // string + vm.selectedName = ko.observable(""); // string + + vm.selectedFullReady = ko.pureComputed(() => !!vm.selectedFullSrc()); + vm.selectedNameReady = ko.pureComputed(() => !!vm.selectedName()); + + vm.openImageInNewTab = () => { + const src = vm.selectedFullSrc(); + if (!src) return; + const win = window.open(); + if (win) { + win.document.write(``); + win.document.title = vm.selectedName(); + } + }; + + vm.showSelectHint = ko.pureComputed(() => { + return !vm.loadingList() && !vm.loadingFull() && vm.images().length > 0 && !vm.selectedFullReady(); + }); + + function ImageVM(dto) { + const im = this; + im.imageName = ko.observable(dto?.Image || ""); + im.extension = ko.observable(dto?.Extension || ""); + im.thumbName = ko.observable(dto?.Thumbnail || ""); + + im.thumbUrl = ko.observable(""); + im.fullUrl = ko.observable(""); + + im.loadingThumb = ko.observable(false); + im.loadingFull = ko.observable(false); + + im.thumbReady = ko.pureComputed(() => !!im.thumbUrl()); + + im._thumbObjectUrl = null; + im._fullObjectUrl = null; + + im.dispose = () => { + if (im._thumbObjectUrl) URL.revokeObjectURL(im._thumbObjectUrl); + if (im._fullObjectUrl) URL.revokeObjectURL(im._fullObjectUrl); + im._thumbObjectUrl = null; + im._fullObjectUrl = null; + }; + } + + function asUrl(data, extHint) { + if (!data) return ""; + + // unwrap common response shapes + if (typeof data === "object" && data.Data && typeof data.Data === "string") data = data.Data; + if (typeof data === "object" && data.data && typeof data.data === "string") data = data.data; + + if (typeof data === "string") { + if (data.startsWith("data:")) return data; + + const ext = (extHint || "jpeg").toLowerCase(); + const mime = + ext === "jpg" || ext === "jpeg" || ext === "thumb" ? "image/jpeg" : + ext === "png" ? "image/png" : + ext === "gif" ? "image/gif" : + "application/octet-stream"; + + return `data:${mime};base64,${data}`; + } + + if (data instanceof Blob) return URL.createObjectURL(data); + + try { + return URL.createObjectURL(new Blob([data])); + } catch (_e) { + return ""; + } + } + + function getIncidentImages(jobId, token) { + return new Promise((resolve) => { + BeaconClient.images.getIncidentImages( + jobId, apiHost, userId, token, + (list, err) => { + if (err) { + resolve(null); + } else { + resolve(list || []); + } + } + ); + }); + } + + function getImageData(name, token) { + return new Promise((resolve, reject) => { + BeaconClient.images.getImageData( + vm.job().id(), name, apiHost, userId, token, + (data) => { + if (data == null) { + reject(new Error("No data returned")); + } else { + resolve(data); + } + } + ); + }); + } + + vm._loadThumb = async (im, token) => { + if (!im) return; + + // If already cached, make sure spinner is off + if (im.thumbUrl && im.thumbUrl()) { + im.loadingThumb(false); + return; + } + + if (im.loadingThumb()) return; + + const thumbName = im.thumbName(); + if (!thumbName) { + im.loadingThumb(false); + return; + } + + im.loadingThumb(true); + try { + const data = await getImageData(thumbName, token); + const url = asUrl(data, "jpeg"); + if (url.startsWith("blob:")) im._thumbObjectUrl = url; + im.thumbUrl(url); + } catch (e) { + console.error("Thumb load failed:", e); + } finally { + im.loadingThumb(false); + } + }; + + vm._loadFull = async (im, token) => { + if (!im) return; + + // If already cached, make sure spinners are off + if (im.fullUrl && im.fullUrl()) { + im.loadingFull(false); + vm.loadingFull(false); + return; + } + + if (im.loadingFull()) return; + + const imageName = im.imageName(); + if (!imageName) { + im.loadingFull(false); + vm.loadingFull(false); + return; + } + + im.loadingFull(true); + vm.loadingFull(true); + try { + const data = await getImageData(imageName, token); + const url = asUrl(data, im.extension()); + if (url.startsWith("blob:")) im._fullObjectUrl = url; + im.fullUrl(url); + } catch (e) { + vm.errorText("Failed to load image."); + console.error("Full load failed:", e); + } finally { + im.loadingFull(false); + vm.loadingFull(false); + } + }; + async function prefetchThumbs(list, token, concurrency = 4) { + let i = 0; + async function worker() { + while (i < list.length) { + const idx = i++; + const im = list[idx]; + if (!im || im.thumbUrl()) continue; + await vm._loadThumb(im, token); + } + } + await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker)); + } + + vm.selectImage = async (im) => { + vm.selectedImage(im || null); + vm.selectedFullSrc(""); + vm.selectedName(im ? im.imageName() : ""); + vm.errorText(""); + + // always reset VM-level spinner on selection change + vm.loadingFull(false); + + if (!im) return; + + // If already cached, show immediately and ensure spinners are off + if (im.fullUrl && im.fullUrl()) { + im.loadingFull(false); + vm.loadingFull(false); + vm.selectedFullSrc(im.fullUrl()); + return; + } + + const token = await getToken(); + await vm._loadFull(im, token); + + vm.selectedFullSrc(im.fullUrl() || ""); + }; + + + vm.openForJob = async (job) => { + vm.errorText(""); + vm.job(job || null); + vm.selectedImage(null); + + // dispose old urls + vm.images().forEach(x => x.dispose && x.dispose()); + vm.images.removeAll(); + + const modalEl = document.getElementById("incidentImagesModal"); + if (!modalEl) return; + + vm.modalInstance = bootstrap.Modal.getOrCreateInstance(modalEl); + vm.modalInstance.show(); + + vm.loadingList(true); + try { + const token = await getToken(); + const list = await getIncidentImages(job.id(), token); + + const vms = (list || []).map(dto => new ImageVM(dto)); + vm.images(vms); + // thumbs in background + prefetchThumbs(vms, token, 4); + + if (vms.length) await vm.selectImage(vms[0]); + } catch (e) { + vm.errorText("Failed to load incident image list."); + console.error("Image list load failed:", e); + } finally { + vm.loadingList(false); + } + + modalEl.addEventListener("hidden.bs.modal", () => { + vm.images().forEach(x => x.dispose && x.dispose()); + vm.images.removeAll(); + vm.selectedImage(null); + vm.job(null); + vm.errorText(""); + vm.loadingList(false); + vm.loadingFull(false); + }, { once: true }); + }; +} diff --git a/src/shared/BeaconClient.js b/src/shared/BeaconClient.js index e2c2a35..e5074b0 100644 --- a/src/shared/BeaconClient.js +++ b/src/shared/BeaconClient.js @@ -19,11 +19,12 @@ import * as frao from './BeaconClient/frao.js'; import * as contacts from './BeaconClient/contacts.js'; import * as messages from './BeaconClient/messages.js'; import * as suppliers from './BeaconClient/suppliers.js'; +import * as images from './BeaconClient/images.js'; -export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers }; +export { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images }; // re-export functions -export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, toFormUrlEncoded }; +export default { job, asset, nitc, operationslog, resources, team, unit, entities, tasking, notifications, geoservices, tags, sectors, frao, contacts, messages, suppliers, images, toFormUrlEncoded }; export function toFormUrlEncoded(obj) { const params = []; diff --git a/src/shared/BeaconClient/images.js b/src/shared/BeaconClient/images.js new file mode 100644 index 0000000..cac075e --- /dev/null +++ b/src/shared/BeaconClient/images.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; + +export function getIncidentImages(id, host, userId = 'notPassed', token, callback) { + $.ajax({ + type: 'GET', + url: host + "/Api/v1/Image/IncidentThumbnails/" + id + "?LighthouseFunction=getIncidentThumbnails&userId=" + userId, + beforeSend: function (n) { + n.setRequestHeader("Authorization", "Bearer " + token) + }, + cache: false, + dataType: 'json', + complete: function (response, textStatus) { + if (textStatus == 'success') { + callback(response.responseJSON); + } else { + callback(null); + } + } + }); +} + +// Returns raw image data as a Blob +export function getImageData(jobId, imageId, host, userId = 'notPassed', token, callback) { + const xhr = new XMLHttpRequest(); + const url = host + "/Api/v1/Image/IncidentImage/" + jobId + "/" + imageId + "/?LighthouseFunction=getImageData&userId=" + userId; + xhr.open('GET', url, true); + xhr.setRequestHeader("Authorization", "Bearer " + token); + xhr.responseType = 'blob'; + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + callback(xhr.response); + } else { + callback(null); + } + }; + xhr.onerror = function () { + callback(null); + }; + xhr.send(); +} diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 4587c19..a69b33d 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -179,7 +179,7 @@ data-bind="event: { click: setTeamSortFromElement }"> Task Count - Team Leader + Team Leader Actions @@ -968,8 +968,13 @@
- +
@@ -2989,11 +2994,14 @@