Skip to content

Commit 818a1e1

Browse files
QRcode1337claude
andcommitted
feat: complete intel dashboard overhaul — flights, views, feeds, settings, AI summaries
Major feature delivery across map visualization, data feeds, and intel analysis: - Flight markers: airplane SVG icons, callsign removed from map, FlightAware links, commercial/private classification - View modes: GLOBE SAT (default), GLOBE STREET, FLAT MAP, GLOBE MAP with basemap switching - Map click captures lat/lon coordinates into Target Intel/HUD - News and GDELT selections populate Target Intel with analysis summaries and clickable links - Live Feeds section: ISS, Tokyo, Tel Aviv, Tehran, Giza, Yellowstone YouTube streams - ISS: distinct enlarged marker, crew lookup via open-notify, live stream embed - ThreatRadar/offseq.com: proxy routes for threats, CVE search, IoC checks - Analytics layers: real GFS weather tiles (RainViewer radar, NOAA GOES IR), Sentinel-2 imagery (EOX cloudless mosaic) — replaces stubs - Settings workspace: local LLM configuration (Ollama/OpenAI-compatible), endpoint/model/API key management, file-based storage - AI summaries: /api/ai/summarize endpoint, "Generate AI Summary" button in Target Intel panel, powered by user-configured local LLM - Non-live intel (outages, threats, GDELT) moved to analytics mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b73367 commit 818a1e1

27 files changed

Lines changed: 1862 additions & 154 deletions

File tree

argus-app/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ yarn-error.log*
4040
*.tsbuildinfo
4141
next-env.d.ts
4242
.env*.local
43+
44+
# user settings (may contain API keys)
45+
/data/settings.json
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { NextResponse } from "next/server";
2+
import { queryLlm } from "@/lib/ai/llmClient";
3+
4+
const SYSTEM_PROMPT = `You are an intelligence analyst. Provide a concise 2-3 sentence summary and analysis of the following item. Focus on strategic significance, potential implications, and key facts. Be direct and factual.`;
5+
6+
export async function POST(req: Request) {
7+
const { text, context } = await req.json();
8+
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
9+
10+
const prompt = context
11+
? `Context: ${context}\n\nItem to analyze:\n${text}`
12+
: `Item to analyze:\n${text}`;
13+
14+
const result = await queryLlm(prompt, SYSTEM_PROMPT);
15+
if (result.error) {
16+
return NextResponse.json({ summary: null, error: result.error }, { status: 502 });
17+
}
18+
return NextResponse.json({ summary: result.text });
19+
}
Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,64 @@
11
import { NextResponse } from "next/server";
22

3-
// Stub response — returns mock data until PostGIS is populated by the ingestor.
4-
// When the ingestor runs, replace this with a real pg query.
5-
const MOCK_LAYERS = [
6-
{
7-
id: 1,
8-
name: "GFS Temperature 2m",
9-
variable: "t2m",
10-
valid_time: null,
11-
tile_url: null, // populated after first ingest
12-
},
13-
{
14-
id: 2,
15-
name: "GFS Wind U 10m",
16-
variable: "u10",
17-
valid_time: null,
18-
tile_url: null,
19-
},
20-
{
21-
id: 3,
22-
name: "GFS Wind V 10m",
23-
variable: "v10",
24-
valid_time: null,
25-
tile_url: null,
26-
},
27-
];
3+
interface RainViewerMap {
4+
path: string;
5+
time: number;
6+
}
7+
8+
interface RainViewerData {
9+
radar?: { past?: RainViewerMap[] };
10+
}
11+
12+
async function getLatestRadarTimestamp(): Promise<string> {
13+
try {
14+
const res = await fetch("https://api.rainviewer.com/public/weather-maps.json", {
15+
next: { revalidate: 300 },
16+
});
17+
const data: RainViewerData = await res.json();
18+
const past = data?.radar?.past;
19+
if (past && past.length > 0) {
20+
return past[past.length - 1].path;
21+
}
22+
} catch {
23+
// fall through to default
24+
}
25+
return "/v2/radar/nowcast";
26+
}
2827

2928
export async function GET() {
30-
return NextResponse.json({ layers: MOCK_LAYERS });
29+
const radarPath = await getLatestRadarTimestamp();
30+
31+
const layers = [
32+
{
33+
id: "gfs_precip_radar",
34+
label: "Precipitation Radar",
35+
source: "RainViewer",
36+
type: "xyz",
37+
tileUrl: `https://tilecache.rainviewer.com${radarPath}/256/{z}/{x}/{y}/6/1_1.png`,
38+
available: true,
39+
},
40+
{
41+
id: "gfs_satellite_ir",
42+
label: "Satellite IR (GOES)",
43+
source: "NOAA / Iowa Mesonet",
44+
type: "xyz",
45+
tileUrl:
46+
"https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/goes-vis-1km-900913/{z}/{x}/{y}.png",
47+
available: true,
48+
},
49+
{
50+
id: "sentinel_imagery",
51+
label: "Sentinel-2 Imagery",
52+
source: "EOX / Sentinel-2 Cloudless",
53+
type: "xyz",
54+
tileUrl:
55+
"https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2021_3857/default/GoogleMapsCompatible/{z}/{y}/{x}.jpg",
56+
available: true,
57+
},
58+
];
59+
60+
return NextResponse.json(
61+
{ layers, available_file_count: layers.length, fallback: false },
62+
{ headers: { "Cache-Control": "public, max-age=300" } },
63+
);
3164
}

