Compare commits

...

3 Commits

Author SHA1 Message Date
267fadfc54 feat(color): add hex code display and click-to-copy functionality
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
Enhances SpoolIcon component with optional showHex prop:
- Displays hex color code next to swatch when enabled
- Click to copy hex code to clipboard
- Shows tooltip with copy hint
- Supports both single and multi-color filaments

Enabled on detail pages:
- Filament show page
- Spool show page

Added translation keys for copy feedback messages.

Closes #33

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:33:21 -06:00
48bd516c0f feat(filament): add spool count and total remaining weight columns
Adds computed spool statistics to filament list:
- spool_count: Number of non-archived spools of this filament
- total_remaining_weight: Sum of remaining weight across all spools

Backend changes:
- Modified database/filament.py find() to compute stats via subquery
- Added fields to Filament pydantic model in api/v1/models.py
- Updated filament API endpoint to include stats in response

Frontend changes:
- Added fields to IFilament interface
- Added columns to filament list table
- Added translation keys

Closes #15

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:31:47 -06:00
0a741c9712 feat(home): redesign dashboard with spool list and low stock alerts
Replaces stat card layout with functional dashboard:
- Prominent spool count with "Add Spool" button
- Paginated list of all spools sorted by last used
- Low stock alert badge (< 100g threshold)
- Color swatch, name, material, and remaining weight columns
- Clickable rows navigate to spool detail
- Secondary stats for filaments and vendors

Closes #12

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:27:14 -06:00
10 changed files with 339 additions and 107 deletions

View File

@@ -33,7 +33,8 @@
"no": "No", "no": "No",
"notAccessTitle": "You don't have permission to access", "notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns", "hideColumns": "Hide Columns",
"clearFilters": "Clear Filters" "clearFilters": "Clear Filters",
"viewAll": "View All"
}, },
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.", "warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
"notifications": { "notifications": {
@@ -51,6 +52,11 @@
"validationError": "Validation error: {{error}}" "validationError": "Validation error: {{error}}"
}, },
"kofi": "Tip me on Ko-fi", "kofi": "Tip me on Ko-fi",
"color": {
"click_to_copy": "Click to copy",
"copied": "Copied {{color}} to clipboard",
"copy_failed": "Failed to copy to clipboard"
},
"loading": "Loading", "loading": "Loading",
"version": "Version", "version": "Version",
"unknown": "Unknown", "unknown": "Unknown",
@@ -259,7 +265,9 @@
"longitudinal": "Longitudinal", "longitudinal": "Longitudinal",
"external_id": "External ID", "external_id": "External ID",
"spools": "Show Spools", "spools": "Show Spools",
"not_set": "Not Set" "not_set": "Not Set",
"spool_count": "Spools",
"total_remaining_weight": "Total Remaining"
}, },
"fields_help": { "fields_help": {
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.", "name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
@@ -324,7 +332,10 @@
"home": { "home": {
"home": "Home", "home": "Home",
"welcome": "Welcome to your Spoolman instance!", "welcome": "Welcome to your Spoolman instance!",
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started." "description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
"all_spools": "All Spools",
"low_stock_alert": "{{count}} spools below {{threshold}}g",
"low_stock_warning": "Low stock"
}, },
"help": { "help": {
"help": "Help", "help": "Help",

View File

@@ -1,16 +1,21 @@
import { CopyOutlined } from "@ant-design/icons";
import { message, Tooltip } from "antd";
import { useTranslate } from "@refinedev/core";
import "./spoolIcon.css"; import "./spoolIcon.css";
interface Props { interface Props {
color: string | { colors: string[]; vertical: boolean }; color: string | { colors: string[]; vertical: boolean };
size?: "small" | "large"; size?: "small" | "large";
no_margin? : boolean no_margin?: boolean;
showHex?: boolean;
} }
export default function SpoolIcon(props: Readonly<Props>) { export default function SpoolIcon(props: Readonly<Props>) {
const t = useTranslate();
let dirClass = "vertical"; let dirClass = "vertical";
let cols = []; let cols: string[] = [];
let size = props.size ? props.size : "small"; const size = props.size ? props.size : "small";
let no_margin = props.no_margin ? "no-margin" : ""; const no_margin = props.no_margin ? "no-margin" : "";
if (typeof props.color === "string") { if (typeof props.color === "string") {
cols = [props.color]; cols = [props.color];
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
cols = props.color.colors; cols = props.color.colors;
} }
return ( // Normalize hex codes (remove # if present, uppercase)
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(hexDisplay);
message.success(t("color.copied", { color: hexDisplay }));
} catch {
message.error(t("color.copy_failed"));
}
};
const swatchContent = (
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}> <div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
{cols.map((col) => ( {normalizedCols.map((col) => (
<div <div
key={col} key={col}
style={{ style={{
backgroundColor: "#" + col.replace("#", ""), backgroundColor: "#" + col,
}} }}
></div> ></div>
))} ))}
</div> </div>
); );
if (props.showHex) {
return (
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
<div
className="spool-icon-with-hex"
onClick={copyToClipboard}
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
>
{swatchContent}
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
{hexDisplay}
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
</span>
</div>
</Tooltip>
);
}
return swatchContent;
} }

View File

@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number", "article_number",
"settings_extruder_temp", "settings_extruder_temp",
"settings_bed_temp", "settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered", "registered",
"comment", "comment",
]; ];
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
width: 100, width: 100,
}), }),
NumberColumn({
...commonProps,
id: "spool_count",
i18ncat: "filament",
unit: "",
maxDecimals: 0,
width: 80,
}),
NumberColumn({
...commonProps,
id: "total_remaining_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 0,
width: 120,
}),
DateColumn({ DateColumn({
...commonProps, ...commonProps,
id: "registered", id: "registered",

View File

@@ -24,6 +24,8 @@ export interface IFilament {
multi_color_direction?: string; multi_color_direction?: string;
external_id?: string; external_id?: string;
extra: { [key: string]: string }; extra: { [key: string]: string };
spool_count?: number;
total_remaining_weight?: number;
} }
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types // IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types

View File

@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("filament.fields.name")}</Title> <Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} /> <TextField value={record?.name} />
<Title level={5}>{t("filament.fields.color_hex")}</Title> <Title level={5}>{t("filament.fields.color_hex")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />} {colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
<Title level={5}>{t("filament.fields.material")}</Title> <Title level={5}>{t("filament.fields.material")}</Title>
<TextField value={record?.material} /> <TextField value={record?.material} />
<Title level={5}>{t("filament.fields.price")}</Title> <Title level={5}>{t("filament.fields.price")}</Title>

View File

@@ -1,129 +1,247 @@
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons"; import {
ExclamationCircleOutlined,
HighlightOutlined,
PlusOutlined,
UserOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Card, Col, Row, Statistic, theme } from "antd"; import { Badge, Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout"; import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import React, { ReactNode } from "react"; import React, { useMemo, useState } from "react";
import { Trans } from "react-i18next"; import { Link, useNavigate } from "react-router";
import { Link } from "react-router"; import SpoolIcon from "../../components/spoolIcon";
import Logo from "../../icon.svg?react"; import { IFilament } from "../filaments/model";
import { ISpool } from "../spools/model"; import { ISpool } from "../spools/model";
import { IVendor } from "../vendors/model";
dayjs.extend(utc); dayjs.extend(utc);
const { useToken } = theme; const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100; // grams
export const Home: React.FC<IResourceComponentsProps> = () => { export const Home: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken(); const { token } = useToken();
const t = useTranslate(); const t = useTranslate();
const navigate = useNavigate();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
// Fetch all spools (not archived) for the main list
const spools = useList<ISpool>({ const spools = useList<ISpool>({
resource: "spool", resource: "spool",
pagination: { pageSize: 1 }, pagination: { current: currentPage, pageSize },
sorters: [{ field: "last_used", order: "desc" }],
meta: {
queryParams: {
allow_archived: false,
},
},
}); });
const filaments = useList<ISpool>({
// Fetch counts for stats
const filaments = useList<IFilament>({
resource: "filament", resource: "filament",
pagination: { pageSize: 1 }, pagination: { pageSize: 1 },
}); });
const vendors = useList<ISpool>({ const vendors = useList<IVendor>({
resource: "vendor", resource: "vendor",
pagination: { pageSize: 1 }, pagination: { pageSize: 1 },
}); });
const hasSpools = !spools.result || spools.result.data.length > 0; // Calculate low stock spools
const lowStockCount = useMemo(() => {
if (!spools.data?.data) return 0;
return spools.data.data.filter(
(spool) => spool.remaining_weight !== undefined && spool.remaining_weight < LOW_STOCK_THRESHOLD
).length;
}, [spools.data?.data]);
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => ( // For accurate low stock count, we need all spools
<Col xs={12} md={6}> const allSpoolsForLowStock = useList<ISpool>({
<Card resource: "spool",
loading={props.loading} pagination: { pageSize: 1000 },
hoverable filters: [
style={{ cursor: "pointer" }} {
styles={{ body: { padding: 0 } }} field: "remaining_weight",
actions={[ operator: "lt",
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}> value: LOW_STOCK_THRESHOLD,
<UnorderedListOutlined /> },
</Link>, ],
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}> meta: {
<PlusOutlined /> queryParams: {
</Link>, allow_archived: false,
]} },
> },
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}> });
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
</Link> const totalLowStock = allSpoolsForLowStock.data?.total ?? lowStockCount;
</Card>
</Col> const spoolColumns = [
); {
title: "",
dataIndex: "color",
key: "color",
width: 50,
render: (_: unknown, record: ISpool) => {
const colorObj = record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
return colorObj ? <SpoolIcon color={colorObj} /> : null;
},
},
{
title: t("spool.fields.filament_name"),
dataIndex: "filament",
key: "filament",
render: (_: unknown, record: ISpool) => {
const vendorName = record.filament.vendor?.name;
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
},
},
{
title: t("spool.fields.material"),
dataIndex: ["filament", "material"],
key: "material",
width: 100,
},
{
title: t("spool.fields.remaining_weight"),
dataIndex: "remaining_weight",
key: "remaining_weight",
width: 120,
align: "right" as const,
render: (weight: number | undefined, record: ISpool) => {
if (weight === undefined) return t("unknown");
const isLowStock = weight < LOW_STOCK_THRESHOLD;
return (
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
{Math.round(weight)} g
{isLowStock && (
<Tooltip title={t("home.low_stock_warning")}>
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
</Tooltip>
)}
</span>
);
},
},
];
return ( return (
<Content <Content
style={{ style={{
padding: "2em 20px", padding: "1.5em 20px",
minHeight: 280, minHeight: 280,
maxWidth: 800, maxWidth: 900,
margin: "0 auto", margin: "0 auto",
backgroundColor: token.colorBgContainer, backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG, borderRadius: token.borderRadiusLG,
color: token.colorText, color: token.colorText,
fontFamily: token.fontFamily, fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}} }}
> >
<Title {/* Header with spool count and add button */}
style={{ <Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
display: "flex", <Col>
alignItems: "center", <Statistic
justifyContent: "center", title={t("spool.spool")}
fontSize: token.fontSizeHeading1, value={spools.data?.total ?? 0}
}} loading={spools.isLoading}
> valueStyle={{ fontSize: 36, fontWeight: 600 }}
<div />
style={{ </Col>
display: "inline-block", <Col>
height: "1.5em", <Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
marginRight: "0.5em", {t("spool.titles.create")}
}} </Button>
> </Col>
<Logo />
</div>
Spoolman
</Title>
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
<ResourceStatsCard
resource="spool"
value={spools.result?.total || 0}
loading={spools.query.isLoading}
icon={<FileOutlined />}
/>
<ResourceStatsCard
resource="filament"
value={filaments.result?.total || 0}
loading={filaments.query.isLoading}
icon={<HighlightOutlined />}
/>
<ResourceStatsCard
resource="vendor"
value={vendors.result?.total || 0}
loading={vendors.query.isLoading}
icon={<UserOutlined />}
/>
</Row> </Row>
{!hasSpools && (
<> {/* Alert badges */}
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p> {totalLowStock > 0 && (
<p> <Row style={{ marginBottom: 16 }}>
<Trans <Col>
i18nKey="home.description" <Link to="/spool?remaining_weight_lt=100">
components={{ <Badge
helpPageLink: <Link to="/help" />, count={totalLowStock}
}} style={{ backgroundColor: token.colorError }}
/> overflowCount={99}
</p> >
</> <Card size="small" hoverable style={{ cursor: "pointer" }}>
<ExclamationCircleOutlined style={{ color: token.colorError, marginRight: 8 }} />
{t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
</Card>
</Badge>
</Link>
</Col>
</Row>
)} )}
{/* Spool list */}
<Card
title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }}
>
<Table
dataSource={spools.data?.data}
columns={spoolColumns}
rowKey="id"
loading={spools.isLoading}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => navigate(`/spool/show/${record.id}`),
style: { cursor: "pointer" },
})}
/>
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={spools.data?.total ?? 0}
onChange={setCurrentPage}
showSizeChanger={false}
size="small"
/>
</div>
</Card>
{/* Secondary stats */}
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12}>
<Link to="/filament">
<Card hoverable size="small">
<Statistic
title={t("filament.filament")}
value={filaments.data?.total ?? 0}
loading={filaments.isLoading}
prefix={<HighlightOutlined />}
/>
</Card>
</Link>
</Col>
<Col xs={12}>
<Link to="/vendor">
<Card hoverable size="small">
<Statistic
title={t("vendor.vendor")}
value={vendors.data?.total ?? 0}
loading={vendors.isLoading}
prefix={<UserOutlined />}
/>
</Card>
</Link>
</Col>
</Row>
</Content> </Content>
); );
}; };

