Release v0.9.75 runtime and onboarding update

Ship the 0.9.75 source update with improved startup/runtime hardening, operator API key onboarding, Meshtastic MQTT controls, Infonet/MeshChat separation, desktop package versioning, and aircraft telemetry refinements.

Also updates focused backend/frontend tests for node settings, Meshtastic MQTT settings, and desktop runtime behavior.
This commit is contained in:
BigBodyCobain
2026-05-06 01:15:54 -06:00
parent a017ba86d6
commit 6ffd54931c
48 changed files with 2024 additions and 375 deletions
@@ -9,12 +9,12 @@ import {
} from '@/lib/updateRuntime';
const RELEASE: GitHubLatestRelease = {
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.7',
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.75',
assets: [
{ name: 'ShadowBroker_0.9.7_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
{ name: 'ShadowBroker_0.9.7_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
{ name: 'ShadowBroker_0.9.7_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
{ name: 'ShadowBroker_0.9.7_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
{ name: 'ShadowBroker_0.9.75_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
{ name: 'ShadowBroker_0.9.75_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
{ name: 'ShadowBroker_0.9.75_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
{ name: 'ShadowBroker_0.9.75_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
],
};
+1
View File
@@ -66,6 +66,7 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
if (joined === 'system/update') return true;
if (pathSegments[0] === 'settings') return true;
if (joined === 'mesh/infonet/ingest') return true;
if (joined === 'mesh/meshtastic/send') return true;
// mesh/peers and all tools/* use require_local_operator on the backend and
// need X-Admin-Key injected on the server-side proxy leg.
if (pathSegments[0] === 'mesh' && pathSegments[1] === 'peers') return true;
+38 -2
View File
@@ -20,11 +20,23 @@ import {
Heart,
} from 'lucide-react';
const CURRENT_VERSION = '0.9.7';
const CURRENT_VERSION = '0.9.75';
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const RELEASE_TITLE = 'Agentic AI Channel + InfoNet Decentralized Intelligence';
const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening';
const HEADLINE_FEATURES = [
{
icon: <Bot size={20} className="text-purple-400" />,
accent: 'purple' as const,
title: 'Agentic onboarding for OpenClaw-compatible agents',
subtitle: 'First-time setup now includes local/direct agent connection, access-tier selection, copyable HMAC setup, and optional Tor hidden-service prep.',
details: [
'The onboarding flow can generate the local agent connection bundle through the existing HMAC API, point agents at /api/ai/tools, and let operators choose restricted read-only or full write access before connecting an agent.',
'Remote mode is labeled honestly: .onion exposes the signed HTTP agent API over Tor. Wormhole/MLS is not claimed as the current agent command transport.',
'The setup copy works for OpenClaw, Hermes, or any custom agent that implements the documented HMAC request contract.',
],
callToAction: 'OPEN FIRST-TIME SETUP -> AI AGENT',
},
{
icon: <Bot size={20} className="text-purple-400" />,
accent: 'purple' as const,
@@ -53,6 +65,26 @@ const HEADLINE_FEATURES = [
];
const NEW_FEATURES = [
{
icon: <Clock size={18} className="text-cyan-400" />,
title: 'Startup and Feed Responsiveness Pass',
desc: 'Map-critical feeds now lean on startup caches and priority preload behavior so the dashboard can paint before heavyweight synthesis jobs finish.',
},
{
icon: <Network size={18} className="text-green-400" />,
title: 'MeshChat MQTT Settings',
desc: 'Public MeshChat stays opt-in and now has an in-panel settings lane for broker, port, username, password, and channel PSK while remaining separated from Wormhole/private mode.',
},
{
icon: <Plane size={18} className="text-cyan-400" />,
title: 'Selected Entity Trails',
desc: 'Flight and vessel trails are drawn only for selected assets, reducing global clutter while still exposing movement history for unknown-route entities.',
},
{
icon: <Plane size={18} className="text-amber-400" />,
title: 'Aircraft Detail Cards',
desc: 'Commercial aircraft stay airline-first, while private and general aviation aircraft can show model-focused Wiki context and imagery when available.',
},
{
icon: <Cpu size={18} className="text-purple-400" />,
title: 'AI Batch Command Channel',
@@ -101,6 +133,10 @@ const NEW_FEATURES = [
];
const BUG_FIXES = [
'Docker proxy and backend port handling hardened so changing the host backend port does not require changing the internal service contract.',
'Global Threat Intercept and live-data startup paths no longer wait on slow-tier synthesis before cached data can paint the UI.',
'MeshChat and Infonet statuses now separate public MQTT participation, private Wormhole mode, and local node bootstrap so the UI does not imply the wrong connection state.',
'Commercial aircraft detail cards no longer show a confusing model image alongside the airline card.',
'Sovereign Shell adaptive polling — voting and challenge windows refresh every 8 seconds while active, every 30 to 60 seconds when idle. Voting feels live without a websocket layer.',
'Per-row write actions (petitions, upgrades, disputes) hold isolated submission state so concurrent forms no longer share a single in-flight slot.',
'Verbatim diagnostic surfacing on every write button. The backend reason text is always shown on rejection — no opaque "denied" toasts.',
+142 -16
View File
@@ -159,7 +159,7 @@ import {
EarthquakeLabels,
ThreatMarkers,
} from '@/components/map/MapMarkers';
import type { DashboardData, KiwiSDR, MaplibreViewerProps, Scanner, SigintSignal } from '@/types/dashboard';
import type { DashboardData, Flight, KiwiSDR, MaplibreViewerProps, Scanner, Ship, SigintSignal } from '@/types/dashboard';
import { useDataKeys } from '@/hooks/useDataStore';
import { useInterpolation } from '@/components/map/hooks/useInterpolation';
import { useClusterLabels } from '@/components/map/hooks/useClusterLabels';
@@ -225,6 +225,63 @@ type GeoExtras = {
type KiwiProps = Partial<KiwiSDR> & GeoExtras;
type ScannerProps = Partial<Scanner> & GeoExtras;
type SigintProps = Partial<SigintSignal> & GeoExtras;
type TrailPoint = { lng: number; lat: number; alt?: number; sog?: number; ts?: number };
type TrailKind = 'flight' | 'ship';
const FLIGHT_SELECTION_TYPES = new Set([
'flight',
'private_flight',
'military_flight',
'private_jet',
'tracked_flight',
]);
function parseTrailPoints(raw: unknown, kind: TrailKind): TrailPoint[] {
if (!Array.isArray(raw)) return [];
return raw
.map((p): TrailPoint | null => {
if (Array.isArray(p)) {
const lat = Number(p[0]);
const lng = Number(p[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (kind === 'ship') {
return { lat, lng, sog: Number(p[2]) || 0, ts: Number(p[3]) || 0 };
}
return { lat, lng, alt: Number(p[2]) || 0, ts: Number(p[3]) || 0 };
}
if (p && typeof p === 'object') {
const point = p as { lat?: number; lng?: number; alt?: number; sog?: number; ts?: number };
const lat = Number(point.lat);
const lng = Number(point.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return {
lat,
lng,
alt: Number(point.alt) || 0,
sog: Number(point.sog) || 0,
ts: Number(point.ts) || 0,
};
}
return null;
})
.filter((p): p is TrailPoint => Boolean(p && (p.lat !== 0 || p.lng !== 0)));
}
function hasKnownRouteName(value?: string | null): boolean {
const normalized = String(value || '').trim().toUpperCase();
return Boolean(normalized && normalized !== 'UNKNOWN');
}
function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean {
if (!entity) return false;
if (dynamicRoute?.orig_loc || dynamicRoute?.dest_loc) return true;
if (!('origin_loc' in entity) && !('origin_name' in entity)) return false;
const flight = entity as Flight;
return Boolean(
(flight.origin_loc && flight.dest_loc)
|| (hasKnownRouteName(flight.origin_name) && hasKnownRouteName(flight.dest_name)),
);
}
const MAP_EXTRA_DATA_KEYS = [
'air_quality',
@@ -479,6 +536,7 @@ const MaplibreViewer = ({
}, [activeLayers.viirs_nightlights, viirsProbeDayKey]);
const [dynamicRoute, setDynamicRoute] = useState<DynamicRoute | null>(null);
const [selectedTrailPoints, setSelectedTrailPoints] = useState<TrailPoint[]>([]);
const prevCallsign = useRef<string | null>(null);
// Oracle region intel for map entity popups
@@ -557,6 +615,7 @@ const MaplibreViewer = ({
if (callsign && callsign !== prevCallsign.current) {
prevCallsign.current = callsign;
setDynamicRoute(null);
fetch(`${API_BASE}/api/route/${callsign}?lat=${entityLat}&lng=${entityLng}`)
.then((res) => res.json())
.then((routeData) => {
@@ -575,6 +634,71 @@ const MaplibreViewer = ({
};
}, [selectedEntity, data]);
useEffect(() => {
let cancelled = false;
const entity = findSelectedEntity(selectedEntity, data);
if (!selectedEntity || !entity) {
setSelectedTrailPoints([]);
return () => {
cancelled = true;
};
}
const isFlight = FLIGHT_SELECTION_TYPES.has(selectedEntity.type);
const isShip = selectedEntity.type === 'ship';
if (!isFlight && !isShip) {
setSelectedTrailPoints([]);
return () => {
cancelled = true;
};
}
if (isFlight && flightHasKnownRoute(entity, dynamicRoute)) {
setSelectedTrailPoints([]);
return () => {
cancelled = true;
};
}
const kind: TrailKind = isShip ? 'ship' : 'flight';
const fallback = parseTrailPoints((entity as Flight | Ship).trail, kind);
if (fallback.length >= 2) {
setSelectedTrailPoints(fallback);
} else {
setSelectedTrailPoints([]);
}
const trailId = String(selectedEntity.id || '').trim();
if (!trailId) {
return () => {
cancelled = true;
};
}
if (isShip && !/^\d+$/.test(trailId)) {
return () => {
cancelled = true;
};
}
const endpoint = isShip
? `${API_BASE}/api/trail/ship/${encodeURIComponent(trailId)}`
: `${API_BASE}/api/trail/flight/${encodeURIComponent(trailId)}`;
fetch(endpoint)
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
if (cancelled || !payload) return;
const points = parseTrailPoints(payload.trail, kind);
setSelectedTrailPoints(points.length >= 2 ? points : fallback);
})
.catch(() => {
if (!cancelled) setSelectedTrailPoints(fallback);
});
return () => {
cancelled = true;
};
}, [selectedEntity, data, dynamicRoute]);
// Fetch oracle region intel for entity popups
useEffect(() => {
if (!selectedEntity) {
@@ -1349,27 +1473,29 @@ const MaplibreViewer = ({
return { type: 'FeatureCollection' as const, features };
}, [selectedEntity, data, dynamicRoute, getSelectedEntityLiveCoords, interpTick]);
// Trail history GeoJSON: shows where the SELECTED aircraft has been
// Trail history GeoJSON: shows where the selected unknown-route aircraft or vessel has been.
const trailGeoJSON = useMemo(() => {
void interpTick;
const entity = findSelectedEntity(selectedEntity, data);
if (!entity || !('trail' in entity) || !entity.trail || entity.trail.length < 2) return null;
if (!entity || selectedTrailPoints.length < 2) return null;
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightHasKnownRoute(entity, dynamicRoute)) {
return null;
}
// Parse trail points — backend sends [lat, lng, alt, ts] arrays
type TrailPt = { lng: number; lat: number; alt: number; ts: number };
const points: TrailPt[] = (
entity.trail as Array<{ lat?: number; lng?: number; alt?: number; ts?: number } | number[]>
).map((p) => {
if (Array.isArray(p)) {
return { lat: p[0] as number, lng: p[1] as number, alt: (p[2] as number) || 0, ts: (p[3] as number) || 0 };
}
return { lat: p.lat ?? 0, lng: p.lng ?? 0, alt: p.alt ?? 0, ts: p.ts ?? 0 };
}).filter((p) => p.lat !== 0 || p.lng !== 0);
// Trails are loaded only for the selected asset to avoid open-map clutter.
const isShipTrail = selectedEntity?.type === 'ship';
const points = [...selectedTrailPoints];
const currentLoc = getSelectedEntityLiveCoords(entity);
if (currentLoc && points.length > 0) {
const lastPt = points[points.length - 1];
points.push({ lng: currentLoc[0], lat: currentLoc[1], alt: lastPt.alt, ts: Date.now() / 1000 });
points.push({
lng: currentLoc[0],
lat: currentLoc[1],
alt: lastPt.alt,
sog: lastPt.sog,
ts: Date.now() / 1000,
});
}
if (points.length < 2) return null;
@@ -1394,7 +1520,7 @@ const MaplibreViewer = ({
type: 'Feature' as const,
properties: {
type: 'trail',
color: altToColor((a.alt + b.alt) / 2),
color: isShipTrail ? '#22d3ee' : altToColor(((a.alt ?? 0) + (b.alt ?? 0)) / 2),
opacity: 0.4 + progress * 0.5, // older segments more transparent
segIndex: i,
},
@@ -1406,7 +1532,7 @@ const MaplibreViewer = ({
}
return { type: 'FeatureCollection' as const, features };
}, [selectedEntity, data, getSelectedEntityLiveCoords, interpTick]);
}, [selectedEntity, data, selectedTrailPoints, dynamicRoute, getSelectedEntityLiveCoords, interpTick]);
// Predictive vector GeoJSON: dotted line projecting ~5 min ahead based on heading + speed
// Skip when entity has a known route (origin+dest) — the route line already shows where it's going
+178 -5
View File
@@ -117,6 +117,17 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
setMeshView,
meshDirectTarget,
setMeshDirectTarget,
meshMqttSettings,
meshMqttForm,
setMeshMqttForm,
meshMqttBusy,
meshMqttStatusText,
meshMqttEnabled,
meshMqttRunning,
meshMqttConnected,
meshMqttConnectionLabel,
saveMeshMqttSettings,
refreshMeshMqttSettings,
// Identity
identity,
publicIdentity,
@@ -1155,17 +1166,179 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
>
INBOX
</button>
<button
onClick={() => setMeshView('settings')}
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
meshView === 'settings'
? 'border-cyan-500/40 text-cyan-300 bg-cyan-950/20'
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-cyan-300'
}`}
>
SETTINGS
</button>
</div>
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
<div
className={`text-[10px] font-mono truncate ${
meshMqttConnected
? 'text-green-300/80'
: meshMqttEnabled
? 'text-amber-300/80'
: 'text-[var(--text-muted)]'
}`}
>
{meshSessionActive && publicMeshAddress
? `ADDR ${publicMeshAddress.toUpperCase()}`
? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}`
: publicMeshAddress
? 'MESH OFF / KEY SAVED'
: 'NO PUBLIC MESH ADDRESS'}
? `${meshMqttConnectionLabel} / KEY SAVED`
: `${meshMqttConnectionLabel} / NO ADDRESS`}
</div>
</div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{!meshSessionActive && (
{meshView === 'settings' && (
<div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-cyan-800/35 bg-cyan-950/10 p-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-cyan-300 tracking-[0.18em]">MESHTASTIC MQTT</div>
<div className="mt-1 text-[10px] text-[var(--text-muted)] leading-[1.5]">
Public Mesh is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for MeshChat.
</div>
</div>
<span
className={`shrink-0 border px-2 py-1 text-[10px] tracking-[0.16em] ${
meshMqttConnected
? 'border-green-500/40 text-green-300'
: meshMqttEnabled
? 'border-amber-500/40 text-amber-300'
: 'border-red-500/35 text-red-300'
}`}
>
{meshMqttConnectionLabel}
</span>
</div>
{meshMqttSettings?.runtime?.last_error && (
<div className="mt-2 text-red-300/80">
LAST ERROR: {meshMqttSettings.runtime.last_error}
</div>
)}
{meshMqttRunning && !meshMqttConnected && !meshMqttSettings?.runtime?.last_error && (
<div className="mt-2 text-amber-300/80">
MQTT bridge is starting. Live messages appear after broker connect.
</div>
)}
</div>
<div className="grid grid-cols-[1fr_70px] gap-2">
<label className="space-y-1">
<span className="text-[var(--text-muted)]">BROKER</span>
<input
value={meshMqttForm.broker}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, broker: e.target.value }))}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
<label className="space-y-1">
<span className="text-[var(--text-muted)]">PORT</span>
<input
value={meshMqttForm.port}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, port: e.target.value }))}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">BROKER LOGIN (optional)</span>
<input
value={meshMqttForm.username}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="blank uses public Meshtastic default"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">
BROKER PASSWORD {meshMqttSettings?.uses_default_credentials ? '(public default)' : meshMqttSettings?.has_password ? '(saved)' : ''}
</span>
<input
type="password"
value={meshMqttForm.password}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder={
meshMqttSettings?.uses_default_credentials
? 'blank uses public Meshtastic default'
: meshMqttSettings?.has_password
? 'leave blank to keep saved password'
: 'blank uses public Meshtastic default'
}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">
CHANNEL PSK HEX {meshMqttSettings?.has_psk ? '(saved)' : '(default LongFast if blank)'}
</span>
<input
type="password"
value={meshMqttForm.psk}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, psk: e.target.value }))}
placeholder="blank uses default LongFast key"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<label className="flex items-center gap-2 border border-[var(--border-primary)]/40 bg-black/20 px-2 py-1 text-cyan-200">
<input
type="checkbox"
checked={meshMqttForm.include_default_roots}
onChange={(e) =>
setMeshMqttForm((prev) => ({ ...prev, include_default_roots: e.target.checked }))
}
/>
DEFAULT PUBLIC ROOTS
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">EXTRA ROOTS</span>
<input
value={meshMqttForm.extra_roots}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, extra_roots: e.target.value }))}
placeholder="comma separated, optional"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<div className="grid grid-cols-3 gap-2 pt-1">
<button
onClick={() => void saveMeshMqttSettings({ enabled: true })}
disabled={meshMqttBusy}
className="border border-green-600/40 bg-green-950/20 px-2 py-1.5 text-green-300 hover:bg-green-950/35 disabled:opacity-50"
>
ENABLE
</button>
<button
onClick={() => void saveMeshMqttSettings({ enabled: false })}
disabled={meshMqttBusy}
className="border border-red-600/35 bg-red-950/15 px-2 py-1.5 text-red-300 hover:bg-red-950/25 disabled:opacity-50"
>
DISABLE
</button>
<button
onClick={() => void refreshMeshMqttSettings()}
disabled={meshMqttBusy}
className="border border-cyan-700/40 bg-cyan-950/15 px-2 py-1.5 text-cyan-300 hover:bg-cyan-950/25 disabled:opacity-50"
>
REFRESH
</button>
</div>
{meshMqttStatusText && (
<div className="text-[10px] text-cyan-200/80 leading-[1.5]">{meshMqttStatusText}</div>
)}
</div>
)}
{!meshSessionActive && meshView !== 'settings' && (
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
MeshChat is off. Turn it on to connect the public mesh lane.
</div>
@@ -15,9 +15,6 @@ import {
import type { DesktopControlAuditReport } from '@/lib/desktopControlContract';
import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient';
import {
clearBrowserIdentityState,
derivePublicMeshAddress,
generateNodeKeys,
getNodeIdentity,
getStoredNodeDescriptor,
getWormholeIdentityDescriptor,
@@ -31,9 +28,7 @@ import {
updateContact,
blockContact,
getDMNotify,
getPublicKeyAlgo,
nextSequence,
signEvent,
verifyEventSignature,
verifyRawSignature,
purgeBrowserContactGraph,
@@ -130,7 +125,6 @@ import {
preferredDmPeerId,
} from '@/mesh/meshDmConsent';
import { deriveSasPhrase } from '@/mesh/meshSas';
import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
import { validateEventPayload } from '@/mesh/meshSchema';
import {
buildDmTrustHint,
@@ -223,6 +217,94 @@ interface GateCompatConsentPromptState {
reason: string;
}
interface MeshMqttRuntime {
enabled?: boolean;
running?: boolean;
connected?: boolean;
broker?: string;
port?: number;
username?: string;
client_id?: string;
message_log_size?: number;
signal_log_size?: number;
last_error?: string;
last_connected_at?: number;
last_disconnected_at?: number;
}
interface MeshMqttSettings {
enabled: boolean;
broker: string;
port: number;
username: string;
uses_default_credentials?: boolean;
has_password: boolean;
has_psk: boolean;
include_default_roots: boolean;
extra_roots: string;
extra_topics: string;
runtime?: MeshMqttRuntime;
}
interface MeshMqttForm {
broker: string;
port: string;
username: string;
password: string;
psk: string;
include_default_roots: boolean;
extra_roots: string;
extra_topics: string;
}
const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address';
function normalizePublicMeshAddress(value: string): string {
const raw = String(value || '').trim().toLowerCase();
const body = raw.startsWith('!') ? raw.slice(1) : raw;
if (!/^[0-9a-f]{8}$/.test(body)) return '';
return `!${body}`;
}
function readStoredPublicMeshAddress(): string {
if (typeof window === 'undefined') return '';
try {
return normalizePublicMeshAddress(window.localStorage.getItem(PUBLIC_MESH_ADDRESS_KEY) || '');
} catch {
return '';
}
}
function writeStoredPublicMeshAddress(address: string): void {
if (typeof window === 'undefined') return;
const normalized = normalizePublicMeshAddress(address);
if (!normalized) return;
try {
window.localStorage.setItem(PUBLIC_MESH_ADDRESS_KEY, normalized);
} catch {
/* ignore */
}
}
function clearStoredPublicMeshAddress(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(PUBLIC_MESH_ADDRESS_KEY);
} catch {
/* ignore */
}
}
function createPublicMeshAddress(): string {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const value = new Uint32Array(1);
window.crypto.getRandomValues(value);
if (value[0]) return `!${value[0].toString(16).padStart(8, '0')}`;
}
const fallback = Math.floor((Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0);
return `!${fallback.toString(16).padStart(8, '0')}`;
}
function describeGateCompatConsentRequired(): string {
return 'Local gate runtime is unavailable for this room.';
}
@@ -315,8 +397,21 @@ export function useMeshChatController({
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshSessionActive, setMeshSessionActive] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel');
const [meshDirectTarget, setMeshDirectTarget] = useState('');
const [meshMqttSettings, setMeshMqttSettings] = useState<MeshMqttSettings | null>(null);
const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({
broker: 'mqtt.meshtastic.org',
port: '1883',
username: '',
password: '',
psk: '',
include_default_roots: true,
extra_roots: '',
extra_topics: '',
});
const [meshMqttBusy, setMeshMqttBusy] = useState(false);
const [meshMqttStatusText, setMeshMqttStatusText] = useState('');
// Identity
const [identity, setIdentity] = useState<NodeIdentity | null>(null);
@@ -329,15 +424,123 @@ export function useMeshChatController({
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : '';
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress);
const publicIdentity = null;
const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress);
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
const meshMqttRuntime = meshMqttSettings?.runtime;
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
const meshMqttRunning = Boolean(meshMqttRuntime?.running);
const meshMqttConnected = Boolean(meshMqttRuntime?.connected);
const meshMqttConnectionLabel = !meshMqttEnabled
? 'MQTT OFF'
: meshMqttConnected
? 'MQTT LIVE'
: meshMqttRunning
? 'MQTT CONNECTING'
: 'MQTT STARTING';
const applyMeshMqttSettings = useCallback((data: MeshMqttSettings) => {
setMeshMqttSettings(data);
setMeshMqttForm((prev) => ({
broker: data.broker || prev.broker || 'mqtt.meshtastic.org',
port: String(data.port || prev.port || '1883'),
username: data.uses_default_credentials ? '' : data.username || prev.username || '',
password: '',
psk: '',
include_default_roots: Boolean(data.include_default_roots),
extra_roots: data.extra_roots || '',
extra_topics: data.extra_topics || '',
}));
}, []);
const refreshMeshMqttSettings = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, { cache: 'no-store' });
if (!res.ok) return null;
const data = (await res.json()) as MeshMqttSettings;
applyMeshMqttSettings(data);
return data;
} catch {
return null;
}
}, [applyMeshMqttSettings]);
const saveMeshMqttSettings = useCallback(
async (updates: Partial<MeshMqttForm> & { enabled?: boolean } = {}) => {
setMeshMqttBusy(true);
setMeshMqttStatusText('');
try {
const nextForm = { ...meshMqttForm, ...updates };
const body: Record<string, unknown> = {
broker: nextForm.broker.trim() || 'mqtt.meshtastic.org',
port: Number.parseInt(nextForm.port, 10) || 1883,
username: nextForm.username.trim(),
include_default_roots: Boolean(nextForm.include_default_roots),
extra_roots: nextForm.extra_roots.trim(),
extra_topics: nextForm.extra_topics.trim(),
};
if (!nextForm.username.trim() && !nextForm.password.trim()) {
body.password = '';
}
if (typeof updates.enabled === 'boolean') {
body.enabled = updates.enabled;
}
if (nextForm.password.trim()) {
body.password = nextForm.password;
}
if (nextForm.psk.trim()) {
body.psk = nextForm.psk.trim();
}
const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(detail || `HTTP ${res.status}`);
}
const data = (await res.json()) as MeshMqttSettings;
applyMeshMqttSettings(data);
if (data.enabled) {
setWormholeEnabled(false);
setWormholeReadyState(false);
setWormholeRnsReady(false);
setWormholeRnsDirectReady(false);
setWormholeRnsPeers({ active: 0, configured: 0 });
setSecureModeCached(false);
}
const status = data.runtime?.connected
? 'MQTT bridge connected.'
: data.enabled
? 'MQTT bridge enabled. Connection may take a few seconds.'
: 'MQTT bridge disabled.';
setMeshMqttStatusText(status);
return { ok: true as const, text: status, data };
} catch (err) {
const text = err instanceof Error ? err.message : 'MQTT settings update failed';
setMeshMqttStatusText(text);
return { ok: false as const, text };
} finally {
setMeshMqttBusy(false);
}
},
[applyMeshMqttSettings, meshMqttForm],
);
const enableMeshMqttBridge = useCallback(async () => {
const result = await saveMeshMqttSettings({ enabled: true });
if (!result.ok) {
throw new Error(result.text);
}
return result;
}, [saveMeshMqttSettings]);
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const streamEnabledForSelectedGateRef = useRef(false);
const displayPublicMeshSender = useCallback(
@@ -345,15 +548,14 @@ export function useMeshChatController({
if (!sender) return '???';
if (
hasPublicLaneIdentity &&
publicIdentity?.nodeId &&
publicMeshAddress &&
sender.toLowerCase() === publicIdentity.nodeId.toLowerCase()
sender.toLowerCase() === publicMeshAddress.toLowerCase()
) {
return publicMeshAddress.toUpperCase();
}
return sender;
},
[hasPublicLaneIdentity, publicIdentity?.nodeId, publicMeshAddress],
[hasPublicLaneIdentity, publicMeshAddress],
);
const openIdentityWizard = useCallback(
@@ -370,6 +572,7 @@ export function useMeshChatController({
useEffect(() => {
if (!clientHydrated) return;
setPublicMeshAddress(readStoredPublicMeshAddress());
setMeshSessionActive(false);
setMeshMessages([]);
setMeshQuickStatus(null);
@@ -525,25 +728,6 @@ export function useMeshChatController({
};
}, []);
useEffect(() => {
let alive = true;
const senderId = storedPublicIdentity?.nodeId || '';
if (!senderId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(senderId)
.then((addr) => {
if (alive) setPublicMeshAddress(addr);
})
.catch(() => {
if (alive) setPublicMeshAddress('');
});
return () => {
alive = false;
};
}, [storedPublicIdentity?.nodeId]);
const flushDmQueue = useCallback(async () => {
const queue = dmSendQueue.current.splice(0);
if (dmSendTimer.current) {
@@ -1155,6 +1339,36 @@ export function useMeshChatController({
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic') return;
let alive = true;
const tick = async () => {
const data = await refreshMeshMqttSettings();
if (!alive || !data) return;
if (!data.enabled && meshSessionActive) {
setMeshQuickStatus({
type: 'err',
text: 'Public Mesh key is ready, but MQTT is off. Enable MQTT in Settings to join the live public lane.',
});
}
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, meshMqttEnabled && !meshMqttConnected ? 5_000 : 15_000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [
activeTab,
expanded,
meshMqttConnected,
meshMqttEnabled,
meshSessionActive,
refreshMeshMqttSettings,
]);
// ─── InfoNet Polling ─────────────────────────────────────────────────────
useEffect(() => {
@@ -2411,7 +2625,7 @@ export function useMeshChatController({
]);
setGateReplyContext(null);
} else if (activeTab === 'meshtastic') {
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
if (!meshSessionActive || !publicMeshAddress) {
setInputValue(msg);
setLastSendTime(0);
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
@@ -2425,8 +2639,20 @@ export function useMeshChatController({
setBusy(false);
return;
}
if (!meshMqttEnabled) {
setInputValue(msg);
setLastSendTime(0);
setSendError('mqtt is off');
setMeshQuickStatus({
type: 'err',
text: 'Public Mesh key is ready, but MQTT is off. Open Settings and enable the public broker.',
});
setMeshView('settings');
setTimeout(() => setSendError(''), 4000);
setBusy(false);
return;
}
const meshDestination = meshDirectTarget.trim() || 'broadcast';
const sequence = nextSequence();
const payload = {
message: msg,
destination: meshDestination,
@@ -2444,8 +2670,7 @@ export function useMeshChatController({
setBusy(false);
return;
}
const signature = await signEvent('message', publicIdentity.nodeId, sequence, payload);
const sendRes = await fetch(`${API_BASE}/api/mesh/send`, {
const sendRes = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2455,14 +2680,8 @@ export function useMeshChatController({
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
sender_id: publicIdentity.nodeId,
node_id: publicIdentity.nodeId,
public_key: publicIdentity.publicKey,
public_key_algo: getPublicKeyAlgo(),
signature,
sequence,
protocol_version: PROTOCOL_VERSION,
credentials: { mesh_region: meshRegion },
sender_id: publicMeshAddress,
mesh_region: meshRegion,
}),
});
if (!sendRes.ok) {
@@ -2476,15 +2695,7 @@ export function useMeshChatController({
if (!sendData.ok) {
setInputValue(msg);
setLastSendTime(0);
if (sendData.detail === 'Invalid signature') {
setSendError('public mesh signature failed');
openIdentityWizard({
type: 'err',
text: 'This public mesh identity did not verify. Reset it, recreate it, then retry.',
});
} else {
setSendError(sendData.detail || 'send failed');
}
setSendError(sendData.detail || 'send failed');
setTimeout(() => setSendError(''), 4000);
return;
}
@@ -3937,6 +4148,7 @@ export function useMeshChatController({
wormholeReadyState &&
!selectedGateAccessReady) ||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
(activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) ||
(activeTab === 'dms' &&
(dmView !== 'chat' ||
!selectedContact ||
@@ -4003,11 +4215,11 @@ export function useMeshChatController({
setIdentityWizardStatus(null);
try {
await disableWormholeForPublicMesh();
const nextIdentity = await generateNodeKeys();
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
setIdentity(nextIdentity);
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
const nextAddress = createPublicMeshAddress();
await enableMeshMqttBridge();
writeStoredPublicMeshAddress(nextAddress);
const readyAddress = nextAddress.toUpperCase();
setPublicMeshAddress(nextAddress);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
@@ -4038,7 +4250,7 @@ export function useMeshChatController({
setIdentityWizardBusy(false);
}
},
[disableWormholeForPublicMesh],
[disableWormholeForPublicMesh, enableMeshMqttBridge],
);
const handleCreatePublicIdentity = useCallback(async () => {
@@ -4059,8 +4271,8 @@ export function useMeshChatController({
setIdentityWizardStatus(null);
setMeshQuickStatus(null);
try {
const savedIdentity = getNodeIdentity();
if (!savedIdentity || !hasSovereignty()) {
const savedAddress = readStoredPublicMeshAddress();
if (!savedAddress) {
const text = 'No saved public mesh key is available. Create a mesh key first.';
setMeshSessionActive(false);
setIdentityWizardStatus({ type: 'err', text });
@@ -4068,10 +4280,9 @@ export function useMeshChatController({
return { ok: false as const, text };
}
await disableWormholeForPublicMesh();
const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
setIdentity(savedIdentity);
setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
await enableMeshMqttBridge();
const readyAddress = savedAddress.toUpperCase();
setPublicMeshAddress(savedAddress);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
@@ -4091,7 +4302,7 @@ export function useMeshChatController({
} finally {
setIdentityWizardBusy(false);
}
}, [disableWormholeForPublicMesh]);
}, [disableWormholeForPublicMesh, enableMeshMqttBridge]);
const handleReplyToMeshAddress = useCallback((address: string) => {
const target = String(address || '').trim();
@@ -4127,14 +4338,8 @@ export function useMeshChatController({
try {
setMeshSessionActive(false);
setMeshMessages([]);
await clearBrowserIdentityState();
setIdentity(null);
clearStoredPublicMeshAddress();
setPublicMeshAddress('');
setContacts({});
setSelectedContact('');
setDmMessages([]);
setAccessRequestsState([]);
setPendingSentState([]);
setIdentityWizardStatus({
type: 'ok',
text: 'Public mesh identity cleared. Start a fresh one when you are ready.',
@@ -4246,6 +4451,17 @@ export function useMeshChatController({
setMeshView,
meshDirectTarget,
setMeshDirectTarget,
meshMqttSettings,
meshMqttForm,
setMeshMqttForm,
meshMqttBusy,
meshMqttStatusText,
meshMqttEnabled,
meshMqttRunning,
meshMqttConnected,
meshMqttConnectionLabel,
saveMeshMqttSettings,
refreshMeshMqttSettings,
// Identity
identity,
publicIdentity,
+208 -37
View File
@@ -100,6 +100,7 @@ const AIRCRAFT_WIKI: Record<string, string> = {
PA46: 'Piper PA-46 Malibu', BE36: 'Beechcraft Bonanza', BE9L: 'Beechcraft King Air',
BE20: 'Beechcraft Super King Air', B350: 'Beechcraft King Air 350', PC12: 'Pilatus PC-12',
PC24: 'Pilatus PC-24', TBM7: 'Daher TBM', TBM8: 'Daher TBM', TBM9: 'Daher TBM',
PIVI: 'Pipistrel Virus',
// Helicopters
R44: 'Robinson R44', R22: 'Robinson R22', R66: 'Robinson R66',
B06: 'Bell 206', B407: 'Bell 407', B412: 'Bell 412',
@@ -196,12 +197,17 @@ function resolveAcTypeWiki(acType: string): string | null {
return null;
}
function resolveAircraftWikiTitle(model: string | undefined): string | null {
if (!model) return null;
return AIRCRAFT_WIKI[model] || resolveAcTypeWiki(model);
}
// Module-level cache for Wikipedia thumbnails (persists across re-renders)
const _wikiThumbCache: Record<string, { url: string | null; loading: boolean }> = {};
function useAircraftImage(model: string | undefined): { imgUrl: string | null; wikiUrl: string | null; loading: boolean } {
const [, forceUpdate] = useState(0);
const wikiTitle = model ? AIRCRAFT_WIKI[model] : undefined;
const wikiTitle = resolveAircraftWikiTitle(model) || undefined;
const wikiUrl = wikiTitle ? `https://en.wikipedia.org/wiki/${wikiTitle.replace(/ /g, '_')}` : null;
useEffect(() => {
@@ -236,6 +242,162 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
};
type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[];
function readTrailTimestamp(point: FlightTrailPoint): number | null {
if (Array.isArray(point)) {
const ts = Number(point[3]);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
const ts = Number(point?.ts);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
function readTrailLatLng(point: FlightTrailPoint): { lat: number; lng: number } | null {
const lat = Number(Array.isArray(point) ? point[0] : point?.lat);
const lng = Number(Array.isArray(point) ? point[1] : point?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng };
}
function distanceNm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const earthRadiusNm = 3440.065;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * earthRadiusNm * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function formatObservedDuration(hours: number): string {
const minutes = Math.max(1, Math.round(hours * 60));
if (minutes < 60) return `${minutes} min`;
const wholeHours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `${wholeHours}h ${remainder}m` : `${wholeHours}h`;
}
function estimateObservedEmissions(flight: any): {
fuelGallons: number;
co2Kg: number;
durationLabel: string;
distanceLabel: string | null;
basisLabel: string;
} | null {
const fuelGph = Number(flight?.emissions?.fuel_gph);
const co2KgPerHour = Number(flight?.emissions?.co2_kg_per_hour);
const trail = Array.isArray(flight?.trail) ? (flight.trail as FlightTrailPoint[]) : [];
if (!Number.isFinite(fuelGph) || !Number.isFinite(co2KgPerHour)) {
return null;
}
const timestamps = trail
.map(readTrailTimestamp)
.filter((ts): ts is number => ts !== null)
.sort((a, b) => a - b);
if (timestamps.length >= 2) {
const elapsedHours = (timestamps[timestamps.length - 1] - timestamps[0]) / 3600;
if (Number.isFinite(elapsedHours) && elapsedHours >= 5 / 60) {
let distance = 0;
let previous: { lat: number; lng: number } | null = null;
for (const point of trail) {
const current = readTrailLatLng(point);
if (previous && current) distance += distanceNm(previous, current);
if (current) previous = current;
}
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: distance > 1 ? `${Math.round(distance).toLocaleString()} nm` : null,
basisLabel: 'trail history',
};
}
}
const origin = Array.isArray(flight?.origin_loc)
? { lng: Number(flight.origin_loc[0]), lat: Number(flight.origin_loc[1]) }
: null;
const current = { lat: Number(flight?.lat), lng: Number(flight?.lng) };
const speedKnots = Number(flight?.speed_knots);
if (
origin &&
Number.isFinite(origin.lat) &&
Number.isFinite(origin.lng) &&
Number.isFinite(current.lat) &&
Number.isFinite(current.lng) &&
Number.isFinite(speedKnots) &&
speedKnots > 50
) {
const flownNm = distanceNm(origin, current);
const elapsedHours = flownNm / speedKnots;
if (Number.isFinite(elapsedHours) && elapsedHours >= 5 / 60 && elapsedHours <= 18) {
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: `${Math.round(flownNm).toLocaleString()} nm`,
basisLabel: 'route progress',
};
}
}
return null;
}
function EmissionsEstimateBlock({ flight }: { flight: any }) {
const observed = estimateObservedEmissions(flight);
const emissions = flight?.emissions;
const context = observed
? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}`
: emissions
? 'Rate only until enough trail history accumulates'
: null;
return (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'FUEL BURNED' : 'FUEL RATE'}
</div>
<div className="text-xs font-bold text-orange-400">
{observed ? (
<>{observed.fuelGallons.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">GAL</span></>
) : emissions ? (
<>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></>
) : 'UNKNOWN'}
</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
</div>
<div className="text-xs font-bold text-red-400">
{observed ? (
<>{observed.co2Kg.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG</span></>
) : emissions ? (
<>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></>
) : 'UNKNOWN'}
</div>
</div>
</div>
{context && (
<div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed">
{context}
{observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
</div>
)}
</div>
);
}
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) {
const data = useDataKeys([
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
@@ -277,12 +439,17 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
const selectedFlightModel = (() => {
if (!selectedEntity) return undefined;
const { type, id } = selectedEntity;
let flight: any = null;
if (type === 'flight') flight = data?.commercial_flights?.[id as number];
else if (type === 'private_flight') flight = data?.private_flights?.[id as number];
else if (type === 'private_jet') flight = data?.private_jets?.[id as number];
else if (type === 'military_flight') flight = data?.military_flights?.[id as number];
else if (type === 'tracked_flight') flight = data?.tracked_flights?.[id as number];
const findByIdOrIndex = (flights?: Array<{ icao24?: string; model?: string }>) => {
if (!flights) return null;
if (typeof id === 'number') return flights[id] || null;
return flights.find((flight) => flight.icao24 === id) || null;
};
let flight: { model?: string } | null = null;
if (type === 'flight') flight = findByIdOrIndex(data?.commercial_flights);
else if (type === 'private_flight') flight = findByIdOrIndex(data?.private_flights);
else if (type === 'private_jet') flight = findByIdOrIndex(data?.private_jets);
else if (type === 'military_flight') flight = findByIdOrIndex(data?.military_flights);
else if (type === 'tracked_flight') flight = findByIdOrIndex(data?.tracked_flights);
return flight?.model;
})();
const { imgUrl: aircraftImgUrl, wikiUrl: aircraftWikiUrl, loading: aircraftImgLoading } = useAircraftImage(selectedFlightModel);
@@ -684,19 +851,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
<EmissionsEstimateBlock flight={flight} />
{flight.alert_link && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
@@ -750,6 +905,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
if (flight) {
const callsign = flight.callsign || "UNKNOWN";
let airline = "UNKNOWN";
const isPrivateFlight = selectedEntity.type === 'private_flight' || selectedEntity.type === 'private_jet';
const aircraftWikiTitle = resolveAircraftWikiTitle(flight.model);
const aircraftModelWikiUrl = aircraftWikiTitle
? `https://en.wikipedia.org/wiki/${aircraftWikiTitle.replace(/ /g, '_')}`
: null;
const showModelWiki = isPrivateFlight || selectedEntity.type === 'military_flight';
if (selectedEntity.type === 'military_flight') {
const mil = flight as import('@/types/dashboard').MilitaryFlight;
@@ -798,7 +959,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
{!isPrivateFlight && selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
target="_blank"
@@ -812,7 +973,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
</div>
{/* Commercial: Airline company Wikipedia image */}
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && (
{!isPrivateFlight && selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
@@ -828,7 +989,18 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
{showModelWiki && aircraftModelWikiUrl ? (
<a
href={aircraftModelWikiUrl}
target="_blank"
rel="noreferrer"
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline"
>
{aircraftWikiTitle || flight.model}
</a>
) : (
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
)}
</div>
{/* Military: Aircraft model Wikipedia image (gold accent) */}
{selectedEntity.type === 'military_flight' && (() => {
@@ -878,8 +1050,19 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
}
return null;
})()}
{/* Private/GA: aircraft model Wikipedia image as the primary visual */}
{isPrivateFlight && aircraftModelWikiUrl && (
<div className="border-b border-[var(--border-primary)] pb-3">
<WikiImage
wikiUrl={aircraftModelWikiUrl}
label={aircraftWikiTitle || flight.model}
maxH="max-h-36"
accent="hover:border-purple-400/60"
/>
</div>
)}
{/* Non-military: Aircraft model photo (secondary, below airline image) */}
{selectedEntity.type !== 'military_flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
{!isPrivateFlight && selectedEntity.type !== 'military_flight' && selectedEntity.type !== 'flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
@@ -924,19 +1107,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
</div>
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
<EmissionsEstimateBlock flight={flight} />
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
+270 -7
View File
@@ -2,9 +2,9 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from 'lucide-react';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
const CURRENT_ONBOARDING_VERSION = '0.9.7-docker-keys-1';
const CURRENT_ONBOARDING_VERSION = '0.9.75-agentic-onboarding-1';
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
@@ -68,6 +68,14 @@ const OnboardingModal = React.memo(function OnboardingModal({
});
const [setupSaving, setSetupSaving] = useState(false);
const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [agentSecret, setAgentSecret] = useState('');
const [agentTier, setAgentTier] = useState<'restricted' | 'full'>('restricted');
const [agentMode, setAgentMode] = useState<'local' | 'remote'>('local');
const [agentLoading, setAgentLoading] = useState(false);
const [agentMsg, setAgentMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [agentCopied, setAgentCopied] = useState(false);
const [torStarting, setTorStarting] = useState(false);
const [torAddress, setTorAddress] = useState('');
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, 'true');
@@ -114,6 +122,110 @@ const OnboardingModal = React.memo(function OnboardingModal({
}
};
const agentEndpoint =
agentMode === 'local'
? 'http://localhost:8000'
: torAddress || '<prepare remote .onion link>';
const agentSnippet = [
`SHADOWBROKER_URL=${agentEndpoint}`,
agentSecret ? `SHADOWBROKER_KEY=${agentSecret}` : 'SHADOWBROKER_KEY=<generate in ShadowBroker>',
`SHADOWBROKER_ACCESS=${agentTier}`,
'',
'# FIRST: load available tools',
`GET ${agentEndpoint}/api/ai/tools`,
'',
'# Auth: HMAC-SHA256 signed requests.',
'# Restricted = read-only telemetry. Full = can write when asked.',
].join('\n');
const remoteAgentNeedsTor = agentMode === 'remote' && !torAddress;
const fetchAgentConnectInfo = async (reveal = true) => {
setAgentLoading(true);
setAgentMsg(null);
try {
const res = await fetch(`/api/ai/connect-info?reveal=${reveal ? 'true' : 'false'}`);
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.ok === false) {
throw new Error(data?.detail || 'Could not prepare agent credentials.');
}
setAgentSecret(data.hmac_secret || '');
setAgentTier(data.access_tier === 'full' ? 'full' : 'restricted');
setAgentMsg({ type: 'ok', text: 'Agent key is ready. Copy it into your local or remote agent runtime.' });
} catch (error) {
setAgentMsg({
type: 'err',
text: error instanceof Error ? error.message : 'Could not prepare agent credentials.',
});
} finally {
setAgentLoading(false);
}
};
const saveAgentTier = async (tier: 'restricted' | 'full') => {
setAgentTier(tier);
setAgentMsg(null);
try {
const res = await fetch('/api/ai/connect-info/access-tier', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tier }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.ok === false) {
throw new Error(data?.detail || 'Could not update agent access tier.');
}
setAgentMsg({
type: 'ok',
text: tier === 'full'
? 'Full access saved. The agent can write to the dashboard when authenticated.'
: 'Restricted access saved. The agent can read telemetry but cannot write.',
});
} catch (error) {
setAgentMsg({
type: 'err',
text: error instanceof Error ? error.message : 'Could not update agent access tier.',
});
}
};
const prepareTorAgentAddress = async () => {
setTorStarting(true);
setAgentMsg(null);
try {
const res = await fetch('/api/settings/tor/start', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.ok === false || !data?.onion_address) {
throw new Error(data?.detail || 'Could not start Tor hidden service.');
}
setTorAddress(data.onion_address);
setAgentMsg({
type: 'ok',
text: 'Tor is ready. The remote agent link is private to your local ShadowBroker node.',
});
} catch (error) {
setAgentMsg({
type: 'err',
text:
error instanceof Error
? error.message
: 'ShadowBroker could not install or start Tor automatically. Check network access and try again.',
});
} finally {
setTorStarting(false);
}
};
const copyAgentSnippet = async () => {
if (remoteAgentNeedsTor) {
setAgentMsg({ type: 'err', text: 'Install Tor and create the remote link first, then copy the agent config.' });
return;
}
await navigator.clipboard.writeText(agentSnippet);
setAgentCopied(true);
setTimeout(() => setAgentCopied(false), 1600);
};
return (
<AnimatePresence>
{/* Backdrop */}
@@ -166,7 +278,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Step Indicators */}
<div className="flex gap-2 px-6 pt-4">
{['API Keys', 'Trust Modes', 'Free Sources'].map((label, i) => (
{['API Keys', 'AI Agent', 'Trust Modes', 'Free Sources'].map((label, i) => (
<button
key={label}
onClick={() => setStep(i)}
@@ -183,7 +295,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
{step === 1 && (
{step === 2 && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
@@ -246,6 +358,157 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
STEP 1 - WHERE IS YOUR AGENT?
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setAgentMode('local')}
className={`border px-4 py-3 text-left transition-all ${
agentMode === 'local'
? 'border-cyan-500/50 bg-cyan-950/40'
: 'border-[var(--border-primary)] hover:border-cyan-500/30'
}`}
>
<p className={`text-sm font-mono font-bold ${agentMode === 'local' ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
Local
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Same machine as ShadowBroker
</p>
</button>
<button
onClick={() => setAgentMode('remote')}
className={`border px-4 py-3 text-left transition-all ${
agentMode === 'remote'
? 'border-violet-500/50 bg-violet-950/40'
: 'border-[var(--border-primary)] hover:border-violet-500/30'
}`}
>
<p className={`text-sm font-mono font-bold ${agentMode === 'remote' ? 'text-violet-300' : 'text-[var(--text-secondary)]'}`}>
Remote
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Different machine over Tor
</p>
</button>
</div>
</div>
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
STEP 2 - WHAT CAN IT DO?
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => void saveAgentTier('restricted')}
className={`border px-4 py-3 text-left transition-all ${
agentTier === 'restricted'
? 'border-green-500/50 bg-green-950/30'
: 'border-[var(--border-primary)] hover:border-green-500/30'
}`}
>
<p className="text-sm text-green-300 font-mono font-bold flex items-center gap-2">
<Shield size={14} /> Read Only
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
Can see live telemetry but cannot change anything
</p>
</button>
<button
onClick={() => void saveAgentTier('full')}
className={`border px-4 py-3 text-left transition-all ${
agentTier === 'full'
? 'border-amber-500/50 bg-amber-950/30'
: 'border-[var(--border-primary)] hover:border-amber-500/30'
}`}
>
<p className="text-sm text-amber-300 font-mono font-bold flex items-center gap-2">
<Network size={14} /> Full Access
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
Can place pins, create layers, and trigger display actions
</p>
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest">
STEP 3 - COPY THIS INTO YOUR AGENT
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Generate a local key, then copy these variables into OpenClaw, Hermes, or another HMAC agent.
</p>
</div>
<button
onClick={() => void fetchAgentConnectInfo(true)}
disabled={agentLoading}
className="px-3 py-2 border border-violet-500/40 text-violet-300 hover:bg-violet-500/10 disabled:opacity-50 text-[11px] font-mono tracking-widest"
>
{agentLoading ? 'GENERATING...' : 'GENERATE'}
</button>
</div>
{remoteAgentNeedsTor && (
<div className="mb-2 border border-violet-500/30 bg-violet-950/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] text-violet-200 font-mono font-bold tracking-widest">
TOR REQUIRED FOR REMOTE AGENTS
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1 leading-relaxed">
ShadowBroker will install or use Tor locally, then create a private .onion link for this backend.
</p>
</div>
<button
onClick={() => void prepareTorAgentAddress()}
disabled={torStarting}
className="shrink-0 px-3 py-2 border border-violet-500/40 text-violet-200 hover:bg-violet-500/10 disabled:opacity-50 text-[10px] font-mono tracking-widest flex items-center gap-2"
>
<Network size={13} />
{torStarting ? 'INSTALLING...' : 'INSTALL TOR'}
</button>
</div>
</div>
)}
<div className="relative">
<pre className="min-h-40 max-h-56 overflow-auto styled-scrollbar bg-[var(--bg-primary)] border border-violet-500/30 p-4 pr-24 text-[12px] text-violet-100 font-mono whitespace-pre-wrap leading-relaxed">
{agentSnippet}
</pre>
<button
onClick={() => void copyAgentSnippet()}
disabled={remoteAgentNeedsTor}
className="absolute top-3 right-3 px-3 py-2 border border-violet-500/50 bg-violet-950/50 text-violet-200 hover:bg-violet-800/30 disabled:opacity-45 disabled:hover:bg-violet-950/50 text-[11px] font-mono tracking-widest flex items-center gap-2"
>
{agentCopied ? <Check size={13} /> : <Copy size={13} />}
{agentCopied ? 'COPIED' : 'COPY'}
</button>
</div>
{agentMsg && (
<p
className={`mt-2 text-sm font-mono ${
agentMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{agentMsg.text}
</p>
)}
</div>
<p className="text-[11px] text-orange-300/80 font-mono leading-relaxed">
Remote agent access uses the signed HTTP API over Tor. Wormhole uses the same Tor/Arti transport lane when it is available; MLS-native agent transport is still planned.
</p>
</div>
)}
{step === 0 && (
<div className="space-y-4">
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
@@ -359,7 +622,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
)}
{step === 2 && (
{step === 3 && (
<div className="space-y-3">
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
These data sources are completely free and require no API keys. They activate
@@ -401,7 +664,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
</button>
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? 'bg-cyan-400' : 'bg-[var(--border-primary)]'}`}
@@ -409,7 +672,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
))}
</div>
{step < 2 ? (
{step < 3 ? (
<button
onClick={() => setStep(step + 1)}
className="px-4 py-2 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-sm font-mono tracking-widest transition-all"
+90 -46
View File
@@ -500,6 +500,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
// stored server-side, and never returned to the browser.
const [apis, setApis] = useState<ApiEntry[]>([]);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyEditing, setApiKeyEditing] = useState<Record<string, boolean>>({});
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
@@ -573,6 +574,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
if (result.keys) setApis(result.keys);
if (result.env) setEnvMeta(result.env);
setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' }));
setApiKeyEditing((prev) => ({ ...prev, [envKey]: false }));
setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` });
} catch (e) {
const message = e instanceof Error ? e.message : 'Could not save API key';
@@ -2215,6 +2217,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
aircraft and vessel feeds.
</p>
</div>
<div className="pl-5 text-[12px] font-mono text-cyan-200/80 leading-relaxed">
Configured keys stay hidden for shared dashboards. Unlock operator tools, then
use ROTATE only when you intentionally want to replace a working credential.
</div>
{envMeta && (
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
<div>
@@ -2344,17 +2350,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
{api.has_key && (
<div className="mt-2 space-y-2 text-[12px] font-mono">
{api.is_set ? (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
<span className="text-[var(--text-muted)]">
edit{' '}
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>{' '}
Enter a replacement below if you need to rotate it.
</span>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
<span className="text-[var(--text-muted)] leading-relaxed">
Secret hidden. Stored write-only on this backend as{' '}
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>
.
</span>
</div>
{api.env_key && (
<button
type="button"
onClick={() => {
if (!(nativeProtected || adminSessionReady)) {
setApiKeyMsg({
type: 'err',
text: 'Unlock operator tools before rotating a configured key.',
});
return;
}
setApiKeyMsg(null);
setApiKeyEditing((prev) => ({
...prev,
[api.env_key as string]: !prev[api.env_key as string],
}));
}}
className={`shrink-0 px-2 py-1 border text-[11px] tracking-widest transition-colors ${
nativeProtected || adminSessionReady
? 'border-yellow-500/40 text-yellow-300 hover:bg-yellow-500/10'
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:border-yellow-500/30 hover:text-yellow-300/80'
}`}
>
{apiKeyEditing[api.env_key] ? 'CANCEL' : 'ROTATE'}
</button>
)}
</div>
{!(nativeProtected || adminSessionReady) && (
<div className="text-[11px] text-yellow-300/70 leading-relaxed">
Operator tools are locked. Viewers can see source status
but cannot replace saved credentials.
</div>
)}
</div>
) : (
<div className="flex items-center gap-2">
@@ -2366,40 +2408,42 @@ const SettingsPanel = React.memo(function SettingsPanel({
</span>
</div>
)}
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
if (!api.env_key) return;
setApiKeyInputs((prev) => ({
...prev,
[api.env_key as string]: event.target.value,
}));
}}
placeholder={
api.is_set
? 'Enter replacement key...'
: `Enter ${api.env_key}...`
}
className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50"
autoComplete="off"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
{(!api.is_set || (api.env_key && apiKeyEditing[api.env_key])) && (
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
if (!api.env_key) return;
setApiKeyInputs((prev) => ({
...prev,
[api.env_key as string]: event.target.value,
}));
}}
placeholder={
api.is_set
? 'Enter replacement key...'
: `Enter ${api.env_key}...`
}
className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50"
autoComplete="off"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
)}
</div>
)}
</div>
@@ -2416,7 +2460,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[13px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter((a) => a.has_key).length} KEYS CONFIGURED</span>
<span>{apis.filter((a) => a.has_key && a.is_set).length} KEYS CONFIGURED</span>
</div>
</div>
</>
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Database, Clock, X } from 'lucide-react';
const CURRENT_VERSION = '0.9.7';
const CURRENT_VERSION = '0.9.75';
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
interface StartupWarmupModalProps {
@@ -81,7 +81,7 @@ export interface NodeSettingsSnapshot {
export const DEFAULT_INFONET_SEED_URL = 'https://node.shadowbroker.info';
const CACHE_TTL_MS = 15000;
const CACHE_TTL_MS = 5000;
type CacheEntry<T> = {
value: T;
+1
View File
@@ -114,6 +114,7 @@ export interface Ship {
source_url?: string;
last_osint_update?: string;
desc?: string;
trail?: Array<{ lat: number; lng: number; sog?: number; ts?: number } | number[]>;
// Tracked yacht enrichment
yacht_alert?: boolean;
yacht_owner?: string;