From a698eaade36066993b7c4665e2b3581dcda6d9bc Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Fri, 14 Aug 2026 10:14:34 +1000 Subject: [PATCH 01/10] Add myAvailability response gems to job view (#411) Adds a row of colour-coded count 'gems' (Activation Accepted, Available, Conditional, Unavailable, Unset) below the Incident Details header on the job view page. Counts and hover popovers listing responder names are populated via a new `lighthouseResponseGems()` function that calls the myavailability/incident Lambda (production only; non-prod Beacon gets placeholder data). Includes a loading pulse animation and full CSS styling for the gem bar. --- .gitignore | 1 + src/contentscripts/jobs/view.js | 16 ++++ src/injectscripts/jobs/view.js | 128 ++++++++++++++++++++++++++++++++ src/styles/jobs.view.css | 68 +++++++++++++++++ 4 files changed, 213 insertions(+) diff --git a/.gitignore b/.gitignore index 1e9bc406..de4551ac 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ Lighthouse.zip .DS_Store *.DS_Store package-lock.json +lambda/myavailability-incident/ diff --git a/src/contentscripts/jobs/view.js b/src/contentscripts/jobs/view.js index 909f68bc..8bd6f16f 100644 --- a/src/contentscripts/jobs/view.js +++ b/src/contentscripts/jobs/view.js @@ -391,6 +391,22 @@ let job_lighthouse_actions = ( ) +// Row of response-count "gems" shown below the Incident Details header. +// Counts and hover names are populated by lighthouseResponseGems() in +// injectscripts/jobs/view.js once the (not yet written) lad_v2/job-responses +// Lambda function is available - see the TODO there for placeholder data. +let job_response_gems = ( +
+ - + - + - + - + - +
+); + +$('#jobID').closest('.widget-header').append(job_response_gems); + $('div.widget.actions-box').after(job_lighthouse_actions) $('#map').parent().before(job_nearest_asset_widget) diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 59705a34..488465bf 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -61,6 +61,134 @@ masterViewModel.teamsViewModel.taskedTeams.subscribe(function () { setTimeout(lighthouseTasking, 0); }); +// The myavailability/incident Lambda only has data for production Beacon +// (apibeacon.ses.nsw.gov.au) - trainbeacon/devbeacon jobIds don't exist in +// that database. Gate on urls.Base (the API root Beacon's own page is +// talking to) so we never send a real request outside prod. +function isProductionBeaconApi() { + return typeof urls !== 'undefined' && typeof urls.Base === 'string' && + urls.Base.indexOf('apibeacon.ses.nsw.gov.au') !== -1; +} + +// Assumes Beacon's jobId is the same value as `activationId` in the mams +// database (View_ActivationRequest) - if gems come back empty/wrong for a +// job you know has responses, that assumption is the first thing to check. +function getJobResponseSummary(jobId, cb) { + if (!isProductionBeaconApi()) { + // Non-prod Beacon (trainbeacon/devbeacon/local) - don't call the real + // Lambda, just show obviously-fake placeholder data so the widget is + // still visible for UI testing. + cb(null, { + closed: false, + categories: { + ActivationAccepted: { Count: 1, Names: ['Fake Test Member'] }, + Available: { Count: 2, Names: ['Fake Test Member', 'Fake Test Member 2'] }, + Conditional: { Count: 1, Names: ['Fake Test Member'] }, + Unavailable: { Count: 1, Names: ['Fake Test Member'] }, + Unset: { Count: 5, Names: [] }, + }, + }); + return; + } + + $.ajax({ + url: 'https://lambda.lighthouse-extension.com/myavailability/incident', + method: 'GET', + data: { activationId: jobId }, + beforeSend: function (n) { + n.setRequestHeader('Authorization', 'Bearer ' + user.accessToken); + }, + dataType: 'json', + success: function (data) { + cb(null, data); + }, + error: function (xhr, status, error) { + cb(error); + }, + }); +} + +// Bootstrap's hover-trigger popover only reacts to the *next* mouseenter - +// if the popover is initialized while the cursor is already sitting over +// the element (very likely here, since this runs right as the async data +// load finishes and someone's been hovering to see what loads), nothing +// shows until they move away and back. Show it immediately in that case. +function initGemPopover($gem, options) { + $gem.popover(options); + if ($gem.is(':hover')) { + $gem.popover('show'); + } +} + +// Fills in the response-count gems (below the Incident Details header, +// built by contentscripts/jobs/view.js) and wires up hover popovers +// listing the names of the people in each category. Once the activation +// is closed, gems show `-` instead of a count and skip the name list. +function lighthouseResponseGems() { + var gemSelectorsByCategory = { + ActivationAccepted: '#lighthouse-gem-activationaccepted', + Available: '#lighthouse-gem-available', + Conditional: '#lighthouse-gem-conditional', + Unavailable: '#lighthouse-gem-unavailable', + Unset: '#lighthouse-gem-unset', + }; + + $('#lighthouse-response-gems').addClass('is-loading'); + + getJobResponseSummary(jobId, function (err, summary) { + $('#lighthouse-response-gems').removeClass('is-loading'); + if (err || !summary) return; + + _.each(gemSelectorsByCategory, function (selector, category) { + var $gem = $(selector); + if ($gem.length === 0) return; + + var title = 'myAvailability: ' + + category.replace(/([a-z])([A-Z])/g, '$1 $2'); + var data = (summary.categories && summary.categories[category]) || { Count: 0, Names: [] }; + + if (data.Count === null) { + $gem.text('-'); + initGemPopover($gem, { + placement: 'bottom', + trigger: 'hover', + html: true, + title: title, + content: 'Activation closed', + container: 'body', + }); + return; + } + + $gem.text(data.Count); + + var MAX_NAMES_SHOWN = 20; + var namesHtml = data.Names && data.Names.length + ? '' + : 'No responders'; + + initGemPopover($gem, { + placement: 'bottom', + trigger: 'hover', + html: true, + title: title, + content: namesHtml, + container: 'body', + }); + }); + }); +} + +whenJobIsReady(function () { + lighthouseResponseGems(); +}); + function lighthouseETAFromNow() { var future = moment(masterViewModel.teamsViewModel.jobTeamStatusEstimatedCompletion.peek()); var now = moment(); diff --git a/src/styles/jobs.view.css b/src/styles/jobs.view.css index 4dade477..82ab9a98 100644 --- a/src/styles/jobs.view.css +++ b/src/styles/jobs.view.css @@ -138,4 +138,72 @@ .nounderline { text-decoration: none !important +} + +.lighthouse-response-gems { + display: flex; + align-items: center; + width: fit-content; + margin-left: auto; + margin-top: 4px; + padding: 3px; + background: rgb(238, 238, 238); +} + +.lighthouse-response-gem { + flex: none; + min-width: 34px; + padding: 4px 10px; + text-align: center; + color: #fff; + font-weight: bold; + font-family: "latobold"; + font-size: 13px; + cursor: default; +} + +.lighthouse-response-gem:first-child { + border-radius: 4px 0 0 4px; +} + +.lighthouse-response-gem:last-child { + border-radius: 0 4px 4px 0; +} + +.lighthouse-response-gems.is-loading .lighthouse-response-gem { + animation: lighthouse-gem-pulse 1.2s ease-in-out infinite; +} + +@keyframes lighthouse-gem-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.lighthouse-response-gem-activationaccepted { + background: #337ab7; +} + +.lighthouse-response-gem-available { + background: #5cb85c; +} + +.lighthouse-response-gem-conditional { + background: #f0ad4e; +} + +.lighthouse-response-gem-unavailable { + background: #d9534f; +} + +.lighthouse-response-gem-unset { + background: #777777; +} + +.lighthouse-response-gem-names { + margin: 0; + padding-left: 18px; } \ No newline at end of file From 9aef21a7a6ce40ae8c47a28e0f60a0b00ad1de10 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Fri, 14 Aug 2026 13:45:32 +1000 Subject: [PATCH 02/10] Add closed-state, pinning, and Create Team action to myAvailability gems (#412) Response gems now show real counts/names even for closed activations (with a lock icon + note instead of blanking numbers), popovers pin open on click instead of only showing on hover, member names carry their id for future use, and long name lists are capped with a "+N more" indicator. Also adds a role-gated "Create Team" button on the Activation Accepted gem that opens /Teams/Create pre-filled with the accepted members and the incident's own HQ. Co-authored-by: Claude Sonnet 5 --- src/injectscripts/jobs/view.js | 123 +++++++++++++++++++++++------- src/injectscripts/teams/create.js | 84 +++++++++++++++++++- src/styles/jobs.view.css | 21 ++++- static/manifest.json | 3 +- 4 files changed, 198 insertions(+), 33 deletions(-) diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 488465bf..1b26fe44 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -81,10 +81,10 @@ function getJobResponseSummary(jobId, cb) { cb(null, { closed: false, categories: { - ActivationAccepted: { Count: 1, Names: ['Fake Test Member'] }, - Available: { Count: 2, Names: ['Fake Test Member', 'Fake Test Member 2'] }, - Conditional: { Count: 1, Names: ['Fake Test Member'] }, - Unavailable: { Count: 1, Names: ['Fake Test Member'] }, + ActivationAccepted: { Count: 1, Names: [{ MemberId: -1, Name: 'Fake Test Member' }] }, + Available: { Count: 2, Names: [{ MemberId: -2, Name: 'Fake Test Member' }, { MemberId: -3, Name: 'Fake Test Member 2' }] }, + Conditional: { Count: 1, Names: [{ MemberId: -4, Name: 'Fake Test Member' }] }, + Unavailable: { Count: 1, Names: [{ MemberId: -5, Name: 'Fake Test Member' }] }, Unset: { Count: 5, Names: [] }, }, }); @@ -108,22 +108,77 @@ function getJobResponseSummary(jobId, cb) { }); } -// Bootstrap's hover-trigger popover only reacts to the *next* mouseenter - -// if the popover is initialized while the cursor is already sitting over -// the element (very likely here, since this runs right as the async data -// load finishes and someone's been hovering to see what loads), nothing -// shows until they move away and back. Show it immediately in that case. +// Shows on hover (as a preview) and pins open on click so it stays visible +// after the mouse leaves - click again, or click anywhere outside the gem +// and its popover, to unpin/close it. Uses a manual trigger and drives +// show/hide ourselves so click-to-pin and hover-preview don't fight each +// other the way combining Bootstrap's built-in "hover click" triggers does. function initGemPopover($gem, options) { - $gem.popover(options); + $gem.data('lighthouse-pinned', false); + $gem.popover(_.extend({}, options, { trigger: 'manual' })); + + $gem.off('.lighthouseGem'); + + $gem.on('mouseenter.lighthouseGem', function () { + $gem.popover('show'); + }); + $gem.on('mouseleave.lighthouseGem', function () { + if (!$gem.data('lighthouse-pinned')) { + $gem.popover('hide'); + } + }); + $gem.on('click.lighthouseGem', function (e) { + e.stopPropagation(); + var pinned = !$gem.data('lighthouse-pinned'); + $gem.data('lighthouse-pinned', pinned); + $gem.popover(pinned ? 'show' : 'hide'); + }); + + // Popovers initialized while the cursor is already sitting over the gem + // (very likely right as the async data load finishes) miss the next + // mouseenter - show immediately in that case. if ($gem.is(':hover')) { $gem.popover('show'); } } +// Click anywhere outside a pinned gem/popover unpins and closes it. +$(document).off('click.lighthouseGemsDismiss').on('click.lighthouseGemsDismiss', function (e) { + var $target = $(e.target); + if ($target.closest('.lighthouse-response-gem').length || $target.closest('.popover').length) { + return; + } + $('.lighthouse-response-gem').each(function () { + var $g = $(this); + if ($g.data('lighthouse-pinned')) { + $g.data('lighthouse-pinned', false); + $g.popover('hide'); + } + }); +}); + +// Delegated (popover content is only in the DOM while shown, so this can't +// bind directly): opens /Teams/Create with the accepted members' ids, the +// same lhquickrecipient-style pattern used for the SMS "message a team" +// button above - teams/create.js's inject script picks the params back up. +// Also passes the incident's own HQ (entityAssignedTo) so the new team +// gets assigned to the HQ that owns the incident, not whatever HQ the +// person creating the team happens to be logged in under. +$(document).off('click.lighthouseCreateTeam').on('click.lighthouseCreateTeam', '.lighthouse-create-team-btn', function (e) { + e.stopPropagation(); + var memberIds = $(this).data('member-ids'); + var entityId = masterViewModel.entityAssignedTo.peek() ? masterViewModel.entityAssignedTo.peek().Id : null; + window.open( + '/Teams/Create?lhmembers=' + escape(JSON.stringify(memberIds)) + '&lhentityid=' + escape(entityId), + '_blank', + ); +}); + // Fills in the response-count gems (below the Incident Details header, // built by contentscripts/jobs/view.js) and wires up hover popovers // listing the names of the people in each category. Once the activation -// is closed, gems show `-` instead of a count and skip the name list. +// is closed, real counts/names still show - a lock icon on the row and a +// note in each popover just indicate the activation is closed. function lighthouseResponseGems() { var gemSelectorsByCategory = { ActivationAccepted: '#lighthouse-gem-activationaccepted', @@ -133,12 +188,19 @@ function lighthouseResponseGems() { Unset: '#lighthouse-gem-unset', }; - $('#lighthouse-response-gems').addClass('is-loading'); + var $gemsRow = $('#lighthouse-response-gems'); + $gemsRow.addClass('is-loading'); getJobResponseSummary(jobId, function (err, summary) { - $('#lighthouse-response-gems').removeClass('is-loading'); + $gemsRow.removeClass('is-loading'); if (err || !summary) return; + var isClosed = !!summary.closed; + $gemsRow.toggleClass('is-closed', isClosed); + if (isClosed && $gemsRow.find('.lighthouse-response-gems-closed-icon').length === 0) { + $gemsRow.prepend(''); + } + _.each(gemSelectorsByCategory, function (selector, category) { var $gem = $(selector); if ($gem.length === 0) return; @@ -147,25 +209,14 @@ function lighthouseResponseGems() { category.replace(/([a-z])([A-Z])/g, '$1 $2'); var data = (summary.categories && summary.categories[category]) || { Count: 0, Names: [] }; - if (data.Count === null) { - $gem.text('-'); - initGemPopover($gem, { - placement: 'bottom', - trigger: 'hover', - html: true, - title: title, - content: 'Activation closed', - container: 'body', - }); - return; - } - $gem.text(data.Count); var MAX_NAMES_SHOWN = 20; var namesHtml = data.Names && data.Names.length - ? '
    ' + _.map(data.Names.slice(0, MAX_NAMES_SHOWN), function (name) { - return '
  • ' + _.escape(name) + '
  • '; + ? '
      ' + _.map(data.Names.slice(0, MAX_NAMES_SHOWN), function (person) { + // MemberId travels with each entry for future use (e.g. linking + // to a profile) but is deliberately not rendered here. + return '
    • ' + _.escape(person.Name) + '
    • '; }).join('') + (data.Names.length > MAX_NAMES_SHOWN ? '
    • +' + (data.Names.length - MAX_NAMES_SHOWN) + ' more
    • ' @@ -173,12 +224,26 @@ function lighthouseResponseGems() { '
    ' : 'No responders'; + var closedNote = isClosed ? '
    Activation closed
    ' : ''; + + // Quick path from "who's accepted" straight into a new team - only + // makes sense for the ActivationAccepted gem, only when there's + // someone to add, and only for users who could actually create a + // team in the first place. + var createTeamButtonHtml = ''; + if (category === 'ActivationAccepted' && data.Names && data.Names.length && user.isInRole(Enum.Role.TeamManagement.Id)) { + var memberIds = _.map(data.Names, function (person) { return person.MemberId; }); + createTeamButtonHtml = '
    ' + + '
    '; + } + initGemPopover($gem, { placement: 'bottom', trigger: 'hover', html: true, title: title, - content: namesHtml, + content: closedNote + namesHtml + createTeamButtonHtml, container: 'body', }); }); diff --git a/src/injectscripts/teams/create.js b/src/injectscripts/teams/create.js index 0dec7ffe..5bd8b10e 100644 --- a/src/injectscripts/teams/create.js +++ b/src/injectscripts/teams/create.js @@ -1,4 +1,4 @@ -/* global teamViewModel, $ */ +/* global teamViewModel, $, _, ko */ //edit and create page. // background js fiddles with create page to expose same viewmodel as OutageDisplayType @@ -9,6 +9,88 @@ if (typeof callsign !== 'undefined' && callsign !== null) { document.title = callsign; } +//prefill members from a ?lhmembers=[id,id,...] param - same +//lhquickrecipient-style pattern messages/create.js uses for jobId/recipients +function parse_query_string(query) { + var vars = query.split('&'); + var query_string = {}; + for (var i = 0; i < vars.length; i++) { + var pair = vars[i].split('='); + if (typeof query_string[pair[0]] === 'undefined') { + query_string[pair[0]] = decodeURIComponent(pair[1]); + } else if (typeof query_string[pair[0]] === 'string') { + var arr = [query_string[pair[0]], decodeURIComponent(pair[1])]; + query_string[pair[0]] = arr; + } else { + query_string[pair[0]].push(decodeURIComponent(pair[1])); + } + } + return query_string; +} + +// memberId here is a RegistrationNumber (mams's identifier), not Beacon's +// internal Person.Id, so lookup goes through PersonManager.SearchPeople. +// ViewModelType: 3 (Enum.PeopleViewModelType.Team.Id, same type +// loadPeople() requests) gets back a person shaped correctly to add +// straight to the team, no separate GetPersonById needed. Prefer an +// already-loaded person from teamViewModel.people() when there's a match +// (has isSelected wired up already); otherwise the search result needs +// isSelected manually attached as a ko.observable before addPersonToTeam +// can call person.isSelected(true) on it. +async function addTeamMemberById(memberId) { + var matched = _.find(teamViewModel.people(), function (p) { return String(p.RegistrationNumber) === String(memberId); }); + + if (!matched) { + try { + var searchResponse = await teamViewModel.PersonManager.SearchPeople({ RegistrationNumber: String(memberId), ViewModelType: 2 }); + var data = searchResponse && searchResponse.Results && searchResponse.Results[0]; + if (!data) { + console.log('lighthouse: could not find person for registration number ' + memberId + ' to add to team'); + return; + } + data.isSelected = ko.observable(false); + matched = data; + } catch (err) { + console.log('lighthouse: error looking up person ' + memberId + ' to add to team - ' + (err && err.message)); + return; + } + } + + teamViewModel.addPersonToTeam(matched); +} + +// Sets the team's Assigned To HQ to the incident's own HQ (rather than +// leaving it defaulted to whatever HQ the person creating the team is +// logged in under) - GetEntityById is async and resolves to the full +// entity object entityAssignedTo expects, not just an id. +async function setTeamEntityById(entityId) { + try { + var entity = await teamViewModel.EntityManager.GetEntityById(entityId); + if (entity) { + teamViewModel.entityAssignedTo(entity); + } + } catch (err) { + console.log('lighthouse: error loading entity ' + entityId + ' to assign team to - ' + (err && err.message)); + } +} + +$(document).ready(function () { + var query = window.location.search.substring(1); + if (!query) return; + var qs = parse_query_string(query); + + if (typeof qs.lhmembers !== 'undefined') { + var memberIds = JSON.parse(unescape(qs.lhmembers)); + $.each(memberIds, function (k, memberId) { + addTeamMemberById(memberId); + }); + } + + if (typeof qs.lhentityid !== 'undefined' && qs.lhentityid !== 'null') { + setTeamEntityById(unescape(qs.lhentityid)); + } +}); + //when team members change teamViewModel.members.subscribe(function() { // auto set the first team member as TL diff --git a/src/styles/jobs.view.css b/src/styles/jobs.view.css index 82ab9a98..aab85d83 100644 --- a/src/styles/jobs.view.css +++ b/src/styles/jobs.view.css @@ -162,11 +162,11 @@ cursor: default; } -.lighthouse-response-gem:first-child { +.lighthouse-response-gem:first-of-type { border-radius: 4px 0 0 4px; } -.lighthouse-response-gem:last-child { +.lighthouse-response-gem:last-of-type { border-radius: 0 4px 4px 0; } @@ -183,6 +183,16 @@ } } +.lighthouse-response-gems-closed-icon { + color: #555555; + font-size: 12px; + margin-right: 6px; +} + +.lighthouse-response-gem-closed-note { + margin-bottom: 4px; +} + .lighthouse-response-gem-activationaccepted { background: #337ab7; } @@ -206,4 +216,11 @@ .lighthouse-response-gem-names { margin: 0; padding-left: 18px; +} + +.lighthouse-create-team-btn-wrap { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #e5e5e5; + text-align: right; } \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json index 198e9076..89f9178f 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -121,7 +121,8 @@ { "matches": [ "https://*.ses.nsw.gov.au/Teams/Create", - "https://*.ses.nsw.gov.au/Teams/Create/" + "https://*.ses.nsw.gov.au/Teams/Create/", + "https://*.ses.nsw.gov.au/Teams/Create?*" ], "js": ["contentscripts/teams/create.js"] }, From 04a6d17171b57f0613b61e110314137f1d5b0a76 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Fri, 14 Aug 2026 13:45:58 +1000 Subject: [PATCH 03/10] Move lad_v2 Beacon auth to a single API Gateway authorizer (#410) Adds LH-BeaconAuthorizerV2, a Lambda REQUEST authorizer wrapping the existing verifyBeaconToken logic (same trusted-issuer allow-list, same beaconApi scope check), and attaches it to all /lad_v2/... routes so invalid/missing tokens are rejected at the gateway before any backend Lambda runs. The five backend Lambdas now read the verified member id from the authorizer's context instead of re-verifying the token themselves, removing duplicated JWKS/JWT logic from each one. Co-authored-by: Claude Sonnet 5 --- lambda/authorizer-v2/index.mjs | 34 ++++++++++++++++++++++++++++++ lambda/default-assets-v2/index.mjs | 13 +++++------- lambda/geocode-v2/index.mjs | 13 +++++------- lambda/map-layers-v2/index.js | 29 +++++++++++-------------- lambda/route-v2/index.mjs | 13 +++++------- lambda/share-v2/index.mjs | 18 +++++----------- 6 files changed, 66 insertions(+), 54 deletions(-) create mode 100644 lambda/authorizer-v2/index.mjs diff --git a/lambda/authorizer-v2/index.mjs b/lambda/authorizer-v2/index.mjs new file mode 100644 index 00000000..1fe74c8a --- /dev/null +++ b/lambda/authorizer-v2/index.mjs @@ -0,0 +1,34 @@ +// API Gateway HTTP API Lambda authorizer (REQUEST type, simple responses, +// payload format 2.0) for the /lad_v2/... routes. Centralizes the Beacon +// token check that used to be duplicated in each of the five lad_v2 +// Lambdas — same trusted-issuer allow-list, same JWKS verification, same +// required scope, via the shared verifyBeaconToken.mjs (see TRUSTED_ISS env +// var to add/remove issuers). +// +// On success, `sub` (the Beacon member id) is returned in `context`, which +// API Gateway forwards to the backend Lambda at +// event.requestContext.authorizer.lambda.sub — so downstream handlers don't +// need the raw token to know who's calling. +import { verifyBeaconToken } from './verifyBeaconToken.mjs'; + +export const handler = async (event) => { + const authHeader = event.headers?.authorization || event.headers?.Authorization; + + try { + const claims = await verifyBeaconToken(authHeader); + return { + isAuthorized: true, + context: { + sub: String(claims.sub || claims.client_id || 'unknown'), + }, + }; + } catch (err) { + console.log(JSON.stringify({ + msg: 'beacon_auth_denied', + fn: 'authorizer-v2', + path: event.rawPath, + error: err?.message || String(err), + })); + return { isAuthorized: false }; + } +}; diff --git a/lambda/default-assets-v2/index.mjs b/lambda/default-assets-v2/index.mjs index dc2a67d8..fc9e0d67 100644 --- a/lambda/default-assets-v2/index.mjs +++ b/lambda/default-assets-v2/index.mjs @@ -25,7 +25,6 @@ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import crypto from 'crypto'; -import { verifyBeaconToken } from './verifyBeaconToken.mjs'; // ── Config ────────────────────────────────────────────────────────── const BUCKET = process.env.BUCKET_NAME || 'lighthouse-default-assets'; @@ -98,13 +97,11 @@ export const handler = async (event) => { return respond(204, ''); } - let claims; - try { - claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); - } catch (err) { - return respond(401, { error: 'Unauthorized', message: err?.message || String(err) }); - } - console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'default-assets-v2', userId: claims.sub || claims.client_id || 'unknown', method })); + // Auth is enforced by the LH-BeaconAuthorizerV2 API Gateway authorizer + // before this handler is ever invoked; `sub` is the verified Beacon + // member id it passes through. + const userId = event.requestContext?.authorizer?.lambda?.sub || 'unknown'; + console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'default-assets-v2', userId, method })); try { // ---------- GET: Bulk fetch for a list of team IDs ---------- diff --git a/lambda/geocode-v2/index.mjs b/lambda/geocode-v2/index.mjs index 1ddc1e73..c570fbaf 100644 --- a/lambda/geocode-v2/index.mjs +++ b/lambda/geocode-v2/index.mjs @@ -1,5 +1,4 @@ import pkg from "@aws-sdk/client-geo-places"; -import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; const { GeoPlacesClient, GeocodeCommand, ReverseGeocodeCommand } = pkg; @@ -26,13 +25,11 @@ export const handler = async (event) => { return { statusCode: 204, headers: corsHeaders, body: "" }; } - let claims; - try { - claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); - } catch (err) { - return json(401, { error: "Unauthorized", message: err?.message || String(err) }); - } - console.log(JSON.stringify({ msg: "beacon_auth", fn: "geocode-v2", userId: claims.sub || claims.client_id || "unknown" })); + // Auth is enforced by the LH-BeaconAuthorizerV2 API Gateway authorizer + // before this handler is ever invoked; `sub` is the verified Beacon + // member id it passes through. + const userId = event.requestContext?.authorizer?.lambda?.sub || "unknown"; + console.log(JSON.stringify({ msg: "beacon_auth", fn: "geocode-v2", userId })); const qsp = event?.queryStringParameters || {}; diff --git a/lambda/map-layers-v2/index.js b/lambda/map-layers-v2/index.js index b8f69818..5584c501 100644 --- a/lambda/map-layers-v2/index.js +++ b/lambda/map-layers-v2/index.js @@ -1,7 +1,6 @@ 'use strict'; const { json, serverError } = require('./lib/response'); -const { verifyBeaconToken } = require('./verifyBeaconToken'); const listLayers = require('./handlers/listLayers'); const createLayer = require('./handlers/createLayer'); const getLayer = require('./handlers/getLayer'); @@ -18,8 +17,8 @@ const updateLayerAttachment = require('./handlers/updateLayerAttachment'); // event.routeKey, which API Gateway sets to " " for // whichever route matched (e.g. "GET /lad_v2/map-layers/{id}"). Every route // except OPTIONS requires a valid `Authorization: Bearer ` -// header, verified against SES's identity server (see -// ./verifyBeaconToken.js). +// header — enforced by the LH-BeaconAuthorizerV2 API Gateway authorizer +// before this Lambda is ever invoked (see lambda/authorizer-v2). const ROUTES = { 'GET /lad_v2/map-layers': listLayers, 'POST /lad_v2/map-layers': createLayer, @@ -45,22 +44,18 @@ exports.handler = async (event) => { return json(404, { error: 'Not found', routeKey }); } - let claims; - try { - claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); - } catch (err) { - return json(401, { error: 'Unauthorized', message: err?.message || String(err) }); - } - console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'map-layers-v2', userId: claims.sub || claims.client_id || 'unknown', route: routeKey })); + // `sub` is the verified Beacon member id, passed through from the + // LH-BeaconAuthorizerV2 authorizer's context. + const userId = event.requestContext?.authorizer?.lambda?.sub || 'unknown'; + console.log(JSON.stringify({ msg: 'beacon_auth', fn: 'map-layers-v2', userId, route: routeKey })); try { - // `claims` (the verified token payload) is passed through so - // permission-sensitive handlers (createLayer, upsertFeature, - // deleteFeature, deleteLayer) can authorize against claims.sub -- the - // Beacon member id, tamper-proof since it comes from a signature- - // verified JWT -- rather than any client-supplied actorId field, which - // a caller could set to whatever it wants. - return await handler(event, claims); + // `claims` is passed through so permission-sensitive handlers + // (createLayer, upsertFeature, deleteFeature, deleteLayer) can + // authorize against claims.sub -- tamper-proof since it comes from the + // gateway's signature-verified JWT -- rather than any client-supplied + // actorId field, which a caller could set to whatever it wants. + return await handler(event, { sub: userId }); } catch (err) { console.error('map-layers-v2 handler error:', err, JSON.stringify({ routeKey })); return serverError(); diff --git a/lambda/route-v2/index.mjs b/lambda/route-v2/index.mjs index 54da51c9..886af428 100644 --- a/lambda/route-v2/index.mjs +++ b/lambda/route-v2/index.mjs @@ -1,5 +1,4 @@ import { GeoRoutesClient, CalculateRoutesCommand } from "@aws-sdk/client-geo-routes"; -import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; const client = new GeoRoutesClient({}); @@ -33,13 +32,11 @@ export const handler = async (event) => { return { statusCode: 204, headers: CORS_HEADERS, body: "" }; } - let claims; - try { - claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); - } catch (err) { - return json(401, { error: "Unauthorized", message: err?.message || String(err) }); - } - console.log(JSON.stringify({ msg: "beacon_auth", fn: "route-v2", userId: claims.sub || claims.client_id || "unknown" })); + // Auth is enforced by the LH-BeaconAuthorizerV2 API Gateway authorizer + // before this handler is ever invoked; `sub` is the verified Beacon + // member id it passes through. + const userId = event.requestContext?.authorizer?.lambda?.sub || "unknown"; + console.log(JSON.stringify({ msg: "beacon_auth", fn: "route-v2", userId })); let body; try { diff --git a/lambda/share-v2/index.mjs b/lambda/share-v2/index.mjs index 5579647f..725fb3ed 100644 --- a/lambda/share-v2/index.mjs +++ b/lambda/share-v2/index.mjs @@ -4,8 +4,6 @@ import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; -import { verifyBeaconToken } from "./verifyBeaconToken.mjs"; - const s3 = new S3Client({}); const BUCKET_NAME = process.env.BUCKET_NAME; const CONFIG_PREFIX = process.env.CONFIG_PREFIX || ""; @@ -33,17 +31,11 @@ export const handler = async (event) => { }; } - let claims; - try { - claims = await verifyBeaconToken(event.headers?.authorization || event.headers?.Authorization); - } catch (err) { - return { - statusCode: 401, - headers: corsHeaders(), - body: JSON.stringify({ message: "Unauthorized", error: err?.message || String(err) }) - }; - } - console.log(JSON.stringify({ msg: "beacon_auth", fn: "share-v2", userId: claims.sub || claims.client_id || "unknown", method })); + // Auth is enforced by the LH-BeaconAuthorizerV2 API Gateway authorizer + // before this handler is ever invoked; `sub` is the verified Beacon + // member id it passes through. + const userId = event.requestContext?.authorizer?.lambda?.sub || "unknown"; + console.log(JSON.stringify({ msg: "beacon_auth", fn: "share-v2", userId, method })); if (method === "POST" && !query.id) { return await handleCreateConfig(rawBody); From 25bcdb42aa1c2bfda13385a103eb178cbf82c0bb Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Sat, 15 Aug 2026 09:19:33 +1000 Subject: [PATCH 04/10] Fix gem popover hiding immediately when cursor moves into it (#413) The popover panel is appended to , not nested under the gem, so moving the mouse from the gem into the panel counted as a mouseleave on the gem and hid it instantly. Track hover on the panel itself once shown, with a short delay before hiding, so crossing the gap between gem and panel doesn't close it. Co-authored-by: Claude Sonnet 5 --- src/injectscripts/jobs/view.js | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 1b26fe44..f14bea65 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -119,16 +119,45 @@ function initGemPopover($gem, options) { $gem.off('.lighthouseGem'); + var hideTimer = null; + function cancelHide() { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + } + function scheduleHide() { + cancelHide(); + hideTimer = setTimeout(function () { + if (!$gem.data('lighthouse-pinned')) { + $gem.popover('hide'); + } + }, 200); + } + $gem.on('mouseenter.lighthouseGem', function () { + cancelHide(); $gem.popover('show'); }); - $gem.on('mouseleave.lighthouseGem', function () { - if (!$gem.data('lighthouse-pinned')) { - $gem.popover('hide'); + // The popover panel itself is a separate element appended to , not + // a child of the gem - moving the mouse from the gem into the panel is a + // real mouseleave on the gem, so without this the popover would vanish + // the instant the cursor crosses into it. Track hover on the panel too + // (bound once it actually exists, via shown.bs.popover) and use a short + // delay before hiding so crossing the gap between the two is forgiving. + $gem.on('shown.bs.popover.lighthouseGem', function () { + var popoverInstance = $gem.data('bs.popover'); + var $tip = popoverInstance && (popoverInstance.$tip || (popoverInstance.tip && popoverInstance.tip())); + if ($tip && $tip.length) { + $tip.off('.lighthouseGem'); + $tip.on('mouseenter.lighthouseGem', cancelHide); + $tip.on('mouseleave.lighthouseGem', scheduleHide); } }); + $gem.on('mouseleave.lighthouseGem', scheduleHide); $gem.on('click.lighthouseGem', function (e) { e.stopPropagation(); + cancelHide(); var pinned = !$gem.data('lighthouse-pinned'); $gem.data('lighthouse-pinned', pinned); $gem.popover(pinned ? 'show' : 'hide'); From e0b940acbbe5b7bfde032b828f3391f3654039eb Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Sat, 15 Aug 2026 14:24:33 +1000 Subject: [PATCH 05/10] Bump actions/checkout and chrome-extension-upload to Node 24 versions (#414) GitHub Actions runners are deprecating Node 20; actions/checkout@v3 and mnao305/chrome-extension-upload@v4.0.1 were being force-run on Node 24 with a deprecation warning. Bumping to actions/checkout@v7 and chrome-extension-upload@v6.0.0, both of which target Node 24 natively. Co-authored-by: Claude Sonnet 5 --- .github/workflows/linter.yml | 2 +- .github/workflows/publish_dev.yml | 4 ++-- .github/workflows/publish_prod.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index cdb40f73..5110a5a5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install modules diff --git a/.github/workflows/publish_dev.yml b/.github/workflows/publish_dev.yml index 2d5154a0..f5daf074 100644 --- a/.github/workflows/publish_dev.yml +++ b/.github/workflows/publish_dev.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install modules @@ -17,7 +17,7 @@ jobs: - name: Build package run: npm run dev - name: Chrome extension upload action - uses: mnao305/chrome-extension-upload@v4.0.1 + uses: mnao305/chrome-extension-upload@v6.0.0 with: file-path: build/*.zip extension-id: ${{ secrets.CHROMESTORE_DEV_ID }} diff --git a/.github/workflows/publish_prod.yml b/.github/workflows/publish_prod.yml index 723be9e8..e6a45874 100644 --- a/.github/workflows/publish_prod.yml +++ b/.github/workflows/publish_prod.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Install modules @@ -18,7 +18,7 @@ jobs: - name: Build Package run: npm run prod - name: Chrome extension upload action - uses: mnao305/chrome-extension-upload@v4.0.1 + uses: mnao305/chrome-extension-upload@v6.0.0 with: file-path: build/*.zip extension-id: ${{ secrets.CHROMESTORE_PROD_ID }} From 6a55a1168e70b74b2b47f7e718d75e16c07260ba Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Sun, 16 Aug 2026 11:09:44 +1000 Subject: [PATCH 06/10] Match dev build version format to prod and extend team-creation gem to Available (#415) Dev/watch builds zero-padded the version timestamp (e.g. 2026.08.16.0941), which Chrome then displays with leading zeros stripped per segment (2026.8.16.941), making the on-disk manifest look out of sync with what's shown in chrome://extensions. Align dev/watch with prod's unpadded format. Also extend the "create team from responders" quick action to the Available gem, not just ActivationAccepted. Co-authored-by: Claude Sonnet 5 --- src/injectscripts/jobs/view.js | 10 +++++----- webpack.dev.js | 2 +- webpack.watch.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index f14bea65..27b5acaf 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -255,12 +255,12 @@ function lighthouseResponseGems() { var closedNote = isClosed ? '
    Activation closed
    ' : ''; - // Quick path from "who's accepted" straight into a new team - only - // makes sense for the ActivationAccepted gem, only when there's - // someone to add, and only for users who could actually create a - // team in the first place. + // Quick path from "who's accepted/available" straight into a new + // team - only makes sense for the ActivationAccepted and Available + // gems, only when there's someone to add, and only for users who + // could actually create a team in the first place. var createTeamButtonHtml = ''; - if (category === 'ActivationAccepted' && data.Names && data.Names.length && user.isInRole(Enum.Role.TeamManagement.Id)) { + if ((category === 'ActivationAccepted' || category === 'Available') && data.Names && data.Names.length && user.isInRole(Enum.Role.TeamManagement.Id)) { var memberIds = _.map(data.Names, function (person) { return person.MemberId; }); createTeamButtonHtml = '
    ' + '
    diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 6a152be6..a35d3e5b 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -3,6 +3,9 @@ global.jQuery = $; import BeaconClient from '../../shared/BeaconClient.js'; const BeaconToken = require('../lib/shared_token_code.js'); +import { startBeaconSignalRConnection, connectionStatus, getConnectionStatus } from './signalr/connection.js'; +import { setPushModeEnabled } from './signalr/pushMode.js'; +import { getSubject } from './signalr/subjects.js'; require('../lib/shared_chrome_code.js'); // side-effect @@ -50,6 +53,7 @@ import { installRowVisibilityBindings } from "./bindings/rowVisibility.js"; import { installDragDropRowBindings } from "./bindings/dragDropRows.js"; import { installSortableArrayBindings } from "./bindings/sortableArray.js"; import { noBubbleFromDisabledButtonsBindings } from "./bindings/noBubble.js" +import { installFlashOnChangeBinding } from "./bindings/flashOnChange.js"; import "./bindings/fastTooltip.js"; // registers ko.bindingHandlers.fastTooltip import "./bindings/bsDropdownOpen.js"; // registers ko.bindingHandlers.bsDropdownOpen @@ -919,13 +923,12 @@ function VM() { // --- End autosuggest --- - self.filteredJobsAgainstConfig = ko.pureComputed(() => { - + // Extracted so it can be reused as a single-job admission check (e.g. for + // SignalR-pushed jobs) as well as the bulk array filter below. + self.jobMatchesConfigFilters = function (jb) { const hqIds = new Set((self.config.incidentFilters() || []).map(f => String(f.id))); const sectorIds = new Set((self.config.sectorFilters() || []).map(s => String(s.id))); - // If sector filtering is active, only include jobs in those sectors - const allowedStatus = self.config.jobStatusFilter(); // allow-list const allowedStatusSet = new Set(allowedStatus || []); const incidentTypeAllowedById = self.config.allowedIncidentTypeIds(); // allow-list (Set in ConfigVM) @@ -945,49 +948,48 @@ function VM() { end.setDate(end.getDate() + self.config.fetchForward()); + const statusName = jb.statusName(); + const jobHqId = String(jb.entityAssignedTo.id()); + const hqMatch = hqIds.size === 0 || hqIds.has(jobHqId); + // Sector filtering — only when scope includes incidents + if (self.config.applySectorsToIncidents() && sectorIds.size > 0) { + const sectorId = String(jb.sector().id()); + const sectorMatch = sectorIds.has(sectorId); + //if no sector and config says to exclude, filter out + if (!jb.sector().id() && self.config.includeIncidentsWithoutSector() === false) { + return false; + } - return ko.utils.arrayFilter(this.jobs(), jb => { - const statusName = jb.statusName(); - const jobHqId = String(jb.entityAssignedTo.id()); - const hqMatch = hqIds.size === 0 || hqIds.has(jobHqId); - - // Sector filtering — only when scope includes incidents - if (self.config.applySectorsToIncidents() && sectorIds.size > 0) { - const sectorId = String(jb.sector().id()); - const sectorMatch = sectorIds.has(sectorId); - - //if no sector and config says to exclude, filter out - if (!jb.sector().id() && self.config.includeIncidentsWithoutSector() === false) { - return false; - } + if (jb.sector().id() && !sectorMatch) return false; + } - if (jb.sector().id() && !sectorMatch) return false; - } + // If allow-list non-empty, only show jobs whose status is in it + if (allowedStatusSet.size > 0 && !allowedStatusSet.has(statusName)) { + return false; + } - // If allow-list non-empty, only show jobs whose status is in it - if (allowedStatusSet.size > 0 && !allowedStatusSet.has(statusName)) { - return false; - } + // If incident type filter non-empty, only show jobs whose type is in it + if (incidentTypeSet.size > 0 && !incidentTypeSet.has(String(jb.typeId()))) { + return false; + } - // If incident type filter non-empty, only show jobs whose type is in it - if (incidentTypeSet.size > 0 && !incidentTypeSet.has(String(jb.typeId()))) { - return false; - } + //date matching + const jobDate = new Date(jb.jobReceived()); - //date matching - const jobDate = new Date(jb.jobReceived()); + if (jobDate < start || jobDate > end) { + return false; + } - if (jobDate < start || jobDate > end) { - return false; - } + //must match HQ filter + if (!hqMatch) return false; - //must match HQ filter - if (!hqMatch) return false; + return true; + }; - return true; - }); + self.filteredJobsAgainstConfig = ko.pureComputed(() => { + return ko.utils.arrayFilter(this.jobs(), jb => self.jobMatchesConfigFilters(jb)); }).extend({ trackArrayChanges: true, rateLimit: { timeout: 50, method: 'notifyWhenChangesStop' } }); self.filteredJobs = ko.pureComputed(() => { @@ -1060,9 +1062,9 @@ function VM() { self.clearTeamSearch = () => self.teamSearch(''); - //just filtered against config not against UI searching - self.filteredTeamsAgainstConfig = ko.pureComputed(() => { - + // Extracted so it can be reused as a single-team admission check (e.g. for + // SignalR-pushed teams) as well as the bulk array filter below. + self.teamMatchesConfigFilters = function (tm) { const allowed = self.config.teamStatusFilter(); // allow-list const allowedSet = new Set(allowed || []); const hqFilterIds = new Set((self.config.teamFilters() || []).map(f => String(f.id))); @@ -1079,47 +1081,48 @@ function VM() { end.setDate(end.getDate() + self.config.fetchForward()); + const status = tm.teamStatusType()?.Name; + const teamHqId = String(tm.assignedTo().id()); + const hqMatch = hqFilterIds.size === 0 || hqFilterIds.has(teamHqId); + if (status == null) { + return false; + } + // If allow-list non-empty, only show teams whose status is in it + if (allowedSet.size > 0 && !allowedSet.has(status)) { + return false; + } - return ko.utils.arrayFilter(self.teams(), tm => { - const status = tm.teamStatusType()?.Name; - const teamHqId = String(tm.assignedTo().id()); - const hqMatch = hqFilterIds.size === 0 || hqFilterIds.has(teamHqId); - if (status == null) { - return false; - } - - // If allow-list non-empty, only show teams whose status is in it - if (allowedSet.size > 0 && !allowedSet.has(status)) { - return false; - } + //must match HQ filter + if (!hqMatch) { + return false; + } - //must match HQ filter - if (!hqMatch) { - return false; - } + // Sector filtering — only when scope includes teams + if (applySectorsToTeams && sectorIds.size > 0) { + const teamSectorId = String(tm.sector()?.id?.() || ''); + if (teamSectorId && !sectorIds.has(teamSectorId)) return false; + if (!teamSectorId && self.config.includeIncidentsWithoutSector() === false) return false; + } - // Sector filtering — only when scope includes teams - if (applySectorsToTeams && sectorIds.size > 0) { - const teamSectorId = String(tm.sector()?.id?.() || ''); - if (teamSectorId && !sectorIds.has(teamSectorId)) return false; - if (!teamSectorId && self.config.includeIncidentsWithoutSector() === false) return false; - } + const statusDate = tm.statusDate(); + if (statusDate < start || statusDate > end) { + return false; + } - const statusDate = tm.statusDate(); - if (statusDate < start || statusDate > end) { - return false; - } + return true; + }; - return true; - }); + //just filtered against config not against UI searching + self.filteredTeamsAgainstConfig = ko.pureComputed(() => { + return ko.utils.arrayFilter(self.teams(), tm => self.teamMatchesConfigFilters(tm)); }).extend({ trackArrayChanges: true, rateLimit: 50 }); self.filteredTeams = ko.pureComputed(() => { const pinnedOnlyTeams = self.showPinnedTeamsOnly(); const pinnedTeamIds = (self.config && self.config.pinnedTeamIds) ? self.config.pinnedTeamIds() : []; const pinnedTeamSet = new Set((pinnedTeamIds || []).map(id => String(id))); - console.log("Filtering teams... pinnedOnly:", pinnedOnlyTeams, "pinnedTeamIds:", pinnedTeamIds, "filteredTeamsAgainstConfig count:", self.filteredTeamsAgainstConfig().length); + console.log("Filtering teams... filteredTeamsAgainstConfig count:", self.filteredTeamsAgainstConfig().length); return ko.utils.arrayFilter(self.filteredTeamsAgainstConfig(), tm => { // pinned-only filter @@ -1455,6 +1458,15 @@ function VM() { self.config = new ConfigVM(self, configDeps); + // Set as soon as config has loaded from storage, before any Job/Team + // gets constructed, so the single-fetch cooldown (Job.js/Team.js) is + // correct from the very first fetch. Kept reactive to the toggle too -- + // cheap to do even though the SignalR connection itself only starts/ + // stops based on this value at page load (see startBeaconSignalRConnection + // below), not fully live mid-session. + setPushModeEnabled(self.config.signalrEnabled()); + self.config.signalrEnabled.subscribe((enabled) => setPushModeEnabled(enabled)); + self.sectorSelectorClick = function (sectorVm, event) { // Find the KO context for the clicked element var ctx = ko.contextFor(event.currentTarget || event.target); @@ -2023,11 +2035,11 @@ function VM() { }; // Tasking registry/upsert (NEW magical 2.0 way of doing it) - self.upsertTaskingFromPayload = function (taskingJson, { teamContext = null } = {}) { + self.upsertTaskingFromPayload = function (taskingJson, { teamContext = null, jobContext = null } = {}) { if (!taskingJson || taskingJson.Id == null) return null; // Resolve shared refs - const jobRef = self.getOrCreateJob(taskingJson.Job); + const jobRef = jobContext || self.getOrCreateJob(taskingJson.Job); //flag the team creation/update as from tasking so its not updated with stale data //otherwise we might overwrite an active team with old stuff @@ -2809,11 +2821,20 @@ function VM() { } }; + // refreshInterval's default (180s) assumes SignalR push is covering + // freshness in between polls. With push disabled there's nothing else + // keeping data current, so fall back to the old 60s cadence regardless + // of what refreshInterval is set to. + function effectiveRefreshIntervalMs() { + if (self.config.signalrEnabled() === false) return 60_000; + return Number(self.config.refreshInterval() || 60) * 1000; + } + let batchTaskingTimer = null; function startBatchTaskingTimer() { if (batchTaskingTimer) clearInterval(batchTaskingTimer); - const interval = Number(self.config.refreshInterval() || 60) * 1000; + const interval = effectiveRefreshIntervalMs(); batchTaskingTimer = setInterval(() => { self.fetchBatchJobTasking(); }, interval); @@ -2909,6 +2930,13 @@ function VM() { myViewModel.jobsLoading(true); + // A push can land for one of these jobs while this request is in + // flight -- its data would already be fresher than what this + // response carries, so anything updated after this point gets + // skipped in the per-page merge below rather than clobbered back to + // this request's (now stale) snapshot. + const pollStartTime = Date.now(); + const t = await getToken(); // blocks here until token is ready const paramsArray = [ @@ -2949,6 +2977,11 @@ function VM() { //console.log("Progress: " + _val + " / " + _total) }, function (jobs) { //call back as they come in per page jobs.Results.forEach(function (t) { + const existing = myViewModel.jobsById.get(t.Id); + if (existing && existing.lastDataUpdate().getTime() > pollStartTime) { + console.log("Skipping poll merge for job", t.Id, "-- fresher push data already applied"); + return; + } myViewModel.getOrCreateJob(t); }) }) @@ -2968,6 +3001,9 @@ function VM() { start.setMinutes(start.getMinutes() + 5); // slight overlap to catch late updates and drift end.setDate(end.getDate() + self.config.fetchForward()); myViewModel.teamsLoading(true); + // See fetchAllJobsData -- protects against a push landing on one of + // these teams while this request is in flight. + const pollStartTime = Date.now(); const t = await getToken(); // blocks here until token is ready BeaconClient.team.teamSearch(hqsFilter, apiHost, start, end, params.userId, t, function (teams) { // teams.Results.forEach(function (t) { @@ -3001,6 +3037,11 @@ function VM() { statusFilterToView, //status filter function (teams) { //per page teams.Results.forEach(function (t) { + const existing = myViewModel.teamsById.get(t.Id); + if (existing && existing.lastDataUpdate.getTime() > pollStartTime) { + console.log("Skipping poll merge for team", t.Id, "-- fresher push data already applied"); + return; + } myViewModel.getOrCreateTeam(t); }) } @@ -3027,8 +3068,7 @@ function VM() { // clear old timer if (jobsTeamsTimer) clearInterval(jobsTeamsTimer); - // interval in seconds → ms - const interval = Number(self.config.refreshInterval() || 60) * 1000; + const interval = effectiveRefreshIntervalMs(); jobsTeamsTimer = setInterval(() => { self.fetchAllJobsData(); @@ -3047,6 +3087,16 @@ function VM() { startBatchTaskingTimer(); }); + // Same for the signalrEnabled toggle -- effectiveRefreshIntervalMs() + // depends on it too, and this takes effect immediately even though the + // SignalR connection itself only starts/stops based on this value at + // page load. + self.config.signalrEnabled.subscribe(() => { + console.log("signalrEnabled changed → restarting timers"); + startJobsTeamsTimer(); + startBatchTaskingTimer(); + }); + const tagFetchPromises = Object.keys(Enum.TagGroup).map((key) => { @@ -3685,6 +3735,7 @@ document.addEventListener('DOMContentLoaded', function () { }); //get tokens + let signalrStarted = false; BeaconToken.fetchBeaconTokenAndKeepReturningValidTokens( apiHost, params.source, @@ -3692,6 +3743,23 @@ document.addEventListener('DOMContentLoaded', function () { console.log("Fetched Beacon token," + rToken); setToken(rToken, rExp); myViewModel?.tokenLoading(false); + + if (!signalrStarted) { + signalrStarted = true; + if (myViewModel?.config?.signalrEnabled() === false) { + console.log('[SignalR] disabled via config -- not connecting'); + } else { + const negotiateUrl = params.signalr; + if (!negotiateUrl) { + console.warn('[SignalR] no signalr param on the page URL -- skipping connection'); + } else { + // Closure over the module-level `token` var (kept current by + // setToken() on every renewal), so each reconnect/negotiate + // re-authenticates with whatever token is live at that moment. + window.__beaconSignalRConnection = startBeaconSignalRConnection(negotiateUrl, () => token); + } + } + } } ); @@ -3714,6 +3782,7 @@ document.addEventListener('DOMContentLoaded', function () { installDragDropRowBindings(); noBubbleFromDisabledButtonsBindings(); installSortableArrayBindings(); + installFlashOnChangeBinding(); registerAcronymTextBinding(); ko.bindingProvider.instance = new ksb(options); @@ -3721,6 +3790,209 @@ document.addEventListener('DOMContentLoaded', function () { ko.options.deferUpdates = true; myViewModel = new VM(); + // Connection status, surfaced in the UI as a persistent banner (see + // tasking.html) -- SignalR is how the whole page gets live data, so + // any state other than 'connected' needs to be very obvious rather + // than fail silently. + myViewModel.signalrStatus = ko.observable(getConnectionStatus()); + connectionStatus.subscribe((status) => myViewModel.signalrStatus(status)); + // Deliberately disabled isn't "disconnected" -- only show the banner + // when the feature is meant to be running but isn't. + myViewModel.signalrDisconnected = ko.pureComputed(() => + myViewModel.config.signalrEnabled() && myViewModel.signalrStatus() !== 'connected' + ); + myViewModel.signalrStatusText = ko.pureComputed(() => { + switch (myViewModel.signalrStatus()) { + case 'connecting': return 'Connecting to live updates…'; + case 'reconnecting': return 'Live updates disconnected — reconnecting…'; + case 'disconnected': return 'Live updates disconnected — data may be out of date'; + default: return ''; + } + }); + + // Wire pushed SignalR events into the live view model. Polling stays + // running in parallel as a safety net -- if a payload shape guess is + // wrong or an event is missed, the next poll cycle self-heals it. + // jobCreated/jobUpdated aren't scoped by our HQ/status/sector/type/date + // filters the way polling's own query is, so a push can arrive for a + // job REST would never have fetched. Admit it, then evict it again if + // it's genuinely new and doesn't pass the same filter polling applies + // server-side -- but never evict a job we already had tracked, since + // an in-scope job simply changing to an out-of-filter state should + // stay tracked and just flip isFilteredIn (handled reactively). + // jobCreated/jobUpdated/jobRejected's actual payload is a Notification + // record -- Id is the notification's own id, the job's real id is + // JobId -- not a job view-model despite the "vm" parameter name in + // Beacon's own source. Remap to the fields Job.js understands before + // merging; passing the raw notification straight into getOrCreateJob + // would key it on the notification's id instead of the job's, + // silently creating a phantom job entry instead of updating the real + // one (confirmed live -- this is why status updates weren't landing). + const mapJobNotificationToJobJson = (n) => ({ + Id: n.JobId, + Identifier: n.JobIdentifier, + ICEMSIncidentIdentifier: n.ICEMSIncidentIdentifier, + JobPriorityTypeId: n.JobPriorityTypeId, + JobStatusTypeId: n.JobStatusTypeId, + EntityAssignedTo: n.Entity, + }); + + const admitJobIfInFilter = (notification) => { + const jobJson = mapJobNotificationToJobJson(notification); + const alreadyTracked = myViewModel.jobsById.has(jobJson.Id); + const job = myViewModel.getOrCreateJob(jobJson); + if (!alreadyTracked && !myViewModel.jobMatchesConfigFilters(job)) { + myViewModel.jobsById.delete(job.id()); + myViewModel.jobs.remove(job); + return null; // evicted -- out of filter, not tracked + } + return job; + }; + + // jobCreated is the one case where a full fetch is worth it: the + // notification carries enough (status/priority/entity) to check it + // against the current filters without hitting the network, but not + // enough to populate a real job (no Address/Sector/Tags/Categories). + // So admit-or-evict using just those fields first -- same as + // admitJobIfInFilter -- and only pay for a REST round-trip when it's + // both genuinely new AND actually in scope. + const admitJobCreatedIfInFilter = (notification) => { + const jobJson = mapJobNotificationToJobJson(notification); + const alreadyTracked = myViewModel.jobsById.has(jobJson.Id); + const job = myViewModel.getOrCreateJob(jobJson); + if (alreadyTracked) return; // duplicate/late delivery, already handled elsewhere + + if (!myViewModel.jobMatchesConfigFilters(job)) { + myViewModel.jobsById.delete(job.id()); + myViewModel.jobs.remove(job); + return; + } + + // force: true -- a brand-new job's cooldown starts at 0 so this + // would pass unforced anyway, but forcing makes the intent + // explicit: this fetch must happen to backfill what this + // notification-derived job is missing (Address/Sector/Tags/...). + job.refreshData({ force: true }); + }; + + getSubject('jobCreated').subscribe(admitJobCreatedIfInFilter); + + // jobUpdated's notification payload is a fixed field set (status/ + // priority/entity/identifier) regardless of what actually changed -- + // it doesn't say whether the real change was e.g. the address or + // sector, which aren't in it at all. The merge above still applies + // immediately (fast status/priority display, and it's what the + // filter check needs), but pull a full copy too so nothing outside + // that fixed set goes silently stale. force: true -- same rule as + // every other push-triggered refresh: SignalR telling us this + // specific job changed is authoritative, not a generic maybe-stale + // trigger, so it shouldn't get swallowed by the cooldown that + // exists to dedupe redundant timer/click refreshes. + getSubject('jobUpdated').subscribe((notification) => { + const job = admitJobIfInFilter(notification); + job?.refreshData({ force: true }); + }); + + getSubject('jobRejected').subscribe(admitJobIfInFilter); + // Same reasoning as admitJobIfInFilter -- team search is also + // HQ/status/sector/date filtered server-side by polling, so a pushed + // team could be out of scope. Only evict on first admission, never + // once a team's already tracked. + const admitTeamIfInFilter = (message) => { + const alreadyTracked = myViewModel.teamsById.has(message.Id); + const team = myViewModel.getOrCreateTeam(message); + if (!team) return; + if (!alreadyTracked && !myViewModel.teamMatchesConfigFilters(team)) { + myViewModel.teamsById.delete(team.id()); + myViewModel.teams.remove(team); + } + }; + getSubject('teamCreated').subscribe(admitTeamIfInFilter); + getSubject('teamUpdated').subscribe(admitTeamIfInFilter); + const upsertTaskingFromPush = (message) => { + const jobRef = myViewModel.jobsById.get(message.JobId); + const teamRef = myViewModel.teamsById.get(message.TeamId); + + if (jobRef && teamRef) { + // Both sides are already tracked locally, so the push payload + // alone is enough -- upsertTaskingFromPayload patches an + // existing tasking (updateFrom, field-by-field) or constructs + // and links a new one, using the refs we already have instead + // of the nested {Job, Team} objects it normally expects (this + // flat payload doesn't carry those). No network round-trip. + myViewModel.upsertTaskingFromPayload(message, { jobContext: jobRef, teamContext: teamRef }); + + // Tasking status changes (Enroute/Onsite/etc) are closely + // tied to the team's own state, but this payload doesn't + // carry the team's own fields -- pull a fresh copy of the + // whole team. force: true -- SignalR telling us this + // specific thing changed is an authoritative signal, not a + // generic "might be stale" trigger like an expand-click or + // timer, so it shouldn't get swallowed by a cooldown that + // happened to be bumped by something unrelated moments ago. + teamRef.refreshData({ force: true }); + return; + } + + // Job or team itself isn't tracked locally -- nothing to link + // the tasking to, so fall back to their normal refresh path, + // forced for the same reason as above. + jobRef?.refreshDataAndTasking({ force: true }); + teamRef?.refreshDataAndTasking({ force: true }); + }; + + getSubject('taskingUpdated').subscribe(upsertTaskingFromPush); + // Beacon registers created/updated pairs for jobs and teams, but only + // taskingUpdated showed up in the handlers we've seen so far -- if + // there's a symmetric taskingCreated we're not aware of yet, this + // catches it too rather than silently missing new taskings. Harmless + // no-op if that name doesn't exist. + getSubject('taskingCreated').subscribe(upsertTaskingFromPush); + + // Refresh the job timeline modal's ops log lane if it's open and + // showing the job this entry belongs to. jobTimelineVM.job() holds + // whatever job the modal was last opened for, which lingers after + // close, so also check the modal is actually visible right now + // before treating it as "open for that incident". + getSubject('opsLogUpdated').subscribe((message) => { + const timelineVm = myViewModel.jobTimelineVM; + const openJob = timelineVm?.job(); + const modalVisible = document.getElementById('jobTimelineModal')?.classList.contains('show'); + if (openJob && modalVisible && openJob.id() === message.JobId) { + timelineVm.refreshCurrentJob({ silent: true }); + } + }); + + // ICEMS unaccepted-notifications refresh: refreshUnacceptedNotifications + // itself already no-ops for non-ICEMS jobs (guards on + // icemsIncidentIdentifier), and a full refetch correctly reflects + // either a new IUM arriving or an existing one being acknowledged + // (by us or someone else) without needing to distinguish which. + const refreshUnacceptedNotificationsFromPush = (message) => { + const job = myViewModel.jobsById.get(message.JobId); + job?.refreshUnacceptedNotifications(); + // Push already did what the 30s poll would have -- re-arm it + // for another full 30s from now instead of letting it fire + // again moments later. + job?.resetUnacceptedNotificationsPolling(); + }; + getSubject('NotificationAcknowledged').subscribe(refreshUnacceptedNotificationsFromPush); + getSubject('IUMReceived').subscribe(refreshUnacceptedNotificationsFromPush); + getSubject('UrgentIUMReceived').subscribe(refreshUnacceptedNotificationsFromPush); + + // ICEMS agency data: no periodic poll at all now, so apply every + // push regardless of expanded state -- otherwise a collapsed job's + // data goes stale and never catches up until its next expand. + // refreshIcemsIncident() itself already no-ops for non-ICEMS jobs. + // force: true -- an authoritative push shouldn't get swallowed by + // the same cooldown that gates the expand-triggered call. + const refreshIcemsIncidentFromPush = (message) => { + myViewModel.jobsById.get(message.JobId)?.refreshIcemsIncident({ force: true }); + }; + getSubject('rsuReceived').subscribe(refreshIcemsIncidentFromPush); + getSubject('iuaReceived').subscribe(refreshIcemsIncidentFromPush); + getSubject('isuReceived').subscribe(refreshIcemsIncidentFromPush); + ko.applyBindings(myViewModel); // Alerts overlay diff --git a/src/pages/tasking/markers/jobMarker.js b/src/pages/tasking/markers/jobMarker.js index 60246847..d4f8c267 100644 --- a/src/pages/tasking/markers/jobMarker.js +++ b/src/pages/tasking/markers/jobMarker.js @@ -57,7 +57,7 @@ export function addOrUpdateJobMarker(ko, map, vm, job) { const key = JSON.stringify(style); if (m._styleKey !== key) { m.setIcon(makeShapeIcon(style)); m._styleKey = key; } m._priorityColor = style.fill || '#6b7280'; - if (!m._popupBound) { m.setPopupContent(node); wireKoForPopup(ko, m, job, vm, popupVM); } + if (!m._popupBound) { m.setPopupContent(node); wireKoForPopup(ko, m, job, vm, vm.mapVM.makeJobPopupVM(job)); } // keep the "New" ring and _isNew flag in correct state upsertPulseRing(pulseLayer, job, m); @@ -154,6 +154,11 @@ export function removeJobMarker(vm, jobOrId) { const m = markers.get(id); if (!m) return; + if (m._pendingUnbindTimer) { + clearTimeout(m._pendingUnbindTimer); + m._pendingUnbindTimer = null; + } + // dispose KO subscriptions (m._subs || []).forEach(s => { try { s.dispose?.(); } catch { /* empty */ } }); m._subs = []; @@ -291,6 +296,14 @@ function wireKoForPopup(ko, marker, job, vm, popupVM) { if (marker._koWired) return; marker.on('popupopen', e => { const el = e.popup.getContent(); + // A reopen (e.g. a double-click toggling closed->open again) can + // land inside the 250ms deferred-unbind window below. If so, the + // pending unbind is now stale -- cancel it, or it'll fire later and + // ko.cleanNode/reset a popup that's live and visibly open again. + if (marker._pendingUnbindTimer) { + clearTimeout(marker._pendingUnbindTimer); + marker._pendingUnbindTimer = null; + } vm.mapVM.setOpen?.('job', job); bindKoToPopup(ko, popupVM, el); job.onPopupOpen && job.onPopupOpen(); @@ -319,8 +332,13 @@ function wireKoForPopup(ko, marker, job, vm, popupVM) { }); marker.on('popupclose', e => { const el = e.popup.getContent(); - // Defer unbinding to after the close animation completes - setTimeout(() => { + // Defer unbinding to after the close animation completes. Tracked + // on the marker so a fast reopen (see 'popupopen' above) can cancel + // it -- otherwise this fires after the reopen and tears down a + // popup that's live and visibly open again. + if (marker._pendingUnbindTimer) clearTimeout(marker._pendingUnbindTimer); + marker._pendingUnbindTimer = setTimeout(() => { + marker._pendingUnbindTimer = null; unbindKoFromPopup(ko, el); }, 250); // 250ms matches Leaflet's default fade animation job.onPopupClose && job.onPopupClose(); diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js index 6f2fc13c..9baa303a 100644 --- a/src/pages/tasking/models/Job.js +++ b/src/pages/tasking/models/Job.js @@ -12,6 +12,7 @@ import { jobsToUI } from "../utils/jobTypesToUI.js"; import { InstantTaskViewModel } from '../viewmodels/InstantTask.js'; import { Enum } from "../utils/enum.js"; +import { getSingleFetchCooldownMs } from "../signalr/pushMode.js"; export function Job(data = {}, deps = {}) { const self = this; @@ -110,6 +111,18 @@ export function Job(data = {}, deps = {}) { // ---- ICEMS agencies involved ---- self._icemsAgenciesRaw = ko.observableArray([]); + // Unlike _findEnumDescription below, returns undefined (not a placeholder) + // on no match -- callers use this to decide whether to overwrite an + // existing value at all, so a fake "Unknown" entry would be worse than + // just leaving the previous value in place. + function _resolveEnumById(enumObj, id) { + if (id == null) return undefined; + for (const key in enumObj) { + if (enumObj[key].Id === id) return enumObj[key]; + } + return undefined; + } + function _findEnumDescription(enumObj, id) { for (const key in enumObj) { if (enumObj[key].Id === id) return enumObj[key]; @@ -340,11 +353,18 @@ export function Job(data = {}, deps = {}) { self.lastDataUpdate = observable(new Date()); self.lastTaskingDataUpdate = new Date(); - - // Minimum cooldown (ms) between single-job tasking fetches. - // Bulk/batch refreshes update lastTaskingDataUpdate directly, - // so this gate also prevents a single fetch right after a batch. - const SINGLE_FETCH_COOLDOWN_MS = 10_000; + // Separate from lastDataUpdate -- that's bumped by updateFromJson on + // every merge (push or fetch), which refreshData()'s cooldown can't use + // directly: a caller that merges push data and then calls refreshData() + // in the same tick would always see "just updated" and never actually + // fetch. This only tracks real REST fetches. + let _lastRefreshDataFetch = 0; + self.lastIcemsUpdate = 0; + + // Minimum cooldown (ms) between single-job tasking fetches. Bulk/batch + // refreshes update lastTaskingDataUpdate directly, so this gate also + // prevents a single fetch right after a batch. Tighter when SignalR + // push is disabled -- see pushMode.js. self.drawJobTargetRing = function () { drawJobTargetRing(self); @@ -421,11 +441,27 @@ export function Job(data = {}, deps = {}) { unacceptedNotificationsInterval.stop(); }; - // ---- ICEMS INCIDENT POLLING (agencies involved) ---- - self.refreshIcemsIncident = async function () { + self.resetUnacceptedNotificationsPolling = function () { + unacceptedNotificationsInterval.reset(); + }; + + // ICEMS agency data: refreshed when the job is expanded (toggleAndLoad) + // and by push (rsuReceived/iuaReceived/isuReceived in main.js) -- no + // periodic poll. Push calls pass force:true (an authoritative "this + // changed" signal shouldn't get swallowed by a cooldown); the + // expand-triggered call doesn't, so re-expanding right after push + // already refreshed it is a no-op. + self.refreshIcemsIncident = async function (opts = {}) { const icemsId = self.icemsIncidentIdentifier(); if (!icemsId) return; + const force = opts.force === true; + if (!force && Date.now() - self.lastIcemsUpdate < getSingleFetchCooldownMs()) { + console.log("Skipping ICEMS incident fetch for job", self.id(), "due to cooldown"); + return; + } + self.lastIcemsUpdate = Date.now(); + try { const data = await fetchIcemsIncident(icemsId); if (data && Array.isArray(data.AgenciesInvolved)) { @@ -436,19 +472,6 @@ export function Job(data = {}, deps = {}) { } }; - const icemsIncidentInterval = makeFilteredInterval(() => { - if (!self.icemsIncidentIdentifier()) return; - self.refreshIcemsIncident(); - }, 30000, { runImmediately: true }); - - self.startIcemsIncidentPolling = function () { - icemsIncidentInterval.start(); - }; - - self.stopIcemsIncidentPolling = function () { - icemsIncidentInterval.stop(); - }; - // Unaccepted notifications polling: filtered in + ICEMS id (original logic) self.shouldPollUnacceptedNotifications = ko.pureComputed(() => { @@ -463,19 +486,6 @@ export function Job(data = {}, deps = {}) { } }); - // ICEMS agency polling: filtered in + expanded + ICEMS id (new logic) - self.shouldPollIcemsIncident = ko.pureComputed(() => { - return self.icemsIncidentIdentifier() && self.isFilteredIn() && self.expanded(); - }); - - self.shouldPollIcemsIncident.subscribe((shouldPoll) => { - if (shouldPoll) { - self.startIcemsIncidentPolling(); - } else { - self.stopIcemsIncidentPolling(); - } - }); - self.toggleAndLoad = function () { if (!self.expanded()) { self.fetchTasking(); @@ -673,8 +683,23 @@ export function Job(data = {}, deps = {}) { if (d.ICEMSIncidentIdentifier !== undefined) setIfChanged(this.icemsIncidentIdentifier, d.ICEMSIncidentIdentifier || null); // structured - if (d.JobPriorityType !== undefined) setIfChanged(this.jobPriorityType, d.JobPriorityType || null); - if (d.JobStatusType !== undefined) setIfChanged(this.jobStatusType, d.JobStatusType || null); + if (d.JobPriorityType !== undefined) { + setIfChanged(this.jobPriorityType, d.JobPriorityType || null); + } else if (d.JobPriorityTypeId !== undefined) { + // Some pushes (e.g. jobUpdated) send only the id, not the full + // {Id,Name,Description} object -- resolve it from the static + // enum instead of leaving jobPriorityType()/priorityName() stale. + const resolved = _resolveEnumById(Enum.JobPriorityType, d.JobPriorityTypeId); + if (resolved) setIfChanged(this.jobPriorityType, resolved); + } + + if (d.JobStatusType !== undefined) { + setIfChanged(this.jobStatusType, d.JobStatusType || null); + } else if (d.JobStatusTypeId !== undefined) { + const resolved = _resolveEnumById(Enum.JobStatusType, d.JobStatusTypeId); + if (resolved) setIfChanged(this.jobStatusType, resolved); + } + if (d.JobType !== undefined) setIfChanged(this.jobType, d.JobType || null); if (d.EntityAssignedTo !== undefined) { @@ -685,8 +710,17 @@ export function Job(data = {}, deps = {}) { setIfChanged(this.entityAssignedTo.latitude, ea?.Latitude ?? null); setIfChanged(this.entityAssignedTo.longitude, ea?.Longitude ?? null); - // Correct handling of ParentEntity - if (ea.ParentEntity !== null) { + // Correct handling of ParentEntity -- distinguish "absent from + // this (possibly partial) payload" from "explicitly null" (REST + // responses always include the key; a push-derived Entity like + // {Id, Code, Name} may simply not carry it at all). + if (ea.ParentEntity === undefined) { + // not part of this payload -- leave existing state as-is + } else if (ea.ParentEntity === null) { + if (this.entityAssignedTo.parentEntity() !== null) { + this.entityAssignedTo.parentEntity(null); + } + } else { const existingParent = this.entityAssignedTo.parentEntity(); if (existingParent) { setIfChanged(existingParent.id, ea.ParentEntity.Id ?? null); @@ -695,10 +729,6 @@ export function Job(data = {}, deps = {}) { } else { this.entityAssignedTo.parentEntity(new Entity(ea.ParentEntity)); } - } else { - if (this.entityAssignedTo.parentEntity() !== null) { - this.entityAssignedTo.parentEntity(null); - } } } @@ -847,10 +877,10 @@ export function Job(data = {}, deps = {}) { self.collapse(); }; - self.refreshDataAndTasking = function () { - self.fetchTasking(); - self.refreshData(); - self.refreshIcemsIncident(); + self.refreshDataAndTasking = function (opts = {}) { + self.fetchTasking(opts); + self.refreshData(opts); + self.refreshIcemsIncident(opts); } self.fetchTasking = function (opts = {}) { @@ -858,7 +888,7 @@ export function Job(data = {}, deps = {}) { if (!force) { const now = Date.now(); const last = self.lastTaskingDataUpdate?.getTime?.() ?? 0; - if (now - last < SINGLE_FETCH_COOLDOWN_MS) { + if (now - last < getSingleFetchCooldownMs()) { console.log("Skipping tasking fetch for job", self.id(), "due to cooldown"); return; // recently refreshed (single or batch), skip } @@ -869,7 +899,13 @@ export function Job(data = {}, deps = {}) { }); }; - self.refreshData = async function () { + self.refreshData = async function (opts = {}) { + const force = opts.force === true; + if (!force && Date.now() - _lastRefreshDataFetch < getSingleFetchCooldownMs()) { + console.log("Skipping job data fetch for job", self.id(), "due to cooldown"); + return; + } + _lastRefreshDataFetch = Date.now(); self.dataLoading(true); fetchUnresolvedActionsLog(self); fetchJobById(self.id(), () => { @@ -907,7 +943,19 @@ export function Job(data = {}, deps = {}) { } }; - return { start, stop }; + // Re-arms the interval for another full intervalMs from now, without + // firing immediately (unlike start()) -- for when something else + // (e.g. a push) already just did what this timer would have done, + // so the next scheduled tick shouldn't land moments later. Only has + // an effect if already polling; never starts a new poll. + const reset = () => { + if (!handle) return; + if (!self.isFilteredIn()) return; + clearInterval(handle); + handle = setInterval(tick, intervalMs); + }; + + return { start, stop, reset }; } } diff --git a/src/pages/tasking/models/Tasking.js b/src/pages/tasking/models/Tasking.js index 8dd483aa..50774ade 100644 --- a/src/pages/tasking/models/Tasking.js +++ b/src/pages/tasking/models/Tasking.js @@ -4,6 +4,7 @@ import moment from "moment"; import L from "leaflet"; import { openURLInBeacon } from '../utils/chromeRunTime.js'; import { showAlert } from '../components/windowAlert.js'; +import { Enum } from "../utils/enum.js"; @@ -89,7 +90,10 @@ export function Tasking(data = {}) { self.isComplete = ko.pureComputed(() => !!self.complete()); // convenience proxies - self.teamCallsign = ko.pureComputed(() => self.team.callsign()); + // self.team can be null -- a tasking may be upserted before its team + // reference resolves (e.g. a REST tasking record with no/partial Team + // data), so these must not assume it's set. + self.teamCallsign = ko.pureComputed(() => self.team ? self.team.callsign() : ''); self.jobIdentifier = ko.pureComputed(() => self.job.identifier()); self.jobTypeName = ko.pureComputed(() => self.job.typeName()); self.jobPriority = ko.pureComputed(() => self.job.priorityName()); @@ -101,13 +105,21 @@ export function Tasking(data = {}) { self.hasJob = ko.pureComputed(() => !!self.job.isFilteredIn()); //same same but different ^ - self.hasTeam = ko.pureComputed(() => !!self.team.isFilteredIn()); + self.hasTeam = ko.pureComputed(() => !!(self.team && self.team.isFilteredIn())); // patch model with partial updates self.updateFrom = (patch = {}) => { - if (patch.CurrentStatus !== undefined) self.currentStatus(patch.CurrentStatus); + if (patch.CurrentStatus !== undefined) { + self.currentStatus(patch.CurrentStatus); + } else if (patch.CurrentStatusId !== undefined) { + // Some pushes (e.g. taskingUpdated) send only the id, not the + // status name -- resolve it from the static enum so isTasked()/ + // isEnroute()/etc (which key off the string) don't go stale. + const resolved = Object.values(Enum.JobTeamStatusType).find(s => s.Id === patch.CurrentStatusId); + if (resolved) self.currentStatus(resolved.Name); + } if (patch.CurrentStatusTime !== undefined) self.currentStatusTime(patch.CurrentStatusTime); if (patch.CurrentStatusId !== undefined) self.currentStatusId(patch.CurrentStatusId); if (patch.EstimatedStatusEndTime !== undefined) self.estimatedStatusEndTime(patch.EstimatedStatusEndTime); diff --git a/src/pages/tasking/models/Team.js b/src/pages/tasking/models/Team.js index 541fea84..330efd80 100644 --- a/src/pages/tasking/models/Team.js +++ b/src/pages/tasking/models/Team.js @@ -9,6 +9,7 @@ import { openURLInBeacon } from '../utils/chromeRunTime.js'; import { Enum } from '../utils/enum.js'; import { loadSharedMapping, saveSharedMapping, pushSharedDefault, fetchSharedDefaults } from '../utils/defaultAssetSync.js'; +import { getSingleFetchCooldownMs } from '../signalr/pushMode.js'; // Shared across all Team instances — single localStorage key const _capKey = 'lh_showCapabilities'; @@ -94,6 +95,15 @@ export function Team(data = {}, deps = {}) { const self = this; self.lastTaskingDataUpdate = new Date(); + // Cooldown between single-team on-demand fetches (refreshData/ + // fetchTasking), whether triggered by expand/popup-open or by a push + // already having delivered fresh data -- tighter when SignalR push is + // disabled, see pushMode.js. + // Bumped by updateFromJson on every merge regardless of source (push or + // fetch) -- lets refreshData() skip a redundant expand/popup-open + // refetch right after a push already delivered fresh data. + self.lastDataUpdate = new Date(); + const { upsertTasking, getTeamTasking, @@ -282,7 +292,12 @@ export function Team(data = {}, deps = {}) { self.rowHasFocus(false); } - self.refreshData = async function () { + self.refreshData = async function (opts = {}) { + const force = opts.force === true; + if (!force && Date.now() - self.lastDataUpdate.getTime() < getSingleFetchCooldownMs()) { + console.log(`[Team ${self.id.peek()}] refreshData throttled — last refreshed ${Date.now() - self.lastDataUpdate.getTime()}ms ago`); + return; + } self.taskingLoading(true); fetchTeamById(self.id(), () => { self.taskingLoading(false); @@ -290,9 +305,9 @@ export function Team(data = {}, deps = {}) { }; - self.refreshDataAndTasking = function () { - self.fetchTasking(); - self.refreshData(); + self.refreshDataAndTasking = function (opts = {}) { + self.fetchTasking(opts); + self.refreshData(opts); // If this team has multiple assets, refresh shared default mapping if (_apiUrl && (self.trackableAssets?.() || []).length > 1) { @@ -532,7 +547,10 @@ export function Team(data = {}, deps = {}) { }); self.updateStatusById = function (statusId) { - const status = Enum.TeamStatusType.some(s => s.Id === statusId); + // TeamStatusType is a plain object keyed by name, not an array -- + // .some() would either throw or (if it somehow ran) return a + // boolean, not the matching entry. + const status = Object.values(Enum.TeamStatusType).find(s => s.Id === statusId); if (status) { self.teamStatusType(status); } @@ -548,7 +566,7 @@ export function Team(data = {}, deps = {}) { const lastFetch = self._lastFetchTaskingTime || 0; const lastData = self.lastTaskingDataUpdate?.getTime?.() ?? 0; const lastRefresh = Math.max(lastFetch, lastData); - if (!force && now - lastRefresh < 10000) { + if (!force && now - lastRefresh < getSingleFetchCooldownMs()) { console.log(`[Team ${self.id.peek()}] fetchTasking throttled — last refreshed ${now - lastRefresh}ms ago`); self.taskingLoading(false); // clear loading state since data is already fresh return; @@ -626,12 +644,22 @@ export function Team(data = {}, deps = {}) { } Team.prototype.updateFromJson = function (d = {}) { + this.lastDataUpdate = new Date(); if (d.Id !== undefined && d.Id !== this.id()) this.id(d.Id); if (d.TaskedJobCount !== undefined && d.TaskedJobCount !== this.taskedJobCount()) this.taskedJobCount(d.TaskedJobCount); if (d.Callsign !== undefined && d.Callsign !== this.callsign()) this.callsign(d.Callsign); if (d.TeamStatusType !== undefined) { + let status = d.TeamStatusType; + if (status && status.Name === undefined && status.Id != null) { + // Some pushes (e.g. teamCreated/teamUpdated) send a reduced + // {Id} object rather than the full {Id,Name,Description} -- + // resolve the full entry from the static enum instead of + // storing the partial object, which would leave + // teamStatusType().Name (and therefore status display) blank. + status = Object.values(Enum.TeamStatusType).find(s => s.Id === status.Id) || status; + } const cur = this.teamStatusType(); - if (!cur || cur.Id !== d.TeamStatusType?.Id) this.teamStatusType(d.TeamStatusType); + if (!cur || cur.Id !== status?.Id) this.teamStatusType(status); } if (d.Members !== undefined) { // Members is an array of objects — compare by length + leader/person ids diff --git a/src/pages/tasking/signalr/connection.js b/src/pages/tasking/signalr/connection.js new file mode 100644 index 00000000..119b2904 --- /dev/null +++ b/src/pages/tasking/signalr/connection.js @@ -0,0 +1,101 @@ +import * as signalR from '@microsoft/signalr'; +import { getSubject } from './subjects.js'; +import { KNOWN_EVENTS } from './knownEvents.js'; +import { Subject } from './subject.js'; + +const RETRY_DELAYS_MS = [0, 2000, 5000, 10000, 15000, 30000]; + +// @microsoft/signalr's default retry policy gives up (permanently closes +// the connection) after a handful of attempts. This connection is how the +// whole page gets live data, so it should never stop trying -- backs off +// to 30s between attempts but keeps retrying forever (never returns null, +// which is the client's signal to stop). +class InfiniteBackoffRetryPolicy { + nextRetryDelayInMilliseconds(retryContext) { + const i = Math.min(retryContext.previousRetryCount, RETRY_DELAYS_MS.length - 1); + return RETRY_DELAYS_MS[i]; + } +} + +let connection = null; + +// 'connecting' | 'connected' | 'reconnecting' | 'disconnected' +export const connectionStatus = new Subject(); +let currentStatus = 'disconnected'; +function setStatus(status) { + currentStatus = status; + connectionStatus.next(status); +} +export function getConnectionStatus() { + return currentStatus; +} + +let manualRestartTimer = null; + +function scheduleManualRestart(negotiateUrl, getAccessToken) { + // Safety net for the case onclose fires anyway (e.g. .stop() was called, + // or the very first negotiate/handshake failed before the retry policy + // above ever got a chance to run) -- keep trying rather than silently + // leaving the page without live updates. + if (manualRestartTimer) return; + manualRestartTimer = setTimeout(() => { + manualRestartTimer = null; + console.log('[SignalR] attempting manual restart after close/failed start'); + connection = null; // allow a fresh HubConnection to be built + startBeaconSignalRConnection(negotiateUrl, getAccessToken); + }, 5000); +} + +/** + * Creates (on first call) and starts the persistent Beacon Comms SignalR + * connection, routing any recognised push into the subject registry. + * negotiateUrl comes from the page's own query params (see main.js). + * getAccessToken is called on every (re)negotiate, so pass a closure over + * the live token rather than a captured string. + */ +export function startBeaconSignalRConnection(negotiateUrl, getAccessToken) { + if (connection) return connection; + + connection = new signalR.HubConnectionBuilder() + .withUrl(negotiateUrl, { accessTokenFactory: getAccessToken }) + .withAutomaticReconnect(new InfiniteBackoffRetryPolicy()) + .configureLogging(signalR.LogLevel.Information) + .build(); + + KNOWN_EVENTS.forEach((eventName) => { + connection.on(eventName, (payload) => { + console.log('[SignalR]', eventName, payload); + getSubject(eventName).next(payload); + }); + }); + + connection.onreconnecting((error) => { + console.log('[SignalR] reconnecting', error); + setStatus('reconnecting'); + }); + connection.onreconnected((connectionId) => { + console.log('[SignalR] reconnected, connectionId=', connectionId); + setStatus('connected'); + }); + connection.onclose((error) => { + console.log('[SignalR] closed', error); + setStatus('disconnected'); + scheduleManualRestart(negotiateUrl, getAccessToken); + }); + + setStatus('connecting'); + connection.start() + .then(() => { + console.log('[SignalR] connected, connectionId=', connection.connectionId); + setStatus('connected'); + }) + .catch((err) => { + console.error('[SignalR] failed to connect:', err); + setStatus('disconnected'); + scheduleManualRestart(negotiateUrl, getAccessToken); + }); + + return connection; +} + +export { getSubject }; diff --git a/src/pages/tasking/signalr/knownEvents.js b/src/pages/tasking/signalr/knownEvents.js new file mode 100644 index 00000000..16cde19e --- /dev/null +++ b/src/pages/tasking/signalr/knownEvents.js @@ -0,0 +1,62 @@ +// Confirmed hub method names, taken directly from Beacon's own frontend +// source (its connection.on(...) registrations against this same hub). +// SignalR's JS client matches method names case-insensitively, so casing +// here doesn't matter for dispatch -- kept matching Beacon's own casing +// for clarity when comparing against their source. +// +// Payload shapes, per Beacon's own handlers: +// jobCreated(vm) -- full job view-model object +// jobUpdated(vm) -- full job view-model object +// jobRejected(vm) -- also a full job view-model object (Id, JobId, Entity, +// JobPriorityTypeId, JobStatusTypeId, JobIdentifier, +// Latitude, Longitude, JobAddress, ...), same shape +// family as jobCreated/jobUpdated +// teamCreated(message) -- confirmed live: a genuinely full Team object +// (Id, Callsign, AssignedTo/CreatedAt, TeamStatusType: {Id,Name, +// Description}, Sector, Members (with nested Person + capability tags), +// TeamType, TaskedJobCount, TeamStatusStartDate, ...) -- matches (or +// exceeds) what Team.js reads, unlike the job events, so no separate +// REST fetch is needed to backfill it. +// teamUpdated(message) -- same shape as teamCreated +// taskingUpdated(message) -- { Id, JobId, TeamId, EntityId, CurrentStatusId, Sequence, ... } +// opsLogUpdated(message) -- full OpsLog entry object: { Id, JobId, +// JobLabel, Entity, Subject, Text, Tags, CreatedOn, CreatedBy, ... } +// NotificationAcknowledged/IUMReceived/UrgentIUMReceived(message) -- same +// Notification-record family as jobCreated/jobUpdated/jobRejected (Id = +// the notification's own id, JobId = the actual job) -- unconfirmed field +// list beyond JobId, but that's all refreshUnacceptedNotifications needs. +// rsuReceived/iuaReceived/isuReceived(message) -- assumed same family, +// JobId = the actual job. Only used to refresh ICEMS agency data. +// +// taskingCreated is NOT confirmed -- Beacon registers created/updated pairs +// for jobs and teams, but only taskingUpdated showed up in the handlers +// seen so far. Registered speculatively on the assumption the pairing is +// symmetric; harmless no-op if the server never actually sends it. +// +// jobRejected was found commented-out in Beacon's own source, using the +// older SignalR v2 client pattern ($.connection.jobHub.client.X) rather than +// the modern connection.on(...) the rest of these use -- it may be dead code +// on a legacy hub rather than something this connection (hub=beacon) will +// ever actually send. Registered anyway since an unmatched guess is a +// harmless no-op. +// +// Any additional method the server sends that isn't listed here still +// surfaces in the console as a "No client method with the name 'X' found" +// warning (logging is enabled at Information level in connection.js) -- +// that's how further real names get discovered and added. +export const KNOWN_EVENTS = [ + 'jobCreated', + 'jobUpdated', + 'jobRejected', + 'teamCreated', + 'teamUpdated', + 'taskingUpdated', + 'taskingCreated', + 'opsLogUpdated', + 'NotificationAcknowledged', + 'IUMReceived', + 'UrgentIUMReceived', + 'rsuReceived', + 'iuaReceived', + 'isuReceived', +]; diff --git a/src/pages/tasking/signalr/pushMode.js b/src/pages/tasking/signalr/pushMode.js new file mode 100644 index 00000000..1537d834 --- /dev/null +++ b/src/pages/tasking/signalr/pushMode.js @@ -0,0 +1,19 @@ +// Whether SignalR push is enabled, per the config-level toggle. Read at +// page load (see main.js) -- toggling it takes effect on next open, since +// tearing down/rebuilding a live connection mid-session isn't wired up. +let enabled = true; + +export function setPushModeEnabled(value) { + enabled = !!value; +} + +export function isPushModeEnabled() { + return enabled; +} + +// Cooldown for single-entity on-demand fetches (refreshData/fetchTasking). +// Tighter when push is off, since nothing else is keeping data current +// between explicit fetches. +export function getSingleFetchCooldownMs() { + return enabled ? 60_000 : 10_000; +} diff --git a/src/pages/tasking/signalr/subject.js b/src/pages/tasking/signalr/subject.js new file mode 100644 index 00000000..b3efe837 --- /dev/null +++ b/src/pages/tasking/signalr/subject.js @@ -0,0 +1,14 @@ +export class Subject { + constructor() { + this._subscribers = new Set(); + } + + subscribe(callback) { + this._subscribers.add(callback); + return () => this._subscribers.delete(callback); + } + + next(value) { + this._subscribers.forEach((callback) => callback(value)); + } +} diff --git a/src/pages/tasking/signalr/subjects.js b/src/pages/tasking/signalr/subjects.js new file mode 100644 index 00000000..fa1dd8e2 --- /dev/null +++ b/src/pages/tasking/signalr/subjects.js @@ -0,0 +1,12 @@ +import { Subject } from './subject.js'; + +const subjects = new Map(); + +// Returns the Subject for a given hub method name, creating it on first use. +// Consumers subscribe by the exact (case-sensitive) method name the server invokes. +export function getSubject(eventName) { + if (!subjects.has(eventName)) { + subjects.set(eventName, new Subject()); + } + return subjects.get(eventName); +} diff --git a/src/pages/tasking/utils/enum.js b/src/pages/tasking/utils/enum.js index dfa04557..3953aa28 100644 --- a/src/pages/tasking/utils/enum.js +++ b/src/pages/tasking/utils/enum.js @@ -629,10 +629,10 @@ export const Enum = { "GroupId": null, "Colour": null }, - "SearchTerrainTaskTypes": { + "SearchCategoryTaskTypes": { "Id": 50, - "Name": "SearchTerrainTaskTypes", - "Description": "Tasks - Search Terrain", + "Name": "SearchCategoryTaskTypes", + "Description": "Tasks - Search Category", "ParentId": null, "GroupId": null, "Colour": null @@ -822,7 +822,7 @@ IncidentAgenciesInvolvedStatus: "RespondedRSQ": { "Id": 7, "Name": "RespondedRSQ", - "Description": "RespondedRSU", + "Description": "RespondedRSQ", "ParentId": null, "GroupId": null, "Colour": null diff --git a/src/pages/tasking/utils/popup_dom_utils.js b/src/pages/tasking/utils/popup_dom_utils.js index 286c8846..7c8f2ab4 100644 --- a/src/pages/tasking/utils/popup_dom_utils.js +++ b/src/pages/tasking/utils/popup_dom_utils.js @@ -16,7 +16,16 @@ export function bindKoToPopup(ko, vm, el) { } export function unbindKoFromPopup(ko, el) { if (!el || !el.__ko_bound__) return; - ko.cleanNode(el); + try { + ko.cleanNode(el); + } catch (e) { + // A concurrent reactive update (e.g. tasking data landing right as the + // popup closes) can interrupt cleanNode's virtual-element tree walk. + // The innerHTML reset below discards the whole subtree regardless, so + // this is safe to ignore rather than let it surface as an uncaught + // error -- and skipping delete/reset below would leave __ko_bound__ + // stuck true, silently breaking the next open. + } delete el.__ko_bound__; resetPopupNode(el); // leave clean for next open } diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 88464e4c..9ecba153 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -409,7 +409,8 @@ export function ConfigVM(root, deps) { } // Other settings - self.refreshInterval = ko.observable(60); + self.signalrEnabled = ko.observable(true); + self.refreshInterval = ko.observable(180); // Guard for reckless refresh interval changes let lastRefreshInterval = self.refreshInterval(); let suppressRecklessModal = false; @@ -1260,7 +1261,11 @@ export function ConfigVM(root, deps) { // Build the current config payload (used by save + share) const buildConfig = () => ({ - refreshInterval: Number(self.refreshInterval()), + // Renamed from refreshInterval so existing saved configs (which + // only have the old key) fall through to the new default instead + // of carrying forward the old 60s value. + refreshIntervalV2: Number(self.refreshInterval()), + signalrEnabled: !!self.signalrEnabled(), fetchPeriod: Number(self.fetchPeriod()), fetchForward: Number(self.fetchForward()), showAdvanced: !!self.showAdvanced(), @@ -1495,7 +1500,8 @@ export function ConfigVM(root, deps) { if (!cfg) { cfg = {} console.log('Using defaults.'); - cfg.refreshInterval = self.refreshInterval(); + cfg.refreshIntervalV2 = self.refreshInterval(); + cfg.signalrEnabled = self.signalrEnabled(); cfg.fetchPeriod = self.fetchPeriod(); cfg.fetchForward = self.fetchForward(); cfg.showAdvanced = self.showAdvanced(); @@ -1525,9 +1531,17 @@ export function ConfigVM(root, deps) { } } // scalar settings - if (typeof cfg.refreshInterval === 'number') { - self.refreshInterval(cfg.refreshInterval); - lastRefreshInterval = cfg.refreshInterval; + // refreshIntervalV2 (renamed from refreshInterval) -- existing saved + // configs only have the old key, so this is undefined for them and + // self.refreshInterval just keeps its constructor default (180). + // Only someone who saves again under the new build will persist a + // value here, at which point it's their own deliberate choice again. + if (typeof cfg.refreshIntervalV2 === 'number') { + self.refreshInterval(cfg.refreshIntervalV2); + lastRefreshInterval = cfg.refreshIntervalV2; + } + if (typeof cfg.signalrEnabled === 'boolean') { + self.signalrEnabled(cfg.signalrEnabled); } if (typeof cfg.fetchPeriod === 'number') { self.fetchPeriod(cfg.fetchPeriod); diff --git a/static/manifest.json b/static/manifest.json index 89f9178f..704931a1 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -17,6 +17,8 @@ "64":"icons/lighthouse64_dev.png" }, "host_permissions": [ + "https://beacon-prod-comms.azurewebsites.net/*", + "https://beacon-train-comms.azurewebsites.net/*", "https://beacon.ses.nsw.gov.au/*", "https://trainbeacon.ses.nsw.gov.au/*", "https://previewbeacon.ses.nsw.gov.au/*", diff --git a/static/pages/tasking.html b/static/pages/tasking.html index d3fb6b89..091a33db 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -7,6 +7,10 @@ +
    + + +
    @@ -238,17 +242,17 @@ -
    +
    -
    +
    -
    +
    @@ -479,6 +483,7 @@ data-bs-auto-close="outside" data-bind="text: ts.currentStatus, css: ts.tagColorFromStatus(), + flashOnChange: ts.currentStatus, click: ts.onStatusDropdownToggleClick"> @@ -1054,17 +1059,17 @@
    - +
    - +
    - +
    @@ -1074,7 +1079,7 @@
    @@ -1537,6 +1542,7 @@ data-bs-auto-close="outside" data-bind="text: ts.currentStatus, css: ts.tagColorFromStatus(), + flashOnChange: ts.currentStatus, click: ts.onStatusDropdownToggleClick">
    @@ -2075,6 +2081,19 @@

    Reduces eye strain in low light environments.
    +
    + +
    + + +
    +
    Receive instant incident, team and tasking updates. If enabling or disabling, restart LAD after saving.
    +
    -
    Status badge counts only Tasked, Enroute & Onsite taskings (excludes Complete, CalledOff, Untasked).
    +
    Tasking count counts only Tasked, Enroute & Onsite taskings (excludes Complete, CalledOff, Untasked).
    diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index cdcbdab2..0b0f707e 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -2387,6 +2387,40 @@ overflow: hidden; background-color: rgba(100, 160, 255, 0.22); } +/* Generic "value just changed" indicator, applied via the flashOnChange binding */ +.flash-on-change { + animation: flash-on-change 0.9s ease-out; +} +@keyframes flash-on-change { + 0% { background-color: rgba(255, 193, 7, 0.55); } + 100% { background-color: transparent; } +} +.dark-mode .flash-on-change { + animation: flash-on-change-dark 0.9s ease-out; +} +@keyframes flash-on-change-dark { + 0% { background-color: rgba(255, 213, 79, 0.4); } + 100% { background-color: transparent; } +} + +/* Same "value just changed" signal, but for text/icons inside wide or full-width + containers -- pulses the glyphs themselves instead of filling a background box, so it + doesn't leave an oversized highlight behind short text in a wide cell */ +.flash-text-on-change { + animation: flash-text-on-change 0.9s ease-out; +} +@keyframes flash-text-on-change { + 0% { color: #b45f06; text-shadow: 0 0 6px rgba(255, 193, 7, 0.75); } + 100% { color: inherit; text-shadow: none; } +} +.dark-mode .flash-text-on-change { + animation: flash-text-on-change-dark 0.9s ease-out; +} +@keyframes flash-text-on-change-dark { + 0% { color: #ffd54f; text-shadow: 0 0 6px rgba(255, 213, 79, 0.85); } + 100% { color: inherit; text-shadow: none; } +} + /* Fast tooltip (no native delay) — positioned fixed via JS to escape overflow clipping */ .fast-tooltip { display: inline-flex; @@ -3420,4 +3454,37 @@ input[type="search"]:focus { .dark-mode .job-search-suggestions li:hover, .dark-mode .job-search-suggestions li.active { background: #3a3a3a; +} + +/* SignalR connection status banner -- deliberately loud, since the whole + page's live data depends on this connection and a silent drop would just + look like nothing is happening. */ +.signalr-status-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: 8px 12px; + background: #c0392b; + color: #fff; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.02em; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + animation: signalr-status-banner-pulse 1.6s ease-in-out infinite; +} + +.signalr-status-banner__icon { + font-size: 16px; +} + +@keyframes signalr-status-banner-pulse { + 0%, 100% { background: #c0392b; } + 50% { background: #e74c3c; } } \ No newline at end of file From 451f1ec018bb1be70f36028a55f0d0e42d3ce592 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 17 Aug 2026 17:30:41 +1000 Subject: [PATCH 08/10] Highlight FR-1 rows and shore up push-notification reliability on tasking (#417) * Highlight FR-1 rows and shore up push-notification reliability on tasking - Give FR-1 (Flood Rescue, Category 1) incident rows a distinct colour, subtly deeper than a normal Rescue-priority row, in both light and dark mode - Move unaccepted-notification refresh onto the shared jobs/teams refresh cycle instead of a per-job 30s timer, and drop a jobReceived fallback so jobs admitted straight from a push notification (which carries no JobReceived) aren't evicted by date filtering - Add the same clock-skew tolerance to the end-of-range date check as the existing start-of-range one, so jobs admitted right at the boundary via push aren't evicted by lag/skew - Make the incidents/teams toolbars and table columns responsive to a narrow sidebar (container queries + minmax column tracks), split the Received column into time/date lines, clamp wrapped Situation/Address text instead of letting it balloon row height, and add a manual per-incident refresh button Co-Authored-By: Claude Sonnet 5 * Fix FR-1 row color inconsistency between light and dark mode Dark mode's row-rescue-fr1 (#7a2f22, L=31%) was lighter than row-rescue (#5a2a2a, L=26%), while light mode has it the other way round (fr1 darker than rescue) -- so which row read as "more severe" flipped depending on theme. Dark mode's fr1 is now darker and more saturated than rescue, matching light mode's direction. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- src/pages/tasking/main.js | 65 +++++++++++--- src/pages/tasking/models/Job.js | 101 ++++++--------------- static/pages/tasking.html | 105 +++++++++++++++------- styles/pages/darkmode.css | 9 ++ styles/pages/tasking.css | 155 +++++++++++++++++++++++++++++++- 5 files changed, 316 insertions(+), 119 deletions(-) diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index a35d3e5b..a76dea37 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -948,6 +948,14 @@ function VM() { end.setDate(end.getDate() + self.config.fetchForward()); + // Same drift/clock-skew allowance as start, above -- without it, a + // job admitted via push the instant it's created (jobReceived from + // the notification's own CreatedOn, essentially "now") sits right + // on the end boundary, and a few hundred ms of processing lag or + // any client/server clock skew is enough to flip jobDate > end and + // evict it (confirmed live: fetchForward=0 gave zero tolerance). + end.setMinutes(end.getMinutes() + 5); + const statusName = jb.statusName(); const jobHqId = String(jb.entityAssignedTo.id()); const hqMatch = hqIds.size === 0 || hqIds.has(jobHqId); @@ -970,8 +978,17 @@ function VM() { return false; } - // If incident type filter non-empty, only show jobs whose type is in it - if (incidentTypeSet.size > 0 && !incidentTypeSet.has(String(jb.typeId()))) { + // If incident type filter non-empty, only show jobs whose type is in + // it -- but only reject when we actually know the type. A job built + // straight from the jobCreated notification has no type information + // at all (unlike status/priority, there's no id to resolve either), + // so typeId() is "" until refreshData() backfills it; treating that + // as "known not to match" would evict every new job whenever any + // type filter is active. isFilteredIn corrects itself reactively + // once the real type lands, so being lenient here at admission time + // costs nothing beyond a job briefly sitting untracked-by-filter. + const typeId = jb.typeId(); + if (incidentTypeSet.size > 0 && typeId && !incidentTypeSet.has(String(typeId))) { return false; } @@ -2973,6 +2990,11 @@ function VM() { myViewModel._markInitialFetchDone(); myViewModel.jobsLoading(false); + // Runs here (not alongside the fetchAllJobsData() call itself) + // so it sees this cycle's actual results -- fetchAllJobsData is + // async and its merges land in this callback, after the network + // round-trip, not synchronously when it's called. + self.fetchAllUnacceptedNotifications(); }, function (_val, _total) { //console.log("Progress: " + _val + " / " + _total) }, function (jobs) { //call back as they come in per page @@ -3060,6 +3082,16 @@ function VM() { }; + // No batch API exists for unaccepted notifications (unlike tasking), + // so this is still one REST call per ICEMS job -- but triggered by the + // shared jobs/teams refresh cycle below instead of each job running its + // own independent 30s timer. + self.fetchAllUnacceptedNotifications = function () { + self.filteredJobs().forEach(job => { + if (job.icemsIncidentIdentifier()) job.refreshUnacceptedNotifications(); + }); + }; + // ---------------- REFRESH TIMER FOR JOBS + TEAMS ----------------- let jobsTeamsTimer = null; @@ -3071,7 +3103,7 @@ function VM() { const interval = effectiveRefreshIntervalMs(); jobsTeamsTimer = setInterval(() => { - self.fetchAllJobsData(); + self.fetchAllJobsData(); // triggers fetchAllUnacceptedNotifications itself, once its results land self.fetchAllTeamData(); }, interval); @@ -3132,7 +3164,7 @@ function VM() { self.UserPressedSaveOnTheConfigModal = function () { //re-fetch data based on new config initialFetchesPending = 2; // teams, jobs - self.fetchAllJobsData(); + self.fetchAllJobsData(); // triggers fetchAllUnacceptedNotifications itself, once its results land self.fetchAllTeamData(); self.fetchAllTrackableAssets(); @@ -3837,9 +3869,26 @@ document.addEventListener('DOMContentLoaded', function () { EntityAssignedTo: n.Entity, }); + // JobReceived isn't in any of these notifications at all -- without + // it, jobMatchesConfigFilters' date check always fails (new + // Date(null) is epoch, always outside the configured range), so a + // brand-new admission would get silently evicted instead of + // admitted. The notification's own CreatedOn is a reasonable proxy. + // Only applied when not already tracked -- otherwise this would + // clobber an existing job's real jobReceived with the + // notification's timestamp. Applies to jobUpdated/jobRejected too, + // not just jobCreated: a job evicted while "New" (outside the + // status filter) hits this same first-admission path again the + // moment a later jobUpdated brings its status back into scope. + const withJobReceivedFallback = (jobJson, notification, alreadyTracked) => { + if (!alreadyTracked) jobJson.JobReceived = notification.CreatedOn; + return jobJson; + }; + const admitJobIfInFilter = (notification) => { const jobJson = mapJobNotificationToJobJson(notification); const alreadyTracked = myViewModel.jobsById.has(jobJson.Id); + withJobReceivedFallback(jobJson, notification, alreadyTracked); const job = myViewModel.getOrCreateJob(jobJson); if (!alreadyTracked && !myViewModel.jobMatchesConfigFilters(job)) { myViewModel.jobsById.delete(job.id()); @@ -3859,6 +3908,7 @@ document.addEventListener('DOMContentLoaded', function () { const admitJobCreatedIfInFilter = (notification) => { const jobJson = mapJobNotificationToJobJson(notification); const alreadyTracked = myViewModel.jobsById.has(jobJson.Id); + withJobReceivedFallback(jobJson, notification, alreadyTracked); const job = myViewModel.getOrCreateJob(jobJson); if (alreadyTracked) return; // duplicate/late delivery, already handled elsewhere @@ -3969,12 +4019,7 @@ document.addEventListener('DOMContentLoaded', function () { // either a new IUM arriving or an existing one being acknowledged // (by us or someone else) without needing to distinguish which. const refreshUnacceptedNotificationsFromPush = (message) => { - const job = myViewModel.jobsById.get(message.JobId); - job?.refreshUnacceptedNotifications(); - // Push already did what the 30s poll would have -- re-arm it - // for another full 30s from now instead of letting it fire - // again moments later. - job?.resetUnacceptedNotificationsPolling(); + myViewModel.jobsById.get(message.JobId)?.refreshUnacceptedNotifications(); }; getSubject('NotificationAcknowledged').subscribe(refreshUnacceptedNotificationsFromPush); getSubject('IUMReceived').subscribe(refreshUnacceptedNotificationsFromPush); diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js index 9baa303a..7708cdbd 100644 --- a/src/pages/tasking/models/Job.js +++ b/src/pages/tasking/models/Job.js @@ -72,8 +72,13 @@ export function Job(data = {}, deps = {}) { self.permissionToEnterPremises = ko.observable(!!data.PermissionToEnterPremises); self.howToEnterPremises = ko.observable(data.HowToEnterPremises ?? null); self.jobReceived = ko.observable(data.JobReceived ?? null); - self.jobPriorityType = ko.observable(data.JobPriorityType || null); // {Id,Name,Description} - self.jobStatusType = ko.observable(data.JobStatusType || null); // {Id,Name,Description} + // Some sources (e.g. the jobCreated/jobUpdated push notification) only + // carry JobPriorityTypeId/JobStatusTypeId, not the full object -- same + // fallback updateFromJson uses below, so a job constructed straight + // from one of those isn't left with a blank status/priority (which + // would otherwise fail jobMatchesConfigFilters' status check). + self.jobPriorityType = ko.observable(data.JobPriorityType || _resolveEnumById(Enum.JobPriorityType, data.JobPriorityTypeId) || null); // {Id,Name,Description} + self.jobStatusType = ko.observable(data.JobStatusType || _resolveEnumById(Enum.JobStatusType, data.JobStatusTypeId) || null); // {Id,Name,Description} self.jobType = ko.observable(data.JobType || null); // {Id,Name,...} self.entityAssignedTo = new Entity(data.EntityAssignedTo || {}); self.lga = ko.observable(data.LGA ?? ""); @@ -316,6 +321,8 @@ export function Job(data = {}, deps = {}) { self.tagsCsv = ko.pureComputed(() => self.tags().map(t => t.name()).join(", ")); self.receivedAt = ko.pureComputed(() => (self.jobReceived() ? moment(self.jobReceived()).format("DD/MM/YYYY HH:mm:ss") : null)); self.receivedAtShort = ko.pureComputed(() => (self.jobReceived() ? moment(self.jobReceived()).format("DD/MM/YY HH:mm:ss") : null)); + self.receivedAtTimeShort = ko.pureComputed(() => (self.jobReceived() ? moment(self.jobReceived()).format("HH:mm:ss") : null)); + self.receivedAtDateShort = ko.pureComputed(() => (self.jobReceived() ? moment(self.jobReceived()).format("DD/MM/YY") : null)); self.latLongDisplay = ko.pureComputed(function () { var lat = self.address.latitude(); var lng = self.address.longitude(); @@ -387,6 +394,9 @@ export function Job(data = {}, deps = {}) { return moment(self.lastDataUpdate()).fromNow(); }); + // No per-job timer -- refreshed as part of the shared jobs/teams refresh + // cycle (fetchAllUnacceptedNotifications in main.js) and by push + // (NotificationAcknowledged/IUMReceived/UrgentIUMReceived). self.refreshUnacceptedNotifications = async function () { if (!self.icemsIncidentIdentifier()) return; @@ -425,26 +435,6 @@ export function Job(data = {}, deps = {}) { }; - // ---- UNACCEPTED NOTIFICATIONS POLLING ---- - const unacceptedNotificationsInterval = makeFilteredInterval(() => { - // extra guard: only if ICEMS id exists - if (!self.icemsIncidentIdentifier()) return; - console.log("Polling unaccepted notifications for job", self.id()); - self.refreshUnacceptedNotifications(); - }, 30000, { runImmediately: true }); - - self.startUnacceptedNotificationsPolling = function () { - unacceptedNotificationsInterval.start(); - }; - - self.stopUnacceptedNotificationsPolling = function () { - unacceptedNotificationsInterval.stop(); - }; - - self.resetUnacceptedNotificationsPolling = function () { - unacceptedNotificationsInterval.reset(); - }; - // ICEMS agency data: refreshed when the job is expanded (toggleAndLoad) // and by push (rsuReceived/iuaReceived/isuReceived in main.js) -- no // periodic poll. Push calls pass force:true (an authoritative "this @@ -473,19 +463,6 @@ export function Job(data = {}, deps = {}) { }; - // Unaccepted notifications polling: filtered in + ICEMS id (original logic) - self.shouldPollUnacceptedNotifications = ko.pureComputed(() => { - return self.icemsIncidentIdentifier() && self.isFilteredIn(); - }); - - self.shouldPollUnacceptedNotifications.subscribe((shouldPoll) => { - if (shouldPoll) { - self.startUnacceptedNotificationsPolling(); - } else { - self.stopUnacceptedNotificationsPolling(); - } - }); - self.toggleAndLoad = function () { if (!self.expanded()) { self.fetchTasking(); @@ -575,6 +552,9 @@ export function Job(data = {}, deps = {}) { }); self.rowColour = ko.pureComputed(() => { + if (self.priorityName() === 'Rescue' && self.typeName() === 'FR' && self.categoriesName() === 'Category1') { + return 'row-rescue-fr1'; + } switch (self.priorityName()) { case 'Rescue': return 'row-rescue'; case 'Priority': return 'row-priority'; @@ -883,6 +863,18 @@ export function Job(data = {}, deps = {}) { self.refreshIcemsIncident(opts); } + // Manual "refresh this incident" trigger (see the refresh button next to + // "Refreshed X ago" in tasking.html) -- forced, since a deliberate click + // should never silently no-op because of a cooldown meant to dedupe + // background timer/push refreshes. Covers every piece of a job's data, + // including unaccepted notifications, which refreshDataAndTasking + // doesn't (that one's also used by paths that shouldn't imply "and + // check ICEMS notifications too"). + self.refreshAllData = function () { + self.refreshDataAndTasking({ force: true }); + self.refreshUnacceptedNotifications(); + } + self.fetchTasking = function (opts = {}) { const force = opts.force === true; if (!force) { @@ -919,44 +911,5 @@ export function Job(data = {}, deps = {}) { }; - // interval that only runs while job is filtered in - function makeFilteredInterval(fn, intervalMs, { runImmediately = false } = {}) { - let handle = null; - - const tick = () => { - // global guard: only run if still filtered in - if (!self.isFilteredIn()) return; - fn(); - }; - - const start = () => { - if (!self.isFilteredIn()) return; // don't start if already filtered out - if (handle) clearInterval(handle); - if (runImmediately) tick(); - handle = setInterval(tick, intervalMs); - }; - - const stop = () => { - if (handle) { - clearInterval(handle); - handle = null; - } - }; - - // Re-arms the interval for another full intervalMs from now, without - // firing immediately (unlike start()) -- for when something else - // (e.g. a push) already just did what this timer would have done, - // so the next scheduled tick shouldn't land moments later. Only has - // an effect if already polling; never starts a new poll. - const reset = () => { - if (!handle) return; - if (!self.isFilteredIn()) return; - clearInterval(handle); - handle = setInterval(tick, intervalMs); - }; - - return { start, stop, reset }; - } - } diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 091a33db..4d5d08a0 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -18,16 +18,16 @@