argus-app/src/app/api/feeds/aisstream/route.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,58 @@ type AisCache = {
2525
cachedAt: string;
2626
};
2727

28+
type RawAisMessage = {
29+
Message?: {
30+
PositionReport?: {
31+
Latitude?: unknown;
32+
Longitude?: unknown;
33+
UserID?: unknown;
34+
MMSI?: unknown;
35+
Sog?: unknown;
36+
Cog?: unknown;
37+
TrueHeading?: unknown;
38+
NavigationalStatus?: unknown;
39+
};
40+
};
41+
MetaData?: {
42+
MMSI?: unknown;
43+
time_utc?: string;
44+
time?: string;
45+
ShipName?: string;
46+
CallSign?: string;
47+
};
48+
};
49+
50+
type WsLike = {
51+
on: (event: string, listener: (...args: unknown[]) => void) => void;
52+
send: (data: string) => void;
53+
close: () => void;
54+
};
55+
56+
type WsConstructor = new (
57+
url: string,
58+
options: { handshakeTimeout: number },
59+
) => WsLike;
60+
2861
let cache: AisCache | null = null;
2962

3063
const REQUEST_TIMEOUT_MS = Number(process.env.AISSTREAM_TIMEOUT_MS ?? 12000);
3164
const MAX_MESSAGES = Number(process.env.AISSTREAM_MAX_MESSAGES ?? 200);
3265

