refactor(security): убрать UI ввода admin token (backend не требует с PR #437) #442

Merged
lekss361 merged 1 commit from refactor/remove-admin-token-ui into main 2026-05-23 12:06:38 +00:00
9 changed files with 69 additions and 483 deletions

View file

@ -68,11 +68,6 @@ interface LeadsStats {
} }
export default function AdminLeadsPage() { export default function AdminLeadsPage() {
const [token] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [statusFilter, setStatusFilter] = useState<string>(""); const [statusFilter, setStatusFilter] = useState<string>("");
const [sourceFilter, setSourceFilter] = useState<string>(""); const [sourceFilter, setSourceFilter] = useState<string>("");
const [convertedFilter, setConvertedFilter] = useState<string>(""); const [convertedFilter, setConvertedFilter] = useState<string>("");
@ -81,36 +76,24 @@ export default function AdminLeadsPage() {
const limit = 50; const limit = 50;
const stats = useQuery({ const stats = useQuery({
queryKey: ["admin-leads-stats", token], queryKey: ["admin-leads-stats"],
queryFn: () => queryFn: () => apiFetch<LeadsStats>("/api/v1/admin/leads/stats?months=12"),
apiFetch<LeadsStats>("/api/v1/admin/leads/stats?months=12", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
}); });
const funnelBySource = useQuery({ const funnelBySource = useQuery({
queryKey: ["admin-leads-funnel-source", token], queryKey: ["admin-leads-funnel-source"],
queryFn: () => queryFn: () =>
apiFetch<FunnelSourceRow[]>( apiFetch<FunnelSourceRow[]>(
"/api/v1/admin/leads/funnel/by-source?months=12", "/api/v1/admin/leads/funnel/by-source?months=12",
{
headers: { "X-Admin-Token": token },
},
), ),
enabled: !!token,
}); });
const funnelMonthly = useQuery({ const funnelMonthly = useQuery({
queryKey: ["admin-leads-funnel-monthly", token], queryKey: ["admin-leads-funnel-monthly"],
queryFn: () => queryFn: () =>
apiFetch<FunnelMonthRow[]>( apiFetch<FunnelMonthRow[]>(
"/api/v1/admin/leads/funnel/monthly?months=24", "/api/v1/admin/leads/funnel/monthly?months=24",
{
headers: { "X-Admin-Token": token },
},
), ),
enabled: !!token,
}); });
const sankeyOption = useMemo(() => { const sankeyOption = useMemo(() => {
@ -239,18 +222,13 @@ export default function AdminLeadsPage() {
}, [funnelMonthly.data]); }, [funnelMonthly.data]);
const sources = useQuery({ const sources = useQuery({
queryKey: ["admin-leads-sources", token], queryKey: ["admin-leads-sources"],
queryFn: () => queryFn: () => apiFetch<string[]>("/api/v1/admin/leads/sources"),
apiFetch<string[]>("/api/v1/admin/leads/sources", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
}); });
const leads = useQuery({ const leads = useQuery({
queryKey: [ queryKey: [
"admin-leads", "admin-leads",
token,
statusFilter, statusFilter,
sourceFilter, sourceFilter,
convertedFilter, convertedFilter,
@ -265,11 +243,8 @@ export default function AdminLeadsPage() {
if (sourceFilter) qs.set("source", sourceFilter); if (sourceFilter) qs.set("source", sourceFilter);
if (convertedFilter !== "") qs.set("converted", convertedFilter); if (convertedFilter !== "") qs.set("converted", convertedFilter);
if (search) qs.set("search", search); if (search) qs.set("search", search);
return apiFetch<LeadsResponse>(`/api/v1/admin/leads/?${qs.toString()}`, { return apiFetch<LeadsResponse>(`/api/v1/admin/leads/?${qs.toString()}`);
headers: { "X-Admin-Token": token },
});
}, },
enabled: !!token,
}); });
const fmtMoney = (v: number | null) => const fmtMoney = (v: number | null) =>
@ -279,18 +254,6 @@ export default function AdminLeadsPage() {
? `${(v / 1e6).toFixed(2)} М` ? `${(v / 1e6).toFixed(2)} М`
: v.toLocaleString("ru"); : v.toLocaleString("ru");
if (!token) {
return (
<p style={{ color: "#9a6700", fontSize: 13 }}>
Введите Admin Token на странице{" "}
<Link href="/admin/scrape" style={{ color: "#1d4ed8" }}>
Скрапер
</Link>{" "}
он сохранится для всех админ-страниц.
</p>
);
}
if (stats.isError || leads.isError) { if (stats.isError || leads.isError) {
const err = String(stats.error ?? leads.error ?? ""); const err = String(stats.error ?? leads.error ?? "");
return ( return (

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; import { cardStyle, labelStyle, td, th } from "@/lib/adminStyles";
import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel"; import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel";
import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel"; import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel";
@ -67,46 +67,31 @@ function diffSecs(a: string | null, b: string | null): string {
} }
export default function ScrapeAllAdminPage() { export default function ScrapeAllAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [filter, setFilter] = useState< const [filter, setFilter] = useState<
"all" | "kn" | "nspd" | "objective" | "nspd_geo" "all" | "kn" | "nspd" | "objective" | "nspd_geo"
>("all"); >("all");
const [logsRunId, setLogsRunId] = useState<number | "">(""); const [logsRunId, setLogsRunId] = useState<number | "">("");
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
const runs = useQuery({ const runs = useQuery({
queryKey: ["unified-runs", token, filter], queryKey: ["unified-runs", filter],
queryFn: () => { queryFn: () => {
const q = const q =
filter !== "all" ? `?scraper_type=${filter}&limit=30` : "?limit=30"; filter !== "all" ? `?scraper_type=${filter}&limit=30` : "?limit=30";
return apiFetch<UnifiedRunRow[]>(`/api/v1/admin/scrape/all/runs${q}`, { return apiFetch<UnifiedRunRow[]>(`/api/v1/admin/scrape/all/runs${q}`);
headers: { "X-Admin-Token": token },
});
}, },
enabled: !!token,
refetchInterval: 5000, refetchInterval: 5000,
}); });
const logs = useQuery({ const logs = useQuery({
queryKey: ["unified-logs", token, filter, logsRunId], queryKey: ["unified-logs", filter, logsRunId],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams({ limit: "100" }); const params = new URLSearchParams({ limit: "100" });
if (filter !== "all") params.set("scraper_type", filter); if (filter !== "all") params.set("scraper_type", filter);
if (logsRunId !== "") params.set("run_id", String(logsRunId)); if (logsRunId !== "") params.set("run_id", String(logsRunId));
return apiFetch<UnifiedLogRow[]>( return apiFetch<UnifiedLogRow[]>(
`/api/v1/admin/scrape/all/logs?${params}`, `/api/v1/admin/scrape/all/logs?${params}`,
{ headers: { "X-Admin-Token": token } },
); );
}, },
enabled: !!token,
refetchInterval: 8000, refetchInterval: 8000,
}); });
@ -146,19 +131,6 @@ export default function ScrapeAllAdminPage() {
аудита. аудита.
</p> </p>
<section style={cardStyle}>
<label style={{ display: "block" }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
</section>
{/* Сводка-плитки */} {/* Сводка-плитки */}
<section <section
style={{ style={{
@ -356,12 +328,12 @@ export default function ScrapeAllAdminPage() {
{/* Job Settings */} {/* Job Settings */}
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<JobSettingsPanel token={token} /> <JobSettingsPanel />
</div> </div>
{/* Bulk geo backfill */} {/* Bulk geo backfill */}
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<BulkGeoPanel token={token} /> <BulkGeoPanel />
</div> </div>
{/* Logs */} {/* Logs */}

View file

@ -122,25 +122,14 @@ function reqPerSec(job: CadastreJob): string {
// Query keys // Query keys
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const jobsKey = (token: string) => ["admin", "cadastre", "jobs", token]; const jobsKey = () => ["admin", "cadastre", "jobs"];
const jobKey = (token: string, id: number) => [ const jobKey = (id: number) => ["admin", "cadastre", "job", id];
"admin",
"cadastre",
"job",
id,
token,
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component // Component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function CadastreScrapeAdminPage() { export default function CadastreScrapeAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [scope, setScope] = useState<ScopeOption>("pilot"); const [scope, setScope] = useState<ScopeOption>("pilot");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [quartersText, setQuartersText] = useState(""); const [quartersText, setQuartersText] = useState("");
@ -152,35 +141,23 @@ export default function CadastreScrapeAdminPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const saveToken = (v: string) => {
setToken(v);
if (typeof window !== "undefined") {
localStorage.setItem("admin_token", v);
}
};
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Queries // Queries
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const jobs = useQuery({ const jobs = useQuery({
queryKey: jobsKey(token), queryKey: jobsKey(),
queryFn: () => queryFn: () =>
apiFetch<CadastreJob[]>("/api/v1/admin/cadastre/jobs?limit=30", { apiFetch<CadastreJob[]>("/api/v1/admin/cadastre/jobs?limit=30"),
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 5000, refetchInterval: 5000,
}); });
// Per-job detail (only fetched when row is expanded) // Per-job detail (only fetched when row is expanded)
const expandedJob = useQuery({ const expandedJob = useQuery({
queryKey: jobKey(token, expandedId ?? 0), queryKey: jobKey(expandedId ?? 0),
queryFn: () => queryFn: () =>
apiFetch<CadastreJob>(`/api/v1/admin/cadastre/jobs/${expandedId}`, { apiFetch<CadastreJob>(`/api/v1/admin/cadastre/jobs/${expandedId}`),
headers: { "X-Admin-Token": token }, enabled: expandedId !== null,
}),
enabled: !!token && expandedId !== null,
refetchInterval: expandedId !== null ? 5000 : false, refetchInterval: expandedId !== null ? 5000 : false,
}); });
@ -204,17 +181,13 @@ export default function CadastreScrapeAdminPage() {
"/api/v1/admin/cadastre/jobs", "/api/v1/admin/cadastre/jobs",
{ {
method: "POST", method: "POST",
headers: {
"X-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify(body), body: JSON.stringify(body),
}, },
); );
}, },
onSuccess: (data) => { onSuccess: (data) => {
setLastResp(data); setLastResp(data);
void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); void queryClient.invalidateQueries({ queryKey: jobsKey() });
}, },
}); });
@ -222,11 +195,11 @@ export default function CadastreScrapeAdminPage() {
mutationFn: (id: number) => mutationFn: (id: number) =>
apiFetch<{ cancelled: boolean }>( apiFetch<{ cancelled: boolean }>(
`/api/v1/admin/cadastre/jobs/${id}/cancel`, `/api/v1/admin/cadastre/jobs/${id}/cancel`,
{ method: "POST", headers: { "X-Admin-Token": token } }, { method: "POST" },
), ),
onSuccess: (_data, id) => { onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); void queryClient.invalidateQueries({ queryKey: jobKey(id) });
void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); void queryClient.invalidateQueries({ queryKey: jobsKey() });
}, },
}); });
@ -234,11 +207,11 @@ export default function CadastreScrapeAdminPage() {
mutationFn: (id: number) => mutationFn: (id: number) =>
apiFetch<{ resumed: boolean }>( apiFetch<{ resumed: boolean }>(
`/api/v1/admin/cadastre/jobs/${id}/resume`, `/api/v1/admin/cadastre/jobs/${id}/resume`,
{ method: "POST", headers: { "X-Admin-Token": token } }, { method: "POST" },
), ),
onSuccess: (_data, id) => { onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); void queryClient.invalidateQueries({ queryKey: jobKey(id) });
void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); void queryClient.invalidateQueries({ queryKey: jobsKey() });
}, },
}); });
@ -248,7 +221,6 @@ export default function CadastreScrapeAdminPage() {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
if (scope === "manual_list" && !quartersText.trim()) { if (scope === "manual_list" && !quartersText.trim()) {
alert("Для Manual list укажите хотя бы один квартал"); alert("Для Manual list укажите хотя бы один квартал");
return; return;
@ -662,22 +634,8 @@ export default function CadastreScrapeAdminPage() {
незавершённых targets. незавершённых targets.
</p> </p>
{/* Admin Token */}
<section style={cardStyle}>
<label style={{ display: "block" }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
</section>
{/* Create job form */} {/* Create job form */}
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={cardStyle}>
<h3 style={sectionTitle}>Запуск bulk harvest</h3> <h3 style={sectionTitle}>Запуск bulk harvest</h3>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ display: "grid", gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
@ -760,7 +718,7 @@ export default function CadastreScrapeAdminPage() {
<div style={{ display: "flex", gap: 12, alignItems: "center" }}> <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<button <button
type="submit" type="submit"
disabled={createJob.isPending || !token} disabled={createJob.isPending}
style={primaryBtn} style={primaryBtn}
> >
{createJob.isPending ? "Создание…" : "Запустить"} {createJob.isPending ? "Создание…" : "Запустить"}
@ -787,11 +745,7 @@ export default function CadastreScrapeAdminPage() {
{/* Jobs table */} {/* Jobs table */}
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={{ ...cardStyle, marginTop: 16 }}>
<h3 style={sectionTitle}>Cadastre jobs (top 30, refresh 5 с)</h3> <h3 style={sectionTitle}>Cadastre jobs (top 30, refresh 5 с)</h3>
{!token ? ( {jobs.isLoading ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token чтобы видеть список jobs.
</p>
) : jobs.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p> <p style={{ color: "#5b6066" }}>Загрузка</p>
) : jobs.isError ? ( ) : jobs.isError ? (
<p style={{ color: "#b3261e" }}> <p style={{ color: "#b3261e" }}>

View file

@ -58,11 +58,6 @@ function diffSecs(a: string | null, b: string | null): string {
} }
export default function GeoScrapeAdminPage() { export default function GeoScrapeAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [sourceKind, setSourceKind] = useState< const [sourceKind, setSourceKind] = useState<
"manual_list" | "rosreestr_pending" "manual_list" | "rosreestr_pending"
@ -89,18 +84,9 @@ export default function GeoScrapeAdminPage() {
const [lastResp, setLastResp] = useState<EnqueueResp | null>(null); const [lastResp, setLastResp] = useState<EnqueueResp | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
const jobs = useQuery({ const jobs = useQuery({
queryKey: ["geo-jobs", token], queryKey: ["geo-jobs"],
queryFn: () => queryFn: () => apiFetch<GeoJob[]>("/api/v1/admin/scrape/geo/jobs?limit=30"),
apiFetch<GeoJob[]>("/api/v1/admin/scrape/geo/jobs?limit=30", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 4000, refetchInterval: 4000,
}); });
@ -122,7 +108,6 @@ export default function GeoScrapeAdminPage() {
: null; : null;
return apiFetch<EnqueueResp>("/api/v1/admin/scrape/geo/jobs", { return apiFetch<EnqueueResp>("/api/v1/admin/scrape/geo/jobs", {
method: "POST", method: "POST",
headers: { "X-Admin-Token": token, "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: name || null, name: name || null,
job_kind: jobKind, job_kind: jobKind,
@ -144,7 +129,7 @@ export default function GeoScrapeAdminPage() {
mutationFn: (id: number) => mutationFn: (id: number) =>
apiFetch<{ cancelled: boolean }>( apiFetch<{ cancelled: boolean }>(
`/api/v1/admin/scrape/geo/jobs/${id}/cancel`, `/api/v1/admin/scrape/geo/jobs/${id}/cancel`,
{ method: "POST", headers: { "X-Admin-Token": token } }, { method: "POST" },
), ),
onSuccess: () => onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: ["geo-jobs"] }), void queryClient.invalidateQueries({ queryKey: ["geo-jobs"] }),
@ -154,7 +139,7 @@ export default function GeoScrapeAdminPage() {
mutationFn: (id: number) => mutationFn: (id: number) =>
apiFetch<{ resumed: boolean }>( apiFetch<{ resumed: boolean }>(
`/api/v1/admin/scrape/geo/jobs/${id}/resume`, `/api/v1/admin/scrape/geo/jobs/${id}/resume`,
{ method: "POST", headers: { "X-Admin-Token": token } }, { method: "POST" },
), ),
onSuccess: () => onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: ["geo-jobs"] }), void queryClient.invalidateQueries({ queryKey: ["geo-jobs"] }),
@ -173,21 +158,8 @@ export default function GeoScrapeAdminPage() {
продолжит с pending targets. продолжит с pending targets.
</p> </p>
<section style={cardStyle}>
<label style={{ display: "block" }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
</section>
{/* Форма создания нового job */} {/* Форма создания нового job */}
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={cardStyle}>
<h3 style={sectionTitle}>🚀 Новый bulk geo-job</h3> <h3 style={sectionTitle}>🚀 Новый bulk geo-job</h3>
<div style={{ display: "grid", gap: 12 }}> <div style={{ display: "grid", gap: 12 }}>
<label> <label>
@ -331,7 +303,7 @@ export default function GeoScrapeAdminPage() {
<button <button
type="button" type="button"
onClick={() => enqueue.mutate()} onClick={() => enqueue.mutate()}
disabled={enqueue.isPending || !token} disabled={enqueue.isPending}
style={primaryBtn} style={primaryBtn}
> >
{enqueue.isPending ? "Создание…" : "🚀 Запустить bulk-job"} {enqueue.isPending ? "Создание…" : "🚀 Запустить bulk-job"}
@ -542,7 +514,6 @@ export default function GeoScrapeAdminPage() {
</section> </section>
<ScrapeLogsPanel <ScrapeLogsPanel
token={token}
scraperType="nspd_geo" scraperType="nspd_geo"
title="Журнал NSPD geo (per-target)" title="Журнал NSPD geo (per-target)"
subtitle="из nspd_geo_log · обновляется каждые 3 сек" subtitle="из nspd_geo_log · обновляется каждые 3 сек"

View file

@ -94,11 +94,6 @@ function diffSecs(a: string | null, b: string | null): string {
} }
export default function ObjectiveEtlAdminPage() { export default function ObjectiveEtlAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [overrideSqlitePath, setOverrideSqlitePath] = useState(""); const [overrideSqlitePath, setOverrideSqlitePath] = useState("");
const [lastTaskId, setLastTaskId] = useState<string | null>(null); const [lastTaskId, setLastTaskId] = useState<string | null>(null);
const [syncGroupsCsv, setSyncGroupsCsv] = useState(""); const [syncGroupsCsv, setSyncGroupsCsv] = useState("");
@ -108,39 +103,26 @@ export default function ObjectiveEtlAdminPage() {
const [showEtl, setShowEtl] = useState(false); const [showEtl, setShowEtl] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
const coverage = useQuery({ const coverage = useQuery({
queryKey: ["objective-coverage", token], queryKey: ["objective-coverage"],
queryFn: () => queryFn: () =>
apiFetch<ObjectiveCoverage>("/api/v1/admin/scrape/objective/coverage", { apiFetch<ObjectiveCoverage>("/api/v1/admin/scrape/objective/coverage"),
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 10000, refetchInterval: 10000,
}); });
const runs = useQuery({ const runs = useQuery({
queryKey: ["objective-runs", token], queryKey: ["objective-runs"],
queryFn: () => queryFn: () =>
apiFetch<ObjectiveRunRow[]>( apiFetch<ObjectiveRunRow[]>(
"/api/v1/admin/scrape/objective/runs?limit=20", "/api/v1/admin/scrape/objective/runs?limit=20",
{ headers: { "X-Admin-Token": token } },
), ),
enabled: !!token,
refetchInterval: 5000, refetchInterval: 5000,
}); });
const cfg = useQuery({ const cfg = useQuery({
queryKey: ["objective-config", token], queryKey: ["objective-config"],
queryFn: () => queryFn: () =>
apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config", { apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config"),
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -155,10 +137,6 @@ export default function ObjectiveEtlAdminPage() {
mutationFn: () => mutationFn: () =>
apiFetch<ObjectiveTriggerResp>("/api/v1/admin/scrape/objective", { apiFetch<ObjectiveTriggerResp>("/api/v1/admin/scrape/objective", {
method: "POST", method: "POST",
headers: {
"X-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ sqlite_path: overrideSqlitePath || null }), body: JSON.stringify({ sqlite_path: overrideSqlitePath || null }),
}), }),
onSuccess: (data) => { onSuccess: (data) => {
@ -178,10 +156,6 @@ export default function ObjectiveEtlAdminPage() {
"/api/v1/admin/scrape/objective/sync-our", "/api/v1/admin/scrape/objective/sync-our",
{ {
method: "POST", method: "POST",
headers: {
"X-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify({ groups: groups.length ? groups : null }), body: JSON.stringify({ groups: groups.length ? groups : null }),
}, },
); );
@ -196,10 +170,6 @@ export default function ObjectiveEtlAdminPage() {
mutationFn: (patch: Partial<ObjectiveConfig>) => mutationFn: (patch: Partial<ObjectiveConfig>) =>
apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config", { apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config", {
method: "PUT", method: "PUT",
headers: {
"X-Admin-Token": token,
"Content-Type": "application/json",
},
body: JSON.stringify(patch), body: JSON.stringify(patch),
}), }),
onSuccess: (data) => { onSuccess: (data) => {
@ -223,22 +193,8 @@ export default function ObjectiveEtlAdminPage() {
только как bootstrap-fallback (свёрнут ниже). только как bootstrap-fallback (свёрнут ниже).
</p> </p>
{/* Token */}
<section style={cardStyle}>
<label style={{ display: "block" }}>
<span style={labelStyle}>Admin Token (X-Admin-Token)</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN — сохранится в localStorage"
style={inputStyle}
/>
</label>
</section>
{/* Coverage */} {/* Coverage */}
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={cardStyle}>
<h3 style={sectionTitle}>📦 Состояние PG (источник истины)</h3> <h3 style={sectionTitle}>📦 Состояние PG (источник истины)</h3>
{coverage.isLoading && ( {coverage.isLoading && (
<p style={{ color: "#5b6066", fontSize: 13 }}>Загрузка</p> <p style={{ color: "#5b6066", fontSize: 13 }}>Загрузка</p>
@ -504,7 +460,7 @@ export default function ObjectiveEtlAdminPage() {
<button <button
type="button" type="button"
onClick={() => saveCfg.mutate(cfgDraft)} onClick={() => saveCfg.mutate(cfgDraft)}
disabled={saveCfg.isPending || !token} disabled={saveCfg.isPending}
style={primaryBtn} style={primaryBtn}
> >
{saveCfg.isPending ? "Сохранение…" : "💾 Сохранить настройки"} {saveCfg.isPending ? "Сохранение…" : "💾 Сохранить настройки"}
@ -580,7 +536,7 @@ export default function ObjectiveEtlAdminPage() {
<button <button
type="button" type="button"
onClick={() => syncOur.mutate()} onClick={() => syncOur.mutate()}
disabled={syncOur.isPending || !token} disabled={syncOur.isPending}
style={primaryBtn} style={primaryBtn}
> >
{syncOur.isPending ? "Запуск…" : "🚀 Запустить наш sync"} {syncOur.isPending ? "Запуск…" : "🚀 Запустить наш sync"}
@ -642,7 +598,7 @@ export default function ObjectiveEtlAdminPage() {
<button <button
type="button" type="button"
onClick={() => trigger.mutate()} onClick={() => trigger.mutate()}
disabled={trigger.isPending || !token} disabled={trigger.isPending}
style={secondaryBtn} style={secondaryBtn}
> >
{trigger.isPending ? "Запуск…" : "Bootstrap ETL"} {trigger.isPending ? "Запуск…" : "Bootstrap ETL"}
@ -799,7 +755,6 @@ export default function ObjectiveEtlAdminPage() {
</section> </section>
<ScrapeLogsPanel <ScrapeLogsPanel
token={token}
scraperType="objective" scraperType="objective"
title="Журнал Objective ETL" title="Журнал Objective ETL"
subtitle="фильтр scraper_type=objective · обновляется каждые 3 сек" subtitle="фильтр scraper_type=objective · обновляется каждые 3 сек"

View file

@ -26,13 +26,6 @@ interface TriggerResp {
developers: string[] | null; developers: string[] | null;
} }
interface CatalogTriggerResp {
task_id: string;
region_code: number;
max_objects: number | null;
force: boolean;
}
interface QueueTask { interface QueueTask {
worker: string; worker: string;
task_id: string; task_id: string;
@ -74,30 +67,20 @@ interface FailureRow {
} }
export default function ScrapeAdminPage() { export default function ScrapeAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [region, setRegion] = useState(66); const [region, setRegion] = useState(66);
const [devs, setDevs] = useState(""); const [devs, setDevs] = useState("");
const [fetchFlats, setFetchFlats] = useState(true); const [fetchFlats, setFetchFlats] = useState(true);
const [extras, setExtras] = useState(true); const [extras, setExtras] = useState(true);
const [downloadPhotos, setDownloadPhotos] = useState(false); const [downloadPhotos, setDownloadPhotos] = useState(false);
const [forceRun, setForceRun] = useState(false); const [forceRun, setForceRun] = useState(false);
const [catalogMaxObjects, setCatalogMaxObjects] = useState<number | "">("");
const [showFailures, setShowFailures] = useState(false); const [showFailures, setShowFailures] = useState(false);
const [failuresRunId, setFailuresRunId] = useState<number | "">(""); const [failuresRunId, setFailuresRunId] = useState<number | "">("");
const [logsRunId, setLogsRunId] = useState<number | "">(""); const [logsRunId, setLogsRunId] = useState<number | "">("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const runs = useQuery({ const runs = useQuery({
queryKey: ["scrape-runs", token], queryKey: ["scrape-runs"],
queryFn: () => queryFn: () => apiFetch<RunRow[]>("/api/v1/admin/scrape/runs?limit=20"),
apiFetch<RunRow[]>("/api/v1/admin/scrape/runs?limit=20", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 5000, refetchInterval: 5000,
}); });
@ -105,7 +88,6 @@ export default function ScrapeAdminPage() {
mutationFn: () => mutationFn: () =>
apiFetch<TriggerResp>("/api/v1/admin/scrape/kn", { apiFetch<TriggerResp>("/api/v1/admin/scrape/kn", {
method: "POST", method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ body: JSON.stringify({
region_code: region, region_code: region,
developers: devs developers: devs
@ -129,27 +111,6 @@ export default function ScrapeAdminPage() {
}, },
}); });
const catalogTrigger = useMutation({
mutationFn: (force: boolean) =>
apiFetch<CatalogTriggerResp>("/api/v1/admin/scrape/kn-catalog-objects", {
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({
region_code: region,
max_objects: catalogMaxObjects === "" ? null : catalogMaxObjects,
force,
}),
}),
onSuccess: (data) => {
console.log("[scrape] catalog queued:", data);
queryClient.invalidateQueries({ queryKey: ["scrape-runs"] });
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] });
},
onError: (err) => {
console.error("[scrape] catalog failed:", err);
},
});
const releaseLock = useMutation({ const releaseLock = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch<{ apiFetch<{
@ -158,7 +119,6 @@ export default function ScrapeAdminPage() {
released: boolean; released: boolean;
}>("/api/v1/admin/scrape/release-lock", { }>("/api/v1/admin/scrape/release-lock", {
method: "POST", method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ body: JSON.stringify({
region_code: region, region_code: region,
developers: devs developers: devs
@ -180,12 +140,8 @@ export default function ScrapeAdminPage() {
}); });
const queue = useQuery({ const queue = useQuery({
queryKey: ["scrape-queue", token], queryKey: ["scrape-queue"],
queryFn: () => queryFn: () => apiFetch<QueueStatus>("/api/v1/admin/scrape/queue"),
apiFetch<QueueStatus>("/api/v1/admin/scrape/queue", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 3000, refetchInterval: 3000,
}); });
@ -201,7 +157,6 @@ export default function ScrapeAdminPage() {
"/api/v1/admin/scrape/revoke", "/api/v1/admin/scrape/revoke",
{ {
method: "POST", method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ task_id, terminate }), body: JSON.stringify({ task_id, terminate }),
}, },
), ),
@ -232,30 +187,25 @@ export default function ScrapeAdminPage() {
}; };
const logs = useQuery({ const logs = useQuery({
queryKey: ["scrape-logs", logsRunId, token], queryKey: ["scrape-logs", logsRunId],
queryFn: () => { queryFn: () => {
const q = const q =
logsRunId !== "" ? `?run_id=${logsRunId}&limit=200` : "?limit=200"; logsRunId !== "" ? `?run_id=${logsRunId}&limit=200` : "?limit=200";
return apiFetch<LogRow[]>(`/api/v1/admin/scrape/logs${q}`, { return apiFetch<LogRow[]>(`/api/v1/admin/scrape/logs${q}`);
headers: { "X-Admin-Token": token },
});
}, },
enabled: !!token,
refetchInterval: 3000, refetchInterval: 3000,
}); });
const failures = useQuery({ const failures = useQuery({
queryKey: ["scrape-failures", failuresRunId, token], queryKey: ["scrape-failures", failuresRunId],
queryFn: () => { queryFn: () => {
const q = const q =
failuresRunId !== "" failuresRunId !== ""
? `?run_id=${failuresRunId}&limit=100` ? `?run_id=${failuresRunId}&limit=100`
: "?limit=100"; : "?limit=100";
return apiFetch<FailureRow[]>(`/api/v1/admin/scrape/failures${q}`, { return apiFetch<FailureRow[]>(`/api/v1/admin/scrape/failures${q}`);
headers: { "X-Admin-Token": token },
});
}, },
enabled: !!token && showFailures, enabled: showFailures,
refetchInterval: showFailures ? 5000 : false, refetchInterval: showFailures ? 5000 : false,
}); });
@ -265,11 +215,6 @@ export default function ScrapeAdminPage() {
} }
}; };
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
return ( return (
<> <>
<h2 style={{ marginTop: 0, fontSize: 18 }}> <h2 style={{ marginTop: 0, fontSize: 18 }}>
@ -281,16 +226,6 @@ export default function ScrapeAdminPage() {
</p> </p>
<section style={cardStyle}> <section style={cardStyle}>
<label style={{ display: "block", marginBottom: 12 }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
<label> <label>
<span style={labelStyle}>Регион</span> <span style={labelStyle}>Регион</span>
@ -382,9 +317,8 @@ export default function ScrapeAdminPage() {
> >
<button <button
onClick={() => trigger.mutate()} onClick={() => trigger.mutate()}
disabled={!token || trigger.isPending} disabled={trigger.isPending}
style={primaryBtn} style={primaryBtn}
title={!token ? "Введите Admin Token выше" : undefined}
> >
{trigger.isPending ? "Запускаю…" : "Запустить сейчас"} {trigger.isPending ? "Запускаю…" : "Запустить сейчас"}
</button> </button>
@ -398,24 +332,11 @@ export default function ScrapeAdminPage() {
releaseLock.mutate(); releaseLock.mutate();
} }
}} }}
disabled={!token || releaseLock.isPending} disabled={releaseLock.isPending}
style={secondaryBtn} style={secondaryBtn}
title="Удалить scrape:kn:lock:<region>:<devs> в Redis"
> >
{releaseLock.isPending ? "Снимаю…" : "🔓 Снять Redis-lock"} {releaseLock.isPending ? "Снимаю…" : "🔓 Снять Redis-lock"}
</button> </button>
{!token ? (
<span
style={{
marginLeft: 12,
color: "#9a6700",
fontSize: 13,
alignSelf: "center",
}}
>
введите Admin Token выше
</span>
) : null}
</div> </div>
{releaseLock.isSuccess ? ( {releaseLock.isSuccess ? (
<div <div
@ -487,118 +408,6 @@ export default function ScrapeAdminPage() {
) : null} ) : null}
</section> </section>
<section style={{ ...cardStyle, marginTop: 16 }}>
<h2 style={{ margin: 0, fontSize: 16 }}>📦 Catalog-object (SSR)</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 6 }}>
Заполняет wall_type, energy_eff, ceiling_height_m, parking_*,
playground_*, scores из публичной страницы каталога DOM.РФ. По
умолчанию пропускает объекты, обновлённые сегодня.
</p>
<div
style={{
marginTop: 12,
display: "flex",
gap: 12,
alignItems: "flex-end",
flexWrap: "wrap",
}}
>
<label>
<span style={labelStyle}>max_objects (пусто = 300)</span>
<input
type="number"
min={1}
max={2000}
value={catalogMaxObjects}
onChange={(e) =>
setCatalogMaxObjects(
e.target.value === "" ? "" : Number(e.target.value),
)
}
placeholder="300"
style={{ ...inputStyle, width: 140 }}
/>
</label>
<button
type="button"
onClick={() => catalogTrigger.mutate(false)}
disabled={!token || catalogTrigger.isPending}
style={primaryBtn}
title="Грузим только новые + не обновлённые сегодня"
>
{catalogTrigger.isPending && catalogTrigger.variables === false
? "Запускаю…"
: "Грузить (skip today)"}
</button>
<button
type="button"
onClick={() => {
if (
confirm(
"Загрузить ВСЕ объекты последнего snapshot (force=true)? Игнорирует skip-today, нагрузка на DOM.РФ выше.",
)
) {
catalogTrigger.mutate(true);
}
}}
disabled={!token || catalogTrigger.isPending}
style={{
...secondaryBtn,
color: "#b3261e",
borderColor: "#fecaca",
}}
title="Force: грузит все объекты независимо от catalog_scraped_at"
>
{catalogTrigger.isPending && catalogTrigger.variables === true
? "Запускаю…"
: "⚡ Загрузить ВСЕ (force)"}
</button>
</div>
{catalogTrigger.isError ? (
<div
style={{
marginTop: 12,
padding: 12,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
wordBreak: "break-word",
}}
>
<strong>Ошибка:</strong> {String(catalogTrigger.error)}
</div>
) : null}
{catalogTrigger.isSuccess ? (
<div
style={{
marginTop: 12,
padding: 12,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 6,
color: "#065f46",
fontSize: 13,
}}
>
<strong> Catalog-object queued.</strong> task_id={" "}
<code
style={{
background: "#dcfce7",
padding: "2px 6px",
borderRadius: 3,
}}
>
{catalogTrigger.data.task_id}
</code>{" "}
· регион {catalogTrigger.data.region_code} · force={" "}
{String(catalogTrigger.data.force)} · max_objects={" "}
{catalogTrigger.data.max_objects ?? "default"}
</div>
) : null}
</section>
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={{ ...cardStyle, marginTop: 16 }}>
<div <div
style={{ style={{
@ -620,11 +429,7 @@ export default function ScrapeAdminPage() {
) : null} ) : null}
</h2> </h2>
</div> </div>
{!token ? ( {queue.isLoading ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token чтобы видеть состояние очереди.
</p>
) : queue.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p> <p style={{ color: "#5b6066" }}>Загрузка</p>
) : queue.isError ? ( ) : queue.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(queue.error)}</p> <p style={{ color: "#b3261e" }}>Ошибка: {String(queue.error)}</p>
@ -859,11 +664,7 @@ export default function ScrapeAdminPage() {
style={{ ...inputStyle, width: 130 }} style={{ ...inputStyle, width: 130 }}
/> />
</div> </div>
{!token ? ( {logs.isLoading ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token чтобы видеть лог.
</p>
) : logs.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p> <p style={{ color: "#5b6066" }}>Загрузка</p>
) : logs.isError ? ( ) : logs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p> <p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p>
@ -937,7 +738,6 @@ export default function ScrapeAdminPage() {
/> />
<button <button
onClick={() => setShowFailures((v) => !v)} onClick={() => setShowFailures((v) => !v)}
disabled={!token}
style={secondaryBtn} style={secondaryBtn}
> >
{showFailures ? "Скрыть" : "Показать"} {showFailures ? "Скрыть" : "Показать"}
@ -1035,11 +835,7 @@ export default function ScrapeAdminPage() {
<section style={{ ...cardStyle, marginTop: 16 }}> <section style={{ ...cardStyle, marginTop: 16 }}>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Последние прогоны</h2> <h2 style={{ marginTop: 0, fontSize: 16 }}>Последние прогоны</h2>
{!token ? ( {runs.isLoading ? (
<p style={{ color: "#5b6066" }}>
Введите admin token чтобы увидеть журнал.
</p>
) : runs.isLoading ? (
<p>Загрузка</p> <p>Загрузка</p>
) : runs.isError ? ( ) : runs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(runs.error)}</p> <p style={{ color: "#b3261e" }}>Ошибка: {String(runs.error)}</p>

View file

@ -37,7 +37,7 @@ const THEMATIC_OPTIONS = [
// ── Component ──────────────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────────────
export function BulkGeoPanel({ token }: { token: string }) { export function BulkGeoPanel() {
const [parallelism, setParallelism] = useState(5); const [parallelism, setParallelism] = useState(5);
const [thematicIds, setThematicIds] = useState<number[]>([2]); const [thematicIds, setThematicIds] = useState<number[]>([2]);
const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">( const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">(
@ -62,7 +62,6 @@ export function BulkGeoPanel({ token }: { token: string }) {
}; };
return apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", { return apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", {
method: "POST", method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
}, },
@ -179,9 +178,9 @@ export function BulkGeoPanel({ token }: { token: string }) {
<button <button
type="submit" type="submit"
disabled={!token || bulkMutation.isPending} disabled={bulkMutation.isPending}
style={ style={
!token || bulkMutation.isPending bulkMutation.isPending
? { ...triggerBtn, opacity: 0.6 } ? { ...triggerBtn, opacity: 0.6 }
: triggerBtn : triggerBtn
} }
@ -193,12 +192,6 @@ export function BulkGeoPanel({ token }: { token: string }) {
</div> </div>
</form> </form>
{!token && (
<p style={{ color: "#a16207", fontSize: 13, marginTop: 12 }}>
Введите Admin Token выше для запуска.
</p>
)}
{errorMsg && ( {errorMsg && (
<div style={errorBox}> <div style={errorBox}>
<strong>Ошибка:</strong> {errorMsg} <strong>Ошибка:</strong> {errorMsg}

View file

@ -64,7 +64,7 @@ function formatIso(s: string): string {
return new Date(s).toLocaleString("ru-RU"); return new Date(s).toLocaleString("ru-RU");
} }
export function JobSettingsPanel({ token }: { token: string }) { export function JobSettingsPanel() {
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({}); const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
@ -82,12 +82,8 @@ export function JobSettingsPanel({ token }: { token: string }) {
}, []); }, []);
const settings = useQuery({ const settings = useQuery({
queryKey: ["job-settings", token], queryKey: ["job-settings"],
queryFn: () => queryFn: () => apiFetch<JobSettingRead[]>("/api/v1/admin/jobs/settings"),
apiFetch<JobSettingRead[]>("/api/v1/admin/jobs/settings", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
}); });
// Initialise drafts when settings data first arrives (or new job_types appear). // Initialise drafts when settings data first arrives (or new job_types appear).
@ -117,7 +113,6 @@ export function JobSettingsPanel({ token }: { token: string }) {
}) => }) =>
apiFetch<JobSettingRead>(`/api/v1/admin/jobs/settings/${job_type}`, { apiFetch<JobSettingRead>(`/api/v1/admin/jobs/settings/${job_type}`, {
method: "PUT", method: "PUT",
headers: { "X-Admin-Token": token },
body: JSON.stringify(update), body: JSON.stringify(update),
}), }),
onSuccess: (data: JobSettingRead) => { onSuccess: (data: JobSettingRead) => {
@ -238,11 +233,7 @@ export function JobSettingsPanel({ token }: { token: string }) {
Централизованные настройки Celery jobs. Изменения применяются с Централизованные настройки Celery jobs. Изменения применяются с
следующего beat-цикла. следующего beat-цикла.
</p> </p>
{!token ? ( {settings.isLoading ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token выше чтобы загрузить настройки.
</p>
) : settings.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p> <p style={{ color: "#5b6066" }}>Загрузка</p>
) : settings.isError ? ( ) : settings.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(settings.error)}</p> <p style={{ color: "#b3261e" }}>Ошибка: {String(settings.error)}</p>
@ -483,7 +474,7 @@ export function JobSettingsPanel({ token }: { token: string }) {
<button <button
type="button" type="button"
onClick={() => handleSave(row.job_type)} onClick={() => handleSave(row.job_type)}
disabled={saving || !token} disabled={saving}
style={ style={
saving ? { ...saveBtn, opacity: 0.6 } : saveBtn saving ? { ...saveBtn, opacity: 0.6 } : saveBtn
} }

View file

@ -17,8 +17,6 @@ interface LogRow {
} }
interface Props { interface Props {
/** Admin token (для X-Admin-Token header). */
token: string;
/** scraper_type filter — kn / nspd / nspd_geo / objective. */ /** scraper_type filter — kn / nspd / nspd_geo / objective. */
scraperType: "kn" | "nspd" | "nspd_geo" | "objective"; scraperType: "kn" | "nspd" | "nspd_geo" | "objective";
/** Имя поля по которому фильтруем run в запросе (по умолчанию `run_id`). */ /** Имя поля по которому фильтруем run в запросе (по умолчанию `run_id`). */
@ -40,7 +38,6 @@ interface Props {
* фильтром по `scraper_type` и опциональным `run_id`. Auto-refetch каждые 3 сек. * фильтром по `scraper_type` и опциональным `run_id`. Auto-refetch каждые 3 сек.
*/ */
export default function ScrapeLogsPanel({ export default function ScrapeLogsPanel({
token,
scraperType, scraperType,
runIdParam = "run_id", runIdParam = "run_id",
limit = 200, limit = 200,
@ -51,7 +48,7 @@ export default function ScrapeLogsPanel({
const [runIdFilter, setRunIdFilter] = useState<number | "">(""); const [runIdFilter, setRunIdFilter] = useState<number | "">("");
const logs = useQuery({ const logs = useQuery({
queryKey: ["scrape-logs", scraperType, runIdFilter, token], queryKey: ["scrape-logs", scraperType, runIdFilter],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ scraper_type: scraperType }); const params = new URLSearchParams({ scraper_type: scraperType });
params.set("limit", String(limit)); params.set("limit", String(limit));
@ -60,10 +57,8 @@ export default function ScrapeLogsPanel({
} }
return apiFetch<LogRow[]>( return apiFetch<LogRow[]>(
`/api/v1/admin/scrape/all/logs?${params.toString()}`, `/api/v1/admin/scrape/all/logs?${params.toString()}`,
{ headers: { "X-Admin-Token": token } },
); );
}, },
enabled: !!token,
refetchInterval: 3000, refetchInterval: 3000,
}); });
@ -109,11 +104,7 @@ export default function ScrapeLogsPanel({
/> />
</div> </div>
{!token ? ( {logs.isLoading ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token чтобы видеть лог.
</p>
) : logs.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p> <p style={{ color: "#5b6066" }}>Загрузка</p>
) : logs.isError ? ( ) : logs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p> <p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p>