diff --git a/package-lock.json b/package-lock.json index 6936266..eaadf90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@patternfly/react-core": "6.1.0", "@patternfly/react-icons": "6.1.0", "@patternfly/react-styles": "6.3.1", + "qrcode-generator": "1.4.4", "react": "18.3.1", "react-dom": "18.3.1" }, @@ -6796,6 +6797,12 @@ "dev": true, "license": "MIT" }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index e7caad3..a470a77 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@patternfly/react-core": "6.1.0", "@patternfly/react-icons": "6.1.0", "@patternfly/react-styles": "6.3.1", + "qrcode-generator": "1.4.4", "react": "18.3.1", "react-dom": "18.3.1" } diff --git a/src/app.scss b/src/app.scss index f21177b..2d02ce6 100644 --- a/src/app.scss +++ b/src/app.scss @@ -135,10 +135,12 @@ label div { line-height: 1.45; } +/* Touch targets sized for gloved field use (>=48px on all controls). */ .pf-v6-c-button, .pf-c-button, button { - min-height: 2.25rem; + min-height: 3rem; + padding-inline: 1.2rem; border-radius: 5px; font-weight: 650; } @@ -172,6 +174,7 @@ select, textarea { width: min(100%, 34rem) !important; max-width: 100%; + min-height: 3rem; padding: 0.55rem 0.65rem; border: 1px solid var(--aos-border-strong); border-radius: 5px; diff --git a/src/app.tsx b/src/app.tsx index 528fa92..3367fdd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -30,6 +30,7 @@ import { parseEnvDefault, serializeEnvDefault, } from './envDefaultFile'; +import { IconsetCard } from './iconsetCard'; import { ServiceManagementCard, type ToastMessage } from './serviceCard'; import { TlsUploadCard } from './tlsCard'; @@ -311,6 +312,8 @@ export const Application: React.FC = () => { + + { } onChange={ev => handleEnvVarChange(key, ev.target.value)} - style={{ width: '300px', fontFamily: 'monospace' }} + style={{ fontFamily: 'monospace' }} /> )} {formErrors[key] && ( diff --git a/src/conf.tsx b/src/conf.tsx index 5ed4d76..83bec7b 100644 --- a/src/conf.tsx +++ b/src/conf.tsx @@ -81,6 +81,38 @@ export const CONF_PARAMS: Record = { required: false, }, + VESSEL_NAME_PREFIX: { + type: 'boolean', + description: 'Prepend conventional ship-type callsign prefixes: T/B (tug), P/V (pilot), M/V (cargo), M/T (tanker), F/V, S/V, SAR, A/P, L/E', + defaultValue: 'true', + validation: /^(true|false|yes|no|1|0)$/i, + required: false, + }, + + SHIPCLASS_COLORS: { + type: 'boolean', + description: 'Color vessel markers by AIS-catcher ship class: tankers red, cargo green, passenger blue, special brown, HSC yellow, fishing pink', + defaultValue: 'true', + validation: /^(true|false|yes|no|1|0)$/i, + required: false, + }, + + SHIPCLASS_ICONS: { + type: 'boolean', + description: 'Use ship-class marker icons from the bundled ais-ships-iconset.zip (dart underway / circle stopped). Import the iconset into TAK clients first — see the ATAK Vessel Iconset card', + defaultValue: 'false', + validation: /^(true|false|yes|no|1|0)$/i, + required: false, + }, + + UNDERWAY_ONLY: { + type: 'boolean', + description: 'Only forward vessels that are underway; drop parked (anchored/moored/aground) hulls so anchorage clutter does not drown the traffic picture', + defaultValue: 'false', + validation: /^(true|false|yes|no|1|0)$/i, + required: false, + }, + MID_DB_FILE: { type: 'path', description: 'Path to the MID database file, used for decoding AIS messages', diff --git a/src/iconset.test.ts b/src/iconset.test.ts new file mode 100644 index 0000000..60d00d2 --- /dev/null +++ b/src/iconset.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Sensors & Signals LLC https://www.snstac.com/ + */ + +import { describe, expect, it } from 'vitest'; + +import { + ICONSET_IMPORT_URL, + ICONSET_QR_SVG, + ICONSET_URL, + ICONSET_VERSION, + qrSvg, +} from './iconset'; + +describe('iconset URLs', () => { + it('downloads from the aiscot repo at a pinned release tag', () => { + expect(ICONSET_URL).toMatch(/^https:\/\/github\.com\/snstac\/aiscot\//); + expect(ICONSET_URL).toContain(`/raw/${ICONSET_VERSION}/`); + expect(ICONSET_VERSION).toMatch(/^v\d+\.\d+\.\d+$/); + expect(ICONSET_URL).toMatch(/ais-ships-iconset\.zip$/); + }); + + it('wraps the download URL in an ATAK import intent', () => { + expect(ICONSET_IMPORT_URL).toBe( + `tak://com.atakmap.app/import?url=${encodeURIComponent(ICONSET_URL)}` + ); + }); +}); + +describe('qrSvg', () => { + it('renders the import URL as an SVG QR code', () => { + const svg = qrSvg(ICONSET_IMPORT_URL); + expect(svg).toBeTruthy(); + expect(svg).toContain(''); + }); + + it('precomputes the import QR at module load', () => { + expect(ICONSET_QR_SVG).toBeTruthy(); + expect(ICONSET_QR_SVG).toContain('= 7.3.0. + * Pinned to the release tag that first shipped it — the installed aiscot + * emits iconsetpath values matching this exact zip, so the download must + * not track a moving branch. The same zip also ships on-box in the aiscot + * package under aiscot/data/ for air-gapped units. + */ +export const ICONSET_VERSION = 'v7.3.0'; +export const ICONSET_URL = + `https://github.com/snstac/aiscot/raw/${ICONSET_VERSION}/src/aiscot/data/ais-ships-iconset.zip`; + +/** + * qrtak-style ATAK import URL: scanning this with ATAK's QR scanner (or the + * device camera) downloads and imports the iconset directly. + */ +export const ICONSET_IMPORT_URL = + `tak://com.atakmap.app/import?url=${encodeURIComponent(ICONSET_URL)}`; + +/** Render a payload as a QR code SVG string, or null if generation fails. */ +export function qrSvg(payload: string): string | null { + try { + const qr = qrcode(0, 'M'); + qr.addData(payload); + qr.make(); + return qr.createSvgTag(3, 8); + } catch { + return null; + } +} + +/** The import-intent QR, rendered once at module load (constant input). */ +export const ICONSET_QR_SVG = qrSvg(ICONSET_IMPORT_URL); diff --git a/src/iconsetCard.tsx b/src/iconsetCard.tsx new file mode 100644 index 0000000..9e011ab --- /dev/null +++ b/src/iconsetCard.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Sensors & Signals LLC https://www.snstac.com/ + */ + +import React, { useState } from 'react'; +import { + Card, + CardBody, + CardExpandableContent, + CardHeader, + CardTitle, +} from '@patternfly/react-core/dist/esm/components/Card/index.js'; +import cockpit from 'cockpit'; + +import { ICONSET_IMPORT_URL, ICONSET_QR_SVG, ICONSET_URL } from './iconset'; + +const _ = cockpit.gettext; + +export function IconsetCard() { + const [expanded, setExpanded] = useState(false); + + return ( + + setExpanded(!expanded)} + toggleButtonProps={{ + id: 'aiscot-iconset-expand', + 'aria-label': expanded + ? _('Collapse iconset downloads') + : _('Expand iconset downloads'), + }} + > + {_('ATAK Vessel Iconset')} + + + +

+ {_('AIS-catcher style ship-class markers for TAK clients: dart when underway, circle when stopped, diamond for AtoN / base station / SART, colored by vessel class (tankers red, cargo green, passenger blue, ...).')} +

+

+ {_('Scan the QR code with ATAK (or the device camera) to import directly, or download the zip and import via Settings → Tool Preferences → Point Dropper → Iconset Manager. Once clients have the iconset, set SHIPCLASS_ICONS=true in the configuration above.')} +

+

+ {_('Both options fetch from github.com. On offline networks, the same zip ships on this device with the aiscot package (aiscot/data/ais-ships-iconset.zip) — copy it to clients by any available means.')} +

+
+ {ICONSET_QR_SVG && ( + + )} +
+
ais-ships-iconset.zip
+
+ {_('Ships with AISCOT ≥ 7.3.0')} +
+ + {_('Download iconset zip')} + +
+
+
+
+
+ ); +} diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 1da0a1a..0000000 --- a/src/index.css +++ /dev/null @@ -1,309 +0,0 @@ -.service-card { - border: 1px solid #ddd; - border-radius: 8px; - padding: 20px; - margin: 20px 0; - background: #f9f9f9; -} - -.config-section { - margin-top: 20px; - padding: 15px; - border: 1px solid #ccc; - border-radius: 5px; - background: #fff; -} - -.env-var-item { - margin: 15px 0; - padding: 10px; - border: 1px solid #eee; - border-radius: 4px; - background: #fafafa; -} - -.env-var-item.modified { - border-color: #ffc107; - background: #fff3cd; -} - -.env-var-item.commented { - background: #f8f9fa; - border-color: #dee2e6; -} - -.env-var-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.env-var-name { - font-weight: bold; - font-family: monospace; - color: #0056b3; -} - -.env-var-controls { - display: flex; - gap: 5px; - align-items: center; -} - -.env-var-input { - width: 100%; - padding: 6px; - border: 1px solid #ccc; - border-radius: 3px; - font-family: monospace; -} - -.env-var-description { - font-size: 0.9em; - color: #666; - margin: 5px 0; -} - -.env-var-original { - font-size: 0.8em; - color: #999; - margin-top: 3px; - font-family: monospace; - background: #f8f9fa; - padding: 3px 6px; - border-radius: 2px; -} - -.shell-preview { - background: #f8f9fa; - border: 1px solid #ddd; - border-radius: 3px; - padding: 10px; - margin: 10px 0; - font-family: monospace; - white-space: pre-wrap; - max-height: 300px; - overflow-y: auto; -} - -.btn-primary { - background: #007bff; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - margin: 3px; -} - -.btn-success { - background: #28a745; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - margin: 3px; -} - -.btn-warning { - background: #ffc107; - color: black; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - margin: 3px; -} - -.btn-danger { - background: #dc3545; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - margin: 3px; -} - -.btn-small { - padding: 4px 8px; - font-size: 0.8em; -} - -.status-message { - padding: 10px; - margin: 10px 0; - border-radius: 4px; - border: 1px solid; -} - -.status-success { - background: #d4edda; - border-color: #c3e6cb; - color: #155724; -} - -.status-error { - background: #f8d7da; - border-color: #f5c6cb; - color: #721c24; -} - -.status-warning { - background: #fff3cd; - border-color: #ffeaa7; - color: #856404; -} - -.add-var-section { - padding: 10px; - border: 2px dashed #ccc; - border-radius: 4px; - text-align: center; - margin: 15px 0; -} - -.shell-validation { - font-size: 0.8em; - margin-top: 5px; -} - -.shell-valid { - color: #28a745; -} - -.shell-invalid { - color: #dc3545; -} - -.quote-style-select { - margin-left: 10px; - padding: 2px 4px; - font-size: 0.8em; -} - -.new-var-input { - margin-right: 10px; - padding: 5px; -} - -.checkbox-group { - margin: 10px 0; -} - -.checkbox-group label { - margin-left: 20px; -} - -.checkbox-group label:first-child { - margin-left: 0; -} - -.env-test-output-custom { - max-height: 150px; -} - -.service-status-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 10px; - margin: 15px 0; -} - -.status-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - background: #f8f9fa; - border-radius: 4px; - border: 1px solid #dee2e6; -} - -.status-label { - font-weight: bold; - color: #495057; -} - -.status-value { - font-family: monospace; - padding: 2px 6px; - border-radius: 3px; -} - -.status-active { - background: #d4edda; - color: #155724; -} - -.status-inactive { - background: #f8d7da; - color: #721c24; -} - -.status-failed { - background: #f8d7da; - color: #721c24; - font-weight: bold; -} - -.status-enabled { - background: #d1ecf1; - color: #0c5460; -} - -.status-disabled { - background: #ffeaa7; - color: #856404; -} - -.service-controls { - margin-top: 15px; - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.service-logs { - background: #000; - color: #0f0; - padding: 10px; - border-radius: 4px; - font-family: monospace; - max-height: 400px; - overflow-y: auto; - margin-top: 10px; - white-space: pre-wrap; - font-size: 0.9em; - line-height: 1.3; -} - -.log-controls { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - flex-wrap: wrap; -} - -.log-controls select { - padding: 4px 8px; - border: 1px solid #ccc; - border-radius: 3px; -} - -.log-following { - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } -} - -.hidden { - display: none !important; -} \ No newline at end of file diff --git a/src/index.html b/src/index.html index 2b06bb4..aa68d68 100644 --- a/src/index.html +++ b/src/index.html @@ -17,12 +17,11 @@ --> - Cockpit Starter Kit + AISCOT - AIS to TAK Gateway -