Add Print Queue UI and slicer post-processing script
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- Create Print Queue page showing pending/completed/cancelled jobs
- Add needs_weighing filter to spool API for flagged spools
- Add IPrintJob interface and needs_weighing to ISpool model
- Add slicer_post_process.py for Elegoo/Orca Slicer integration
- Add translations for print queue feature
- Bump version to 0.23C.3

The workflow:
1. Slicer post-processing script creates pending print job
2. After print, user confirms (deducts filament) or cancels (flags for weighing)
3. Spools needing weigh-in are shown in Print Queue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 21:40:22 -06:00
parent 4cd58ebae9
commit 9365e399be
9 changed files with 797 additions and 7 deletions

View File

@@ -9,6 +9,7 @@ import {
FileOutlined,
HighlightOutlined,
HomeOutlined,
PrinterOutlined,
QuestionOutlined,
TableOutlined,
ToolOutlined,
@@ -142,6 +143,14 @@ function App() {
icon: <UserOutlined />,
},
},
{
name: "print-queue",
list: "/print-queue",
meta: {
canDelete: false,
icon: <PrinterOutlined />,
},
},
{
name: "locations",
list: "/locations",
@@ -225,6 +234,7 @@ function App() {
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>

View File

@@ -0,0 +1,456 @@
import {
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
FileOutlined,
ReloadOutlined,
ToolOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core";
import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import React, { useMemo } from "react";
import { Link } from "react-router";
import { IPrintJob, ISpool } from "../spools/model";
dayjs.extend(utc);
dayjs.extend(relativeTime);
const { useToken } = theme;
const { confirm } = Modal;
export const PrintQueue: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const invalidate = useInvalidate();
const { mutate: customMutate } = useCustomMutation();
// Fetch print jobs
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
resource: "print-job",
pagination: { mode: "off" },
sorters: [{ field: "created", order: "desc" }],
});
// Fetch spools that need weighing
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
filters: [{ field: "needs_weighing", operator: "eq", value: true }],
pagination: { mode: "off" },
});
// Categorize jobs
const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => {
const jobs = printJobs?.data || [];
return {
pendingJobs: jobs.filter((j) => j.status === "pending"),
completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10),
cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10),
};
}, [printJobs]);
const handleComplete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_complete"),
content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }),
icon: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
okText: t("buttons.confirm"),
okType: "primary",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/complete`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_completed"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_complete_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleCancel = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_cancel"),
content: t("print_queue.confirm_cancel_description"),
icon: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
okText: t("print_queue.cancel_job"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/cancel`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_cancelled"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_cancel_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleDelete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_delete"),
content: t("print_queue.confirm_delete_description"),
icon: <DeleteOutlined style={{ color: token.colorError }} />,
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}`,
method: "delete",
values: {},
successNotification: {
message: t("print_queue.job_deleted"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
},
});
},
});
};
const handleClearWeighingFlag = (spool: ISpool) => {
customMutate({
url: `spool/${spool.id}`,
method: "patch",
values: { needs_weighing: false },
successNotification: {
message: t("print_queue.weighing_cleared"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
};
const getSpoolName = (job: IPrintJob) => {
if (job.spool?.filament) {
const fil = job.spool.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
}
return `Spool #${job.spool_id}`;
};
const pendingColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
render: (filename: string) => (
<Space>
<FileOutlined />
{filename}
</Space>
),
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.created"),
dataIndex: "created",
key: "created",
render: (created: string) => (
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(created).fromNow()}
</Tooltip>
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Space>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => handleComplete(record)}
>
{t("print_queue.complete")}
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={() => handleCancel(record)}
>
{t("print_queue.cancel")}
</Button>
</Space>
),
},
];
const historyColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.status"),
dataIndex: "status",
key: "status",
render: (status: string) => (
<Tag color={status === "completed" ? "success" : "error"}>
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
</Tag>
),
},
{
title: t("print_queue.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (finished: string) =>
finished ? (
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(finished).fromNow()}
</Tooltip>
) : (
"-"
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
),
},
];
const weighingColumns = [
{
title: t("spool.fields.id"),
dataIndex: "id",
key: "id",
},
{
title: t("spool.fields.filament_name"),
key: "filament",
render: (_: unknown, record: ISpool) => {
const fil = record.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
},
},
{
title: t("spool.fields.location"),
dataIndex: "location",
key: "location",
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: ISpool) => (
<Space>
<Link to={`/spool/edit/${record.id}`}>
<Button type="primary" icon={<ToolOutlined />}>
{t("print_queue.weigh_in")}
</Button>
</Link>
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
<Button onClick={() => handleClearWeighingFlag(record)}>
{t("print_queue.clear_flag")}
</Button>
</Tooltip>
</Space>
),
},
];
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
return (
<Content
style={{
padding: "2em 20px",
minHeight: 280,
maxWidth: 1200,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<Title level={2} style={{ margin: 0 }}>
{t("print_queue.title")}
</Title>
<Button
icon={<ReloadOutlined />}
onClick={() => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list"] });
}}
>
{t("buttons.refresh")}
</Button>
</div>
{/* Stats cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.pending")}
value={pendingJobs.length}
valueStyle={{ color: token.colorWarning }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.needs_weighing")}
value={spoolsNeedingWeighing?.data?.length || 0}
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.completed_today")}
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
valueStyle={{ color: token.colorSuccess }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.total_jobs")}
value={printJobs?.data?.length || 0}
/>
</Card>
</Col>
</Row>
{/* Pending Jobs */}
<Card
title={
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
<span>{t("print_queue.pending_jobs")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
{pendingJobs.length > 0 ? (
<Table
dataSource={pendingJobs}
columns={pendingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_pending_jobs")} />
)}
</Card>
{/* Spools Needing Weighing */}
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
<Card
title={
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
<span>{t("print_queue.needs_weighing")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
<Table
dataSource={spoolsNeedingWeighing?.data || []}
columns={weighingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
</Card>
)}
{/* Recent History */}
<Card title={t("print_queue.recent_history")}>
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
<Table
dataSource={[...completedJobs, ...cancelledJobs].sort(
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
).slice(0, 10)}
columns={historyColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_history")} />
)}
</Card>
</Content>
);
};
export default PrintQueue;

View File

@@ -23,9 +23,22 @@ export interface ISpool {
lot_nr?: string;
comment?: string;
archived: boolean;
needs_weighing: boolean;
extra: { [key: string]: string };
}
export interface IPrintJob {
id: number;
spool_id: number;
spool?: ISpool;
created: string;
finished?: string;
filename: string;
filament_used_g: number;
filament_used_mm?: number;
status: "pending" | "completed" | "cancelled";
}
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };