Centralize scan terminal requests behind a lightweight client
The scan terminal hooks were each carrying their own fetch, SSE parsing, error normalization, previous-data handling, and market request behavior. This adds a small scanTerminalClient plus RemoteData helpers so terminal data, city detail, market scans, and AI city streams share one request boundary without introducing React Query. Constraint: Do not add dependencies or change backend API contracts. Rejected: Introduce React Query immediately | too broad for this release and would force larger UI state rewrites. Rejected: Move localStorage caches in the same pass | safer to first isolate network and stream IO before cache policy migration. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Live SSE cancellation against production latency.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { extractStreamingAirportRead } from "@/components/dashboard/scan-terminal/ai-city-stream";
|
||||
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
||||
import {
|
||||
buildBrowserBackendHeaders,
|
||||
fetchBackendApi,
|
||||
} from "@/lib/backend-api";
|
||||
import type {
|
||||
CityDetail,
|
||||
MarketScan,
|
||||
ScanTerminalResponse,
|
||||
} from "@/lib/dashboard-types";
|
||||
|
||||
export type RemoteData<T> =
|
||||
| { status: "idle" }
|
||||
| { status: "loading"; previous?: T }
|
||||
| { status: "success"; data: T; freshAt: number }
|
||||
| { status: "error"; error: string; previous?: T };
|
||||
|
||||
export type AiCityStreamProgress = {
|
||||
stage?: string | null;
|
||||
message_en?: string | null;
|
||||
message_zh?: string | null;
|
||||
final_judgment_en?: string | null;
|
||||
final_judgment_zh?: string | null;
|
||||
metar_read_en?: string | null;
|
||||
metar_read_zh?: string | null;
|
||||
raw_length?: number | null;
|
||||
};
|
||||
|
||||
type AiCityStreamEvent = {
|
||||
data: Record<string, unknown>;
|
||||
event: string;
|
||||
};
|
||||
|
||||
type TerminalQueryOptions = {
|
||||
forceRefresh?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type CityDetailQueryOptions = {
|
||||
forceRefresh?: boolean;
|
||||
marketSlug?: string | null;
|
||||
signal?: AbortSignal;
|
||||
targetDate?: string | null;
|
||||
};
|
||||
|
||||
type MarketScanQueryOptions = CityDetailQueryOptions & {
|
||||
lite?: boolean;
|
||||
};
|
||||
|
||||
type AiCityReadOptions = {
|
||||
city: string;
|
||||
forceRefresh?: boolean;
|
||||
locale: string;
|
||||
onProgress?: (progress: AiCityStreamProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
function getRemoteError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function readPreviousRemoteData<T>(remote: RemoteData<T>): T | undefined {
|
||||
if (remote.status === "success") return remote.data;
|
||||
if ("previous" in remote) return remote.previous;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toRemoteLoading<T>(current: RemoteData<T>): RemoteData<T> {
|
||||
return {
|
||||
status: "loading",
|
||||
previous: readPreviousRemoteData(current),
|
||||
};
|
||||
}
|
||||
|
||||
export function toRemoteSuccess<T>(data: T): RemoteData<T> {
|
||||
return {
|
||||
data,
|
||||
freshAt: Date.now(),
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export function toRemoteError<T>(
|
||||
error: unknown,
|
||||
current: RemoteData<T>,
|
||||
): RemoteData<T> {
|
||||
return {
|
||||
error: getRemoteError(error),
|
||||
previous: readPreviousRemoteData(current),
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetchBackendApi(path, init);
|
||||
if (response.ok) return response.json() as Promise<T>;
|
||||
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = String(payload?.error || payload?.detail || message);
|
||||
} catch {
|
||||
try {
|
||||
const raw = await response.text();
|
||||
message = raw ? `${message} · ${raw.slice(0, 240)}` : message;
|
||||
} catch {
|
||||
// Keep HTTP status message.
|
||||
}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
|
||||
const eventLines = block
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
eventLines.forEach((line) => {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || event;
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
});
|
||||
if (!dataLines.length) return null;
|
||||
try {
|
||||
const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
|
||||
return { data, event };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAiCityForecastStream(
|
||||
response: Response,
|
||||
locale: string,
|
||||
onProgress?: (progress: AiCityStreamProgress) => void,
|
||||
) {
|
||||
if (!response.body) {
|
||||
return response.json() as Promise<AiCityForecastPayload>;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let accumulatedRaw = "";
|
||||
let finalPayload: AiCityForecastPayload | null = null;
|
||||
|
||||
const consumeBlock = (block: string) => {
|
||||
const parsed = parseAiCityStreamBlock(block);
|
||||
if (!parsed) return;
|
||||
const { data, event } = parsed;
|
||||
if (event === "final") {
|
||||
finalPayload = data as AiCityForecastPayload;
|
||||
return;
|
||||
}
|
||||
if (event === "progress" || event === "preview") {
|
||||
onProgress?.(data as AiCityStreamProgress);
|
||||
return;
|
||||
}
|
||||
if (event !== "delta") return;
|
||||
|
||||
const content = String(data.content || "");
|
||||
const rawLength = Number(data.raw_length);
|
||||
if (content) {
|
||||
accumulatedRaw += content;
|
||||
}
|
||||
const streamingAirportRead = extractStreamingAirportRead(accumulatedRaw, locale);
|
||||
const progress: AiCityStreamProgress = {
|
||||
raw_length: Number.isFinite(rawLength) ? rawLength : null,
|
||||
};
|
||||
if (streamingAirportRead) {
|
||||
if (locale === "en-US") {
|
||||
progress.metar_read_en = streamingAirportRead;
|
||||
} else {
|
||||
progress.metar_read_zh = streamingAirportRead;
|
||||
}
|
||||
}
|
||||
onProgress?.(progress);
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const blocks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = blocks.pop() || "";
|
||||
blocks.forEach(consumeBlock);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
consumeBlock(buffer);
|
||||
}
|
||||
if (!finalPayload) {
|
||||
throw new Error("AI stream ended before final payload");
|
||||
}
|
||||
return finalPayload;
|
||||
}
|
||||
|
||||
async function getTerminal({
|
||||
forceRefresh = false,
|
||||
signal,
|
||||
}: TerminalQueryOptions = {}) {
|
||||
const params = new URLSearchParams({
|
||||
scan_mode: "tradable",
|
||||
min_price: "0.05",
|
||||
max_price: "0.95",
|
||||
min_edge_pct: "2",
|
||||
min_liquidity: "500",
|
||||
market_type: "maxtemp",
|
||||
time_range: "today",
|
||||
limit: "36",
|
||||
force_refresh: String(forceRefresh),
|
||||
});
|
||||
if (forceRefresh) {
|
||||
params.set("_ts", String(Date.now()));
|
||||
}
|
||||
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
|
||||
return readJsonOrThrow<ScanTerminalResponse>(
|
||||
`/api/scan/terminal?${params.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getCityDetail(city: string, options: CityDetailQueryOptions = {}) {
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: String(options.forceRefresh ?? false),
|
||||
});
|
||||
if (options.marketSlug) params.set("market_slug", options.marketSlug);
|
||||
if (options.targetDate) params.set("target_date", options.targetDate);
|
||||
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
|
||||
return readJsonOrThrow<CityDetail>(
|
||||
`/api/city/${encodeURIComponent(city)}/detail?${params.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getMarketScan(city: string, options: MarketScanQueryOptions = {}) {
|
||||
const params = new URLSearchParams({
|
||||
force_refresh: String(options.forceRefresh ?? false),
|
||||
});
|
||||
if (options.targetDate) params.set("target_date", options.targetDate);
|
||||
if (options.marketSlug) params.set("market_slug", options.marketSlug);
|
||||
if (options.lite != null) params.set("lite", String(options.lite));
|
||||
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
|
||||
return readJsonOrThrow<MarketScan>(
|
||||
`/api/city/${encodeURIComponent(city)}/market-scan?${params.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function streamAiCityRead({
|
||||
city,
|
||||
forceRefresh = false,
|
||||
locale,
|
||||
onProgress,
|
||||
signal,
|
||||
}: AiCityReadOptions) {
|
||||
const headers = await buildBrowserBackendHeaders({
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
const response = await fetchBackendApi("/api/scan/terminal/ai-city/stream", {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
city,
|
||||
force_refresh: forceRefresh,
|
||||
locale,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detailMessage = "";
|
||||
try {
|
||||
const raw = await response.text();
|
||||
const errorPayload = JSON.parse(raw);
|
||||
const message = String(errorPayload?.error || "").trim();
|
||||
const rawDetail = String(errorPayload?.detail || "").trim();
|
||||
const elapsed = Number(errorPayload?.elapsed_ms);
|
||||
const timeout = Number(errorPayload?.timeout_ms);
|
||||
detailMessage = [
|
||||
message,
|
||||
rawDetail,
|
||||
Number.isFinite(elapsed) && Number.isFinite(timeout)
|
||||
? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
detailMessage = "";
|
||||
}
|
||||
throw new Error(
|
||||
detailMessage
|
||||
? `HTTP ${response.status} · ${detailMessage}`
|
||||
: `HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return readAiCityForecastStream(response, locale, onProgress);
|
||||
}
|
||||
|
||||
export const scanTerminalClient = {
|
||||
getCityDetail,
|
||||
getMarketScan,
|
||||
getTerminal,
|
||||
streamAiCityRead,
|
||||
};
|
||||
@@ -5,13 +5,11 @@ import type {
|
||||
AiCityForecastPayload,
|
||||
AiCityForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/types";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import {
|
||||
buildBrowserBackendHeaders,
|
||||
fetchBackendApi,
|
||||
} from "@/lib/backend-api";
|
||||
scanTerminalClient,
|
||||
type AiCityStreamProgress,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import { extractStreamingAirportRead } from "./ai-city-stream";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
|
||||
@@ -30,22 +28,6 @@ const aiCityForecastStateCache = new Map<
|
||||
>();
|
||||
let activeAiCityForecastStreams = 0;
|
||||
|
||||
type AiCityStreamProgress = {
|
||||
stage?: string | null;
|
||||
message_en?: string | null;
|
||||
message_zh?: string | null;
|
||||
final_judgment_en?: string | null;
|
||||
final_judgment_zh?: string | null;
|
||||
metar_read_en?: string | null;
|
||||
metar_read_zh?: string | null;
|
||||
raw_length?: number | null;
|
||||
};
|
||||
|
||||
type AiCityStreamEvent = {
|
||||
data: Record<string, unknown>;
|
||||
event: string;
|
||||
};
|
||||
|
||||
function getStorage() {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
@@ -155,97 +137,6 @@ function runQueuedAiCityForecastTask<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null {
|
||||
const eventLines = block
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
eventLines.forEach((line) => {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || event;
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
});
|
||||
if (!dataLines.length) return null;
|
||||
try {
|
||||
const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
|
||||
return { data, event };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAiCityForecastStream(
|
||||
response: Response,
|
||||
locale: string,
|
||||
onProgress?: (progress: AiCityStreamProgress) => void,
|
||||
) {
|
||||
if (!response.body) {
|
||||
return response.json() as Promise<AiCityForecastPayload>;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let accumulatedRaw = "";
|
||||
let finalPayload: AiCityForecastPayload | null = null;
|
||||
|
||||
const consumeBlock = (block: string) => {
|
||||
const parsed = parseAiCityStreamBlock(block);
|
||||
if (!parsed) return;
|
||||
const { data, event } = parsed;
|
||||
if (event === "final") {
|
||||
finalPayload = data as AiCityForecastPayload;
|
||||
return;
|
||||
}
|
||||
if (event === "progress" || event === "preview") {
|
||||
onProgress?.(data as AiCityStreamProgress);
|
||||
return;
|
||||
}
|
||||
if (event === "delta") {
|
||||
const content = String(data.content || "");
|
||||
const rawLength = Number(data.raw_length);
|
||||
if (content) {
|
||||
accumulatedRaw += content;
|
||||
}
|
||||
const streamingAirportRead = extractStreamingAirportRead(
|
||||
accumulatedRaw,
|
||||
locale,
|
||||
);
|
||||
const progress: AiCityStreamProgress = {
|
||||
raw_length: Number.isFinite(rawLength) ? rawLength : null,
|
||||
};
|
||||
if (streamingAirportRead) {
|
||||
if (locale === "en-US") {
|
||||
progress.metar_read_en = streamingAirportRead;
|
||||
} else {
|
||||
progress.metar_read_zh = streamingAirportRead;
|
||||
}
|
||||
}
|
||||
onProgress?.(progress);
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const blocks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = blocks.pop() || "";
|
||||
blocks.forEach(consumeBlock);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
consumeBlock(buffer);
|
||||
}
|
||||
if (!finalPayload) {
|
||||
throw new Error("AI stream ended before final payload");
|
||||
}
|
||||
return finalPayload;
|
||||
}
|
||||
|
||||
function requestAiCityForecast({
|
||||
city,
|
||||
forceRefresh,
|
||||
@@ -263,48 +154,12 @@ function requestAiCityForecast({
|
||||
if (pending) return pending;
|
||||
|
||||
const request = runQueuedAiCityForecastTask(async () => {
|
||||
const headers = await buildBrowserBackendHeaders({
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
return scanTerminalClient.streamAiCityRead({
|
||||
city,
|
||||
forceRefresh,
|
||||
locale,
|
||||
onProgress,
|
||||
});
|
||||
const response = await fetchBackendApi("/api/scan/terminal/ai-city/stream", {
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
city,
|
||||
force_refresh: forceRefresh,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detailMessage = "";
|
||||
try {
|
||||
const raw = await response.text();
|
||||
const errorPayload = JSON.parse(raw);
|
||||
const message = String(errorPayload?.error || "").trim();
|
||||
const rawDetail = String(errorPayload?.detail || "").trim();
|
||||
const elapsed = Number(errorPayload?.elapsed_ms);
|
||||
const timeout = Number(errorPayload?.timeout_ms);
|
||||
detailMessage = [
|
||||
message,
|
||||
rawDetail,
|
||||
Number.isFinite(elapsed) && Number.isFinite(timeout)
|
||||
? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
detailMessage = "";
|
||||
}
|
||||
throw new Error(
|
||||
detailMessage
|
||||
? `HTTP ${response.status} · ${detailMessage}`
|
||||
: `HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return readAiCityForecastStream(response, locale, onProgress);
|
||||
}, () => {
|
||||
onProgress?.({
|
||||
stage: "queued",
|
||||
@@ -614,7 +469,6 @@ export function useCityMarketScan({
|
||||
detailCityName: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan;
|
||||
const [marketScan, setMarketScan] = useState<MarketScan | null>(
|
||||
detail?.market_scan || null,
|
||||
);
|
||||
@@ -671,8 +525,10 @@ export function useCityMarketScan({
|
||||
} else {
|
||||
setMarketStatus("loading");
|
||||
}
|
||||
void ensureCityMarketScan(detailCityName, false, {
|
||||
const controller = new AbortController();
|
||||
void scanTerminalClient.getMarketScan(detailCityName, {
|
||||
lite: false,
|
||||
signal: controller.signal,
|
||||
targetDate: detail.local_date || null,
|
||||
})
|
||||
.then((payload) => {
|
||||
@@ -690,8 +546,9 @@ export function useCityMarketScan({
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [detail, detailCityName, enabled, ensureCityMarketScan]);
|
||||
}, [detail, detailCityName, enabled]);
|
||||
|
||||
return { marketScan, marketStatus };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
buildBrowserBackendHeaders,
|
||||
fetchBackendApi,
|
||||
} from "@/lib/backend-api";
|
||||
scanTerminalClient,
|
||||
toRemoteError,
|
||||
toRemoteLoading,
|
||||
toRemoteSuccess,
|
||||
type RemoteData,
|
||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
|
||||
@@ -18,8 +21,12 @@ export function useScanTerminalQuery({
|
||||
proAccessLoading: boolean;
|
||||
}) {
|
||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||
const [scanRemote, setScanRemote] = useState<RemoteData<ScanTerminalResponse>>({
|
||||
status: "idle",
|
||||
});
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const scanAbortRef = useRef<AbortController | null>(null);
|
||||
const scanRequestSeqRef = useRef(0);
|
||||
const scanLoadingRef = useRef(false);
|
||||
const lastForcedScanRefreshAtRef = useRef(0);
|
||||
@@ -34,56 +41,36 @@ export function useScanTerminalQuery({
|
||||
} = {}) => {
|
||||
if (proAccessLoading || !isPro) return;
|
||||
const requestSeq = ++scanRequestSeqRef.current;
|
||||
scanAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
if (forceRefresh) {
|
||||
lastForcedScanRefreshAtRef.current = Date.now();
|
||||
}
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = true;
|
||||
setScanLoading(true);
|
||||
setScanRemote((current) => toRemoteLoading(current));
|
||||
}
|
||||
setScanError(null);
|
||||
const params = new URLSearchParams({
|
||||
scan_mode: "tradable",
|
||||
min_price: "0.05",
|
||||
max_price: "0.95",
|
||||
min_edge_pct: "2",
|
||||
min_liquidity: "500",
|
||||
market_type: "maxtemp",
|
||||
time_range: "today",
|
||||
limit: "36",
|
||||
force_refresh: String(forceRefresh),
|
||||
});
|
||||
if (forceRefresh) {
|
||||
params.set("_ts", String(Date.now()));
|
||||
}
|
||||
try {
|
||||
const headers = await buildBrowserBackendHeaders({
|
||||
Accept: "application/json",
|
||||
});
|
||||
const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
const payload = await scanTerminalClient.getTerminal({
|
||||
forceRefresh,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = String(payload?.error || payload?.detail || message);
|
||||
} catch {
|
||||
// Keep HTTP status message.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const payload = (await response.json()) as ScanTerminalResponse;
|
||||
if (requestSeq !== scanRequestSeqRef.current) return;
|
||||
setTerminalData(payload);
|
||||
setScanRemote(toRemoteSuccess(payload));
|
||||
setScanError(null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setScanError(message);
|
||||
setScanRemote((current) => toRemoteError(error, current));
|
||||
} finally {
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
}
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = false;
|
||||
setScanLoading(false);
|
||||
@@ -100,11 +87,18 @@ export function useScanTerminalQuery({
|
||||
setScanLoading(false);
|
||||
setScanError(null);
|
||||
setTerminalData(null);
|
||||
setScanRemote({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
||||
}, [fetchScanTerminal, isPro, proAccessLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scanAbortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshScanTerminalManually = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const lastForced = lastForcedScanRefreshAtRef.current;
|
||||
@@ -133,6 +127,7 @@ export function useScanTerminalQuery({
|
||||
refreshScanTerminalManually,
|
||||
scanError,
|
||||
scanLoading,
|
||||
scanRemote,
|
||||
terminalData,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user