Compare commits

...

7 Commits

Author SHA1 Message Date
Brent Perteet
081ff485fa fix: correct UM portal branding asset 2026-08-23 20:54:07 -05:00
Brent Perteet
d0b2f28762 feat(sprint-3): refresh responsive UlHub portal 2026-08-20 22:00:38 -05:00
Brent Perteet
e552ae4e5c fix(S2-a): mount broker auth files outside source tree 2026-08-20 15:53:20 -05:00
Brent Perteet
8aea87c3cd fix(S2-a): keep broker credential database outside Git 2026-08-20 15:52:14 -05:00
Brent Perteet
8f638bfa4f fix(S2-a): keep plaintext MQTT inside compose network 2026-08-20 15:44:38 -05:00
Brent Perteet
9ffe021354 fix(S2-a): expose scoped app MQTT through WSS 2026-08-20 15:43:42 -05:00
Brent Perteet
b66ae2cc47 fix(S2-a): load public MQTT TLS cert from broker volume 2026-08-20 15:27:03 -05:00
17 changed files with 1776 additions and 247 deletions

View File

@@ -12,12 +12,27 @@ exposes three listeners, each with a different trust model:
| Port | Protocol | Auth | Who it's for | | Port | Protocol | Auth | Who it's for |
|------|----------|------|---------------| |------|----------|------|---------------|
| `1883` | MQTT (plaintext) | username/password | internal services (e.g. the Laravel subscriber, Python publisher) | | `1883` | MQTT (plaintext, Compose network only) | username/password | internal backend service; not host-published |
| `8883` | MQTT over TLS | **client certificate** | field devices | | `8883` | MQTT over TLS | **client certificate** | field devices |
| `8884` | MQTT over TLS | username/password (server cert only) | scoped app clients and administrators | | `443` (`/mqtt` → loopback `9001`) | MQTT over WSS/TLS | username/password | scoped app clients |
| `8884` | MQTT over TLS | username/password (server cert only) | scoped app clients and administrators on networks that expose the raw port |
The former anonymous WebSocket listener on `9001` is disabled. The current web portal receives The former anonymous WebSocket listener on `9001` is now authenticated, uses the same ACL as the
live updates from the backend rather than connecting directly to Mosquitto. TLS listeners, and is bound to host loopback only. Nginx exposes it as WSS at `/mqtt` on port 443;
the web portal itself continues to receive live updates from the backend.
The TLS listeners use `mosquitto/certs/public-fullchain.pem` and
`mosquitto/certs/public-privkey.pem`, copied from the host's Let's Encrypt certificate during
deployment with owner `1883:1883` and mode `0600`. These files are deployment secrets/artifacts
and are excluded from Git.
The live password database is likewise outside Git at
`/home/ubuntu/.config/ul-platform/mosquitto.passwd`, bind-mounted read-only as
`/run/secrets/mosquitto_passwd`. The deployed ACL is copied to
`/home/ubuntu/.config/ul-platform/mosquitto.acl` and bind-mounted beside it. Both files are owned
by broker uid/gid 1883 with mode 0600. Provisioning updates the host password file and restarts Mosquitto; the
tracked `mosquitto/config/passwd` is only a legacy/bootstrap sample and must not receive new
organization credentials.
Device authentication happens on **port 8883**. A device presents a client Device authentication happens on **port 8883**. A device presents a client
certificate signed by the app's own Certificate Authority (CA); Mosquitto certificate signed by the app's own Certificate Authority (CA); Mosquitto

View File

@@ -11,14 +11,13 @@ services:
mosquitto: mosquitto:
image: eclipse-mosquitto:2 image: eclipse-mosquitto:2
ports: ports:
- "1883:1883"
- "8883:8883" - "8883:8883"
- "8884:8884" - "8884:8884"
- "127.0.0.1:9001:9001"
volumes: volumes:
- ./mosquitto:/mosquitto - ./mosquitto:/mosquitto
# Publicly trusted server identity for app MQTTS. The device listener still - /home/ubuntu/.config/ul-platform/mosquitto.passwd:/run/secrets/mosquitto_passwd:ro
# validates client certificates against /mosquitto/certs/ca.crt. - /home/ubuntu/.config/ul-platform/mosquitto.acl:/run/secrets/mosquitto_acl:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
backend: backend:
build: build:

View File

@@ -2,8 +2,16 @@ per_listener_settings true
# Plain MQTT — internal services and clients authenticate with username/password on port 1883 # Plain MQTT — internal services and clients authenticate with username/password on port 1883
listener 1883 0.0.0.0 listener 1883 0.0.0.0
password_file /mosquitto/config/passwd password_file /run/secrets/mosquitto_passwd
acl_file /mosquitto/config/devices.acl acl_file /run/secrets/mosquitto_acl
allow_anonymous false
# Authenticated MQTT over WebSocket for app clients. Docker binds this listener only to
# host loopback; nginx supplies the public WSS/TLS endpoint at /mqtt on port 443.
listener 9001 0.0.0.0
protocol websockets
password_file /run/secrets/mosquitto_passwd
acl_file /run/secrets/mosquitto_acl
allow_anonymous false allow_anonymous false
# TLS MQTT — devices authenticate with client certificates (port 8883) # TLS MQTT — devices authenticate with client certificates (port 8883)
@@ -14,19 +22,19 @@ allow_anonymous false
# since there's no config/cert hot-reload. # since there's no config/cert hot-reload.
listener 8883 0.0.0.0 listener 8883 0.0.0.0
cafile /mosquitto/certs/ca.crt cafile /mosquitto/certs/ca.crt
certfile /etc/letsencrypt/live/dev.hub.umagul.net/fullchain.pem certfile /mosquitto/certs/public-fullchain.pem
keyfile /etc/letsencrypt/live/dev.hub.umagul.net/privkey.pem keyfile /mosquitto/certs/public-privkey.pem
require_certificate true require_certificate true
use_identity_as_username true use_identity_as_username true
allow_anonymous false allow_anonymous false
acl_file /mosquitto/config/devices.acl acl_file /run/secrets/mosquitto_acl
# TLS MQTT — app/admin username+password access (port 8884). App usernames are orgIds; # TLS MQTT — app/admin username+password access (port 8884). App usernames are orgIds;
# devices.acl confines them to ul/{orgId}/app/... . No anonymous listener is exposed. # devices.acl confines them to ul/{orgId}/app/... . No anonymous listener is exposed.
listener 8884 0.0.0.0 listener 8884 0.0.0.0
certfile /etc/letsencrypt/live/dev.hub.umagul.net/fullchain.pem certfile /mosquitto/certs/public-fullchain.pem
keyfile /etc/letsencrypt/live/dev.hub.umagul.net/privkey.pem keyfile /mosquitto/certs/public-privkey.pem
require_certificate false require_certificate false
password_file /mosquitto/config/passwd password_file /run/secrets/mosquitto_passwd
allow_anonymous false allow_anonymous false
acl_file /mosquitto/config/devices.acl acl_file /run/secrets/mosquitto_acl

View File

