4 Commits

Author SHA1 Message Date
Forbes
f7aa673d2c fix(sse): disable read deadline for long-lived SSE connections
The server's ReadTimeout (15s) was closing SSE connections shortly after
they were established, causing a rapid connect/disconnect loop. The handler
already disabled WriteTimeout but not ReadTimeout.
2026-02-08 22:52:42 -06:00
2157b40d06 Merge pull request 'feat(web): BOM merge resolution UI (#47)' (#55) from issue-47-bom-merge-ui into main
Reviewed-on: #55
2026-02-09 02:09:02 +00:00
Forbes
25c42bd70b feat(web): add BOM merge resolution UI with source badges and dropdown
- Add source badges (assembly=teal, manual=blue) to BOM display rows
- Add info banner when assembly-sourced entries exist
- Change source input from text field to select dropdown
- Add merge response types to types.ts

Closes #47
2026-02-08 19:56:33 -06:00
8d88f77ff6 Merge pull request 'feat: expose file attachment stats as item properties' (#54) from issue-37-file-stats into main
Reviewed-on: #54
2026-02-09 01:26:17 +00:00
3 changed files with 90 additions and 6 deletions

View File

@@ -16,9 +16,12 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
return
}
// Disable the write deadline for this long-lived connection.
// The server's WriteTimeout (15s) would otherwise kill it.
// Disable read and write deadlines for this long-lived connection.
// The server's ReadTimeout/WriteTimeout (15s) would otherwise kill it.
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Time{}); err != nil {
s.logger.Warn().Err(err).Msg("failed to disable read deadline for SSE")
}
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE")
}

View File

@@ -212,6 +212,38 @@ export interface UpdateBOMEntryRequest {
metadata?: Record<string, unknown>;
}
// BOM Merge
export interface MergeBOMResponse {
status: string;
diff: MergeBOMDiff;
warnings: MergeWarning[];
resolve_url: string;
}
export interface MergeBOMDiff {
added: MergeDiffEntry[];
removed: MergeDiffEntry[];
quantity_changed: MergeQtyChange[];
unchanged: MergeDiffEntry[];
}
export interface MergeDiffEntry {
part_number: string;
quantity: number | null;
}
export interface MergeQtyChange {
part_number: string;
old_quantity: number | null;
new_quantity: number | null;
}
export interface MergeWarning {
type: string;
part_number: string;
message: string;
}
// Schema properties
export interface PropertyDef {
type: string;

View File

@@ -46,6 +46,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0;
const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0);
const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0);
const assemblyCount = entries.filter((e) => e.source === "assembly").length;
const formToRequest = () => ({
child_part_number: form.child_part_number,
@@ -139,12 +140,15 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
/>
</td>
<td style={tdStyle}>
<input
<select
value={form.source}
onChange={(e) => setForm({ ...form, source: e.target.value })}
placeholder="Source"
style={inputStyle}
/>
>
<option value=""></option>
<option value="manual">manual</option>
<option value="assembly">assembly</option>
</select>
</td>
<td style={tdStyle}>
<input
@@ -247,6 +251,24 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
)}
</div>
{isEditor && assemblyCount > 0 && (
<div
style={{
padding: "0.35rem 0.6rem",
marginBottom: "0.5rem",
borderRadius: "0.3rem",
backgroundColor: "rgba(148,226,213,0.1)",
border: "1px solid rgba(148,226,213,0.3)",
fontSize: "0.75rem",
color: "var(--ctp-subtext1)",
}}
>
{assemblyCount} assembly-sourced{" "}
{assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the
FreeCAD assembly will remain here until manually deleted.
</div>
)}
<div style={{ overflow: "auto" }}>
<table
style={{
@@ -289,7 +311,15 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
>
{e.child_part_number}
</td>
<td style={tdStyle}>{e.source ?? ""}</td>
<td style={tdStyle}>
{e.source === "assembly" ? (
<span style={assemblyBadge}>assembly</span>
) : e.source === "manual" ? (
<span style={manualBadge}>manual</span>
) : (
"—"
)}
</td>
<td
style={{
...tdStyle,
@@ -420,6 +450,25 @@ const saveBtnStyle: React.CSSProperties = {
marginRight: "0.25rem",
};
const sourceBadgeBase: React.CSSProperties = {
padding: "0.1rem 0.4rem",
borderRadius: "1rem",
fontSize: "0.7rem",
fontWeight: 500,
};
const assemblyBadge: React.CSSProperties = {
...sourceBadgeBase,
backgroundColor: "rgba(148,226,213,0.2)",
color: "var(--ctp-teal)",
};
const manualBadge: React.CSSProperties = {
...sourceBadgeBase,
backgroundColor: "rgba(137,180,250,0.2)",
color: "var(--ctp-blue)",
};
const cancelBtnStyle: React.CSSProperties = {
padding: "0.2rem 0.4rem",
fontSize: "0.75rem",