feat: add raw artifact audit trail
This commit is contained in:
@@ -27,12 +27,14 @@ import {
|
||||
archiveWatchlistItem,
|
||||
createDataSource,
|
||||
createIngestionRun,
|
||||
createRawArtifact,
|
||||
createWatchlistItem,
|
||||
fetchDataSources,
|
||||
fetchAreaScores,
|
||||
fetchIngestionRuns,
|
||||
fetchMarketOverview,
|
||||
fetchNeighborhoodScores,
|
||||
fetchRawArtifacts,
|
||||
fetchWatchlistItems,
|
||||
finishIngestionRun,
|
||||
} from "@/lib/api";
|
||||
@@ -40,10 +42,12 @@ import type {
|
||||
AreaScore,
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
CreateWatchlistItem,
|
||||
DataSource,
|
||||
IngestionRun,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
WatchlistItem,
|
||||
} from "@/lib/api";
|
||||
import { formatNumber, formatPrice } from "@/lib/utils";
|
||||
@@ -88,6 +92,10 @@ export function MarketDashboard() {
|
||||
queryKey: ["ingestion-runs"],
|
||||
queryFn: fetchIngestionRuns,
|
||||
});
|
||||
const rawArtifacts = useQuery({
|
||||
queryKey: ["raw-artifacts"],
|
||||
queryFn: fetchRawArtifacts,
|
||||
});
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createWatchlistItem,
|
||||
onSuccess: async () => {
|
||||
@@ -112,6 +120,15 @@ export function MarketDashboard() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] });
|
||||
},
|
||||
});
|
||||
const createRawArtifactMutation = useMutation({
|
||||
mutationFn: createRawArtifact,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["raw-artifacts"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["ingestion-runs"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const finishRunMutation = useMutation({
|
||||
mutationFn: ({ id, rowCount }: { id: number; rowCount?: number }) =>
|
||||
finishIngestionRun(id, { status: "succeeded", row_count: rowCount }),
|
||||
@@ -127,13 +144,15 @@ export function MarketDashboard() {
|
||||
watchlist.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
ingestionRuns.isLoading;
|
||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||
const hasError =
|
||||
overview.isError ||
|
||||
scores.isError ||
|
||||
neighborhoodScores.isError ||
|
||||
watchlist.isError ||
|
||||
dataSources.isError ||
|
||||
ingestionRuns.isError;
|
||||
ingestionRuns.isError ||
|
||||
rawArtifacts.isError;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-4 py-5 sm:px-6 lg:px-8">
|
||||
@@ -156,6 +175,7 @@ export function MarketDashboard() {
|
||||
void watchlist.refetch();
|
||||
void dataSources.refetch();
|
||||
void ingestionRuns.refetch();
|
||||
void rawArtifacts.refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-label="刷新"
|
||||
@@ -276,12 +296,16 @@ export function MarketDashboard() {
|
||||
<ImportCenterPanel
|
||||
sources={dataSources.data ?? []}
|
||||
runs={ingestionRuns.data ?? []}
|
||||
artifacts={rawArtifacts.data ?? []}
|
||||
loading={isLoading}
|
||||
importLoading={importLoading}
|
||||
creatingSource={createDataSourceMutation.isPending}
|
||||
creatingRun={createRunMutation.isPending}
|
||||
creatingArtifact={createRawArtifactMutation.isPending}
|
||||
finishingRunId={finishRunMutation.variables?.id ?? null}
|
||||
onCreateSource={(payload) => createDataSourceMutation.mutate(payload)}
|
||||
onCreateRun={(payload) => createRunMutation.mutate(payload)}
|
||||
onCreateArtifact={(payload) => createRawArtifactMutation.mutate(payload)}
|
||||
onFinishRun={(id, rowCount) => finishRunMutation.mutate({ id, rowCount })}
|
||||
/>
|
||||
</div>
|
||||
@@ -292,22 +316,30 @@ export function MarketDashboard() {
|
||||
function ImportCenterPanel({
|
||||
sources,
|
||||
runs,
|
||||
artifacts,
|
||||
loading,
|
||||
importLoading,
|
||||
creatingSource,
|
||||
creatingRun,
|
||||
creatingArtifact,
|
||||
finishingRunId,
|
||||
onCreateSource,
|
||||
onCreateRun,
|
||||
onCreateArtifact,
|
||||
onFinishRun,
|
||||
}: {
|
||||
sources: DataSource[];
|
||||
runs: IngestionRun[];
|
||||
artifacts: RawArtifact[];
|
||||
loading: boolean;
|
||||
importLoading: boolean;
|
||||
creatingSource: boolean;
|
||||
creatingRun: boolean;
|
||||
creatingArtifact: boolean;
|
||||
finishingRunId: number | null;
|
||||
onCreateSource: (payload: CreateDataSource) => void;
|
||||
onCreateRun: (payload: CreateIngestionRun) => void;
|
||||
onCreateArtifact: (payload: CreateRawArtifact) => void;
|
||||
onFinishRun: (id: number, rowCount?: number) => void;
|
||||
}) {
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
@@ -316,6 +348,12 @@ function ImportCenterPanel({
|
||||
const [sourceId, setSourceId] = useState("");
|
||||
const [rawUri, setRawUri] = useState("");
|
||||
const [rowCount, setRowCount] = useState("");
|
||||
const [artifactRunId, setArtifactRunId] = useState("");
|
||||
const [artifactSourceId, setArtifactSourceId] = useState("");
|
||||
const [artifactFilename, setArtifactFilename] = useState("");
|
||||
const [artifactRawUri, setArtifactRawUri] = useState("");
|
||||
const [artifactSha256, setArtifactSha256] = useState("");
|
||||
const [artifactSizeBytes, setArtifactSizeBytes] = useState("");
|
||||
|
||||
function submitSource(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -348,6 +386,36 @@ function ImportCenterPanel({
|
||||
setRawUri("");
|
||||
}
|
||||
|
||||
function submitArtifact(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const parsedRunId = Number(artifactRunId);
|
||||
const parsedSourceId = Number(artifactSourceId);
|
||||
const parsedSizeBytes = Number(artifactSizeBytes);
|
||||
if (
|
||||
!artifactFilename.trim() ||
|
||||
!artifactRawUri.trim() ||
|
||||
artifactSha256.trim().length !== 64 ||
|
||||
!Number.isFinite(parsedSizeBytes) ||
|
||||
parsedSizeBytes < 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onCreateArtifact({
|
||||
run_id: Number.isFinite(parsedRunId) && parsedRunId > 0 ? parsedRunId : undefined,
|
||||
source_id:
|
||||
Number.isFinite(parsedSourceId) && parsedSourceId > 0 ? parsedSourceId : undefined,
|
||||
original_filename: artifactFilename.trim(),
|
||||
raw_uri: artifactRawUri.trim(),
|
||||
sha256: artifactSha256.trim(),
|
||||
size_bytes: parsedSizeBytes,
|
||||
uploaded_by: "dashboard",
|
||||
});
|
||||
setArtifactFilename("");
|
||||
setArtifactRawUri("");
|
||||
setArtifactSha256("");
|
||||
setArtifactSizeBytes("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
@@ -415,7 +483,7 @@ function ImportCenterPanel({
|
||||
<DataSourceTable sources={sources} loading={loading} />
|
||||
<IngestionRunTable
|
||||
runs={runs}
|
||||
loading={loading}
|
||||
loading={importLoading}
|
||||
rowCount={rowCount}
|
||||
onRowCountChange={setRowCount}
|
||||
finishingRunId={finishingRunId}
|
||||
@@ -425,6 +493,73 @@ function ImportCenterPanel({
|
||||
setRowCount("");
|
||||
}}
|
||||
/>
|
||||
<div className="xl:col-span-2">
|
||||
<form className="mb-3 grid gap-2 lg:grid-cols-[120px_120px_1fr_1fr_1.5fr_120px_44px]" onSubmit={submitArtifact}>
|
||||
<select
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
value={artifactRunId}
|
||||
onChange={(event) => setArtifactRunId(event.target.value)}
|
||||
aria-label="原始文件批次"
|
||||
>
|
||||
<option value="">批次</option>
|
||||
{runs.map((run) => (
|
||||
<option key={run.run_id} value={run.run_id}>
|
||||
#{run.run_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
value={artifactSourceId}
|
||||
onChange={(event) => setArtifactSourceId(event.target.value)}
|
||||
aria-label="原始文件数据源"
|
||||
>
|
||||
<option value="">数据源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.source_id} value={source.source_id}>
|
||||
{source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
placeholder="文件名"
|
||||
value={artifactFilename}
|
||||
onChange={(event) => setArtifactFilename(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
placeholder="raw uri"
|
||||
value={artifactRawUri}
|
||||
onChange={(event) => setArtifactRawUri(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
placeholder="sha256"
|
||||
value={artifactSha256}
|
||||
onChange={(event) => setArtifactSha256(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 rounded-md border border-[var(--border)] bg-white px-3 text-sm"
|
||||
inputMode="numeric"
|
||||
placeholder="字节"
|
||||
value={artifactSizeBytes}
|
||||
onChange={(event) => setArtifactSizeBytes(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
creatingArtifact ||
|
||||
!artifactFilename.trim() ||
|
||||
artifactSha256.trim().length !== 64
|
||||
}
|
||||
aria-label="登记原始文件"
|
||||
title="登记原始文件"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<RawArtifactTable artifacts={artifacts} loading={importLoading} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -539,6 +674,47 @@ function IngestionRunTable({
|
||||
);
|
||||
}
|
||||
|
||||
function RawArtifactTable({
|
||||
artifacts,
|
||||
loading,
|
||||
}: {
|
||||
artifacts: RawArtifact[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="h-48 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>原始文件</TableHead>
|
||||
<TableHead>数据源</TableHead>
|
||||
<TableHead>批次</TableHead>
|
||||
<TableHead>SHA-256</TableHead>
|
||||
<TableHead className="text-right">大小</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{artifacts.map((artifact) => (
|
||||
<TableRow key={artifact.artifact_id}>
|
||||
<TableCell className="font-medium">{artifact.original_filename}</TableCell>
|
||||
<TableCell>{artifact.source_name ?? "-"}</TableCell>
|
||||
<TableCell>{artifact.run_id ? `#${artifact.run_id}` : "-"}</TableCell>
|
||||
<TableCell className="max-w-80 truncate font-mono text-xs text-[var(--muted-foreground)]">
|
||||
{artifact.sha256}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(artifact.size_bytes)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborhoodRankingPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -109,6 +109,7 @@ export type IngestionRun = {
|
||||
run_id: number;
|
||||
source_id: number | null;
|
||||
source_name: string | null;
|
||||
raw_artifact_id: number | null;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
@@ -121,6 +122,7 @@ export type CreateIngestionRun = {
|
||||
source_id?: number;
|
||||
status?: string;
|
||||
raw_uri?: string;
|
||||
raw_artifact_id?: number;
|
||||
row_count?: number;
|
||||
error_message?: string;
|
||||
};
|
||||
@@ -131,6 +133,33 @@ export type FinishIngestionRun = {
|
||||
error_message?: string;
|
||||
};
|
||||
|
||||
export type RawArtifact = {
|
||||
artifact_id: number;
|
||||
run_id: number | null;
|
||||
source_id: number | null;
|
||||
source_name: string | null;
|
||||
original_filename: string;
|
||||
raw_uri: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
mime_type: string | null;
|
||||
uploaded_by: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateRawArtifact = {
|
||||
run_id?: number;
|
||||
source_id?: number;
|
||||
original_filename: string;
|
||||
raw_uri: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
mime_type?: string;
|
||||
uploaded_by?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
||||
|
||||
@@ -197,6 +226,17 @@ export async function finishIngestionRun(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRawArtifacts(): Promise<RawArtifact[]> {
|
||||
return fetchJson("/api/v1/raw-artifacts");
|
||||
}
|
||||
|
||||
export async function createRawArtifact(payload: CreateRawArtifact): Promise<RawArtifact> {
|
||||
return fetchJson("/api/v1/raw-artifacts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
|
||||
Reference in New Issue
Block a user