@@ -21,8 +21,16 @@ server {
client_max_body_size 20m; client_max_body_size 20m;
location /mqtt { location /mqtt {
# Anonymous MQTT-over-WebSocket was removed for Sprint 2 security criterion B1. # Authenticated, ACL-confined MQTT-over-WebSocket. Mosquitto is bound to loopback;
return 410; # nginx terminates publicly trusted TLS so phones can use standard port 443.
proxy_pass http://127.0.0.1:9001/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
} }
location / { location / {

12
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "next/core-web-vitals",
"overrides": [
{
"files": ["pages/settings/mqtt-certs.tsx", "pages/sim/index.tsx"],
"rules": {
"@next/next/no-html-link-for-pages": "off",
"react/no-unescaped-entities": "off"
}
}
]
}

View File

@@ -1,66 +1,141 @@
import Head from 'next/head'; import Head from "next/head";
import Link from 'next/link'; import Image from "next/image";
import { useRouter } from 'next/router'; import Link from "next/link";
import { ReactNode } from 'react'; import { useRouter } from "next/router";
import { useAuth } from '../lib/auth-context'; import { ReactNode } from "react";
import { useAuth } from "../lib/auth-context";
export default function Layout({ children, title }: { children: ReactNode; title?: string }) { const NAV_ITEMS = [
{ href: "/", label: "Jobs" },
{ href: "/settings/devices", label: "Devices" },
{ href: "/settings/members", label: "Members" },
{ href: "/settings/api-keys", label: "API keys" },
{ href: "/settings/mqtt-certs", label: "MQTT certs" },
];
export default function Layout({
children,
title,
}: {
children: ReactNode;
title?: string;
}) {
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth(); const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
const router = useRouter(); const router = useRouter();
const isActive = (href: string) =>
href === "/"
? router.pathname === "/" || router.pathname.startsWith("/jobs")
: router.pathname.startsWith(href);
const handleLogout = async () => {
await logout();
router.push("/login");
};
const navLinks = NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="ul-shell__nav-link"
aria-current={isActive(item.href) ? "page" : undefined}
>
{item.label}
</Link>
));
return ( return (
<> <>
<Head> <Head>
<title>{title ? `${title} · UlHub` : 'UlHub'}</title> <title>{title ? `${title} · UlHub` : "UlHub"}</title>
</Head> </Head>
<header <a className="ul-skip-link" href="#main-content">
style={{ Skip to main content
display: 'flex', </a>
alignItems: 'center', <header className="ul-shell__header">
gap: '1.5rem', <Link href="/" className="ul-shell__brand" aria-label="UlHub jobs home">
padding: '0.75rem 1.5rem', <Image
borderBottom: '1px solid #ddd', className="ul-shell__brand-mark"
fontFamily: 'sans-serif', src="/um-trace-mark.png"
}} alt=""
> width={40}
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}> height={40}
UlHub priority
unoptimized
/>
<span>UlHub</span>
</Link> </Link>
{user && ( {user && (
<> <>
<nav style={{ display: 'flex', gap: '1rem' }}> <nav
<Link href="/">Jobs</Link> className="ul-shell__nav ul-shell__nav--desktop"
<Link href="/settings/devices">Devices</Link> aria-label="Primary navigation"
<Link href="/settings/members">Members</Link> >
<Link href="/settings/api-keys">API Keys</Link> {navLinks}
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
</nav> </nav>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <div className="ul-shell__context">
{memberships.length > 1 ? ( <div className="ul-org-context">
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}> <span className="ul-org-context__label" id="active-org-label">
{memberships.map((m) => ( Active organization
<option key={m.org.id} value={m.org.id}> </span>
{m.org.name} {memberships.length > 1 ? (
</option> <select
))} className="ul-org-context__select"
</select> aria-labelledby="active-org-label"
) : ( value={activeOrg?.org.id ?? ""}
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span> onChange={(event) => setActiveOrgId(event.target.value)}
)} >
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span> {memberships.map((membership) => (
<button <option key={membership.org.id} value={membership.org.id}>
onClick={async () => { {membership.org.name}
await logout(); </option>
router.push('/login'); ))}
}} </select>
> ) : (
Log out <strong className="ul-org-context__name">
</button> {activeOrg?.org.name ?? "No organization"}
</strong>
)}
{activeOrg && (
<span className="ul-org-context__role">
{activeOrg.role.replace("_", " ")}
</span>
)}
</div>
<details className="ul-shell__menu">
<summary>Menu</summary>
<nav
className="ul-shell__nav ul-shell__nav--mobile"
aria-label="Mobile navigation"
>
{navLinks}
<div className="ul-shell__mobile-account">
<span>{user.email}</span>
<button
className="ul-button ul-button--quiet"
onClick={handleLogout}
>
Log out
</button>
</div>
</nav>
</details>
<div className="ul-shell__account">
<span className="ul-shell__email">{user.email}</span>
<button
className="ul-button ul-button--quiet"
onClick={handleLogout}
>
Log out
</button>
</div>
</div> </div>
</> </>
)} )}
</header> </header>
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main> <main id="main-content" className="ul-shell__main" tabIndex={-1}>
{children}
</main>
</> </>
); );
} }

View File

@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
type StatusTone = "info" | "success" | "warning" | "danger" | "neutral";
const STATUS_PRESENTATION: Record<
string,
{ icon: string; label: string; tone: StatusTone }
> = {
OPEN: { icon: "○", label: "Open", tone: "info" },
IN_PROGRESS: { icon: "◐", label: "In progress", tone: "warning" },
COMPLETED: { icon: "✓", label: "Completed", tone: "success" },
CANCELLED: { icon: "—", label: "Cancelled", tone: "neutral" },
LIVE: { icon: "●", label: "Live", tone: "success" },
CONNECTING: { icon: "↻", label: "Connecting", tone: "info" },
OFFLINE: { icon: "○", label: "Offline · reconnecting", tone: "warning" },
ERROR: { icon: "!", label: "Connection error · retrying", tone: "danger" },
};
export function humanizeStatus(status: string): string {
return (
STATUS_PRESENTATION[status]?.label ??
status
.toLowerCase()
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
);
}
export function StatusBadge({
status,
label,
}: {
status: string;
label?: string;
}) {
const presentation = STATUS_PRESENTATION[status] ?? {
icon: "•",
label: humanizeStatus(status),
tone: "neutral" as const,
};
return (
<span className={`ul-status-badge ul-status-badge--${presentation.tone}`}>
<span className="ul-status-badge__icon" aria-hidden="true">
{presentation.icon}
</span>
<span>{label ?? presentation.label}</span>
</span>
);
}
export function PageState({
kind,
title,
children,
}: {
kind: "loading" | "empty" | "error";
title: string;
children?: ReactNode;
}) {
const icon = kind === "error" ? "!" : kind === "empty" ? "□" : "…";
return (
<div
className={`ul-page-state ul-page-state--${kind}`}
role={kind === "error" ? "alert" : "status"}
>
<span className="ul-page-state__icon" aria-hidden="true">
{icon}
</span>
<div>
<strong>{title}</strong>
{children && <div className="ul-page-state__detail">{children}</div>}
</div>
</div>
);
}
export function Metric({
label,
value,
detail,
}: {
label: string;
value: ReactNode;
detail?: ReactNode;
}) {
return (
<div className="ul-metric">
<span className="ul-metric__label">{label}</span>
<strong className="ul-metric__value">{value}</strong>
{detail && <span className="ul-metric__detail">{detail}</span>}
</div>
);
}

View File