33-
function normalizeAisMessage(raw: any): AisVessel | null {
34-
const position = raw?.Message?.PositionReport;
35-
const metadata = raw?.MetaData;
66+
function rawDataToString(data: unknown): string {
67+
if (typeof data === "string") return data;
68+
if (data instanceof Buffer) return data.toString("utf8");
69+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
70+
if (Array.isArray(data) && data.every((item) => item instanceof Buffer)) {
71+
return Buffer.concat(data).toString("utf8");
72+
}
73+
return String(data);
74+
}
75+
76+
function normalizeAisMessage(raw: unknown): AisVessel | null {
77+
const message = raw as RawAisMessage;
78+
const position = message?.Message?.PositionReport;
79+
const metadata = message?.MetaData;
3680
if (!position) return null;
3781

3882
const lat = Number(position.Latitude);
@@ -61,8 +105,8 @@ async function fetchSnapshotFromWs(apiKey: string): Promise<AisPayload> {
61105
process.env.WS_NO_BUFFER_UTIL = "1";
62106
process.env.WS_NO_UTF_8_VALIDATE = "1";
63107

64-
const wsModule: any = await import("ws");
65-
const WebSocket = wsModule.default ?? wsModule;
108+
const wsModule = (await import("ws")) as { default?: WsConstructor };
109+
const WebSocket = (wsModule.default ?? (wsModule as unknown as WsConstructor));
66110

67111
return new Promise((resolve, reject) => {
68112
const vessels = new Map<number, AisVessel>();
@@ -97,9 +141,9 @@ async function fetchSnapshotFromWs(apiKey: string): Promise<AisPayload> {
97141
);
98142
});
99143

100-
ws.on("message", (data: any) => {
144+
ws.on("message", (data: unknown) => {
101145
try {
102-
const parsed = JSON.parse(data.toString());
146+
const parsed = JSON.parse(rawDataToString(data));
103147
const vessel = normalizeAisMessage(parsed);
104148
if (vessel) vessels.set(vessel.mmsi, vessel);
105149

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextResponse } from "next/server";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
type AstroPerson = {
6+
craft: string;
7+
name: string;
8+
};
9+
10+
type AstrosResponse = {
11+
message?: string;
12+
number?: number;
13+
people?: AstroPerson[];
14+
};
15+
16+
const OPEN_NOTIFY_ASTROS = "http://api.open-notify.org/astros.json";
17+
const DEFAULT_VIDEO_URL = "https://www.youtube.com/embed/21X5lGlDOfg";
18+
const DEFAULT_MORE_INFO_URL = "https://www.nasa.gov/international-space-station/";
19+
20+
export async function GET() {
21+
try {
22+
const controller = new AbortController();
23+
const timeout = setTimeout(() => controller.abort(), 6000);
24+
25+
let crew: string[] = [];
26+
try {
27+
const response = await fetch(OPEN_NOTIFY_ASTROS, {
28+
cache: "no-store",
29+
signal: controller.signal,
30+
});
31+
if (response.ok) {
32+
const payload = (await response.json()) as AstrosResponse;
33+
crew = (payload.people ?? [])
34+
.filter((person) => person.craft?.toUpperCase().includes("ISS"))
35+
.map((person) => person.name);
36+
}
37+
} finally {
38+
clearTimeout(timeout);
39+
}
40+
41+
return NextResponse.json(
42+
{
43+
craft: "ISS",
44+
crew,
45+
updatedAt: new Date().toISOString(),
46+
videoUrl: DEFAULT_VIDEO_URL,
47+
moreInfoUrl: DEFAULT_MORE_INFO_URL,
48+
},
49+
{
50+
status: 200,
51+
headers: {
52+
"Cache-Control": "public, max-age=180",
53+
},
54+
},
55+
);
56+
} catch (error) {
57+
return NextResponse.json(
58+
{
59+
craft: "ISS",
60+
crew: [],
61+
updatedAt: new Date().toISOString(),
62+
videoUrl: DEFAULT_VIDEO_URL,
63+
moreInfoUrl: DEFAULT_MORE_INFO_URL,
64+
error: error instanceof Error ? error.message : "Failed to resolve ISS crew",
65+
},
66+
{ status: 200 },
67+
);
68+
}
69+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from "next/server";
2+
3+
const BASE = "https://radar.offseq.com/api/v1";
4+
5+
export async function POST(req: Request) {
6+
const body = await req.json();
7+
try {
8+
const res = await fetch(`${BASE}/threats/check-iocs`, {
9+
method: "POST",
10+
headers: { "Content-Type": "application/json", Accept: "application/json" },
11+
body: JSON.stringify(body),
12+
});
13+
if (!res.ok) throw new Error(`IoC check: ${res.status}`);
14+
const data = await res.json();
15+
return NextResponse.json(data);
16+
} catch (e: unknown) {
17+
const msg = e instanceof Error ? e.message : "IoC check failed";
18+
return NextResponse.json({ matches: [], error: msg }, { status: 502 });
19+
}
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { NextResponse } from "next/server";
2+
import { normalizeThreatRadar } from "@/lib/ingest/threatradar";
3+
4+
const BASE = "https://radar.offseq.com/api/v1";
5+
6+
export async function GET(req: Request) {
7+
const { searchParams } = new URL(req.url);
8+
const limit = searchParams.get("limit") ?? "10";
9+
try {
10+
const res = await fetch(`${BASE}/threats?limit=${limit}`, {
11+
headers: { Accept: "application/json" },
12+
next: { revalidate: 300 },
13+
});
14+
if (!res.ok) throw new Error(`ThreatRadar: ${res.status}`);
15+
const raw = await res.json();
16+
return NextResponse.json(normalizeThreatRadar(raw));
17+
} catch (e: unknown) {
18+
const msg = e instanceof Error ? e.message : "ThreatRadar unavailable";
19+
return NextResponse.json({ threats: [], total: 0, updatedAt: new Date().toISOString(), error: msg }, { status: 502 });
20+
}
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from "next/server";
2+
3+
const BASE = "https://radar.offseq.com/api/v1";
4+
5+
export async function GET(req: Request) {
6+
const { searchParams } = new URL(req.url);
7+
const q = searchParams.get("q") ?? "";
8+
if (!q) return NextResponse.json({ error: "q param required" }, { status: 400 });
9+
try {
10+
const res = await fetch(`${BASE}/threats/search?q=${encodeURIComponent(q)}`, {
11+
headers: { Accept: "application/json" },
12+
});
13+
if (!res.ok) throw new Error(`Search: ${res.status}`);
14+
const data = await res.json();
15+
return NextResponse.json(data);
16+
} catch (e: unknown) {
17+
const msg = e instanceof Error ? e.message : "Search failed";
18+
return NextResponse.json({ results: [], error: msg }, { status: 502 });
19+
}
20+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from "next/server";
2+
import { readSettings, writeSettings } from "@/lib/settings";
3+
import { AppSettings } from "@/types/settings";
4+
5+
export async function GET() {
6+
const settings = await readSettings();
7+
const safe = {
8+
...settings,
9+
llm: { ...settings.llm, apiKey: settings.llm.apiKey ? "••••••" : undefined },
10+
};
11+
return NextResponse.json(safe);
12+
}
13+
14+
export async function POST(req: Request) {
15+
const body = (await req.json()) as Partial<AppSettings>;
16+
const current = await readSettings();
17+
const merged: AppSettings = {
18+
...current,
19+
llm: {
20+
...current.llm,
21+
...(body.llm ?? {}),
22+
apiKey: body.llm?.apiKey === "••••••" ? current.llm.apiKey : (body.llm?.apiKey ?? current.llm.apiKey),
23+
},
24+
};
25+
await writeSettings(merged);
26+
return NextResponse.json({ ok: true });
27+
}

0 commit comments

Comments
 (0)