Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+6 -1
View File
@@ -5,8 +5,9 @@ import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from ".
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -14,6 +15,8 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "devices", label: "Devices" },
{ id: "groups", label: "Group map" },
{ id: "attempts", label: "Sign-in attempts" },
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -696,6 +699,8 @@ export function AdminPage() {
{tab === "devices" ? <DevicesTab /> : null}
{tab === "groups" ? <GroupsTab /> : null}
{tab === "attempts" ? <AttemptsTab /> : null}
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
</div>
</>
);
@@ -0,0 +1,113 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DEPARTMENTS, makeMe, renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const ADMIN = makeMe({
user: { display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
const SETTINGS = {
interval_shop_minutes: 5,
interval_off_minutes: 30,
shop_days: [0, 1, 2, 3, 4],
shop_start: "06:00",
shop_end: "18:00",
window_back_days: 14,
window_ahead_days: 30,
golive_date: null,
departments: [],
drawing_recheck_hours: 4,
drawing_budget: 60,
drawing_interval_minutes: 30,
labor_tickets: true,
auto_release: true,
auto_release_max: 25,
paused: false,
next_run_at: "2026-10-16T08:05:00Z",
};
const RUN = {
id: 7,
job: "visual-feed",
trigger: "schedule",
started_at: "2026-10-16T07:58:00Z",
finished_at: "2026-10-16T07:58:10Z",
status: "ok",
counts: { new_drafts: 1 },
errors: [],
};
function base() {
return {
"/api/auth/me": { body: ADMIN },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
"/api/admin/feed/settings": { body: SETTINGS },
"/api/jobs/visual-feed/runs": { body: [RUN] },
"/api/jobs": { body: { jobs: [], heartbeat: { at: "2026-10-16T07:59:00Z", build: "1.0", running: [], stale: false } } },
"/api/admin/feed/resource-map": {
body: [
{ resource: "WELD-PART", target: "03", confirmed: true, rows_last_run: 40, updated_at: null },
{ resource: "NEWRES", target: null, confirmed: false, rows_last_run: 3, updated_at: null },
{ resource: "MFG", target: "fold", confirmed: false, rows_last_run: 10, updated_at: null },
],
},
};
}
describe("Admin feed tabs", () => {
it("the Feed tab PATCHes only the changed fields", async () => {
const fetch = mockFetch({ ...base(), "PATCH /api/admin/feed/settings": { body: { ...SETTINGS, paused: true, interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"] } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
expect(screen.getByRole("table", { name: "Last feed runs" })).toBeInTheDocument();
fireEvent.change(within(form).getByLabelText("Every (min) in shop hours"), { target: { value: "10" } });
fireEvent.click(within(within(form).getByRole("group", { name: "Shop days" })).getByRole("button", { name: "Sat" }));
fireEvent.click(await within(within(form).getByRole("group", { name: "Departments" })).findByRole("button", { name: "03 Weld Parts" }));
fireEvent.click(within(form).getByLabelText(/^Paused/));
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Saved. The worker picks up the new settings at once.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toEqual([
{ interval_shop_minutes: 10, shop_days: [0, 1, 2, 3, 4, 5], departments: ["03"], paused: true },
]);
});
it("the Feed tab refuses a number out of range and sends nothing", async () => {
const fetch = mockFetch(base());
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Feed" }));
const form = await screen.findByRole("form", { name: "Feed settings" });
fireEvent.change(within(form).getByLabelText("Drawing lookups per run"), { target: { value: "900" } });
fireEvent.click(within(form).getByRole("button", { name: "Save" }));
expect(await screen.findByText("Drawing lookups per run: a whole number from 1 to 500.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/feed/settings")).toHaveLength(0);
});
it("the VISUAL resources tab saves a row with PUT and an unassign with DELETE", async () => {
const fetch = mockFetch({
...base(),
"PUT /api/admin/feed/resource-map/NEWRES": { body: { resource: "NEWRES", target: "14", confirmed: true, rows_last_run: 3, updated_at: null } },
"DELETE /api/admin/feed/resource-map/MFG": { status: 204 },
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "VISUAL resources" }));
const table = await screen.findByRole("table", { name: "VISUAL resources" });
expect(screen.getByText("1 resource is not assigned. Their work orders are left out of the feed.")).toBeInTheDocument();
const rows = within(table).getAllByRole("row");
expect(rows.map((r) => within(r).queryAllByRole("cell")[0]?.textContent ?? "")).toEqual(["", "WELD-PART", "MFG", "NEWRES"]);
fireEvent.change(await within(table).findByLabelText("NEWRES goes to"), { target: { value: "14" } });
fireEvent.click(within(table).getByRole("button", { name: "Save NEWRES" }));
await waitFor(() => expect(callsTo(fetch, "PUT", "/api/admin/feed/resource-map/NEWRES")).toEqual([{ target: "14" }]));
fireEvent.change(within(table).getByLabelText("MFG goes to"), { target: { value: "" } });
fireEvent.click(await screen.findByRole("button", { name: "Save 1 change" }));
await waitFor(() => expect(callsTo(fetch, "DELETE", "/api/admin/feed/resource-map/MFG")).toHaveLength(1));
});
});
+350
View File
@@ -0,0 +1,350 @@
// Admin: the VISUAL feed settings and the resource map (phase 3).
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { feedAdminApi, jobsApi } from "../../api/jobs";
import type { ResourceMapRow } from "../../api/types";
import { DAYS, feedPatch, NUMS, toForm, type Form, type NumKey } from "../../lib/feedSettings";
import { RunsTable } from "../../components/RunsTable";
import { useChanged } from "../../lib/changes";
import { errorText, fmtAgo, fmtIn, fmtTime } from "../../lib/format";
import { useShopDepartments } from "../../lib/useDepartments";
import { useLoad } from "../../lib/useLoad";
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
function Msg({ msg }: { msg: { ok: boolean; text: string } | null }) {
if (!msg) return null;
return (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
);
}
/* ---------- Feed ---------- */
export function FeedTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.settings(s));
const [runs, reloadRuns] = useLoad((s) => jobsApi.runs("visual-feed", 10, s));
const [jobs, reloadJobs] = useLoad((s) => jobsApi.list(s));
const [form, setForm] = useState<Form | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const settings = load.state === "ok" ? load.data : null;
useEffect(() => {
if (settings) setForm(toForm(settings));
}, [settings]);
useChanged(
() => {
reloadRuns();
reloadJobs();
},
{ delayMs: 1000, filter: (ev) => ev.event === "feed" },
);
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
if (!settings || !form) return null;
const setNum = (k: NumKey, v: string) => setForm((f) => (f ? { ...f, nums: { ...f.nums, [k]: v } } : f));
const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => (f ? { ...f, [k]: v } : f));
const save = async (e: FormEvent) => {
e.preventDefault();
const r = feedPatch(settings, form);
if ("error" in r) {
setMsg({ ok: false, text: r.error });
return;
}
if (!Object.keys(r.patch).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
await feedAdminApi.saveSettings(r.patch);
setMsg({ ok: true, text: "Saved. The worker picks up the new settings at once." });
reload();
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const runNow = async (job: string) => {
setBusy(true);
setMsg(null);
try {
await jobsApi.run(job);
setMsg({ ok: true, text: job === "visual-feed" ? "Feed run requested." : "Drawing lookup requested." });
reloadJobs();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const hb = jobs.state === "ok" ? jobs.data.heartbeat : null;
const feedJob = jobs.state === "ok" ? jobs.data.jobs.find((j) => j.job === "visual-feed") : undefined;
const next = settings.next_run_at ?? feedJob?.next_run_at ?? null;
return (
<div className="stack">
<div className="sec-h">
<span className="small">
{hb ? (
hb.stale ? (
<span className="due over">Worker not running (last seen {fmtAgo(hb.at)}).</span>
) : (
<>Worker seen {fmtAgo(hb.at)}{hb.build ? ` · build ${hb.build}` : ""}. </>
)
) : null}
{settings.paused ? <b className="due over">Paused. </b> : null}
Next feed run: <b>{next ? `${fmtTime(next)} (${fmtIn(next)})` : "not scheduled"}</b>
</span>
<span className="grow" />
<button className="btn sm" type="button" disabled={busy} onClick={() => void runNow("drawing-lookup")}>
Look up drawings now
</button>
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void runNow("visual-feed")}>
Run now
</button>
</div>
<Msg msg={msg} />
<form className="panel stack" onSubmit={save} noValidate aria-label="Feed settings">
<label className="ckl">
<input type="checkbox" checked={form.paused} onChange={(e) => set("paused", e.target.checked)} />
Paused: no scheduled feed, drawing or auto-release runs. Run now still works.
</label>
<h3>Schedule</h3>
<div className="chips" role="group" aria-label="Shop days">
{DAYS.map(([d, l]) => (
<button key={d} type="button" className="chip sm" aria-pressed={form.shop_days.includes(d)} onClick={() => set("shop_days", toggle(form.shop_days, d).sort())}>
{l}
</button>
))}
</div>
<div className="frow">
<label className="f">
Shop hours start
<input className="in" type="time" value={form.shop_start} onChange={(e) => set("shop_start", e.target.value)} />
</label>
<label className="f">
Shop hours end
<input className="in" type="time" value={form.shop_end} onChange={(e) => set("shop_end", e.target.value)} />
</label>
{NUMS.slice(0, 2).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<h3>What is read</h3>
<div className="frow">
{NUMS.slice(2, 4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
<label className="f">
Go-live date
<input className="in" type="date" value={form.golive_date} onChange={(e) => set("golive_date", e.target.value)} />
</label>
</div>
<div className="small muted">Work orders released before the go-live date stay on paper. Clerical can pull one in.</div>
<div className="small">
<b>Departments</b> <span className="muted">Only work orders for these become packets. None picked means every department the resource map routes to.</span>
</div>
<div className="chips" role="group" aria-label="Departments">
{depts.map((d) => (
<button
key={d.code}
type="button"
className="chip sm"
aria-pressed={form.departments.includes(d.code)}
onClick={() => set("departments", toggle(form.departments, d.code).sort())}
>
{d.code} {d.name}
</button>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.labor_tickets} onChange={(e) => set("labor_tickets", e.target.checked)} />
Read labor ticket hours (at most every 30 minutes)
</label>
<h3>Drawings and release</h3>
<div className="frow">
{NUMS.slice(4).map(([k, l, lo, hi]) => (
<label key={k} className="f">
{l}
<input className="in" type="number" min={lo} max={hi} value={form.nums[k]} onChange={(e) => setNum(k, e.target.value)} />
</label>
))}
</div>
<label className="ckl">
<input type="checkbox" checked={form.auto_release} onChange={(e) => set("auto_release", e.target.checked)} />
Send drafts to the warehouse on their own when the drawing is attached and nothing is flagged
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save
</button>
</div>
</form>
<h3>Last ten feed runs</h3>
{runs.state === "ok" ? <RunsTable runs={runs.data} label="Last feed runs" /> : runs.state === "error" ? <div className="callout warn">{runs.message}</div> : null}
</div>
);
}
/* ---------- VISUAL resources ---------- */
export function ResourcesTab() {
const depts = useShopDepartments();
const [load, reload] = useLoad((s) => feedAdminApi.resourceMap(s));
const [edits, setEdits] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const rows = useMemo(
() => (load.state === "ok" ? [...load.data].sort((a, b) => (b.rows_last_run ?? 0) - (a.rows_last_run ?? 0) || a.resource.localeCompare(b.resource)) : []),
[load],
);
const byName = useMemo(() => new Map(rows.map((r) => [r.resource, r])), [rows]);
const pending = Object.keys(edits).filter((k) => (byName.get(k)?.target ?? "") !== edits[k]);
const saveOne = async (r: ResourceMapRow, target: string) => {
if (target === "") {
if (r.target) await feedAdminApi.unmapResource(r.resource);
} else {
await feedAdminApi.mapResource(r.resource, target);
}
};
const save = async (names: string[]) => {
setBusy(true);
setMsg(null);
let done = 0;
try {
for (const k of names) {
const r = byName.get(k);
const t = edits[k];
if (!r || t === undefined) continue;
await saveOne(r, t);
done++;
setEdits((e) => {
const n = { ...e };
delete n[k];
return n;
});
}
setMsg({ ok: true, text: done === 1 ? "Saved. The feed uses it from its next run." : `${done} saved. The feed uses them from its next run.` });
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
reload();
}
};
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
const unassigned = rows.filter((r) => !r.target).length;
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
Which department gets the packet for each VISUAL resource (RESOURCE_ID). Starting assignments are a best guess from the resource names. Check each one.
Lines are counted from the last feed run.
</span>
<span className="grow" />
{pending.length ? (
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void save(pending)}>
Save {pending.length} change{pending.length === 1 ? "" : "s"}
</button>
) : null}
</div>
<Msg msg={msg} />
{unassigned ? (
<div className="callout warn flush">
{unassigned === 1 ? "1 resource is" : `${unassigned} resources are`} not assigned. Their work orders are left out of the feed.
</div>
) : null}
<div className="tw">
<table aria-label="VISUAL resources">
<thead>
<tr>
<th>Resource</th>
<th className="num">Lines in last run</th>
<th>Goes to</th>
<th>Confirmed</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => {
const cur = edits[r.resource] ?? r.target ?? "";
const dirty = cur !== (r.target ?? "");
return (
<tr key={r.resource}>
<td className="mono">{r.resource}</td>
<td className="num">{r.rows_last_run ?? "-"}</td>
<td>
<select
className="in"
aria-label={`${r.resource} goes to`}
value={cur}
onChange={(e) => setEdits((x) => ({ ...x, [r.resource]: e.target.value }))}
>
<option value="">Not assigned</option>
{depts.map((d) => (
<option key={d.code} value={d.code}>
{d.code} {d.name}
</option>
))}
{cur && cur !== "fold" && cur !== "ignore" && !depts.some((d) => d.code === cur) ? <option value={cur}>{cur}</option> : null}
<option value="fold">Fold into the packet (MFG, inspection, material)</option>
<option value="ignore">Ignore</option>
</select>
{dirty ? <span className="small due soon"> unsaved</span> : null}
</td>
<td className="small">{r.confirmed ? "✓ confirmed" : r.target ? <span className="muted">guess</span> : <span className="due over">not assigned</span>}</td>
<td>
{dirty ? (
<button className="btn sm" type="button" disabled={busy} onClick={() => void save([r.resource])} aria-label={`Save ${r.resource}`}>
Save
</button>
) : null}
</td>
</tr>
);
})}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No resources yet. They appear after the first feed run.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{rows.some((r) => r.updated_at) ? (
<div className="small muted">Last change {fmtTime(rows.map((r) => r.updated_at ?? "").sort().pop() ?? null)}.</div>
) : null}
</div>
);
}