View File

@@ -163,7 +163,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("spool.fields.id")}</Title> <Title level={5}>{t("spool.fields.id")}</Title>
<NumberField value={record?.id ?? ""} /> <NumberField value={record?.id ?? ""} />
<Title level={5}>{t("spool.fields.filament")}</Title> <Title level={5}>{t("spool.fields.filament")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />} {colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<TextField value={record ? filamentURL(record?.filament) : ""} /> <TextField value={record ? filamentURL(record?.filament) : ""} />
<Title level={5}>{t("spool.fields.price")}</Title> <Title level={5}>{t("spool.fields.price")}</Title>
<TextField value={spoolPrice(record)} /> <TextField value={spoolPrice(record)} />

View File

@@ -366,7 +366,7 @@ async def find(
else: else:
filter_by_ids = None filter_by_ids = None
db_items, total_count = await filament.find( db_items, total_count, spool_stats = await filament.find(
db=db, db=db,
ids=filter_by_ids, ids=filter_by_ids,
vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
@@ -383,7 +383,14 @@ async def find(
# Set x-total-count header for pagination # Set x-total-count header for pagination
return JSONResponse( return JSONResponse(
content=jsonable_encoder( content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items), (
Filament.from_db(
db_item,
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
)
for db_item in db_items
),
exclude_none=True, exclude_none=True,
), ),
headers={"x-total-count": str(total_count)}, headers={"x-total-count": str(total_count)},

View File

@@ -221,9 +221,25 @@ class Filament(BaseModel):
"Query the /fields endpoint for more details about the fields." "Query the /fields endpoint for more details about the fields."
), ),
) )
spool_count: Optional[int] = Field(
None,
ge=0,
description="Number of spools of this filament (excluding archived). Computed field.",
examples=[5],
)
total_remaining_weight: Optional[float] = Field(
None,
ge=0,
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
examples=[2500.0],
)
@staticmethod @staticmethod
def from_db(item: models.Filament) -> "Filament": def from_db(
item: models.Filament,
spool_count: Optional[int] = None,
total_remaining_weight: Optional[float] = None,
) -> "Filament":
"""Create a new Pydantic filament object from a database filament object.""" """Create a new Pydantic filament object from a database filament object."""
return Filament( return Filament(
id=item.id, id=item.id,
@@ -251,6 +267,8 @@ class Filament(BaseModel):
), ),
external_id=item.external_id, external_id=item.external_id,
extra={field.key: field.value for field in item.extra}, extra={field.key: field.value for field in item.extra},
spool_count=spool_count,
total_remaining_weight=total_remaining_weight,
) )

View File

@@ -114,13 +114,16 @@ async def find(
sort_by: Optional[dict[str, SortOrder]] = None, sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: int = 0, offset: int = 0,
) -> tuple[list[models.Filament], int]: ) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
"""Find a list of filament objects by search criteria. """Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value. Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name. The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items. Returns a tuple containing:
- list of filaments
- total count of matching items
- dict mapping filament_id to (spool_count, total_remaining_weight)
""" """
stmt = ( stmt = (
select(models.Filament) select(models.Filament)
@@ -160,7 +163,25 @@ async def find(
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
return result, total_count # Fetch spool statistics for all filaments in the result
spool_stats: dict[int, tuple[int, float]] = {}
if result:
filament_ids = [f.id for f in result]
stats_stmt = (
select(
models.Spool.filament_id,
func.count(models.Spool.id).label("spool_count"),
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
)
.where(models.Spool.filament_id.in_(filament_ids))
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
.group_by(models.Spool.filament_id)
)
stats_rows = await db.execute(stats_stmt)
for row in stats_rows.all():
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
return result, total_count, spool_stats
async def update( async def update(