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 }} 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/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); diff --git a/package-lock.json b/package-lock.json index e56ad3cf..89df853a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/runtime": "^7.27.0", "@fortawesome/fontawesome-free": "^5.15.4", + "@microsoft/signalr": "^10.0.11", "@tmcw/togeojson": "^5.7.0", "@xmldom/xmldom": "^0.9.10", "bootstrap": "^4.6.2", @@ -1993,6 +1994,19 @@ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "peer": true }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2610,6 +2624,18 @@ "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4302,6 +4328,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4311,6 +4346,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -4445,6 +4489,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4520,7 +4574,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -5986,6 +6039,26 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6498,15 +6571,32 @@ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6679,6 +6769,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -7005,6 +7101,12 @@ "node": ">=20.0.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -7482,6 +7584,27 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -7604,6 +7727,15 @@ "node": ">=4" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", @@ -7688,6 +7820,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7739,6 +7881,12 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack": { "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", @@ -7904,6 +8052,16 @@ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz", "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8009,6 +8167,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 73d612aa..f34d6a6d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@babel/runtime": "^7.27.0", "@fortawesome/fontawesome-free": "^5.15.4", + "@microsoft/signalr": "^10.0.11", "@tmcw/togeojson": "^5.7.0", "@xmldom/xmldom": "^0.9.10", "bootstrap": "^4.6.2", 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/all.js b/src/injectscripts/all.js index 5d57a50c..16c86c41 100644 --- a/src/injectscripts/all.js +++ b/src/injectscripts/all.js @@ -57,6 +57,8 @@ whenWeAreReady(function () { urls.Base + '&source=' + location.origin + + '&signalr=' + + encodeURIComponent(urls.SignalR) + '&hq=' + user.currentHqId + '&start=' + diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 59705a34..6411512d 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -40,6 +40,12 @@ var assetMapRenderAtTime; // eslint-disable-next-line @typescript-eslint/no-unused-vars var assetMapRenderTimer; +// Cache of the most recent getJobResponseSummary() result, so the create-team +// picker modal (opened well after the popover fetch, from either the +// Available or ActivationAccepted "Create Team" button) can read both +// categories' names without re-fetching. +var lastResponseSummary = null; + //if ops logs update masterViewModel.notesViewModel.opsLogEntries.subscribe(lighthouseDictionary); @@ -61,6 +67,337 @@ 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: [{ 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: [] }, + }, + }); + 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); + }, + }); +} + +// 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.data('lighthouse-pinned', false); + $gem.popover(_.extend({}, options, { trigger: 'manual' })); + + $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'); + }); + // 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'); + }); + + // 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 the create-team picker modal, pre-ticked for +// whichever category's button was clicked, with the other category's list +// available too but left unticked. +$(document).off('click.lighthouseCreateTeam').on('click.lighthouseCreateTeam', '.lighthouse-create-team-btn', function (e) { + e.stopPropagation(); + if (!lastResponseSummary || !lastResponseSummary.categories) return; + + var clickedCategory = $(this).data('category'); + var categories = lastResponseSummary.categories; + + renderCreateTeamPickerList( + '#lighthouseCreateTeamPickerAccepted', + categories.ActivationAccepted && categories.ActivationAccepted.Names, + clickedCategory === 'ActivationAccepted', + ); + renderCreateTeamPickerList( + '#lighthouseCreateTeamPickerAvailable', + categories.Available && categories.Available.Names, + clickedCategory === 'Available', + ); + renderCreateTeamPickerList( + '#lighthouseCreateTeamPickerConditional', + categories.Conditional && categories.Conditional.Names, + clickedCategory === 'Conditional', + ); + + // Close the popover the button lives in so it doesn't sit open behind the modal. + $('.lighthouse-response-gem').each(function () { + var $g = $(this); + $g.data('lighthouse-pinned', false); + $g.popover('hide'); + }); + + $('#lighthouseCreateTeamPickerModal').modal(); +}); + +// 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, 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', + Available: '#lighthouse-gem-available', + Conditional: '#lighthouse-gem-conditional', + Unavailable: '#lighthouse-gem-unavailable', + Unset: '#lighthouse-gem-unset', + }; + + var $gemsRow = $('#lighthouse-response-gems'); + $gemsRow.addClass('is-loading'); + + getJobResponseSummary(jobId, function (err, summary) { + $gemsRow.removeClass('is-loading'); + if (err || !summary) return; + + lastResponseSummary = summary; + + 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; + + var title = 'myAvailability: ' + + category.replace(/([a-z])([A-Z])/g, '$1 $2'); + var data = (summary.categories && summary.categories[category]) || { Count: 0, Names: [] }; + + $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 (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
  • ' + : '') + + '
' + : 'No responders'; + + var closedNote = isClosed ? '
Activation closed
' : ''; + + // Quick path from "who's accepted/available/conditional" into a new + // team - only makes sense for those three gems, only when there's + // someone to add, and only for users who could actually create a + // team in the first place. Opens the picker modal (below) pre-ticked + // for whichever category's button was clicked, rather than + // navigating straight off with just that category's names. + var createTeamButtonHtml = ''; + if ((category === 'ActivationAccepted' || category === 'Available' || category === 'Conditional') && data.Names && data.Names.length && user.isInRole(Enum.Role.TeamManagement.Id)) { + createTeamButtonHtml = '
' + + '
'; + } + + initGemPopover($gem, { + placement: 'bottom', + trigger: 'hover', + html: true, + title: title, + content: closedNote + namesHtml + createTeamButtonHtml, + container: 'body', + }); + }); + }); +} + +whenJobIsReady(function () { + lighthouseResponseGems(); +}); + +// Lets the user tick/untick individual people from the Available and +// Accepted lists before committing to a team, rather than the old +// behaviour of jumping straight to /Teams/Create with a single category's +// names. Built once and appended to below; the two
    s are filled +// in each time it's opened (see the .lighthouse-create-team-btn handler +// above and renderCreateTeamPickerList()). +function buildCreateTeamPickerModal() { + return ( + + ); +} + +function renderCreateTeamPickerList(listSelector, people, checked) { + var $list = $(listSelector); + if (!people || !people.length) { + $list.html('
  • No responders
  • '); + return; + } + $list.html(_.map(people, function (person) { + return '
  • '; + }).join('')); +} + +var createTeamPickerModal = buildCreateTeamPickerModal(); +$('body').append(createTeamPickerModal); + +// This modal is built and appended at module load time, before lighthouseUrl +// (set async via postMessage from the content script) is guaranteed to +// exist yet - baking it straight into the JSX above like the gem popovers +// do risked a ReferenceError. Fill the logo in once it's actually ready. +whenLighthouseIsReady(function () { + $(createTeamPickerModal) + .find('#lighthouseCreateTeamPickerLogo') + .attr('src', lighthouseUrl + 'icons/lh-black.png'); +}); + +// Same navigation the old direct button used - lhmembers/lhentityid picked +// back up by teams/create.js's inject script - just built from whichever +// checkboxes are ticked across both lists instead of one fixed category. +$(createTeamPickerModal) + .find('#lighthouseCreateTeamPickerSubmit') + .click(function () { + var memberIds = _.map($('.lighthouse-create-team-picker-checkbox:checked'), function (el) { + return $(el).data('member-id'); + }); + if (!memberIds.length) return; + + var entityId = masterViewModel.entityAssignedTo.peek() ? masterViewModel.entityAssignedTo.peek().Id : null; + window.open( + '/Teams/Create?lhmembers=' + escape(JSON.stringify(memberIds)) + '&lhentityid=' + escape(entityId), + '_blank', + ); + $('#lighthouseCreateTeamPickerModal').modal('hide'); + }); + function lighthouseETAFromNow() { var future = moment(masterViewModel.teamsViewModel.jobTeamStatusEstimatedCompletion.peek()); var now = moment(); diff --git a/src/injectscripts/teams/create.js b/src/injectscripts/teams/create.js index 0dec7ffe..373e01d7 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,104 @@ 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); + + teamViewModel.setSelectedTeamType({ Id: 1 }); + + 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') { + var desiredEntityId = unescape(qs.lhentityid); + + // The Team Create page can default Assigned To on its own (e.g. to the + // creating user's home HQ) shortly after load - if we set ours first, + // its default overwrites us straight after. Wait for entityAssignedTo + // to actually get defined once, then apply ours right after and stop + // listening, so we always land last regardless of who's first. + if (teamViewModel.entityAssignedTo.peek()) { + setTeamEntityById(desiredEntityId); + } else { + var entityDefinedSub = teamViewModel.entityAssignedTo.subscribe(function () { + entityDefinedSub.dispose(); + setTeamEntityById(desiredEntityId); + }); + } + } +}); + //when team members change teamViewModel.members.subscribe(function() { // auto set the first team member as TL diff --git a/src/pages/tasking/bindings/flashOnChange.js b/src/pages/tasking/bindings/flashOnChange.js new file mode 100644 index 00000000..f7b7d213 --- /dev/null +++ b/src/pages/tasking/bindings/flashOnChange.js @@ -0,0 +1,49 @@ +import ko from "knockout"; + +// Shared "this value just changed" mechanics: toggles cssClass on element whenever the bound +// value changes, so a CSS animation can pick it up. Doesn't fire on the initial render. +function makeFlashBindingHandler(cssClass) { + return { + init: function (element, valueAccessor) { + let isFirstRun = true; + let timeoutId = null; + + ko.computed({ + read: function () { + ko.unwrap(valueAccessor()); + if (isFirstRun) { + isFirstRun = false; + return; + } + element.classList.remove(cssClass); + void element.offsetWidth; // restart the animation if it's still running + element.classList.add(cssClass); + clearTimeout(timeoutId); + timeoutId = setTimeout(function () { + element.classList.remove(cssClass); + }, 900); + }, + disposeWhenNodeIsRemoved: element + }); + + ko.utils.domNodeDisposal.addDisposeCallback(element, function () { + clearTimeout(timeoutId); + }); + } + }; +} + +export function installFlashOnChangeBinding() { + // data-bind="flashOnChange: someObservable" -- flashes the bound element's background. + // Intended for elements that are already sized to their own content (buttons, badges, + // pills) -- a background wash looks like a highlighted chip there. Avoid on elements + // that span a wider container (e.g. a fixed-width table cell) since the flash then fills + // the whole column behind the text rather than hugging it. + ko.bindingHandlers.flashOnChange = makeFlashBindingHandler("flash-on-change"); + + // data-bind="flashTextOnChange: someObservable" -- pulses the text itself (color + glow) + // instead of filling a background box. Use this for plain text/icons inside wide or + // full-width containers (table cells, full-width buttons) where a background flash would + // look like an oversized block behind short text. + ko.bindingHandlers.flashTextOnChange = makeFlashBindingHandler("flash-text-on-change"); +} diff --git a/src/pages/tasking/bindings/rowTransitions.js b/src/pages/tasking/bindings/rowTransitions.js new file mode 100644 index 00000000..0c6f9a8f --- /dev/null +++ b/src/pages/tasking/bindings/rowTransitions.js @@ -0,0 +1,70 @@ +import ko from "knockout"; +import $ from "jquery"; + +// Kill-switch: flip to false to fully disable list add/remove animations everywhere +// fadeForeach/slideForeach are used, without touching any of the data-bind attributes +// that reference them. Rows just appear/disappear instantly again, same as plain +// "foreach" -- nothing else about the binding changes. +const ENABLED = true; + +const DURATION = 180; + +function isElementNode(node) { + return node.nodeType === 1; // ko's template engine can pass comment/text nodes here too +} + +function removeImmediately(element) { + if (element.parentNode) element.parentNode.removeChild(element); +} + +// Drop-in replacements for the native "foreach" binding -- same +// "data-bind=\"fadeForeach: { data: someArray, as: 'x' }\"" usage -- that animate rows +// in/out on add/remove instead of popping them in place. Two variants because a +// height-based slide doesn't animate reliably across browsers, so table rows fade +// while free-standing list items (li/div) slide. +function makeAnimatedForeachBinding(animateIn, animateOut) { + function wrapAccessor(valueAccessor) { + return function () { + const raw = ko.unwrap(valueAccessor()); + const options = (raw && typeof raw === "object" && !Array.isArray(raw) && "data" in raw) + ? raw + : { data: raw }; + + return Object.assign({}, options, { + afterAdd: function (el) { + if (ENABLED && isElementNode(el)) animateIn(el); + options.afterAdd?.apply(this, arguments); + }, + beforeRemove: function (el) { + if (ENABLED && isElementNode(el)) { + animateOut(el); + } else { + removeImmediately(el); + } + options.beforeRemove?.apply(this, arguments); + } + }); + }; + } + + return { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + return ko.bindingHandlers.foreach.init(element, wrapAccessor(valueAccessor), allBindings, viewModel, bindingContext); + }, + update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + return ko.bindingHandlers.foreach.update(element, wrapAccessor(valueAccessor), allBindings, viewModel, bindingContext); + } + }; +} + +export function installRowTransitionBindings() { + ko.bindingHandlers.fadeForeach = makeAnimatedForeachBinding( + (el) => $(el).hide().fadeIn(DURATION), + (el) => $(el).stop(true, true).fadeOut(DURATION, function () { $(this).remove(); }) + ); + + ko.bindingHandlers.slideForeach = makeAnimatedForeachBinding( + (el) => $(el).hide().slideDown(DURATION), + (el) => $(el).stop(true, true).slideUp(DURATION, function () { $(this).remove(); }) + ); +} diff --git a/src/pages/tasking/components/asset_popup.js b/src/pages/tasking/components/asset_popup.js index 698a7bcf..dc7b2210 100644 --- a/src/pages/tasking/components/asset_popup.js +++ b/src/pages/tasking/components/asset_popup.js @@ -37,17 +37,17 @@ export function buildAssetPopupKO() {
    Team(s)
    -
    +
    - +
    - Current Taskings: + Current Taskings:
    @@ -65,7 +65,7 @@ export function buildAssetPopupKO() { data-bind="visible: tm.filteredTaskings() && tm.filteredTaskings().length">
      + data-bind="slideForeach: { data: tm.filteredTaskings(), as: 'tsk' }">
    • - + @@ -86,7 +86,7 @@ export function buildAssetPopupKO() {
      + data-bind="text: tsk.prettyAddress, flashTextOnChange: tsk.prettyAddress">
      @@ -94,7 +94,7 @@ export function buildAssetPopupKO() {
      + data-bind="text: job.situationOnScene, flashTextOnChange: job.situationOnScene">
      diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index 452116c9..0478edbc 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -6,19 +6,20 @@ export function buildJobPopupKO() { class="fw-bold text-center" style="color:white;background: black"> - +
      + text: typeName() + ' - ' + statusName(), + flashTextOnChange: typeName() + ' - ' + statusName()">
    - +
    @@ -126,14 +127,14 @@ export function buildJobPopupKO() {
    -
    +
    -
    +
    No situation on scene available.
    @@ -155,24 +156,24 @@ export function buildJobPopupKO() { Actions - + + data-bind="text: currentStatus, css: tagColorFromStatus(), flashOnChange: currentStatus">
    diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 6a152be6..023a4c2b 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,8 @@ 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 { installRowTransitionBindings } from "./bindings/rowTransitions.js"; import "./bindings/fastTooltip.js"; // registers ko.bindingHandlers.fastTooltip import "./bindings/bsDropdownOpen.js"; // registers ko.bindingHandlers.bsDropdownOpen @@ -919,13 +924,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 +949,65 @@ 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); + + // 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 allow-list non-empty, only show jobs whose status is in it + if (allowedStatusSet.size > 0 && !allowedStatusSet.has(statusName)) { + 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 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 -- 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; + } - // 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 +1080,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 +1099,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 +1476,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 +2053,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 +2839,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 +2948,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 = [ @@ -2945,10 +2991,20 @@ 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 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 +3024,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 +3060,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); }) } @@ -3019,6 +3083,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; @@ -3027,11 +3101,10 @@ 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(); + self.fetchAllJobsData(); // triggers fetchAllUnacceptedNotifications itself, once its results land self.fetchAllTeamData(); }, interval); @@ -3047,6 +3120,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) => { @@ -3082,7 +3165,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(); @@ -3714,6 +3797,8 @@ document.addEventListener('DOMContentLoaded', function () { installDragDropRowBindings(); noBubbleFromDisabledButtonsBindings(); installSortableArrayBindings(); + installFlashOnChangeBinding(); + installRowTransitionBindings(); registerAcronymTextBinding(); ko.bindingProvider.instance = new ksb(options); @@ -3721,6 +3806,247 @@ 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, + }); + + // 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()); + 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); + withJobReceivedFallback(jobJson, notification, alreadyTracked); + 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) => { + myViewModel.jobsById.get(message.JobId)?.refreshUnacceptedNotifications(); + }; + 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); + + // Start the connection now that every subject subscription above is + // registered (so nothing pushed right at connect time is silently + // dropped -- Subject has no replay) and myViewModel/config + // definitely exist (so signalrEnabled() reads the real saved value + // instead of racing construction and guessing "enabled" by + // default). getToken() resolves once, reliably, whenever the first + // token lands -- no separate "started" guard or hooking into the + // token-fetch callback needed. + (async () => { + if (!myViewModel.config.signalrEnabled()) { + console.log('[SignalR] disabled via config -- not connecting'); + return; + } + const negotiateUrl = params.signalr; + if (!negotiateUrl) { + console.warn('[SignalR] no signalr param on the page URL -- skipping connection'); + return; + } + await getToken(); + // 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); + })(); + 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..7708cdbd 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; @@ -71,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 ?? ""); @@ -110,6 +116,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]; @@ -303,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(); @@ -340,11 +360,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); @@ -367,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; @@ -405,27 +435,23 @@ 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(); - }; - - // ---- ICEMS INCIDENT POLLING (agencies involved) ---- - self.refreshIcemsIncident = async function () { + // 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,45 +462,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(() => { - return self.icemsIncidentIdentifier() && self.isFilteredIn(); - }); - - self.shouldPollUnacceptedNotifications.subscribe((shouldPoll) => { - if (shouldPoll) { - self.startUnacceptedNotificationsPolling(); - } else { - self.stopUnacceptedNotificationsPolling(); - } - }); - - // 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()) { @@ -565,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'; @@ -673,8 +663,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 +690,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 +709,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 +857,22 @@ 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); + } + + // 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 = {}) { @@ -858,7 +880,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 +891,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(), () => { @@ -883,32 +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; - } - }; - - return { start, stop }; - } - } 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..cdcd6d5f 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,17 +292,24 @@ 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(), () => { + try { + await fetchTeamById(self.id()); + } finally { self.taskingLoading(false); - }); + } }; - 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 +549,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 +568,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 +646,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..612c77db --- /dev/null +++ b/src/pages/tasking/signalr/connection.js @@ -0,0 +1,103 @@ +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()) + // Error (not Information) -- suppresses the library's own per-message + // chatter and "No client method with the name 'X' found" warnings for + // unregistered subjects, while still surfacing real connection errors. + .configureLogging(signalR.LogLevel.Error) + .build(); + + KNOWN_EVENTS.forEach((eventName) => { + connection.on(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..918422c3 --- /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 is a +// silent no-op now that connection.js logs at Error level -- bump that +// back down to Information temporarily to surface "No client method with +// the name 'X' found" warnings if hunting for further real event names. +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/src/styles/jobs.view.css b/src/styles/jobs.view.css index 4dade477..1257a60c 100644 --- a/src/styles/jobs.view.css +++ b/src/styles/jobs.view.css @@ -138,4 +138,106 @@ .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-of-type { + border-radius: 4px 0 0 4px; +} + +.lighthouse-response-gem:last-of-type { + 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-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; +} + +.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; +} + +.lighthouse-create-team-btn-wrap { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #e5e5e5; + text-align: right; +} + +.lighthouse-create-team-picker-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} + +.lighthouse-create-team-picker-list li { + padding: 3px 0; +} + +.lighthouse-create-team-picker-list label { + font-weight: normal; + cursor: pointer; } \ No newline at end of file diff --git a/static/manifest.json b/static/manifest.json index 198e9076..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/*", @@ -121,7 +123,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"] }, diff --git a/static/pages/tasking.html b/static/pages/tasking.html index d3fb6b89..0737db13 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -7,6 +7,10 @@ +
    + + +
    @@ -14,16 +18,16 @@