mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-12 06:56:37 +02:00
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:
@@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user