mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 21:08:13 +02:00
f91ddcf38b
Reported by @tg12. Pre-fix, two problems lived on the GET endpoint:
1. `GET /api/ai/connect-info?reveal=true` returned the full HMAC
secret in the response body on every Connect modal open. Even
gated to require_local_operator, that put the secret into
browser history, dev-tools network panels, browser disk caches,
HAR exports, and screen captures.
2. The same GET endpoint auto-bootstrapped (generated + persisted)
the secret on a mere read. Side effects on a GET are a footgun:
browser prefetchers, mirror tools, and casual curl-from-history
would all silently mint+persist a fresh secret.
Backend (backend/routers/ai_intel.py)
-------------------------------------
GET /api/ai/connect-info — always returns the MASKED
fingerprint (first6 + bullets
+ last4). No `?reveal` param.
NO auto-bootstrap. When the
secret is missing, returns
`hmac_secret_set: false` and
tells the caller to POST to
/bootstrap.
POST /api/ai/connect-info/bootstrap — NEW. Mints+persists the secret
if missing. Idempotent. Never
returns the full secret in the
response body.
POST /api/ai/connect-info/reveal — NEW. Returns the full secret
with Cache-Control: no-store,
no-cache, must-revalidate +
Pragma: no-cache + Expires: 0.
POST so the body never lands
in URL history. 404 (with a
pointer to /bootstrap) when
the secret isn't set.
POST /api/ai/connect-info/regenerate — keeps existing one-time-reveal
behavior (regen IS a deliberate
destructive action triggered
by the operator). Same
no-store/no-cache headers added
so even the regen response
doesn't get cached.
Frontend (AIIntelPanel.tsx, OnboardingModal.tsx)
------------------------------------------------
* On mount: GET (masked only). If hmac_secret_set: false, fire a
transparent POST /bootstrap and refresh the masked fingerprint.
Operator sees no behavior change from pre-#302.
* Reveal (eye icon): lazy POST /reveal — secret only travels when
the operator explicitly clicks the button.
* Copy: lazy POST /reveal too — copying without a prior reveal
works exactly like before, just routed through the new endpoint.
* Regenerate: POST returns the new secret (same as before, but the
response now has no-store headers).
* The displayed snippet uses the masked fingerprint until the
operator clicks Reveal or Copy.
Tests (backend/tests/test_openclaw_connect_info_reveal.py — 13 tests)
---------------------------------------------------------------------
* GET returns masked + the full secret never appears in r.text
* GET does NOT auto-bootstrap when missing
* GET silently ignores any ?reveal=true query (back-compat noise)
* POST /bootstrap mints when missing, idempotent when set
* POST /bootstrap never returns the full secret
* POST /reveal returns the full secret with Cache-Control: no-store,
no-cache + Pragma: no-cache + Expires: 0
* POST /reveal 404s with a pointer to /bootstrap when no secret
* POST /regenerate returns the new secret with the same headers
* Anonymous remote callers get 403 on ALL FOUR endpoints (parametric
regression against the same allowlist used elsewhere).
Adjacent suites still green: test_openclaw_route_security,
test_no_new_duplicate_routes, test_control_surface_auth. 67/67 pass
locally.
Credit: @tg12 for the audit report.
1938 lines
93 KiB
TypeScript
1938 lines
93 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { getBackendEndpoint } from '@/lib/backendEndpoint';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Brain,
|
|
MapPin,
|
|
Trash2,
|
|
Minus,
|
|
Plus,
|
|
Crosshair,
|
|
Navigation,
|
|
RefreshCw,
|
|
X,
|
|
Link2,
|
|
Copy,
|
|
Check,
|
|
Shield,
|
|
Eye,
|
|
EyeOff,
|
|
AlertTriangle,
|
|
Zap,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Globe,
|
|
Rss,
|
|
} from 'lucide-react';
|
|
import { API_BASE } from '@/lib/api';
|
|
import type { AIIntelPin, AIIntelLayer, SatelliteScene } from '@/types/aiIntel';
|
|
import { useTranslation } from '@/i18n';
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
import {
|
|
createLayer as apiCreateLayer,
|
|
updateLayer as apiUpdateLayer,
|
|
deleteLayer as apiDeleteLayer,
|
|
refreshLayerFeed as apiRefreshLayerFeed,
|
|
fetchSatelliteImages,
|
|
} from '@/lib/aiIntelClient';
|
|
|
|
interface AIIntelPanelProps {
|
|
onFlyTo?: (lat: number, lng: number) => void;
|
|
isMinimized?: boolean;
|
|
onMinimizedChange?: (minimized: boolean) => void;
|
|
pinPlacementMode?: boolean;
|
|
onPinPlacementModeChange?: (active: boolean) => void;
|
|
}
|
|
|
|
/* ─── Agent Identity (Ed25519 keypair — future MLS upgrade) ───────── */
|
|
|
|
function WormholeIdentitySection() {
|
|
const [identity, setIdentity] = React.useState<{
|
|
bootstrapped: boolean;
|
|
node_id: string;
|
|
public_key: string;
|
|
} | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [bootstrapping, setBootstrapping] = React.useState(false);
|
|
|
|
const fetchIdentity = React.useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await fetch(`${API_BASE}/api/ai/agent-identity`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setIdentity(data);
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
React.useEffect(() => { fetchIdentity(); }, [fetchIdentity]);
|
|
|
|
const handleBootstrap = async (force: boolean = false) => {
|
|
if (force && !confirm('Regenerate agent identity? The old keypair will be permanently destroyed.')) return;
|
|
setBootstrapping(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ force }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setIdentity(data);
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setBootstrapping(false); }
|
|
};
|
|
|
|
const handleRevoke = async () => {
|
|
if (!confirm('Permanently revoke agent identity? This cannot be undone.')) return;
|
|
try {
|
|
await fetch(`${API_BASE}/api/ai/agent-identity`, { method: 'DELETE' });
|
|
setIdentity({ bootstrapped: false, node_id: '', public_key: '' });
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="bg-cyan-950/20 border border-cyan-700/30 px-4 py-3">
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest uppercase flex items-center gap-2">
|
|
<Link2 size={12} />
|
|
Agent Identity
|
|
<span className="text-cyan-400 animate-pulse">loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-cyan-950/20 border border-cyan-700/30 px-4 py-3.5">
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest uppercase flex items-center gap-2 mb-2.5">
|
|
<Link2 size={12} />
|
|
Agent Identity (Ed25519)
|
|
<span className="ml-auto text-[9px] text-cyan-600 bg-cyan-900/30 px-1.5 py-0.5 border border-cyan-700/30">
|
|
HMAC AUTH
|
|
</span>
|
|
</div>
|
|
|
|
{identity?.bootstrapped ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
|
<span className="text-xs font-mono text-emerald-300">Identity Active</span>
|
|
</div>
|
|
<div className="bg-black/40 border border-cyan-800/30 px-3 py-2 text-xs font-mono text-cyan-300">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-gray-500">Node ID:</span>
|
|
<span className="text-cyan-300 select-all">{identity.node_id}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-1">
|
|
<span className="text-gray-500">Public Key:</span>
|
|
<span className="text-cyan-400/60 text-[10px]">
|
|
{identity.public_key ? identity.public_key.substring(0, 12) + '...' : 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-500 leading-relaxed">
|
|
Agent has its own Ed25519 identity, separate from the operator. Commands currently
|
|
travel via HMAC-authenticated HTTP (not E2EE). Private key never leaves this server.
|
|
</p>
|
|
<div className="flex gap-2 mt-1">
|
|
<button
|
|
onClick={() => handleBootstrap(true)}
|
|
disabled={bootstrapping}
|
|
className="text-[10px] font-mono px-2.5 py-1 bg-cyan-900/30 border border-cyan-700/40 text-cyan-400 hover:bg-cyan-800/40 hover:text-cyan-300 transition-colors disabled:opacity-50"
|
|
title="Regenerate agent identity"
|
|
>
|
|
{bootstrapping ? 'Regenerating...' : 'Regenerate'}
|
|
</button>
|
|
<button
|
|
onClick={handleRevoke}
|
|
className="text-[10px] font-mono px-2.5 py-1 bg-red-900/20 border border-red-700/40 text-red-400 hover:bg-red-800/30 hover:text-red-300 transition-colors"
|
|
title="Revoke agent identity"
|
|
>
|
|
<Trash2 size={10} className="inline mr-1" />
|
|
Revoke
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-mono text-gray-400 leading-relaxed">
|
|
Generate an Ed25519 identity for your agent. This keypair is used for
|
|
mesh signing. Commands currently travel via HMAC-authenticated HTTP.
|
|
</p>
|
|
<button
|
|
onClick={() => handleBootstrap(false)}
|
|
disabled={bootstrapping}
|
|
className="text-xs font-mono px-3 py-1.5 bg-cyan-600/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-600/40 hover:text-cyan-200 transition-colors disabled:opacity-50"
|
|
title="Bootstrap agent identity"
|
|
>
|
|
{bootstrapping ? 'Bootstrapping...' : 'Bootstrap Agent Identity'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Command Channel Status ───────────────────────────────────────── */
|
|
|
|
function ChannelStatusSection() {
|
|
const [channelInfo, setChannelInfo] = React.useState<{
|
|
ok: boolean;
|
|
tier: number;
|
|
reason: string;
|
|
transport: string;
|
|
forward_secrecy: boolean;
|
|
sealed_sender: boolean;
|
|
pending_commands: number;
|
|
completed_commands: number;
|
|
pending_tasks: number;
|
|
stats: Record<string, number>;
|
|
} | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [taskType, setTaskType] = React.useState('alert');
|
|
const [taskPayload, setTaskPayload] = React.useState('');
|
|
const [pushing, setPushing] = React.useState(false);
|
|
|
|
const fetchStatus = React.useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await fetch(`${API_BASE}/api/ai/channel/status`);
|
|
if (res.ok) setChannelInfo(await res.json());
|
|
} catch { /* ignore */ }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
React.useEffect(() => { fetchStatus(); }, [fetchStatus]);
|
|
|
|
const handlePushTask = async () => {
|
|
if (!taskPayload.trim()) return;
|
|
setPushing(true);
|
|
try {
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = JSON.parse(taskPayload);
|
|
} catch {
|
|
payload = { message: taskPayload };
|
|
}
|
|
await fetch(`${API_BASE}/api/ai/channel/task`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_type: taskType, payload }),
|
|
});
|
|
setTaskPayload('');
|
|
fetchStatus();
|
|
} catch { /* ignore */ }
|
|
finally { setPushing(false); }
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="bg-emerald-950/20 border border-emerald-700/30 px-4 py-3">
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest uppercase flex items-center gap-2">
|
|
<Zap size={12} />
|
|
Command Channel
|
|
<span className="text-emerald-400 animate-pulse">loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const tier = channelInfo?.tier ?? 1;
|
|
const tierLabel = 'HMAC Direct';
|
|
const tierColor = 'amber';
|
|
|
|
return (
|
|
<div className="bg-emerald-950/20 border border-emerald-700/30 px-4 py-3.5">
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest uppercase flex items-center gap-2 mb-2.5">
|
|
<Zap size={12} />
|
|
Command Channel
|
|
<span className={`ml-auto text-[9px] text-${tierColor}-400 bg-${tierColor}-900/30 px-1.5 py-0.5 border border-${tierColor}-700/30`}>
|
|
TIER {tier}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Tier Status */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full bg-${tierColor}-400 ${channelInfo?.ok ? 'animate-pulse' : ''}`} />
|
|
<span className={`text-xs font-mono text-${tierColor}-300`}>{tierLabel}</span>
|
|
<span className="text-[10px] font-mono text-gray-600 ml-auto">{channelInfo?.transport}</span>
|
|
</div>
|
|
|
|
{/* Security badges */}
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{channelInfo?.forward_secrecy && (
|
|
<span className="text-[9px] font-mono px-1.5 py-0.5 bg-emerald-900/30 border border-emerald-700/30 text-emerald-400">
|
|
FORWARD SECRECY
|
|
</span>
|
|
)}
|
|
{channelInfo?.sealed_sender && (
|
|
<span className="text-[9px] font-mono px-1.5 py-0.5 bg-emerald-900/30 border border-emerald-700/30 text-emerald-400">
|
|
SEALED SENDER
|
|
</span>
|
|
)}
|
|
<span className="text-[9px] font-mono px-1.5 py-0.5 bg-gray-800/50 border border-gray-700/30 text-gray-400">
|
|
BIDIRECTIONAL
|
|
</span>
|
|
</div>
|
|
|
|
{/* Queue stats */}
|
|
<div className="bg-black/40 border border-emerald-800/30 px-3 py-2 text-xs font-mono">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<span className="text-gray-500 text-[10px]">Pending</span>
|
|
<div className="text-emerald-300">{channelInfo?.pending_commands ?? 0}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500 text-[10px]">Completed</span>
|
|
<div className="text-emerald-300">{channelInfo?.completed_commands ?? 0}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500 text-[10px]">Tasks Queued</span>
|
|
<div className="text-amber-300">{channelInfo?.pending_tasks ?? 0}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[10px] font-mono text-gray-500 leading-relaxed">
|
|
Commands authenticated via HMAC-SHA256 with body-integrity binding over HTTP.
|
|
Wire privacy relies on TLS. End-to-end encryption is not yet available for this channel.
|
|
</p>
|
|
|
|
{/* Push task */}
|
|
<div className="border-t border-emerald-800/20 pt-2 mt-1">
|
|
<div className="text-[10px] font-mono text-gray-500 mb-1.5 uppercase tracking-wider">Push Task to Agent</div>
|
|
<div className="flex gap-1.5">
|
|
<select
|
|
value={taskType}
|
|
onChange={e => setTaskType(e.target.value)}
|
|
className="bg-black/60 border border-emerald-800/40 text-emerald-300 text-[10px] font-mono px-1.5 py-1 w-20"
|
|
title="Task type"
|
|
>
|
|
<option value="alert">alert</option>
|
|
<option value="request">request</option>
|
|
<option value="sync">sync</option>
|
|
<option value="custom">custom</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
value={taskPayload}
|
|
onChange={e => setTaskPayload(e.target.value)}
|
|
placeholder='{"message":"..."} or plain text'
|
|
className="flex-1 bg-black/60 border border-emerald-800/40 text-emerald-200 text-[10px] font-mono px-2 py-1 placeholder:text-gray-600"
|
|
onKeyDown={e => e.key === 'Enter' && handlePushTask()}
|
|
title="Task payload"
|
|
/>
|
|
<button
|
|
onClick={handlePushTask}
|
|
disabled={pushing || !taskPayload.trim()}
|
|
className="text-[10px] font-mono px-2 py-1 bg-emerald-900/30 border border-emerald-700/40 text-emerald-400 hover:bg-emerald-800/40 hover:text-emerald-300 transition-colors disabled:opacity-50"
|
|
title="Push task to agent"
|
|
>
|
|
{pushing ? '...' : 'Push'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Connect OpenClaw Modal Body ─────────────────────────────────── */
|
|
|
|
interface ConnectModalBodyProps {
|
|
apiEndpoint: string;
|
|
handleCopy: (text: string) => void;
|
|
copied: boolean;
|
|
}
|
|
|
|
function ConnectModalBody({ apiEndpoint, handleCopy, copied }: ConnectModalBodyProps) {
|
|
const [riskAccepted, setRiskAccepted] = React.useState(false);
|
|
const [accessTier, setAccessTier] = React.useState<'restricted' | 'full'>('restricted');
|
|
const [connectionMode, setConnectionMode] = React.useState<'local' | 'remote'>('local');
|
|
// hmacSecret holds the FULL secret once the operator has clicked
|
|
// Reveal (or after a regenerate). maskedHmacSecret is the safe-to-show
|
|
// fingerprint returned by GET /api/ai/connect-info and is loaded on
|
|
// mount. The two are independent state slots so a stale full secret
|
|
// can never leak back into the UI after a regenerate.
|
|
const [hmacSecret, setHmacSecret] = React.useState('');
|
|
const [maskedHmacSecret, setMaskedHmacSecret] = React.useState('');
|
|
const [hmacLoading, setHmacLoading] = React.useState(false);
|
|
const [revealing, setRevealing] = React.useState(false);
|
|
const [tierSaving, setTierSaving] = React.useState(false);
|
|
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
|
const [showResetConfirm, setShowResetConfirm] = React.useState(false);
|
|
const [resetting, setResetting] = React.useState(false);
|
|
const [regenerating, setRegenerating] = React.useState(false);
|
|
const [showSecret, setShowSecret] = React.useState(false);
|
|
const [snippetCopied, setSnippetCopied] = React.useState(false);
|
|
|
|
// Node state
|
|
const [nodeEnabled, setNodeEnabled] = React.useState(false);
|
|
const [nodeLoading, setNodeLoading] = React.useState(true);
|
|
const [nodeToggling, setNodeToggling] = React.useState(false);
|
|
const [nodeId, setNodeId] = React.useState('');
|
|
const [nodeConfirmed, setNodeConfirmed] = React.useState(false);
|
|
|
|
const [remoteUrl, setRemoteUrl] = React.useState('');
|
|
|
|
// Tor state
|
|
const [torStarting, setTorStarting] = React.useState(false);
|
|
const [torError, setTorError] = React.useState('');
|
|
const [torOnion, setTorOnion] = React.useState('');
|
|
|
|
// Issue #302 (tg12): the full HMAC secret no longer travels through
|
|
// GET /api/ai/connect-info on every modal open. The flow is now:
|
|
//
|
|
// 1. GET /api/ai/connect-info — always returns the masked fingerprint
|
|
// (first6 + bullets + last4). `hmacSecret` stays empty until the
|
|
// operator clicks the Reveal (eye) button below.
|
|
// 2. POST /api/ai/connect-info/bootstrap — fires once on mount if the
|
|
// backend reports `hmac_secret_set: false`. Idempotent and never
|
|
// returns the secret in the response.
|
|
// 3. POST /api/ai/connect-info/reveal — fires when the operator clicks
|
|
// Reveal or Copy without the secret yet loaded. Returns the full
|
|
// secret with strict `Cache-Control: no-store` so it doesn't land
|
|
// in browser caches or HAR exports.
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
setHmacLoading(true);
|
|
const res = await fetch(`${API_BASE}/api/ai/connect-info`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
setMaskedHmacSecret(data.masked_hmac_secret || '');
|
|
setAccessTier(data.access_tier === 'full' ? 'full' : 'restricted');
|
|
|
|
// Transparent first-use bootstrap. Mirrors the pre-#302 UX of
|
|
// "open modal → secret exists" without the GET side-effect.
|
|
if (!data.hmac_secret_set) {
|
|
const bootRes = await fetch(
|
|
`${API_BASE}/api/ai/connect-info/bootstrap`,
|
|
{ method: 'POST' },
|
|
);
|
|
if (bootRes.ok) {
|
|
const bootData = await bootRes.json();
|
|
setMaskedHmacSecret(bootData.masked_hmac_secret || '');
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setHmacLoading(false); }
|
|
})();
|
|
(async () => {
|
|
try {
|
|
setNodeLoading(true);
|
|
const res = await fetch(`${API_BASE}/api/settings/node`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setNodeEnabled(!!data.node_enabled || !!data.enabled);
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setNodeLoading(false); }
|
|
})();
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/ai/agent-identity`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.bootstrapped) setNodeId(data.node_id || '');
|
|
}
|
|
} catch { /* ignore */ }
|
|
})();
|
|
// Fetch Tor status
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/settings/tor`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.onion_address) {
|
|
setTorOnion(data.onion_address);
|
|
setRemoteUrl(data.onion_address);
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
})();
|
|
}, []);
|
|
|
|
// One-click remote setup: start node + bootstrap identity + start Tor + get address
|
|
const handleRemoteSetup = async () => {
|
|
setTorStarting(true);
|
|
setTorError('');
|
|
try {
|
|
// 1. Enable mesh node
|
|
await fetch(`${API_BASE}/api/settings/node`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: true }),
|
|
});
|
|
setNodeEnabled(true);
|
|
setNodeConfirmed(true);
|
|
|
|
// 2. Bootstrap agent identity (gets node_id)
|
|
const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ force: false }),
|
|
});
|
|
if (idRes.ok) {
|
|
const idData = await idRes.json();
|
|
if (idData.node_id) setNodeId(idData.node_id);
|
|
}
|
|
|
|
// 3. Start Tor hidden service
|
|
const torRes = await fetch(`${API_BASE}/api/settings/tor/start`, { method: 'POST' });
|
|
const torData = await torRes.json();
|
|
if (torData.ok && torData.onion_address) {
|
|
setTorOnion(torData.onion_address);
|
|
setRemoteUrl(torData.onion_address);
|
|
} else {
|
|
setTorError(torData.detail || 'Failed to start Tor');
|
|
}
|
|
} catch {
|
|
setTorError('Failed to connect to backend');
|
|
}
|
|
finally { setTorStarting(false); }
|
|
};
|
|
|
|
const handleResetAll = async () => {
|
|
setResetting(true);
|
|
setShowResetConfirm(false);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/settings/agent/reset-all`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
// Update local state with new credentials. reset-all returns
|
|
// the new HMAC secret in-band (same one-time-disclosure rule
|
|
// as /regenerate — a deliberate destructive action). Refresh
|
|
// both slots so the masked display stays in sync.
|
|
if (data.new_hmac_secret) {
|
|
setHmacSecret(data.new_hmac_secret);
|
|
const s = String(data.new_hmac_secret);
|
|
setMaskedHmacSecret(
|
|
s.length > 10 ? s.slice(0, 6) + '•'.repeat(8) + s.slice(-4) : '•'.repeat(16),
|
|
);
|
|
}
|
|
if (data.new_onion) {
|
|
setTorOnion(data.new_onion);
|
|
setRemoteUrl(data.new_onion);
|
|
}
|
|
if (data.new_node_id) setNodeId(data.new_node_id);
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setResetting(false); }
|
|
};
|
|
|
|
const handleTierChange = async (tier: 'restricted' | 'full') => {
|
|
setAccessTier(tier);
|
|
setTierSaving(true);
|
|
try {
|
|
await fetch(`${API_BASE}/api/ai/connect-info/access-tier`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tier }),
|
|
});
|
|
} catch { /* ignore */ }
|
|
finally { setTierSaving(false); }
|
|
};
|
|
|
|
// Issue #302: POST /reveal returns the full secret with strict
|
|
// no-store headers. Lazily fetched — never on mount. Returns the
|
|
// secret string so callers can copy it immediately without waiting
|
|
// for React state propagation.
|
|
const revealHmacSecret = async (): Promise<string> => {
|
|
if (hmacSecret) return hmacSecret;
|
|
setRevealing(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/ai/connect-info/reveal`, {
|
|
method: 'POST',
|
|
});
|
|
if (!res.ok) return '';
|
|
const data = await res.json();
|
|
const secret = String(data.hmac_secret || '');
|
|
setHmacSecret(secret);
|
|
return secret;
|
|
} catch {
|
|
return '';
|
|
} finally {
|
|
setRevealing(false);
|
|
}
|
|
};
|
|
|
|
const handleRegenerate = async () => {
|
|
setRegenerating(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/ai/connect-info/regenerate`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Regenerate is a deliberate destructive action — operator needs
|
|
// to see the new secret once to update their OpenClaw config.
|
|
// Both the full and masked forms refresh in one shot.
|
|
setHmacSecret(data.hmac_secret || '');
|
|
setMaskedHmacSecret(data.masked_hmac_secret || '');
|
|
setShowSecret(true);
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setRegenerating(false); }
|
|
};
|
|
|
|
const handleNodeToggle = async (enable: boolean) => {
|
|
setNodeToggling(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/settings/node`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: enable }),
|
|
});
|
|
if (res.ok) {
|
|
setNodeEnabled(enable);
|
|
// Auto-bootstrap agent identity when enabling node
|
|
if (enable && !nodeId) {
|
|
try {
|
|
const idRes = await fetch(`${API_BASE}/api/ai/agent-identity/bootstrap`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ force: false }),
|
|
});
|
|
if (idRes.ok) {
|
|
const idData = await idRes.json();
|
|
setNodeId(idData.node_id || '');
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
finally { setNodeToggling(false); }
|
|
};
|
|
|
|
// Issue #302: prefer the server-supplied fingerprint
|
|
// (maskedHmacSecret) \u2014 it's filled on mount via the (no-secret) GET.
|
|
// If the operator has clicked Reveal, fall through to deriving the
|
|
// mask from the in-memory full secret so we keep the same shape
|
|
// (first6 + bullets + last4) regardless of source. Final fallback
|
|
// (no secret loaded yet) is a generic bullet string.
|
|
const maskedSecret =
|
|
maskedHmacSecret ||
|
|
(hmacSecret
|
|
? hmacSecret.slice(0, 6) + '\u2022'.repeat(8) + hmacSecret.slice(-4)
|
|
: '\u2022'.repeat(16));
|
|
|
|
// Resolve the endpoint URL
|
|
const resolvedUrl = connectionMode === 'local'
|
|
? 'http://localhost:8000'
|
|
: remoteUrl.trim() || '';
|
|
|
|
// Connection snippet — display is masked, copy has real key
|
|
const buildSnippet = (key: string) => {
|
|
const lines: string[] = [];
|
|
if (resolvedUrl) lines.push(`SHADOWBROKER_URL=${resolvedUrl}`);
|
|
lines.push(`SHADOWBROKER_KEY=${key}`);
|
|
lines.push(`SHADOWBROKER_ACCESS=${accessTier}`);
|
|
if (connectionMode === 'remote' && nodeId) lines.push(`SHADOWBROKER_NODE_ID=${nodeId}`);
|
|
lines.push('');
|
|
lines.push('# OPERATING DIRECTIVE:');
|
|
lines.push('# You are a remote OpenClaw agent connected to ShadowBroker.');
|
|
lines.push('# ShadowBroker is a live intelligence and telemetry platform, not a narrow single-purpose API.');
|
|
lines.push('# Treat the platform as having broad live telemetry unless a focused check proves otherwise.');
|
|
lines.push("# Do not claim a layer, entity type, or capability is unavailable until you verify it from live data.");
|
|
lines.push("# If something is absent, say 'not found in current telemetry' or 'not present in the current dataset' rather than saying ShadowBroker cannot track it.");
|
|
lines.push('# Full access means you MAY place pins, create layers, inject data, set watches, and trigger displays, but do not perform write actions unless the user asks or the task clearly requires it.');
|
|
lines.push('# For ordinary questions, prefer read commands and concise answers grounded in live results.');
|
|
|
|
// Tool manifest — first thing the agent should hit
|
|
if (resolvedUrl) {
|
|
lines.push('');
|
|
lines.push('# FIRST: Load your tools from this endpoint:');
|
|
lines.push(`# GET ${resolvedUrl}/api/ai/tools`);
|
|
lines.push('# Returns structured tool definitions (names, params, types, examples).');
|
|
lines.push('# Load these as your available tool/function definitions on connect.');
|
|
lines.push('# Prefer compact lookups first: get_summary, search_telemetry, find_flights, find_ships, search_news, entities_near, get_layer_slice.');
|
|
lines.push('# Reserve get_telemetry, get_slow_telemetry, and get_report for rare full-context pulls.');
|
|
lines.push('# BATCH COMMANDS: POST /api/ai/channel/batch with {"commands": [{"cmd": "...", "args": {...}}, ...]} (max 20).');
|
|
lines.push('# Batch executes all commands concurrently in one HTTP round-trip. Use it whenever you need 2+ lookups.');
|
|
lines.push('# Example: batch entities_near + search_news + get_correlations in one call instead of 3 sequential calls.');
|
|
lines.push('# INCREMENTAL UPDATES: get_layer_slice supports since_version. Pass the version from the previous response to skip unchanged data (instant 0-byte response when nothing changed).');
|
|
lines.push("# get_summary is full layer discovery: use it to learn every live telemetry layer before concluding something is unavailable.");
|
|
lines.push('# get_layer_slice is uncapped by default. Pass limit_per_layer only when you intentionally want a smaller slice.');
|
|
lines.push("# UAP sightings, wastewater, and tracked_flights/VIP aircraft are real layers when populated. Verify with get_summary/get_layer_slice before claiming they don't exist.");
|
|
lines.push("# fishing_activity is the fishing-vessel activity layer. Aliases like gfw and global_fishing_watch should be treated as fishing_activity.");
|
|
lines.push("# Use search_telemetry as the Google-style entry point whenever the user gives you a person, place, company, owner, nickname, or natural-language phrase and you do not already know the source layer.");
|
|
lines.push("# Example: 'Where is Jerry Jones yacht?' -> search_telemetry('Jerry Jones') first, then refine with find_ships only after you identify the ship match.");
|
|
lines.push("# For fuzzy natural-language lookups like 'Patriots jet' or 'Jerry Jones yacht', inspect the ranked search_telemetry candidates before making a hard claim.");
|
|
lines.push("# search_telemetry returns ranked candidates grouped by entity type, so use the groups to narrow aircraft vs ships vs events before answering.");
|
|
lines.push("# For AF1/AF2 or other VIP aircraft, use find_flights first when the domain is obvious, then get_layer_slice(['tracked_flights']) if you need raw layer context.");
|
|
lines.push("# If one domain-specific command returns 0, do not conclude the entity is absent. Fall back to search_telemetry before any layer pull.");
|
|
lines.push("# If search_telemetry returns several plausible matches, summarize the top candidates instead of pretending one uncertain hit is definitive.");
|
|
lines.push("# If a user asks 'what is near here', use entities_near before pulling large datasets.");
|
|
lines.push("# If a user asks about a topic or incident, use search_news before downloading the full slow feed.");
|
|
}
|
|
|
|
// SAR (Synthetic Aperture Radar) ground-change layer
|
|
lines.push('');
|
|
lines.push('# SAR GROUND-CHANGE LAYER:');
|
|
lines.push('# ShadowBroker has a full SAR (Synthetic Aperture Radar) layer that detects ground changes through cloud cover, at night, anywhere on Earth.');
|
|
lines.push('# Two modes — both free:');
|
|
lines.push('# Mode A (Catalog): Free Sentinel-1 scene metadata from Alaska Satellite Facility. No account needed. Shows radar passes over AOIs and next-pass timing.');
|
|
lines.push('# Mode B (Anomalies): Pre-processed ground-change alerts from NASA OPERA (DISP deformation, DSWx water, DIST-ALERT vegetation), Copernicus EGMS, GFM floods, UNOSAT/EMS damage. Requires free Earthdata token.');
|
|
lines.push('# SAR commands (all routed through /api/ai/channel/command):');
|
|
lines.push('# sar_status — check Mode A/B status. ALWAYS call this first when the user asks about SAR/radar/deformation/floods. If Mode B is off, the response includes signup URLs to paste to the user.');
|
|
lines.push('# sar_anomalies_recent(kind?, limit?) — latest anomalies. Kinds: ground_deformation, surface_water_change, flood_extent, vegetation_disturbance, damage_assessment, coherence_change.');
|
|
lines.push('# sar_anomalies_near(lat, lon, radius_km?, kind?, limit?) — anomalies within radius of a point.');
|
|
lines.push('# sar_scene_search(aoi_id?, limit?) — Sentinel-1 scene catalog (Mode A, always works when AOIs exist).');
|
|
lines.push('# sar_coverage_for_aoi(aoi_id?) — per-AOI coverage and rough next-pass estimate.');
|
|
lines.push('# sar_aoi_list — list all operator-defined Areas of Interest.');
|
|
lines.push('# sar_aoi_add(id, name, center_lat, center_lon, radius_km?, category?, description?) — create/update an AOI (write command).');
|
|
lines.push('# sar_aoi_remove(aoi_id) — delete an AOI (write command).');
|
|
lines.push('# sar_pin_click(anomaly_id) — fetch the full detail payload for a specific anomaly (same data as the map popup). Returns {anomaly, aoi, recent_scenes}.');
|
|
lines.push('# sar_focus_aoi(aoi_id, zoom?) — fly the operator\'s map to an AOI center. The frontend picks this up in real time.');
|
|
lines.push('# sar_pin_from_anomaly(anomaly_id, label?) — promote a SAR anomaly to a persistent AI Intel pin on the map (write command).');
|
|
lines.push('# sar_watch_anomaly(aoi_id, kind?) — set up a watchdog that fires when matching anomalies appear in an AOI (write command).');
|
|
lines.push('# SAR rules: (1) Call sar_status first. (2) If Mode B is off, paste the help URLs — never tell the user to "search for it". (3) Anomalies have evidence_hash — preserve it when promoting to pins. (4) AOI categories: watchlist, conflict, infrastructure, natural_hazard, border, maritime.');
|
|
|
|
// Analysis zones — agent-placed map overlays with written assessments
|
|
lines.push('');
|
|
lines.push('# ANALYSIS ZONES (agent-authored map notes):');
|
|
lines.push('# The old regex-based "contradiction detector" has been REMOVED. It pattern-matched denial keywords against outages and produced constant false positives.');
|
|
lines.push('# Instead, you — the agent — place colored square overlays on the map with a written assessment that the operator can read by clicking the zone.');
|
|
lines.push('# Think of these as sticky notes: "I noticed X in this area, here is what I think it means." The operator can delete any zone by clicking the trash icon in the popup.');
|
|
lines.push('# Analysis zone commands (all routed through /api/ai/channel/command):');
|
|
lines.push('# list_analysis_zones — list all currently active zones (read).');
|
|
lines.push('# place_analysis_zone(lat, lng, title, body, category?, severity?, drivers?, cell_size_deg?, ttl_hours?) — drop a new zone (write).');
|
|
lines.push('# category: contradiction | analysis | warning | observation | hypothesis (default: analysis)');
|
|
lines.push('# severity: high | medium | low (default: medium — controls fill opacity, not an alarm level)');
|
|
lines.push('# drivers: up to 5 short bullet strings shown as "KEY INDICATORS" in the popup.');
|
|
lines.push('# body: your full written assessment (up to ~2000 chars), shown verbatim in the "AGENT ASSESSMENT" section — newlines preserved.');
|
|
lines.push('# cell_size_deg: square size in degrees (default 2.0 ≈ ~220km). Use smaller (0.3-0.8) for city-scale, larger (3-5) for regional.');
|
|
lines.push('# ttl_hours: optional auto-expiry. Omit for permanent until user deletes.');
|
|
lines.push('# delete_analysis_zone(zone_id) — remove a specific zone you placed (write).');
|
|
lines.push('# clear_analysis_zones — wipe all zones (write, use sparingly).');
|
|
lines.push('# Analysis zone rules:');
|
|
lines.push('# (1) Only place zones when you have something genuinely worth noting. Do NOT spam the map.');
|
|
lines.push('# (2) Write the body as a short intelligence note in YOUR voice — what you observed, what it might mean, what you are NOT sure about. 2-6 sentences is ideal.');
|
|
lines.push('# (3) Use category="contradiction" (amber) when official statements conflict with telemetry; "warning" (red) for active threats; "observation" (blue) for neutral notes; "hypothesis" (purple) for speculative reads; "analysis" (cyan, default) for general assessments.');
|
|
lines.push('# (4) Prefer placing zones in response to operator questions or emerging events you spot while reviewing telemetry, not on a fixed schedule.');
|
|
lines.push('# (5) Zones persist across restarts. If yours become stale, clean them up with delete_analysis_zone.');
|
|
|
|
// SSE endpoint (preferred for remote — works over Tor, keeps circuit warm)
|
|
if (resolvedUrl) {
|
|
lines.push('');
|
|
lines.push('# Real-time push (SSE stream — works over Tor, keeps circuit warm):');
|
|
lines.push(`# GET ${resolvedUrl}/api/ai/channel/sse (keep open, receives events)`);
|
|
lines.push(`# POST ${resolvedUrl}/api/ai/channel/command (send commands)`);
|
|
lines.push('# Command replies are returned immediately from POST /api/ai/channel/command.');
|
|
lines.push('# Use SSE for pushed alerts/tasks. Use /api/ai/channel/poll only as a fallback if SSE is unavailable.');
|
|
lines.push('# Suggested lookup flow:');
|
|
lines.push('# 1. get_summary (discover available layers + counts)');
|
|
lines.push('# 2. Batch your focused lookups: POST /api/ai/channel/batch with multiple commands in one call');
|
|
lines.push('# e.g. {"commands": [{"cmd":"find_flights","args":{"callsign":"AF1"}}, {"cmd":"search_news","args":{"query":"military"}}]}');
|
|
lines.push('# 3. For repeat polling, use get_layer_slice with since_version to skip unchanged data');
|
|
lines.push('# 4. Only pull full telemetry (get_telemetry/get_report) if focused commands were insufficient');
|
|
lines.push('# 5. Use write commands only when the user explicitly wants an action on the map/system');
|
|
}
|
|
|
|
if (connectionMode === 'remote' && resolvedUrl.includes('.onion')) {
|
|
lines.push('');
|
|
lines.push('# .onion requires Tor on the agent machine too:');
|
|
lines.push('# 1. Install Tor: sudo apt install tor (or brew install tor)');
|
|
lines.push('# 2. Tor starts a SOCKS5 proxy on localhost:9050');
|
|
lines.push('# 3. Route requests through it: pip install PySocks requests[socks]');
|
|
lines.push('# proxies = {"http": "socks5h://127.0.0.1:9050", "https": "socks5h://127.0.0.1:9050"}');
|
|
lines.push('# requests.get(SHADOWBROKER_URL + "/api/health", proxies=proxies)');
|
|
}
|
|
return lines.join('\n');
|
|
};
|
|
const displaySnippet = buildSnippet(maskedSecret);
|
|
|
|
// Issue #302: the copy snippet needs the FULL secret. Pre-#302 we kept
|
|
// it in memory from the GET-with-reveal load; now we lazy-fetch via
|
|
// POST /reveal only when the operator actually clicks Copy. If they
|
|
// already revealed, the in-memory value is reused (no extra request).
|
|
const handleCopySnippet = async () => {
|
|
const secret = hmacSecret || (await revealHmacSecret());
|
|
if (!secret) return;
|
|
navigator.clipboard.writeText(buildSnippet(secret));
|
|
setSnippetCopied(true);
|
|
setTimeout(() => setSnippetCopied(false), 2000);
|
|
};
|
|
|
|
// Remote mode requires node confirmed + a reachable URL
|
|
const remoteReady = connectionMode === 'local' || (nodeConfirmed && resolvedUrl.length > 0);
|
|
|
|
return (
|
|
<div className="px-6 py-5 space-y-5">
|
|
|
|
{/* ── Risk acceptance ──────────────────────────────── */}
|
|
{!riskAccepted && (
|
|
<div className="bg-amber-950/40 border border-amber-600/50 px-4 py-3.5">
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle size={18} className="text-amber-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
<div className="text-xs font-mono text-amber-300 font-bold tracking-wider uppercase mb-1.5">
|
|
Heads Up
|
|
</div>
|
|
<p className="text-xs font-mono text-amber-200/80 leading-relaxed">
|
|
Connecting an AI agent gives it access to your ShadowBroker data.
|
|
You control what it can do (read-only or full access). You're
|
|
responsible for what your agent does with it.
|
|
</p>
|
|
<button
|
|
onClick={() => setRiskAccepted(true)}
|
|
className="mt-3 px-4 py-2 text-xs font-mono tracking-wider bg-amber-600/30 border border-amber-500/50 text-amber-300 hover:bg-amber-600/50 hover:text-amber-200 transition-colors"
|
|
>
|
|
I UNDERSTAND, CONTINUE
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Main flow ────────────────────────────────────── */}
|
|
{riskAccepted && (
|
|
<>
|
|
{/* Step 1: Where is your agent? */}
|
|
<div>
|
|
<div className="text-[11px] font-mono text-violet-400 tracking-widest mb-2.5 uppercase font-bold">
|
|
Step 1 — Where is your agent?
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => setConnectionMode('local')}
|
|
className={`text-left px-4 py-3 border transition-all ${
|
|
connectionMode === 'local'
|
|
? 'bg-cyan-950/40 border-cyan-500/50'
|
|
: 'bg-black/30 border-gray-700/40 hover:border-gray-600/60'
|
|
}`}
|
|
title="Agent running on this machine"
|
|
>
|
|
<div className={`text-sm font-mono font-bold ${connectionMode === 'local' ? 'text-cyan-300' : 'text-gray-400'}`}>
|
|
Local
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-500 mt-1">
|
|
Same machine as ShadowBroker
|
|
</p>
|
|
</button>
|
|
<button
|
|
onClick={() => setConnectionMode('remote')}
|
|
className={`text-left px-4 py-3 border transition-all ${
|
|
connectionMode === 'remote'
|
|
? 'bg-violet-950/40 border-violet-500/50'
|
|
: 'bg-black/30 border-gray-700/40 hover:border-gray-600/60'
|
|
}`}
|
|
title="Agent running on another computer"
|
|
>
|
|
<div className={`text-sm font-mono font-bold ${connectionMode === 'remote' ? 'text-violet-300' : 'text-gray-400'}`}>
|
|
Remote
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-500 mt-1">
|
|
Different machine over network
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 2 (Remote): Generate private link — one button does everything */}
|
|
{connectionMode === 'remote' && (
|
|
<div>
|
|
<div className="text-[11px] font-mono text-violet-400 tracking-widest mb-2.5 uppercase font-bold">
|
|
Step 2 — Generate your private link
|
|
</div>
|
|
|
|
{torOnion && remoteReady ? (
|
|
/* Already have an address — show it */
|
|
<div className="bg-emerald-950/30 border border-emerald-600/40 px-4 py-3 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
|
<span className="text-xs font-mono text-emerald-300">Private link active</span>
|
|
</div>
|
|
<div className="text-xs font-mono text-emerald-200 select-all break-all">{torOnion}</div>
|
|
<p className="text-[10px] font-mono text-gray-500">
|
|
This .onion address is persistent and stays the same across restarts.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
/* Need to generate */
|
|
<div className="space-y-2.5">
|
|
{!torStarting && (
|
|
<button
|
|
onClick={handleRemoteSetup}
|
|
className="w-full py-3 text-sm font-mono tracking-wider border border-emerald-500/50 bg-emerald-600/20 text-emerald-300 hover:bg-emerald-600/40 hover:text-emerald-100 transition-colors"
|
|
>
|
|
GENERATE PRIVATE LINK
|
|
</button>
|
|
)}
|
|
{torStarting && (
|
|
<div className="w-full py-3 text-sm font-mono tracking-wider border border-violet-500/50 bg-violet-600/20 text-violet-300 text-center animate-pulse">
|
|
SETTING UP SECURE CONNECTION...
|
|
</div>
|
|
)}
|
|
{torError && (
|
|
<div className="bg-red-950/30 border border-red-700/40 px-4 py-2.5 text-[10px] font-mono text-red-300">
|
|
{torError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step N: Access Level */}
|
|
<div>
|
|
<div className="text-[11px] font-mono text-violet-400 tracking-widest mb-2.5 uppercase font-bold">
|
|
Step {connectionMode === 'local' ? '2' : '3'} — What can it do?
|
|
{tierSaving && <span className="text-violet-300 animate-pulse ml-1">saving...</span>}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => handleTierChange('restricted')}
|
|
className={`text-left px-4 py-3 border transition-all ${
|
|
accessTier === 'restricted'
|
|
? 'bg-emerald-950/40 border-emerald-500/50'
|
|
: 'bg-black/30 border-gray-700/40 hover:border-gray-600/60'
|
|
}`}
|
|
title="Read-only access"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Shield size={14} className={accessTier === 'restricted' ? 'text-emerald-400' : 'text-gray-500'} />
|
|
<span className={`text-sm font-mono font-bold ${accessTier === 'restricted' ? 'text-emerald-300' : 'text-gray-400'}`}>
|
|
Read Only
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-500 leading-relaxed">
|
|
Can see your data but can't change anything
|
|
</p>
|
|
</button>
|
|
<button
|
|
onClick={() => handleTierChange('full')}
|
|
className={`text-left px-4 py-3 border transition-all ${
|
|
accessTier === 'full'
|
|
? 'bg-red-950/30 border-red-500/50'
|
|
: 'bg-black/30 border-gray-700/40 hover:border-gray-600/60'
|
|
}`}
|
|
title="Full read+write access"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Zap size={14} className={accessTier === 'full' ? 'text-red-400' : 'text-gray-500'} />
|
|
<span className={`text-sm font-mono font-bold ${accessTier === 'full' ? 'text-red-300' : 'text-gray-400'}`}>
|
|
Full Access
|
|
</span>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-500 leading-relaxed">
|
|
Can place pins, inject data, post to mesh
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step N+1: Connection Credentials */}
|
|
<div>
|
|
<div className="text-[11px] font-mono text-violet-400 tracking-widest mb-2.5 uppercase font-bold">
|
|
Step {connectionMode === 'local' ? '3' : '4'} — Copy this into your agent
|
|
</div>
|
|
|
|
{!remoteReady ? (
|
|
<div className="bg-black/40 border border-amber-700/40 px-4 py-3 text-xs font-mono text-amber-400/80 flex items-center gap-2">
|
|
<AlertTriangle size={12} />
|
|
Start the mesh node above first
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-[10px] font-mono text-gray-500 mb-2">
|
|
{connectionMode === 'local'
|
|
? 'Paste these as environment variables or add them to your agent\u2019s config.'
|
|
: 'Give these to your agent. The key is masked below \u2014 COPY sends the real key to your clipboard.'}
|
|
</p>
|
|
<div className="relative">
|
|
<pre className="bg-black/60 border border-violet-800/40 px-4 py-3 pr-20 text-xs font-mono text-violet-300 whitespace-pre-wrap break-all leading-relaxed">
|
|
{hmacLoading ? 'Loading...' : displaySnippet}
|
|
</pre>
|
|
<button
|
|
onClick={handleCopySnippet}
|
|
className="absolute top-2 right-2 px-2.5 py-1.5 bg-violet-600/40 border border-violet-500/50 text-violet-300 hover:bg-violet-600/60 hover:text-violet-100 transition-colors text-[10px] font-mono tracking-wider flex items-center gap-1.5"
|
|
title="Copy connection config (copies real key to clipboard)"
|
|
>
|
|
{snippetCopied ? <><Check size={12} /> COPIED</> : <><Copy size={12} /> COPY</>}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Done indicator */}
|
|
{remoteReady && (
|
|
<div className="bg-emerald-950/30 border border-emerald-600/40 px-4 py-3 flex items-center gap-3">
|
|
<Check size={16} className="text-emerald-400 shrink-0" />
|
|
<p className="text-xs font-mono text-emerald-300">
|
|
{connectionMode === 'local'
|
|
? 'Done. Your agent authenticates via HMAC-signed requests to localhost. Use WebSocket or SSE for persistent real-time comms.'
|
|
: 'Done. Your agent registers with this node. Open GET /api/ai/channel/sse for real-time push over a single Tor circuit.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Advanced (collapsed) ─────────────────────── */}
|
|
<div className="border-t border-gray-800/50 pt-3">
|
|
<button
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
className="flex items-center gap-2 text-[11px] font-mono text-gray-500 tracking-widest uppercase hover:text-gray-400 transition-colors w-full"
|
|
>
|
|
{showAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
Advanced
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="mt-4 space-y-4">
|
|
{/* HMAC Key Management */}
|
|
<div>
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest mb-2 uppercase">
|
|
HMAC Key
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 bg-black/60 border border-violet-800/40 px-3 py-2 text-xs font-mono text-violet-300 overflow-hidden text-ellipsis">
|
|
{/* Issue #302: when the operator hasn't clicked
|
|
Reveal yet, hmacSecret is empty and we fall
|
|
back to maskedHmacSecret (the safe fingerprint
|
|
returned by GET /api/ai/connect-info). */}
|
|
{showSecret && hmacSecret ? hmacSecret : (maskedHmacSecret || maskedSecret)}
|
|
</code>
|
|
<button
|
|
onClick={async () => {
|
|
if (showSecret) {
|
|
setShowSecret(false);
|
|
return;
|
|
}
|
|
// Need the full secret in state before showing it.
|
|
const secret = await revealHmacSecret();
|
|
if (secret) setShowSecret(true);
|
|
}}
|
|
disabled={revealing}
|
|
className="p-2 bg-violet-600/20 border border-violet-500/40 text-violet-400 hover:bg-violet-600/40 transition-colors shrink-0 disabled:opacity-50"
|
|
title={showSecret ? 'Hide' : 'Reveal'}
|
|
>
|
|
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
// Copy needs the full secret. Fetch it lazily if
|
|
// the operator hasn't clicked Reveal yet — no
|
|
// point making them reveal first just to copy.
|
|
const secret = hmacSecret || (await revealHmacSecret());
|
|
if (secret) handleCopy(secret);
|
|
}}
|
|
disabled={revealing}
|
|
className="p-2 bg-violet-600/20 border border-violet-500/40 text-violet-400 hover:bg-violet-600/40 transition-colors shrink-0 disabled:opacity-50"
|
|
title="Copy key"
|
|
>
|
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
</button>
|
|
<button
|
|
onClick={handleRegenerate}
|
|
disabled={regenerating}
|
|
className="p-2 bg-red-900/20 border border-red-700/40 text-red-400 hover:bg-red-800/30 transition-colors disabled:opacity-50 shrink-0"
|
|
title="Regenerate (invalidates old key)"
|
|
>
|
|
<RefreshCw size={14} className={regenerating ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-gray-600 mt-1">
|
|
Regenerating creates a new key and immediately invalidates the old one.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Node Control */}
|
|
<div className="bg-violet-950/20 border border-violet-700/30 px-4 py-3">
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest mb-2 uppercase">
|
|
Mesh Node
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${nodeEnabled ? 'bg-emerald-400 animate-pulse' : 'bg-gray-600'}`} />
|
|
<span className={`text-xs font-mono ${nodeEnabled ? 'text-emerald-300' : 'text-gray-500'}`}>
|
|
{nodeLoading ? 'Checking...' : nodeEnabled ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
{!nodeLoading && (
|
|
<button
|
|
onClick={() => handleNodeToggle(!nodeEnabled)}
|
|
disabled={nodeToggling}
|
|
className={`text-[10px] font-mono px-2.5 py-1 border transition-colors disabled:opacity-50 ${
|
|
nodeEnabled
|
|
? 'bg-red-900/20 border-red-700/40 text-red-400 hover:bg-red-800/30'
|
|
: 'bg-emerald-900/20 border-emerald-700/40 text-emerald-400 hover:bg-emerald-800/30'
|
|
}`}
|
|
>
|
|
{nodeToggling ? '...' : nodeEnabled ? 'Stop Node' : 'Start Node'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{nodeId && (
|
|
<div className="mt-2 text-[10px] font-mono text-gray-500">
|
|
Node ID: <span className="text-violet-400/70 select-all">{nodeId}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Agent Identity */}
|
|
<WormholeIdentitySection />
|
|
|
|
{/* Command Channel */}
|
|
<ChannelStatusSection />
|
|
|
|
{/* API Endpoint */}
|
|
<div>
|
|
<div className="text-[11px] font-mono text-gray-500 tracking-widest mb-2 uppercase">API Endpoint</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 bg-black/60 border border-violet-800/40 px-3 py-2 text-xs font-mono text-violet-300 select-all">
|
|
{apiEndpoint}
|
|
</code>
|
|
<button
|
|
onClick={() => handleCopy(apiEndpoint)}
|
|
className="p-2 bg-violet-600/20 border border-violet-500/40 text-violet-400 hover:bg-violet-600/40 transition-colors shrink-0"
|
|
title="Copy endpoint"
|
|
>
|
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nuclear Reset */}
|
|
<div className="border-t border-red-900/30 pt-4">
|
|
<button
|
|
onClick={() => setShowResetConfirm(true)}
|
|
disabled={resetting}
|
|
className="w-full py-2.5 text-[11px] font-mono tracking-wider border border-red-700/40 bg-red-950/20 text-red-400 hover:bg-red-900/30 hover:text-red-300 transition-colors disabled:opacity-50"
|
|
>
|
|
{resetting ? 'RESETTING...' : 'RESET ALL CREDENTIALS'}
|
|
</button>
|
|
<p className="text-[10px] font-mono text-gray-600 mt-1.5 leading-relaxed">
|
|
Generates a new HMAC key, .onion address, and node identity. Your agent will be fully disconnected and will need new credentials.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reset confirmation dialog */}
|
|
<ConfirmDialog
|
|
open={showResetConfirm}
|
|
title="Reset All Agent Credentials"
|
|
message={`This will:\n\n• Generate a new HMAC key (old one dies instantly)\n• Destroy your .onion address and create a new one\n• Revoke the current node identity\n\nYour agent will be completely disconnected. You will need to send it new credentials.\n\nThis cannot be undone.`}
|
|
confirmLabel={resetting ? 'RESETTING...' : 'RESET EVERYTHING'}
|
|
cancelLabel="CANCEL"
|
|
danger={true}
|
|
onConfirm={handleResetAll}
|
|
onCancel={() => setShowResetConfirm(false)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AIIntelPanel({
|
|
onFlyTo,
|
|
isMinimized: isMinimizedProp,
|
|
onMinimizedChange,
|
|
pinPlacementMode,
|
|
onPinPlacementModeChange,
|
|
}: AIIntelPanelProps) {
|
|
const { t } = useTranslation();
|
|
const [internalMinimized, setInternalMinimized] = useState(true);
|
|
const isMinimized = isMinimizedProp !== undefined ? isMinimizedProp : internalMinimized;
|
|
const setIsMinimized = (val: boolean | ((prev: boolean) => boolean)) => {
|
|
const newVal = typeof val === 'function' ? val(isMinimized) : val;
|
|
setInternalMinimized(newVal);
|
|
onMinimizedChange?.(newVal);
|
|
};
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
// Confirm dialog state
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
title: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
onConfirm: () => void;
|
|
} | null>(null);
|
|
|
|
// Layers + pins
|
|
const [layers, setLayers] = useState<AIIntelLayer[]>([]);
|
|
const [pins, setPins] = useState<AIIntelPin[]>([]);
|
|
const [expandedLayers, setExpandedLayers] = useState<Set<string>>(new Set());
|
|
const [newLayerName, setNewLayerName] = useState('');
|
|
const [newLayerFeedUrl, setNewLayerFeedUrl] = useState('');
|
|
const [showNewLayer, setShowNewLayer] = useState(false);
|
|
|
|
// Near Me
|
|
const [nearMeRadius, setNearMeRadius] = useState(100);
|
|
const [nearMeResults, setNearMeResults] = useState<any>(null);
|
|
|
|
// Satellite imagery search
|
|
const [satLat, setSatLat] = useState('');
|
|
const [satLng, setSatLng] = useState('');
|
|
const [satScenes, setSatScenes] = useState<SatelliteScene[]>([]);
|
|
const [satSearching, setSatSearching] = useState(false);
|
|
const [satLocationQuery, setSatLocationQuery] = useState('');
|
|
const [satGeocoding, setSatGeocoding] = useState(false);
|
|
|
|
// Connect panel
|
|
const [showConnect, setShowConnect] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const apiEndpoint = getBackendEndpoint();
|
|
|
|
const handleCopy = useCallback((text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}, []);
|
|
|
|
const totalPins = pins.length;
|
|
|
|
// ── Data fetching ───────────────────────────────────────────────
|
|
const refreshData = useCallback(async () => {
|
|
try {
|
|
const [layerResp, pinResp] = await Promise.all([
|
|
fetch(`${API_BASE}/api/ai/layers`),
|
|
fetch(`${API_BASE}/api/ai/pins?limit=500`),
|
|
]);
|
|
if (layerResp.ok) {
|
|
const ld = await layerResp.json();
|
|
setLayers(ld.layers || []);
|
|
}
|
|
if (pinResp.ok) {
|
|
const pd = await pinResp.json();
|
|
setPins(pd.pins || []);
|
|
}
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'AI Intel unavailable');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refreshData();
|
|
const tid = setInterval(refreshData, 30_000);
|
|
return () => clearInterval(tid);
|
|
}, [refreshData]);
|
|
|
|
// ── Layer actions ───────────────────────────────────────────────
|
|
const handleCreateLayer = async () => {
|
|
const name = newLayerName.trim();
|
|
if (!name) return;
|
|
setBusy(true);
|
|
try {
|
|
const feedUrl = newLayerFeedUrl.trim();
|
|
await apiCreateLayer({
|
|
name,
|
|
source: feedUrl ? 'feed' : 'user',
|
|
...(feedUrl ? { feed_url: feedUrl } : {}),
|
|
});
|
|
setNewLayerName('');
|
|
setNewLayerFeedUrl('');
|
|
setShowNewLayer(false);
|
|
await refreshData();
|
|
} catch {}
|
|
setBusy(false);
|
|
};
|
|
|
|
const handleToggleLayerVisibility = async (layerId: string, currentlyVisible: boolean) => {
|
|
try {
|
|
await apiUpdateLayer(layerId, { visible: !currentlyVisible });
|
|
await refreshData();
|
|
} catch {}
|
|
};
|
|
|
|
const handleDeleteLayer = (layerId: string) => {
|
|
const layer = layers.find((l) => l.id === layerId);
|
|
const layerPinCount = pins.filter((p) => p.layer_id === layerId).length;
|
|
const name = layer?.name || 'this layer';
|
|
const msg =
|
|
layerPinCount > 0
|
|
? `Delete "${name}" and all ${layerPinCount} pin${layerPinCount === 1 ? '' : 's'} in it?\n\nThis cannot be undone.`
|
|
: `Delete layer "${name}"?`;
|
|
setConfirmDialog({
|
|
title: 'DELETE LAYER',
|
|
message: msg,
|
|
confirmLabel: 'DELETE',
|
|
onConfirm: async () => {
|
|
setConfirmDialog(null);
|
|
setBusy(true);
|
|
try {
|
|
await apiDeleteLayer(layerId);
|
|
await refreshData();
|
|
} catch {}
|
|
setBusy(false);
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRefreshFeed = async (layerId: string) => {
|
|
setBusy(true);
|
|
try {
|
|
await apiRefreshLayerFeed(layerId);
|
|
await refreshData();
|
|
} catch {}
|
|
setBusy(false);
|
|
};
|
|
|
|
const toggleLayerExpanded = (layerId: string) => {
|
|
setExpandedLayers(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(layerId)) next.delete(layerId);
|
|
else next.add(layerId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// ── Pin actions ─────────────────────────────────────────────────
|
|
const deletePin = (pinId: string) => {
|
|
const target = pins.find((p) => p.id === pinId);
|
|
const label = target?.label || 'this pin';
|
|
setConfirmDialog({
|
|
title: 'DELETE PIN',
|
|
message: `Delete pin "${label}"?\n\nThis cannot be undone.`,
|
|
confirmLabel: 'DELETE',
|
|
onConfirm: async () => {
|
|
setConfirmDialog(null);
|
|
try {
|
|
await fetch(`${API_BASE}/api/ai/pins/${pinId}`, { method: 'DELETE' });
|
|
await refreshData();
|
|
} catch {}
|
|
},
|
|
});
|
|
};
|
|
|
|
// ── Near Me ──────────────────────────────────────────────────────
|
|
const fetchNearMe = async () => {
|
|
if (!navigator.geolocation) {
|
|
setError('Geolocation not available');
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
|
);
|
|
const { latitude: lat, longitude: lng } = pos.coords;
|
|
const resp = await fetch(
|
|
`${API_BASE}/api/ai/news-near?lat=${lat}&lng=${lng}&radius=${nearMeRadius}`,
|
|
);
|
|
if (!resp.ok) throw new Error(`${resp.status}`);
|
|
setNearMeResults(await resp.json());
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Near Me failed');
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
// ── Satellite imagery search ─────────────────────────────────────
|
|
const handleLocationLookup = async () => {
|
|
const q = satLocationQuery.trim();
|
|
if (!q) return;
|
|
setSatGeocoding(true);
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=1`);
|
|
if (!resp.ok) throw new Error(`${resp.status}`);
|
|
const data = await resp.json();
|
|
const first = data.results?.[0];
|
|
if (first && typeof first.lat === 'number' && typeof first.lng === 'number') {
|
|
setSatLat(first.lat.toFixed(5));
|
|
setSatLng(first.lng.toFixed(5));
|
|
// Auto-search imagery at the resolved location
|
|
setSatSearching(true);
|
|
setSatScenes([]);
|
|
try {
|
|
const imgs = await fetchSatelliteImages(first.lat, first.lng, 3);
|
|
setSatScenes(imgs.scenes || []);
|
|
if (!imgs.scenes?.length) setError('No scenes found for this location');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Satellite search failed');
|
|
}
|
|
setSatSearching(false);
|
|
} else {
|
|
setError(`Location "${q}" not found`);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Geocoding failed');
|
|
}
|
|
setSatGeocoding(false);
|
|
};
|
|
|
|
const handleSatSearch = async () => {
|
|
const lat = parseFloat(satLat);
|
|
const lng = parseFloat(satLng);
|
|
if (isNaN(lat) || isNaN(lng)) {
|
|
setError('Enter valid lat/lng coordinates');
|
|
return;
|
|
}
|
|
setSatSearching(true);
|
|
setSatScenes([]);
|
|
setError(null);
|
|
try {
|
|
const resp = await fetchSatelliteImages(lat, lng, 3);
|
|
setSatScenes(resp.scenes || []);
|
|
if (!resp.scenes?.length) setError('No scenes found for this location');
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Satellite search failed');
|
|
}
|
|
setSatSearching(false);
|
|
};
|
|
|
|
// ── Render ───────────────────────────────────────────────────────
|
|
return (
|
|
<div className="flex flex-col select-none">
|
|
{/* Header */}
|
|
<div
|
|
onClick={() => setIsMinimized(!isMinimized)}
|
|
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-violet-950/40 transition-colors border-b border-violet-500/30 bg-violet-950/20"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Brain size={16} className="text-violet-400" />
|
|
<span className="text-[12px] text-violet-400 font-mono tracking-widest font-bold">
|
|
{t('ai.title').toUpperCase()}
|
|
</span>
|
|
{totalPins > 0 && (
|
|
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-violet-500/20 border border-violet-500/40 text-violet-300">
|
|
{totalPins}
|
|
</span>
|
|
)}
|
|
{error && (
|
|
<span className="text-[11px] font-mono px-1.5 py-0.5 bg-red-500/20 text-red-400">
|
|
OFFLINE
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isMinimized ? (
|
|
<Plus size={16} className="text-violet-400" />
|
|
) : (
|
|
<Minus size={16} className="text-violet-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{!isMinimized && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden border-x border-b border-violet-500/20 bg-[var(--bg-elevated)]"
|
|
>
|
|
<div className="p-3 space-y-3 max-h-[60vh] overflow-y-auto styled-scrollbar">
|
|
|
|
{/* ── Connect OpenClaw Button ──────────────────────── */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setShowConnect(true); }}
|
|
className="w-full flex items-center justify-center gap-2 py-2 text-[12px] font-mono tracking-wider border transition-all bg-violet-600/15 border-violet-500/30 text-violet-400 hover:bg-violet-600/25 hover:text-violet-300 hover:border-violet-500/50"
|
|
title="Connect your OpenClaw AI agent"
|
|
>
|
|
<Link2 size={14} />
|
|
CONNECT OPENCLAW
|
|
</button>
|
|
|
|
{/* ── Pin Placement Button ─────────────────────────── */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onPinPlacementModeChange?.(!pinPlacementMode)}
|
|
className={`w-full flex items-center justify-center gap-2 py-2 text-[12px] font-mono tracking-wider border transition-all ${
|
|
pinPlacementMode
|
|
? 'bg-amber-600/25 border-amber-500/50 text-amber-300 animate-pulse'
|
|
: 'bg-violet-600/10 border-violet-500/20 text-violet-300 hover:bg-violet-600/20 hover:border-violet-500/40'
|
|
}`}
|
|
>
|
|
<Crosshair size={14} />
|
|
{pinPlacementMode ? 'CLICK MAP TO PLACE PIN...' : 'PLACE PIN ON MAP'}
|
|
</button>
|
|
|
|
{/* ── Pin Layers ──────────────────────────────────── */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<MapPin size={12} className="text-violet-400" />
|
|
<span className="text-[11px] font-mono text-violet-400 tracking-widest">
|
|
PIN LAYERS
|
|
</span>
|
|
<span className="text-[10px] font-mono text-[var(--text-muted)]">
|
|
({layers.length})
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNewLayer(!showNewLayer)}
|
|
className="text-[10px] font-mono text-violet-400/70 hover:text-violet-300 transition-colors flex items-center gap-1"
|
|
>
|
|
<Plus size={10} /> NEW
|
|
</button>
|
|
</div>
|
|
|
|
{/* New layer form */}
|
|
{showNewLayer && (
|
|
<div className="space-y-1">
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="text"
|
|
value={newLayerName}
|
|
onChange={(e) => setNewLayerName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateLayer()}
|
|
placeholder="Layer name..."
|
|
autoFocus
|
|
className="flex-1 px-2 py-1.5 text-[12px] font-mono bg-[var(--bg-primary)] border border-violet-500/30 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-violet-500/50 outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreateLayer}
|
|
disabled={busy || !newLayerName.trim()}
|
|
className="px-3 py-1.5 text-[11px] font-mono bg-violet-600/30 border border-violet-500/50 text-violet-300 hover:bg-violet-600/50 transition-colors disabled:opacity-40"
|
|
>
|
|
ADD
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Rss size={10} className="text-emerald-400/50 flex-shrink-0" />
|
|
<input
|
|
type="text"
|
|
value={newLayerFeedUrl}
|
|
onChange={(e) => setNewLayerFeedUrl(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateLayer()}
|
|
placeholder="Feed URL (optional GeoJSON/JSON)..."
|
|
className="flex-1 px-2 py-1 text-[11px] font-mono bg-[var(--bg-primary)] border border-emerald-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-emerald-500/40 outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Layer list */}
|
|
{layers.length === 0 && !showNewLayer && (
|
|
<div className="text-[11px] font-mono text-[var(--text-muted)] px-2 py-3 text-center border border-dashed border-[var(--border-primary)]">
|
|
No layers yet. Create one or let OpenClaw add them.
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-0.5">
|
|
{layers.map((layer) => {
|
|
const isExpanded = expandedLayers.has(layer.id);
|
|
const layerPins = pins.filter((p) => p.layer_id === layer.id);
|
|
return (
|
|
<div key={layer.id} className="border border-[var(--border-primary)]">
|
|
{/* Layer header */}
|
|
<div className="flex items-center gap-1.5 px-2 py-1.5 hover:bg-violet-500/5 transition-colors">
|
|
{/* Expand/collapse */}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleLayerExpanded(layer.id)}
|
|
className="text-[var(--text-muted)] hover:text-violet-400 transition-colors"
|
|
>
|
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
</button>
|
|
|
|
{/* Color dot */}
|
|
<div
|
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: layer.color }}
|
|
/>
|
|
|
|
{/* Name + count */}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleLayerExpanded(layer.id)}
|
|
className="flex-1 text-left min-w-0"
|
|
>
|
|
<span className="text-[11px] font-mono text-[var(--text-primary)] truncate block">
|
|
{layer.name}
|
|
</span>
|
|
</button>
|
|
<span className="text-[9px] font-mono text-[var(--text-muted)]">
|
|
{layer.pin_count}
|
|
</span>
|
|
|
|
{/* Source badge */}
|
|
{layer.source === 'openclaw' && (
|
|
<span className="text-[11px] font-mono text-violet-400/60 px-1 border border-violet-500/20 rounded-sm">
|
|
AI
|
|
</span>
|
|
)}
|
|
|
|
{/* Feed badge + refresh */}
|
|
{layer.feed_url && (
|
|
<>
|
|
<span
|
|
className="text-[11px] font-mono text-emerald-400/60 px-1 border border-emerald-500/20 rounded-sm flex items-center gap-0.5"
|
|
title={`Feed: ${layer.feed_url}\nInterval: ${layer.feed_interval}s${layer.feed_last_fetched ? `\nLast: ${new Date(layer.feed_last_fetched * 1000).toLocaleTimeString()}` : ''}`}
|
|
>
|
|
<Rss size={8} />
|
|
FEED
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); handleRefreshFeed(layer.id); }}
|
|
className="text-emerald-400/40 hover:text-emerald-400 transition-colors"
|
|
title="Refresh feed now"
|
|
>
|
|
<RefreshCw size={10} />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Visibility toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleToggleLayerVisibility(layer.id, layer.visible)}
|
|
className="text-[var(--text-muted)] hover:text-violet-400 transition-colors"
|
|
title={layer.visible ? 'Hide layer' : 'Show layer'}
|
|
>
|
|
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
|
|
</button>
|
|
|
|
{/* Delete */}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteLayer(layer.id)}
|
|
className="text-red-400/40 hover:text-red-400 transition-colors"
|
|
title="Delete layer and all its pins"
|
|
>
|
|
<Trash2 size={10} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Expanded: show pins */}
|
|
{isExpanded && layerPins.length > 0 && (
|
|
<div className="border-t border-[var(--border-primary)] bg-black/20">
|
|
{layerPins.slice(0, 30).map((pin) => (
|
|
<div
|
|
key={pin.id}
|
|
className="flex items-center justify-between px-3 py-1 hover:bg-violet-500/5 transition-colors group cursor-pointer"
|
|
onClick={() => onFlyTo?.(pin.lat, pin.lng)}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<div
|
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: pin.color }}
|
|
/>
|
|
<span className="text-[10px] font-mono text-[var(--text-secondary)] truncate">
|
|
{pin.label}
|
|
</span>
|
|
{pin.entity_attachment && (
|
|
<span className="text-[11px] font-mono text-cyan-400/60 px-1 border border-cyan-500/20 rounded-sm">
|
|
TRACKING
|
|
</span>
|
|
)}
|
|
{pin.source === 'openclaw' && (
|
|
<span className="text-[11px] font-mono text-violet-400/50">AI</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
deletePin(pin.id);
|
|
}}
|
|
className="opacity-0 group-hover:opacity-100 text-red-400/50 hover:text-red-400 transition-all"
|
|
title="Delete pin"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{layerPins.length > 30 && (
|
|
<div className="text-[9px] font-mono text-[var(--text-muted)] text-center py-1 border-t border-[var(--border-primary)]">
|
|
+ {layerPins.length - 30} more
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isExpanded && layerPins.length === 0 && (
|
|
<div className="border-t border-[var(--border-primary)] bg-black/20 px-3 py-2">
|
|
<span className="text-[10px] font-mono text-[var(--text-muted)]">
|
|
No pins in this layer
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Ungrouped pins (no layer_id) */}
|
|
{pins.filter(p => !p.layer_id).length > 0 && (
|
|
<div className="border border-[var(--border-primary)] mt-1">
|
|
<div className="px-2 py-1.5 text-[10px] font-mono text-[var(--text-muted)] tracking-widest">
|
|
UNGROUPED ({pins.filter(p => !p.layer_id).length})
|
|
</div>
|
|
<div className="border-t border-[var(--border-primary)] bg-black/20">
|
|
{pins.filter(p => !p.layer_id).slice(0, 20).map((pin) => (
|
|
<div
|
|
key={pin.id}
|
|
className="flex items-center justify-between px-3 py-1 hover:bg-violet-500/5 transition-colors group cursor-pointer"
|
|
onClick={() => onFlyTo?.(pin.lat, pin.lng)}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<div
|
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: pin.color }}
|
|
/>
|
|
<span className="text-[10px] font-mono text-[var(--text-secondary)] truncate">
|
|
{pin.label}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); deletePin(pin.id); }}
|
|
className="opacity-0 group-hover:opacity-100 text-red-400/50 hover:text-red-400 transition-all"
|
|
title="Delete pin"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Near Me ─────────────────────────────────────── */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Navigation size={12} className="text-emerald-400" />
|
|
<span className="text-[11px] font-mono text-emerald-400 tracking-widest">NEAR ME</span>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{[50, 100, 500, 1000].map((r) => (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
onClick={() => setNearMeRadius(r)}
|
|
className={`flex-1 px-1 py-1 text-[10px] font-mono border transition-colors ${
|
|
nearMeRadius === r
|
|
? 'border-emerald-500/50 bg-emerald-500/20 text-emerald-300'
|
|
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-emerald-400'
|
|
}`}
|
|
>
|
|
{r}mi
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={fetchNearMe}
|
|
disabled={busy}
|
|
className="w-full py-2 text-[11px] font-mono tracking-wider bg-emerald-600/20 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-600/40 transition-colors disabled:opacity-40 flex items-center justify-center gap-1.5"
|
|
>
|
|
<Navigation size={12} />
|
|
SCAN NEARBY ({nearMeRadius}mi)
|
|
</button>
|
|
{nearMeResults && (
|
|
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
|
{(nearMeResults.gdelt || []).slice(0, 3).map((g: any, i: number) => (
|
|
<div key={i} className="text-[10px] font-mono text-amber-300 px-2 py-1 bg-amber-500/10 border border-amber-500/20">
|
|
{g.name} ({g.count} events) -- {g.distance_miles}mi
|
|
</div>
|
|
))}
|
|
{(nearMeResults.news || []).slice(0, 3).map((n: any, i: number) => (
|
|
<div key={i} className="text-[10px] font-mono text-sky-300 px-2 py-1 bg-sky-500/10 border border-sky-500/20">
|
|
{n.title?.slice(0, 60)} -- {n.distance_miles}mi
|
|
</div>
|
|
))}
|
|
{!nearMeResults.gdelt?.length && !nearMeResults.news?.length && (
|
|
<div className="text-[10px] font-mono text-emerald-400/50 px-2">
|
|
All clear -- nothing notable within {nearMeRadius}mi
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Satellite Imagery ──────────────────────────── */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Globe size={12} className="text-sky-400" />
|
|
<span className="text-[11px] font-mono text-sky-400 tracking-widest">SATELLITE IMAGERY</span>
|
|
</div>
|
|
{/* Location lookup (place name) */}
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="text"
|
|
value={satLocationQuery}
|
|
onChange={(e) => setSatLocationQuery(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleLocationLookup()}
|
|
placeholder="Search location (e.g. Tehran, Kyiv)..."
|
|
className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleLocationLookup}
|
|
disabled={satGeocoding || !satLocationQuery.trim()}
|
|
className="px-2 py-1.5 text-[11px] font-mono bg-sky-600/20 border border-sky-500/40 text-sky-300 hover:bg-sky-600/40 transition-colors disabled:opacity-40"
|
|
title="Look up location and search imagery"
|
|
>
|
|
{satGeocoding ? '...' : 'GO'}
|
|
</button>
|
|
</div>
|
|
<div className="text-[9px] font-mono text-[var(--text-muted)] text-center">
|
|
— or enter coordinates —
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="text"
|
|
value={satLat}
|
|
onChange={(e) => setSatLat(e.target.value)}
|
|
placeholder="Lat"
|
|
className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={satLng}
|
|
onChange={(e) => setSatLng(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSatSearch()}
|
|
placeholder="Lng"
|
|
className="flex-1 px-2 py-1.5 text-[11px] font-mono bg-[var(--bg-primary)] border border-sky-500/20 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-sky-500/50 outline-none"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSatSearch}
|
|
disabled={satSearching || !satLat.trim() || !satLng.trim()}
|
|
className="w-full py-2 text-[11px] font-mono tracking-wider bg-sky-600/20 border border-sky-500/40 text-sky-300 hover:bg-sky-600/40 transition-colors disabled:opacity-40 flex items-center justify-center gap-1.5"
|
|
>
|
|
<Globe size={12} />
|
|
{satSearching ? 'SEARCHING...' : 'SEARCH SENTINEL-2'}
|
|
</button>
|
|
|
|
{/* Error message (inline) */}
|
|
{error && !satSearching && satScenes.length === 0 && (
|
|
<div className="text-[10px] font-mono text-red-400/80 bg-red-500/10 border border-red-500/20 px-2 py-1.5">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Results */}
|
|
{satScenes.length > 0 && (
|
|
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
|
{satScenes.map((scene) => (
|
|
<div
|
|
key={scene.scene_id}
|
|
className="border border-sky-500/20 bg-black/30 overflow-hidden"
|
|
>
|
|
{/* Thumbnail */}
|
|
{scene.thumbnail_url && (
|
|
<a href={scene.fullres_url || scene.thumbnail_url} target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={scene.thumbnail_url}
|
|
alt={scene.scene_id}
|
|
className="w-full h-24 object-cover hover:opacity-80 transition-opacity"
|
|
loading="lazy"
|
|
/>
|
|
</a>
|
|
)}
|
|
{/* Info bar */}
|
|
<div className="flex items-center justify-between px-2 py-1.5">
|
|
<div className="min-w-0">
|
|
<div className="text-[10px] font-mono text-sky-300 truncate">
|
|
{scene.platform} — {scene.datetime ? new Date(scene.datetime).toLocaleDateString() : 'N/A'}
|
|
</div>
|
|
<div className="text-[9px] font-mono text-[var(--text-muted)]">
|
|
Cloud: {scene.cloud_cover != null ? `${Math.round(scene.cloud_cover)}%` : 'N/A'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{scene.bbox && scene.bbox.length >= 4 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const centerLat = (scene.bbox[1] + scene.bbox[3]) / 2;
|
|
const centerLng = (scene.bbox[0] + scene.bbox[2]) / 2;
|
|
onFlyTo?.(centerLat, centerLng);
|
|
}}
|
|
className="px-2 py-0.5 text-[9px] font-mono bg-sky-600/30 border border-sky-500/40 text-sky-300 hover:bg-sky-600/50 transition-colors"
|
|
>
|
|
SHOW ON MAP
|
|
</button>
|
|
)}
|
|
{scene.fullres_url && (
|
|
<a
|
|
href={scene.fullres_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-2 py-0.5 text-[9px] font-mono bg-violet-600/20 border border-violet-500/30 text-violet-300 hover:bg-violet-600/40 transition-colors"
|
|
>
|
|
FULL RES
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Refresh ─────────────────────────────────────── */}
|
|
<button
|
|
type="button"
|
|
onClick={refreshData}
|
|
className="w-full py-1.5 text-[10px] font-mono tracking-wider border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-violet-400 hover:border-violet-500/40 transition-colors flex items-center justify-center gap-1"
|
|
title="Refresh AI Intel"
|
|
>
|
|
<RefreshCw size={10} /> REFRESH
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ── Connect OpenClaw Modal (Portal) ──────────────────── */}
|
|
{showConnect && ReactDOM.createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
|
onClick={() => setShowConnect(false)}
|
|
>
|
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
className="relative w-[560px] max-w-[90vw] max-h-[85vh] overflow-y-auto bg-[#0c0c14] border border-violet-500/40 shadow-2xl shadow-violet-900/30"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-violet-500/30 bg-violet-950/20 sticky top-0 z-10">
|
|
<div className="flex items-center gap-3">
|
|
<Link2 size={18} className="text-violet-400" />
|
|
<span className="text-sm font-mono text-violet-400 tracking-widest font-bold uppercase">Connect OpenClaw Agent</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowConnect(false)}
|
|
className="text-gray-500 hover:text-white transition-colors p-1"
|
|
title="Close"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<ConnectModalBody apiEndpoint={apiEndpoint} handleCopy={handleCopy} copied={copied} />
|
|
</motion.div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* In-app confirmation dialog */}
|
|
<ConfirmDialog
|
|
open={!!confirmDialog}
|
|
title={confirmDialog?.title || ''}
|
|
message={confirmDialog?.message || ''}
|
|
confirmLabel={confirmDialog?.confirmLabel}
|
|
danger
|
|
onConfirm={() => confirmDialog?.onConfirm()}
|
|
onCancel={() => setConfirmDialog(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|