@@ -9,7 +9,6 @@ import type MapViewConstructor from '@arcgis/core/views/MapView.js';
import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types';
const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || ''; const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || '';
const LIVE_COLOR = '#1a73e8';
const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat] const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat]
const DEFAULT_ZOOM = 4; const DEFAULT_ZOOM = 4;
@@ -63,6 +62,10 @@ function colorFor(utilityType: string): string {
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN; return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
} }
function semanticColor(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function orderKey(p: MapPoint): number { function orderKey(p: MapPoint): number {
return p.sequence ?? new Date(p.recordedAt).getTime(); return p.sequence ?? new Date(p.recordedAt).getTime();
} }
@@ -78,10 +81,10 @@ function popupHtml(point: MapPoint): string {
const rows = pointDetailRows(point) const rows = pointDetailRows(point)
.map( .map(
([label, value]) => ([label, value]) =>
`<tr><td style="color:#666;padding-right:0.75rem;white-space:nowrap;">${label}</td><td style="font-weight:600;">${value}</td></tr>`, `<tr><td class="ul-map-popup-table__label">${label}</td><td class="ul-map-popup-table__value">${value}</td></tr>`,
) )
.join(''); .join('');
return `<table style="border-collapse:collapse;font-family:sans-serif;">${rows}</table>`; return `<table class="ul-map-popup-table">${rows}</table>`;
} }
// Esri opens/closes/dismisses-on-outside-click popups natively via each // Esri opens/closes/dismisses-on-outside-click popups natively via each
@@ -94,7 +97,7 @@ function pointGraphic(mods: EsriModules, point: MapPoint, color: string) {
style: 'circle', style: 'circle',
color, color,
size: 10, size: 10,
outline: { color: '#ffffff', width: 1.5 }, outline: { color: semanticColor('--ul-color-on-primary'), width: 1.5 },
}, },
popupTemplate: { popupTemplate: {
title: `${point.utilityType} · ${point.fixType}`, title: `${point.utilityType} · ${point.fixType}`,
@@ -119,15 +122,16 @@ function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) {
} }
function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) { function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
const liveColor = semanticColor('--ul-color-primary');
const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat }; const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat };
const marker = new mods.Graphic({ const marker = new mods.Graphic({
geometry: position, geometry: position,
symbol: { symbol: {
type: 'simple-marker', type: 'simple-marker',
style: 'circle', style: 'circle',
color: LIVE_COLOR, color: liveColor,
size: 16, size: 16,
outline: { color: '#ffffff', width: 2 }, outline: { color: semanticColor('--ul-color-on-primary'), width: 2 },
}, },
popupTemplate: { popupTemplate: {
title: `Live · ${status.serial}`, title: `Live · ${status.serial}`,
@@ -144,15 +148,15 @@ function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
}), }),
symbol: { symbol: {
type: 'simple-fill', type: 'simple-fill',
color: [...hexToRgb(LIVE_COLOR), 0.15], color: [...hexChannels(liveColor), 0.15],
outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 }, outline: { color: [...hexChannels(liveColor), 0.3], width: 1 },
}, },
} as ConstructorParameters<typeof GraphicConstructor>[0]); } as ConstructorParameters<typeof GraphicConstructor>[0]);
return [halo, marker]; return [halo, marker];
} }
function hexToRgb(hex: string): [number, number, number] { function hexChannels(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16); const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
} }
@@ -165,6 +169,9 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
const modsRef = useRef<EsriModules | null>(null); const modsRef = useRef<EsriModules | null>(null);
const fittedSignatureRef = useRef(''); const fittedSignatureRef = useRef('');
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [loadError, setLoadError] = useState(false);
const sizeClass = heightPx <= 360 ? 'ul-map--compact' : heightPx >= 500 ? 'ul-map--detail' : '';
const mapClassName = ['ul-map', sizeClass].filter(Boolean).join(' ');
useEffect(() => { useEffect(() => {
if (!containerRef.current || !API_KEY) { if (!containerRef.current || !API_KEY) {
@@ -173,26 +180,33 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
let cancelled = false; let cancelled = false;
let view: InstanceType<typeof MapViewConstructor> | undefined; let view: InstanceType<typeof MapViewConstructor> | undefined;
loadEsriModules().then((mods) => { loadEsriModules()
if (cancelled || !containerRef.current) { .then((mods) => {
return; if (cancelled || !containerRef.current) {
} return;
const pointsLayer = new mods.GraphicsLayer(); }
const liveLayer = new mods.GraphicsLayer(); const pointsLayer = new mods.GraphicsLayer();
const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] }); const liveLayer = new mods.GraphicsLayer();
view = new mods.MapView({ const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] });
container: containerRef.current, view = new mods.MapView({
map, container: containerRef.current,
center: DEFAULT_CENTER, map,
zoom: DEFAULT_ZOOM, center: DEFAULT_CENTER,
}); zoom: DEFAULT_ZOOM,
});
modsRef.current = mods; modsRef.current = mods;
pointsLayerRef.current = pointsLayer; pointsLayerRef.current = pointsLayer;
liveLayerRef.current = liveLayer; liveLayerRef.current = liveLayer;
viewRef.current = view; viewRef.current = view;
setReady(true); setReady(true);
}); })
.catch((error: Error) => {
if (!cancelled) {
console.error('EsriJobMap: map failed to load', error);
setLoadError(true);
}
});
return () => { return () => {
cancelled = true; cancelled = true;
@@ -267,25 +281,23 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
if (!API_KEY) { if (!API_KEY) {
return ( return (
<div <div className={`${mapClassName} ul-map__fallback`} role="status">
style={{
height: heightPx,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #999',
borderRadius: 8,
color: '#666',
}}
>
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map. Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
</div> </div>
); );
} }
if (loadError) {
return (
<div className={`${mapClassName} ul-map__fallback`} role="alert">
The locate map could not be loaded. Check the network connection and ArcGIS configuration.
</div>
);
}
return ( return (
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}> <div className={mapClassName} role="region" aria-label="Interactive locate map" aria-busy={!ready}>
<div ref={containerRef} style={{ height: '100%' }} /> <div ref={containerRef} className="ul-map__canvas" />
</div> </div>
); );
} }

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from "react";
import type { LiveStatus, MapPoint } from '../components/map/types'; import type { LiveStatus, MapPoint } from "../components/map/types";
export type JobStreamState = "connecting" | "live" | "offline" | "error";
// Subscribes to live points + live position status for a job over the // Subscribes to live points + live position status for a job over the
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes // backend WebSocket. Reconnects with capped exponential backoff; resubscribes
@@ -9,6 +11,8 @@ export function useJobStream(
onPoints: (points: MapPoint[]) => void, onPoints: (points: MapPoint[]) => void,
onStatus?: (status: LiveStatus) => void, onStatus?: (status: LiveStatus) => void,
) { ) {
const [connectionState, setConnectionState] =
useState<JobStreamState>("connecting");
const pointsRef = useRef(onPoints); const pointsRef = useRef(onPoints);
pointsRef.current = onPoints; pointsRef.current = onPoints;
const statusRef = useRef(onStatus); const statusRef = useRef(onStatus);
@@ -16,6 +20,7 @@ export function useJobStream(
useEffect(() => { useEffect(() => {
if (!jobId) { if (!jobId) {
setConnectionState("offline");
return; return;
} }
@@ -25,12 +30,17 @@ export function useJobStream(
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
const connect = () => { const connect = () => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; let socketErrored = false;
setConnectionState(attempt === 0 ? "connecting" : "offline");
const proto = window.location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
socket.onopen = () => { socket.onopen = () => {
attempt = 0; attempt = 0;
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` })); setConnectionState("live");
socket?.send(
JSON.stringify({ type: "subscribe", channel: `job:${jobId}` }),
);
}; };
socket.onmessage = (event) => { socket.onmessage = (event) => {
@@ -39,9 +49,9 @@ export function useJobStream(
if (msg.jobId !== jobId) { if (msg.jobId !== jobId) {
return; return;
} }
if (msg.type === 'points') { if (msg.type === "points") {
pointsRef.current(msg.points); pointsRef.current(msg.points);
} else if (msg.type === 'status') { } else if (msg.type === "status") {
statusRef.current?.(msg as LiveStatus); statusRef.current?.(msg as LiveStatus);
} }
} catch { } catch {
@@ -49,10 +59,18 @@ export function useJobStream(
} }
}; };
socket.onerror = () => {
socketErrored = true;
setConnectionState("error");
};
socket.onclose = () => { socket.onclose = () => {
if (closed) { if (closed) {
return; return;
} }
if (!socketErrored) {
setConnectionState("offline");
}
attempt += 1; attempt += 1;
const delay = Math.min(1000 * 2 ** attempt, 15000); const delay = Math.min(1000 * 2 ** attempt, 15000);
timer = setTimeout(connect, delay); timer = setTimeout(connect, delay);
@@ -69,4 +87,6 @@ export function useJobStream(
socket?.close(); socket?.close();
}; };
}, [jobId]); }, [jobId]);
return connectionState;
} }

View File

@@ -6,7 +6,8 @@
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "next lint" "lint": "next lint",
"test": "node --test tests/*.test.mjs"
}, },
"dependencies": { "dependencies": {
"esri-loader": "^3.7.0", "esri-loader": "^3.7.0",

View File

@@ -1,5 +1,6 @@
import type { AppProps } from 'next/app'; import type { AppProps } from "next/app";
import { AuthProvider } from '../lib/auth-context'; import { AuthProvider } from "../lib/auth-context";
import "../styles/global.css";
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
return ( return (

View File

@@ -1,8 +1,9 @@
import Link from 'next/link'; import Link from "next/link";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import Layout from '../components/Layout'; import Layout from "../components/Layout";
import { api } from '../lib/api'; import { humanizeStatus, PageState, StatusBadge } from "../components/PortalUi";
import { useRequireAuth } from '../lib/auth-context'; import { api } from "../lib/api";
import { useRequireAuth } from "../lib/auth-context";
interface JobRow { interface JobRow {
id: string; id: string;
@@ -16,103 +17,146 @@ interface JobRow {
_count: { points: number }; _count: { points: number };
} }
const STATUSES = ['', 'OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; const STATUSES = ["", "OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
const STATUS_COLORS: Record<string, string> = {
OPEN: '#1976d2',
IN_PROGRESS: '#f57c00',
COMPLETED: '#388e3c',
CANCELLED: '#9e9e9e',
};
export default function JobsPage() { export default function JobsPage() {
const { user, activeOrg, loading } = useRequireAuth(); const { user, activeOrg, loading } = useRequireAuth();
const [jobs, setJobs] = useState<JobRow[]>([]); const [jobs, setJobs] = useState<JobRow[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [status, setStatus] = useState(''); const [status, setStatus] = useState("");
const [q, setQ] = useState(''); const [q, setQ] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false);
useEffect(() => { useEffect(() => {
if (!activeOrg) { if (!activeOrg) {
return; return;
} }
let cancelled = false;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (status) params.set('status', status); if (status) params.set("status", status);
if (q) params.set('q', q); if (q) params.set("q", q);
setFetching(true);
api api
.get<{ jobs: JobRow[]; total: number }>(`/api/orgs/${activeOrg.org.id}/jobs?${params}`) .get<{ jobs: JobRow[]; total: number }>(
`/api/orgs/${activeOrg.org.id}/jobs?${params}`,
)
.then((res) => { .then((res) => {
if (cancelled) return;
setJobs(res.jobs); setJobs(res.jobs);
setTotal(res.total); setTotal(res.total);
setError(null); setError(null);
}) })
.catch((err) => setError(err.message)); .catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setFetching(false);
});
return () => {
cancelled = true;
};
}, [activeOrg, status, q]); }, [activeOrg, status, q]);
if (loading || !user) { if (loading || !user) {
return null; return <PageState kind="loading" title="Loading your workspace…" />;
} }
return ( return (
<Layout title="Jobs"> <Layout title="Jobs">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}> <header className="ul-page-header">
<h1 style={{ margin: 0 }}>Jobs</h1> <div className="ul-page-header__title-group">
<span style={{ color: '#888' }}>{total} total</span> <span className="ul-page-header__eyebrow">Operations</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}> <h1>Jobs</h1>
<input placeholder="Search ticket, title, address…" value={q} onChange={(e) => setQ(e.target.value)} /> <p className="ul-page-header__meta" aria-live="polite">
<select value={status} onChange={(e) => setStatus(e.target.value)}> {fetching
{STATUSES.map((s) => ( ? "Updating jobs…"
<option key={s} value={s}> : `${total} ${total === 1 ? "job" : "jobs"}`}
{s || 'All statuses'} </p>
</div>
<Link href="/jobs/new" className="ul-button">
<span aria-hidden="true">+</span>&nbsp; New job
</Link>
</header>
<section className="ul-panel ul-filter-bar" aria-label="Job filters">
<label className="ul-field">
<span className="ul-field__label">Search jobs</span>
<input
type="search"
placeholder="Ticket, title, or address"
value={q}
onChange={(event) => setQ(event.target.value)}
/>
</label>
<label className="ul-field">
<span className="ul-field__label">Status</span>
<select
value={status}
onChange={(event) => setStatus(event.target.value)}
>
{STATUSES.map((item) => (
<option key={item} value={item}>
{item ? humanizeStatus(item) : "All statuses"}
</option> </option>
))} ))}
</select> </select>
<Link href="/jobs/new"> </label>
<button>+ New job</button> </section>
</Link>
{error ? (
<PageState kind="error" title="Jobs could not be loaded">
{error}
</PageState>
) : jobs.length === 0 && !fetching ? (
<PageState kind="empty" title="No jobs found">
{q || status
? "Try clearing or changing the current filters."
: "Create a job, or let a field device post points to auto-create its ticket."}
</PageState>
) : (
<div className="ul-panel ul-table-wrap" aria-busy={fetching}>
<table className="ul-table">
<caption className="ul-visually-hidden">
Jobs for the active organization
</caption>
<thead>
<tr>
<th scope="col">Ticket</th>
<th scope="col">Title</th>
<th scope="col">Address</th>
<th scope="col">Status</th>
<th scope="col">Source</th>
<th scope="col">Assignee</th>
<th scope="col" className="ul-table__numeric">
Points
</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td data-label="Ticket">
<Link className="ul-ticket-link" href={`/jobs/${job.id}`}>
{job.ticketNumber}
</Link>
</td>
<td data-label="Title">{job.title}</td>
<td data-label="Address">{job.address ?? "—"}</td>
<td data-label="Status">
<StatusBadge status={job.status} />
</td>
<td data-label="Source">{humanizeStatus(job.source)}</td>
<td data-label="Assignee">{job.assignedTo?.name ?? "—"}</td>
<td data-label="Points" className="ul-table__numeric">
{job._count.points}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> )}
{error && <p style={{ color: '#c62828' }}>{error}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.5rem' }}>Ticket</th>
<th>Title</th>
<th>Address</th>
<th>Status</th>
<th>Source</th>
<th>Assignee</th>
<th style={{ textAlign: 'right' }}>Points</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.5rem' }}>
<Link href={`/jobs/${job.id}`}>{job.ticketNumber}</Link>
</td>
<td>{job.title}</td>
<td>{job.address ?? '—'}</td>
<td>
<span style={{ color: STATUS_COLORS[job.status] ?? '#333', fontWeight: 600 }}>{job.status}</span>
</td>
<td>{job.source}</td>
<td>{job.assignedTo?.name ?? '—'}</td>
<td style={{ textAlign: 'right' }}>{job._count.points}</td>
</tr>
))}
{jobs.length === 0 && !error && (
<tr>
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
No jobs yet. Create one, or let a device post points to auto-create its ticket.
</td>
</tr>
)}
</tbody>
</table>
</Layout> </Layout>
); );
} }

View File

@@ -1,11 +1,22 @@
import { useRouter } from 'next/router'; import Link from "next/link";
import { useCallback, useEffect, useState } from 'react'; import { useRouter } from "next/router";
import Layout from '../../components/Layout'; import { useCallback, useEffect, useState } from "react";
import JobMap from '../../components/map/JobMap'; import Layout from "../../components/Layout";
import { pointSummary, type LiveStatus, type MapPoint } from '../../components/map/types'; import {
import { api } from '../../lib/api'; humanizeStatus,
import { useRequireAuth } from '../../lib/auth-context'; Metric,
import { useJobStream } from '../../lib/use-job-stream'; PageState,
StatusBadge,
} from "../../components/PortalUi";
import JobMap from "../../components/map/JobMap";
import {
pointSummary,
type LiveStatus,
type MapPoint,
} from "../../components/map/types";
import { api } from "../../lib/api";
import { useRequireAuth } from "../../lib/auth-context";
import { useJobStream } from "../../lib/use-job-stream";
interface JobDetail { interface JobDetail {
id: string; id: string;
@@ -21,12 +32,13 @@ interface JobDetail {
_count: { points: number }; _count: { points: number };
} }
const STATUSES = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; const STATUSES = ["OPEN", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
export default function JobDetailPage() { export default function JobDetailPage() {
const { user, activeOrg, loading } = useRequireAuth(); const { user, activeOrg, loading } = useRequireAuth();
const router = useRouter(); const router = useRouter();
const jobId = typeof router.query.jobId === 'string' ? router.query.jobId : null; const jobId =
typeof router.query.jobId === "string" ? router.query.jobId : null;
const orgId = activeOrg?.org.id ?? null; const orgId = activeOrg?.org.id ?? null;
const [job, setJob] = useState<JobDetail | null>(null); const [job, setJob] = useState<JobDetail | null>(null);
@@ -34,19 +46,40 @@ export default function JobDetailPage() {
const [live, setLive] = useState(0); const [live, setLive] = useState(0);
const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null); const [liveStatus, setLiveStatus] = useState<LiveStatus | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
useEffect(() => { useEffect(() => {
if (!orgId || !jobId) { if (!orgId || !jobId) {
return; return;
} }
api let cancelled = false;
.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`) setJob(null);
.then(setJob) setPoints([]);
.catch((err) => setError(err.message)); setLive(0);
api setLiveStatus(null);
.get<{ points: MapPoint[] }>(`/api/orgs/${orgId}/jobs/${jobId}/points`) setError(null);
.then((res) => setPoints(res.points)) setFetching(true);
.catch((err) => setError(err.message)); Promise.all([
api.get<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`),
api.get<{ points: MapPoint[] }>(
`/api/orgs/${orgId}/jobs/${jobId}/points`,
),
])
.then(([jobResult, pointResult]) => {
if (cancelled) return;
setJob(jobResult);
setPoints(pointResult.points);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setFetching(false);
});
return () => {
cancelled = true;
};
}, [orgId, jobId]); }, [orgId, jobId]);
const onLivePoints = useCallback((incoming: MapPoint[]) => { const onLivePoints = useCallback((incoming: MapPoint[]) => {
@@ -58,67 +91,163 @@ export default function JobDetailPage() {
setLive((n) => n + incoming.length); setLive((n) => n + incoming.length);
}, []); }, []);
const onStatus = useCallback((status: LiveStatus) => setLiveStatus(status), []); const onStatus = useCallback(
(status: LiveStatus) => setLiveStatus(status),
[],
);
useJobStream(jobId, onLivePoints, onStatus); const streamState = useJobStream(jobId, onLivePoints, onStatus);
const updateStatus = async (status: string) => { const updateStatus = async (status: string) => {
if (!orgId || !jobId) { if (!orgId || !jobId) {
return; return;
} }
setUpdatingStatus(true);
try { try {
setJob(await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, { status })); setJob(
await api.patch<JobDetail>(`/api/orgs/${orgId}/jobs/${jobId}`, {
status,
}),
);
setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} finally {
setUpdatingStatus(false);
} }
}; };
if (loading || !user) { if (loading || !user) {
return null; return <PageState kind="loading" title="Loading your workspace…" />;
} }
return ( const streamStatus = streamState.toUpperCase();
<Layout title={job ? job.ticketNumber : 'Job'}> const streamLabel =
{error && <p style={{ color: '#c62828' }}>{error}</p>} streamState === "live"
{job && ( ? live > 0
<> ? `Live · ${live} new ${live === 1 ? "point" : "points"} this session`
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexWrap: 'wrap' }}> : "Live · waiting for activity"
<h1 style={{ margin: 0 }}>{job.ticketNumber}</h1> : undefined;
<span style={{ fontSize: '1.1rem' }}>{job.title}</span>
<select value={job.status} onChange={(e) => updateStatus(e.target.value)} style={{ marginLeft: 'auto' }}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<p style={{ color: '#666' }}>
{job.address && <>{job.address} · </>}
source {job.source} · created {new Date(job.createdAt).toLocaleString()}
{job.assignedTo && <> · assigned to {job.assignedTo.name}</>}
{job.dueAt && <> · locate by {new Date(job.dueAt).toLocaleString()}</>}
</p>
{job.description && <p>{job.description}</p>}
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555', flexWrap: 'wrap' }}> return (
<span> <Layout title={job ? job.ticketNumber : "Job"}>
<strong>{points.length}</strong> points <nav className="ul-breadcrumb" aria-label="Breadcrumb">
</span> <Link href="/"> All jobs</Link>
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}> </nav>
live{live > 0 ? ` (+${live} this session)` : ''}
</span> {error && !job ? (
{points.length > 0 && ( <PageState kind="error" title="Job could not be loaded">
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span> {error}
</PageState>
) : fetching || !job ? (
<PageState kind="loading" title="Loading job and locate points…" />
) : (
<>
<header className="ul-detail-header">
<div>
<span className="ul-detail-header__eyebrow">Locate job</span>
<div className="ul-detail-header__title-row">
<h1>{job.ticketNumber}</h1>
<span className="ul-detail-header__title">{job.title}</span>
</div>
<p className="ul-detail-header__subtitle">
{job.address ?? "No address"} · {humanizeStatus(job.source)}{" "}
source
</p>
</div>
<label className="ul-field">
<span className="ul-field__label">Job status</span>
<select
value={job.status}
disabled={updatingStatus}
aria-describedby="job-status-update"
onChange={(event) => updateStatus(event.target.value)}
>
{STATUSES.map((status) => (
<option key={status} value={status}>
{humanizeStatus(status)}
</option>
))}
</select>
<span
id="job-status-update"
className="ul-visually-hidden"
aria-live="polite"
>
{updatingStatus
? "Updating job status"
: `Current status: ${humanizeStatus(job.status)}`}
</span>
</label>
</header>
{error && (
<PageState kind="error" title="The latest update failed">
{error}
</PageState>
)}
<section className="ul-panel ul-job-summary" aria-label="Job summary">
<Metric
label="Status"
value={<StatusBadge status={job.status} />}
/>
<Metric
label="Created"
value={new Date(job.createdAt).toLocaleDateString()}
detail={new Date(job.createdAt).toLocaleTimeString()}
/>
<Metric
label="Locate by"
value={
job.dueAt ? new Date(job.dueAt).toLocaleDateString() : "Not set"
}
detail={
job.dueAt ? new Date(job.dueAt).toLocaleTimeString() : undefined
}
/>
<Metric
label="Assigned to"
value={job.assignedTo?.name ?? "Unassigned"}
detail={job.assignedTo?.email}
/>
<Metric label="Source" value={humanizeStatus(job.source)} />
<Metric label="Recorded points" value={points.length} />
{job.description && (
<p className="ul-job-summary__description">{job.description}</p>
)} )}
{liveStatus && ( </section>
<span style={{ color: '#1a73e8' }}>
transmitter {liveStatus.serial} live ({new Date(liveStatus.recordedAt).toLocaleTimeString()}) <div className="ul-live-strip" aria-live="polite">
<StatusBadge status={streamStatus} label={streamLabel} />
{points.length > 0 && (
<span className="ul-live-strip__latest">
Latest point: {pointSummary(points[points.length - 1])}
</span> </span>
)} )}
{liveStatus && (
<StatusBadge
status="LIVE"
label={`Transmitter ${liveStatus.serial} · ${new Date(liveStatus.recordedAt).toLocaleTimeString()}`}
/>
)}
</div> </div>
<JobMap points={points} liveStatus={liveStatus} heightPx={520} /> <section
className="ul-map-section"
aria-labelledby="live-map-heading"
>
<div className="ul-section-heading">
<div>
<span className="ul-section-heading__eyebrow">
Live operations
</span>
<h2 id="live-map-heading">Locate map</h2>
</div>
<StatusBadge status={streamStatus} />
</div>
<JobMap points={points} liveStatus={liveStatus} heightPx={520} />
</section>
</> </>
)} )}
</Layout> </Layout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

859
web/styles/global.css Normal file
View File

@@ -0,0 +1,859 @@
@import "./tokens.css";
:root {
color-scheme: light;
--portal-canvas: var(--ul-color-canvas);
--portal-surface: var(--ul-color-surface);
--portal-surface-subtle: var(--ul-color-surface-raised);
--portal-map-field: var(--ul-color-map);
--portal-text: var(--ul-color-text);
--portal-text-muted: var(--ul-color-text-muted);
--portal-primary: var(--ul-color-primary);
--portal-on-primary: var(--ul-color-on-primary);
--portal-attention: var(--ul-color-accent);
--portal-success: var(--ul-color-success);
--portal-warning: var(--ul-color-warning);
--portal-danger: var(--ul-color-error);
--portal-border: var(--ul-color-border);
--portal-border-strong: var(--ul-color-text-muted);
--portal-focus: var(--ul-color-focus);
--portal-font-ui: var(--ul-type-family-sans);
--portal-font-mono: var(--ul-type-family-mono);
--portal-space-1: var(--ul-space-2xs);
--portal-space-2: var(--ul-space-xs);
--portal-space-3: var(--ul-space-sm);
--portal-space-4: var(--ul-space-md);
--portal-space-6: var(--ul-space-lg);
--portal-space-8: var(--ul-space-xl);
--portal-radius-sm: var(--ul-radius-sm);
--portal-radius-md: var(--ul-radius-md);
--portal-shadow: var(--ul-elevation-raised);
--portal-motion: var(--ul-motion-base) ease-out;
}
* {
box-sizing: border-box;
}
html {
background: var(--portal-canvas);
color: var(--portal-text);
font-family: var(--portal-font-ui);
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--portal-canvas);
color: var(--portal-text);
font-family: var(--portal-font-ui);
line-height: 1.5;
}
a {
color: var(--portal-primary);
text-underline-offset: 0.2em;
}
button,
input,
select,
textarea {
color: inherit;
font: inherit;
}
button,
select,
input[type="button"],
input[type="submit"] {
min-height: var(--ul-touch-minimum);
}
input,
select,
textarea {
min-height: var(--ul-touch-minimum);
border: 1px solid var(--portal-border-strong);
border-radius: var(--portal-radius-sm);
background: var(--portal-surface);
padding: var(--portal-space-2) var(--portal-space-3);
}
:where(a, button, input, select, textarea, summary):focus-visible {
outline: 3px solid var(--portal-focus);
outline-offset: 3px;
}
.ul-skip-link {
position: fixed;
z-index: 1000;
top: var(--portal-space-2);
left: var(--portal-space-2);
translate: 0 -200%;
border-radius: var(--portal-radius-sm);
background: var(--portal-surface);
padding: var(--portal-space-3) var(--portal-space-4);
color: var(--portal-text);
font-weight: 700;
box-shadow: var(--portal-shadow);
}
.ul-skip-link:focus {
translate: 0;
}
.ul-shell__header {
position: sticky;
z-index: 50;
top: 0;
display: flex;
min-height: 4.5rem;
align-items: center;
gap: var(--portal-space-6);
border-bottom: 1px solid var(--portal-border);
background: var(--portal-surface);
padding: var(--portal-space-3) clamp(1rem, 3vw, 2rem);
box-shadow: var(--portal-shadow);
}
.ul-shell__brand {
display: inline-flex;
flex: 0 0 auto;
min-height: var(--ul-touch-minimum);
align-items: center;
gap: var(--portal-space-2);
color: var(--portal-text);
font-size: 1.125rem;
font-weight: 750;
text-decoration: none;
}
.ul-shell__brand-mark {
display: block;
box-sizing: border-box;
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
padding: 0.375rem;
border-radius: var(--portal-radius-sm);
border: 1px solid var(--portal-border);
background: var(--portal-surface);
}
.ul-shell__nav {
display: flex;
align-items: center;
gap: var(--portal-space-1);
}
.ul-shell__nav-link {
display: inline-flex;
min-height: var(--ul-touch-minimum);
align-items: center;
border-radius: var(--portal-radius-sm);
padding: 0 var(--portal-space-3);
color: var(--portal-text-muted);
font-weight: 650;
text-decoration: none;
}
.ul-shell__nav-link:hover,
.ul-shell__nav-link[aria-current="page"] {
background: var(--portal-surface-subtle);
color: var(--portal-text);
}
.ul-shell__nav-link[aria-current="page"] {
box-shadow: inset 0 -0.2rem var(--portal-primary);
}
.ul-shell__context {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: center;
justify-content: flex-end;
gap: var(--portal-space-4);
}
.ul-org-context {
display: grid;
min-width: 10rem;
grid-template-columns: minmax(0, auto) auto;
align-items: center;
column-gap: var(--portal-space-2);
}
.ul-org-context__label {
grid-column: 1 / -1;
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 650;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ul-org-context__select {
max-width: 13rem;
height: var(--ul-touch-minimum);
min-height: var(--ul-touch-minimum);
padding-block: 0;
font-weight: 700;
}
.ul-org-context__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ul-org-context__role {
border: 1px solid var(--portal-border);
border-radius: 999px;
padding: 0 var(--portal-space-2);
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 700;
text-transform: lowercase;
}
.ul-shell__account {
display: flex;
align-items: center;
gap: var(--portal-space-2);
}
.ul-shell__email {
max-width: 12rem;
overflow: hidden;
color: var(--portal-text-muted);
font-size: 0.875rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.ul-shell__menu {
display: none;
position: relative;
}
.ul-shell__menu summary {
display: inline-flex;
min-height: var(--ul-touch-minimum);
cursor: pointer;
align-items: center;
border: 1px solid var(--portal-border-strong);
border-radius: var(--portal-radius-sm);
padding: 0 var(--portal-space-3);
font-weight: 700;
list-style: none;
}
.ul-shell__menu summary::-webkit-details-marker {
display: none;
}
.ul-shell__nav--mobile {
position: absolute;
top: calc(100% + var(--portal-space-2));
right: 0;
width: min(18rem, calc(100vw - 2rem));
flex-direction: column;
align-items: stretch;
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-surface);
padding: var(--portal-space-2);
box-shadow: var(--portal-shadow);
}
.ul-shell__mobile-account {
display: grid;
gap: var(--portal-space-2);
margin-top: var(--portal-space-2);
border-top: 1px solid var(--portal-border);
padding: var(--portal-space-3) var(--portal-space-2) var(--portal-space-2);
color: var(--portal-text-muted);
overflow-wrap: anywhere;
}
.ul-shell__main {
width: min(100% - 2rem, 75rem);
margin-inline: auto;
padding-block: clamp(1.25rem, 4vw, 2.5rem) 4rem;
}
.ul-button {
display: inline-flex;
min-height: var(--ul-touch-minimum);
cursor: pointer;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: var(--portal-radius-sm);
background: var(--portal-primary);
padding: 0 var(--portal-space-4);
color: var(--portal-on-primary);
font-weight: 700;
text-decoration: none;
transition:
filter var(--portal-motion),
transform var(--portal-motion);
}
.ul-button:hover {
filter: brightness(0.9);
}
.ul-button:active {
transform: translateY(1px);
}
.ul-button:disabled {
cursor: not-allowed;
filter: grayscale(1);
opacity: 0.6;
}
.ul-button--quiet {
border-color: var(--portal-border);
background: transparent;
color: var(--portal-text);
}
.ul-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--portal-space-4);
margin-bottom: var(--portal-space-6);
}
.ul-page-header__title-group {
min-width: 0;
}
.ul-page-header h1,
.ul-detail-header h1 {
margin: 0;
font-size: clamp(1.75rem, 4vw, 2.5rem);
line-height: 1.15;
}
.ul-page-header__eyebrow,
.ul-section-heading__eyebrow,
.ul-detail-header__eyebrow {
display: block;
margin-bottom: var(--portal-space-1);
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ul-page-header__meta,
.ul-detail-header__subtitle {
margin: var(--portal-space-1) 0 0;
color: var(--portal-text-muted);
}
.ul-panel {
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-surface);
box-shadow: var(--portal-shadow);
}
.ul-filter-bar {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(10rem, auto);
gap: var(--portal-space-3);
margin-bottom: var(--portal-space-4);
padding: var(--portal-space-4);
}
.ul-field {
display: grid;
gap: var(--portal-space-1);
}
.ul-field__label {
color: var(--portal-text-muted);
font-size: 0.875rem;
font-weight: 700;
}
.ul-table-wrap {
overflow-x: auto;
}
.ul-table {
width: 100%;
border-collapse: collapse;
}
.ul-table th,
.ul-table td {
padding: var(--portal-space-3) var(--portal-space-4);
border-bottom: 1px solid var(--portal-border);
text-align: left;
vertical-align: middle;
}
.ul-table th {
background: var(--portal-surface-subtle);
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ul-table tbody tr:last-child td {
border-bottom: 0;
}
.ul-table tbody tr:hover {
background: color-mix(
in srgb,
var(--portal-primary) 5%,
var(--portal-surface)
);
}
.ul-table__numeric {
font-family: var(--portal-font-mono);
font-variant-numeric: tabular-nums;
text-align: right !important;
}
.ul-ticket-link {
display: inline-flex;
min-height: var(--ul-touch-minimum);
align-items: center;
font-family: var(--portal-font-mono);
font-weight: 700;
}
.ul-status-badge {
display: inline-flex;
min-height: 2rem;
align-items: center;
gap: var(--portal-space-2);
border: 1px solid currentColor;
border-radius: 999px;
padding: 0 var(--portal-space-3);
font-size: 0.8125rem;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
}
.ul-status-badge__icon {
font-family: var(--portal-font-mono);
font-size: 1rem;
}
.ul-status-badge--info {
color: var(--portal-primary);
}
.ul-status-badge--success {
color: var(--portal-success);
}
.ul-status-badge--warning {
color: var(--portal-warning);
}
.ul-status-badge--danger {
color: var(--portal-danger);
}
.ul-status-badge--neutral {
color: var(--portal-text-muted);
}
.ul-page-state {
display: flex;
min-height: 7rem;
align-items: center;
justify-content: center;
gap: var(--portal-space-3);
border: 1px dashed var(--portal-border-strong);
border-radius: var(--portal-radius-md);
background: var(--portal-surface-subtle);
padding: var(--portal-space-6);
color: var(--portal-text-muted);
text-align: left;
}
.ul-page-state--error {
border-style: solid;
color: var(--portal-danger);
}
.ul-page-state__icon {
display: inline-grid;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid currentColor;
border-radius: 50%;
font-family: var(--portal-font-mono);
font-weight: 800;
}
.ul-page-state__detail {
margin-top: var(--portal-space-1);
}
.ul-detail-header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, auto);
gap: var(--portal-space-6);
align-items: end;
margin-bottom: var(--portal-space-4);
}
.ul-detail-header__title-row {
display: flex;
align-items: baseline;
gap: var(--portal-space-3);
flex-wrap: wrap;
}
.ul-detail-header__title {
font-size: clamp(1.125rem, 2vw, 1.375rem);
font-weight: 550;
}
.ul-breadcrumb {
margin-bottom: var(--portal-space-4);
}
.ul-breadcrumb a {
display: inline-flex;
min-height: var(--ul-touch-minimum);
align-items: center;
font-weight: 700;
}
.ul-job-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--portal-space-4);
margin-block: var(--portal-space-4) var(--portal-space-6);
padding: var(--portal-space-4);
}
.ul-job-summary__description {
grid-column: 1 / -1;
margin: 0;
padding-top: var(--portal-space-3);
border-top: 1px solid var(--portal-border);
}
.ul-metric {
display: grid;
align-content: start;
gap: var(--portal-space-1);
}
.ul-metric__label {
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ul-metric__value {
font-family: var(--portal-font-mono);
font-size: 1.5rem;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.ul-metric__detail {
color: var(--portal-text-muted);
font-size: 0.875rem;
}
.ul-live-strip {
display: flex;
align-items: center;
gap: var(--portal-space-3);
flex-wrap: wrap;
margin-bottom: var(--portal-space-4);
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-surface);
padding: var(--portal-space-3) var(--portal-space-4);
}
.ul-live-strip__latest {
min-width: 12rem;
flex: 1 1 auto;
color: var(--portal-text-muted);
font-family: var(--portal-font-mono);
font-size: 0.875rem;
}
.ul-map-section {
overflow: hidden;
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-map-field);
box-shadow: var(--portal-shadow);
}
.ul-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--portal-space-4);
border-bottom: 1px solid var(--portal-border);
background: var(--portal-surface);
padding: var(--portal-space-3) var(--portal-space-4);
}
.ul-section-heading h2 {
margin: 0;
font-size: 1.25rem;
}
.ul-map {
position: relative;
height: 30rem;
overflow: hidden;
background: var(--portal-map-field);
}
.ul-map--compact {
height: 20rem;
}
.ul-map--detail {
height: min(32.5rem, 65vh);
min-height: 24rem;
}
.ul-map__canvas {
height: 100%;
}
.ul-map__fallback {
display: grid;
place-items: center;
padding: var(--portal-space-6);
}
.ul-map-popup-table {
border-collapse: collapse;
font-family: var(--portal-font-ui);
}
.ul-map-popup-table td {
padding-block: var(--portal-space-1);
}
.ul-map-popup-table__label {
padding-right: var(--portal-space-3);
color: var(--portal-text-muted);
white-space: nowrap;
}
.ul-map-popup-table__value {
font-family: var(--portal-font-mono);
font-weight: 650;
}
.ul-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
}
/* CSS custom properties are not valid in media conditions; values mirror the generated breakpoint tokens. */
@media (max-width: 1200px) {
.ul-shell__nav--desktop,
.ul-shell__email {
display: none;
}
.ul-shell__menu {
display: block;
}
}
@media (max-width: 900px) {
.ul-shell__header {
align-items: flex-start;
gap: var(--portal-space-3);
flex-wrap: wrap;
}
.ul-shell__context {
width: 100%;
justify-content: space-between;
}
.ul-org-context {
min-width: 0;
flex: 1 1 auto;
}
.ul-shell__account {
order: 3;
}
.ul-page-header,
.ul-detail-header {
grid-template-columns: 1fr;
flex-direction: column;
}
.ul-page-header .ul-button {
width: 100%;
}
.ul-filter-bar {
grid-template-columns: 1fr;
}
.ul-table-wrap {
overflow: visible;
}
.ul-table,
.ul-table tbody,
.ul-table tr,
.ul-table td {
display: block;
width: 100%;
}
.ul-table thead {
display: none;
}
.ul-table tbody {
display: grid;
gap: var(--portal-space-3);
}
.ul-table tbody tr {
overflow: hidden;
border: 1px solid var(--portal-border);
border-radius: var(--portal-radius-md);
background: var(--portal-surface);
}
.ul-table td {
display: grid;
min-height: var(--ul-touch-minimum);
grid-template-columns: 7rem minmax(0, 1fr);
align-items: center;
gap: var(--portal-space-3);
border-bottom: 1px solid var(--portal-border);
text-align: left !important;
}
.ul-table td::before {
content: attr(data-label);
color: var(--portal-text-muted);
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ul-table td:first-child {
background: var(--portal-surface-subtle);
}
.ul-table td:last-child {
border-bottom: 0;
}
.ul-job-summary {
grid-template-columns: 1fr 1fr;
}
.ul-map--detail {
height: 28rem;
min-height: 22rem;
}
}
@media (max-width: 600px) {
.ul-shell__header {
position: static;
}
.ul-shell__brand span:last-child,
.ul-shell__account {
display: none;
}
.ul-org-context__role {
display: none;
}
.ul-shell__main {
width: min(100% - 1.5rem, 75rem);
}
.ul-page-header h1,
.ul-detail-header h1 {
overflow-wrap: anywhere;
}
.ul-job-summary {
grid-template-columns: 1fr;
}
.ul-job-summary__description {
grid-column: auto;
}
.ul-live-strip {
align-items: flex-start;
flex-direction: column;
}
.ul-map--detail {
height: 24rem;
min-height: 20rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@media (forced-colors: active) {
.ul-status-badge,
.ul-page-state,
.ul-panel,
.ul-map-section {
forced-color-adjust: auto;
}
}

99
web/styles/tokens.css Normal file
View File

@@ -0,0 +1,99 @@
/* GENERATED FILE — DO NOT EDIT.
* Source: meta/contracts/design-tokens.json v3.0.0
* Run: node meta/scripts/generate-design-tokens.mjs
*/
:root,
[data-ul-theme="default"] {
color-scheme: light;
--ul-color-canvas: #EEF3F2;
--ul-color-surface: #FCFEFD;
--ul-color-surface-raised: #FFFFFF;
--ul-color-surface-strong: #14252A;
--ul-color-text: #14252A;
--ul-color-text-muted: #5D6B6E;
--ul-color-primary: #0B7774;
--ul-color-primary-pressed: #075F5D;
--ul-color-accent: #B82D46;
--ul-color-border: #C7D2D0;
--ul-color-focus: #B82D46;
--ul-color-map: #DCE9E7;
--ul-color-success: #347A55;
--ul-color-warning: #A85C00;
--ul-color-error: #B42318;
--ul-color-neutral: #40565B;
--ul-color-disabled: #819092;
--ul-color-on-strong: #FFFFFF;
--ul-color-on-primary: #FFFFFF;
--ul-color-on-accent: #FFFFFF;
--ul-space-2xs: 4px;
--ul-space-xs: 8px;
--ul-space-sm: 12px;
--ul-space-md: 16px;
--ul-space-lg: 24px;
--ul-space-xl: 32px;
--ul-space-2xl: 48px;
--ul-radius-sm: 8px;
--ul-radius-md: 12px;
--ul-radius-lg: 20px;
--ul-radius-pill: 999px;
--ul-touch-minimum: 48px;
--ul-touch-comfortable: 56px;
--ul-touch-primary: 64px;
--ul-type-family-sans: "IBM Plex Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ul-type-family-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--ul-type-display-size: 2.5rem;
--ul-type-display-line-height: 1.2;
--ul-type-display-weight: 700;
--ul-type-page-size: 1.75rem;
--ul-type-page-line-height: 1.2142857142857142;
--ul-type-page-weight: 700;
--ul-type-section-size: 1.375rem;
--ul-type-section-line-height: 1.2727272727272727;
--ul-type-section-weight: 600;
--ul-type-body-size: 1rem;
--ul-type-body-line-height: 1.5;
--ul-type-body-weight: 400;
--ul-type-body-sm-size: 0.875rem;
--ul-type-body-sm-line-height: 1.4285714285714286;
--ul-type-body-sm-weight: 400;
--ul-type-label-size: 0.875rem;
--ul-type-label-line-height: 1.4285714285714286;
--ul-type-label-weight: 600;
--ul-type-mono-size: 1rem;
--ul-type-mono-line-height: 1.375;
--ul-type-mono-weight: 500;
--ul-motion-fast: 120ms;
--ul-motion-base: 200ms;
--ul-elevation-raised: 0 2px 8px rgba(20, 37, 42, 0.12);
--ul-elevation-overlay: 0 6px 16px rgba(20, 37, 42, 0.16);
--ul-breakpoint-compact: 600px;
--ul-breakpoint-medium: 900px;
--ul-breakpoint-wide: 1200px;
}
[data-ul-theme="sunlight"] {
color-scheme: light;
--ul-color-canvas: #FFFFFF;
--ul-color-surface: #FFFFFF;
--ul-color-surface-raised: #FFFFFF;
--ul-color-surface-strong: #000000;
--ul-color-text: #000000;
--ul-color-text-muted: #344448;
--ul-color-primary: #075F5D;
--ul-color-primary-pressed: #14252A;
--ul-color-accent: #982138;
--ul-color-border: #5D6B6E;
--ul-color-focus: #982138;
--ul-color-map: #E8F0EF;
--ul-color-success: #245F40;
--ul-color-warning: #754000;
--ul-color-error: #871A12;
--ul-color-neutral: #14252A;
--ul-color-disabled: #344448;
--ul-color-on-strong: #FFFFFF;
--ul-color-on-primary: #FFFFFF;
--ul-color-on-accent: #FFFFFF;
}

View File

@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const read = (path) => readFileSync(resolve(webRoot, path), "utf8");
const require = createRequire(import.meta.url);
function loadTsxComponent(path) {
const typescript = require("typescript");
const output = typescript.transpileModule(read(path), {
compilerOptions: {
esModuleInterop: true,
jsx: typescript.JsxEmit.ReactJSX,
module: typescript.ModuleKind.CommonJS,
target: typescript.ScriptTarget.ES2020,
},
}).outputText;
const loadedModule = { exports: {} };
new Function("require", "module", "exports", output)(
require,
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
const migratedPresentation = [
"components/Layout.tsx",
"components/PortalUi.tsx",
"pages/index.tsx",
"pages/jobs/[jobId].tsx",
];
test("migrated shell and job pages use reusable classes without inline presentation", () => {
for (const path of migratedPresentation) {
const source = read(path);
assert.doesNotMatch(
source,
/\bstyle\s*=/,
`${path} must not use inline style props`,
);
assert.doesNotMatch(
source,
/#[\da-f]{3,8}\b/i,
`${path} must not define visual color literals`,
);
}
});
test("the authenticated shell keeps organization and navigation context accessible", () => {
const layout = read("components/Layout.tsx");
assert.match(layout, /Active organization/);
assert.match(layout, /activeOrg\.role/);
assert.match(layout, /aria-current=/);
assert.match(layout, /Skip to main content/);
assert.match(layout, /aria-label="Primary navigation"/);
assert.match(layout, /src="\/um-trace-mark\.png"/);
assert.doesNotMatch(layout, />\s*UL\s*</);
});
test("the portal uses the cropped UM foreground without nested icon padding", () => {
const layout = read("components/Layout.tsx");
const css = read("styles/global.css");
assert.match(layout, /src="\/um-trace-mark\.png"/);
assert.doesNotMatch(layout, /um-trace-icon\.svg/);
assert.match(css, /\.ul-shell__brand-mark\s*\{[^}]*object-fit:\s*contain;/s);
assert.match(css, /\.ul-shell__brand-mark\s*\{[^}]*padding:\s*0\.375rem;/s);
});
test("status presentation always combines a readable label with an icon", () => {
const components = read("components/PortalUi.tsx");
for (const status of [
"OPEN",
"IN_PROGRESS",
"COMPLETED",
"CANCELLED",
"LIVE",
"OFFLINE",
"ERROR",
]) {
assert.match(
components,
new RegExp(`${status}: \\{ icon: .+, label: .+, tone:`),
);
}
assert.match(components, /ul-status-badge__icon/);
assert.match(components, /aria-hidden="true"/);
});
test("status and error-state components render accessible text and semantics", () => {
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
const { PageState, StatusBadge } = loadTsxComponent(
"components/PortalUi.tsx",
);
const status = renderToStaticMarkup(
React.createElement(StatusBadge, { status: "IN_PROGRESS" }),
);
assert.match(status, />In progress</);
assert.match(status, /ul-status-badge--warning/);
assert.match(status, /aria-hidden="true"/);
const error = renderToStaticMarkup(
React.createElement(PageState, {
kind: "error",
title: "Jobs could not be loaded",
}),
);
assert.match(error, /role="alert"/);
assert.match(error, /Jobs could not be loaded/);
});
test("responsive, focus, touch-target, and reduced-motion rules are present", () => {
const css = read("styles/global.css");
assert.ok(css.startsWith('@import "./tokens.css";'));
for (const token of [
"--ul-color-canvas",
"--ul-color-primary",
"--ul-color-map",
"--ul-touch-minimum",
"--ul-type-family-sans",
]) {
assert.match(css, new RegExp(`var\\(${token}\\)`));
}
assert.match(css, /:focus-visible/);
assert.match(css, /min-height:\s*var\(--ul-touch-minimum\)/);
assert.match(css, /@media \(max-width: 1200px\)/);
assert.match(css, /@media \(max-width: 900px\)/);
assert.match(css, /@media \(max-width: 600px\)/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
});
test("job pages preserve organization-scoped APIs, streaming, and provider-neutral map usage", () => {
const jobs = read("pages/index.tsx");
const detail = read("pages/jobs/[jobId].tsx");
const dispatcher = read("components/map/JobMap.tsx");
const stream = read("lib/use-job-stream.ts");
assert.match(jobs, /\/api\/orgs\/\$\{activeOrg\.org\.id\}\/jobs/);
assert.match(detail, /\/api\/orgs\/\$\{orgId\}\/jobs\/\$\{jobId\}/);
assert.match(detail, /<JobMap/);
assert.doesNotMatch(detail, /EsriJobMap/);
assert.match(dispatcher, /dynamic\(\(\) => import\('\.\/esri\/EsriJobMap'\)/);
assert.match(stream, /channel: `job:\$\{jobId\}`/);
assert.match(stream, /return connectionState/);
});