Merge branch 'main' into feat-38-39-sse-server-mode
This commit is contained in:
57
README.md
57
README.md
@@ -13,7 +13,7 @@ Kindred Silo is an R&D-oriented item database with:
|
||||
- **Role-based access control** (admin > editor > viewer) with API tokens and sessions
|
||||
- **ODS import/export** for items, BOMs, and project sheets
|
||||
- **Audit/completeness scoring** with weighted per-category property validation
|
||||
- **Web UI** with htmx-based item browser, project management, and schema editing
|
||||
- **Web UI** — React SPA (Vite + TypeScript, Catppuccin Mocha theme) for item browsing, project management, schema editing, and audit
|
||||
- **CAD integration** via REST API ([silo-mod](https://git.kindred-systems.com/kindred/silo-mod), [silo-calc](https://git.kindred-systems.com/kindred/silo-calc))
|
||||
- **Physical inventory** tracking with hierarchical locations (schema ready)
|
||||
|
||||
@@ -22,24 +22,33 @@ Kindred Silo is an R&D-oriented item database with:
|
||||
```
|
||||
silo/
|
||||
├── cmd/
|
||||
│ ├── silo/ # CLI tool
|
||||
│ └── silod/ # API server
|
||||
│ ├── silo/ # CLI tool
|
||||
│ └── silod/ # API server
|
||||
├── internal/
|
||||
│ ├── api/ # HTTP handlers, routes, templates (76 endpoints)
|
||||
│ ├── auth/ # Authentication (local, LDAP, OIDC)
|
||||
│ ├── config/ # Configuration loading
|
||||
│ ├── db/ # PostgreSQL repositories
|
||||
│ ├── migration/ # Property migration utilities
|
||||
│ ├── odoo/ # Odoo ERP integration
|
||||
│ ├── ods/ # ODS spreadsheet library
|
||||
│ ├── partnum/ # Part number generation
|
||||
│ ├── schema/ # YAML schema parsing
|
||||
│ └── storage/ # MinIO file storage
|
||||
├── migrations/ # Database migrations (10 files)
|
||||
├── schemas/ # Part numbering schemas (YAML)
|
||||
├── deployments/ # Docker Compose and systemd configs
|
||||
├── scripts/ # Deployment and setup scripts
|
||||
└── docs/ # Documentation
|
||||
│ ├── api/ # HTTP handlers and routes (75 endpoints)
|
||||
│ ├── auth/ # Authentication (local, LDAP, OIDC)
|
||||
│ ├── config/ # Configuration loading
|
||||
│ ├── db/ # PostgreSQL repositories
|
||||
│ ├── migration/ # Property migration utilities
|
||||
│ ├── odoo/ # Odoo ERP integration
|
||||
│ ├── ods/ # ODS spreadsheet library
|
||||
│ ├── partnum/ # Part number generation
|
||||
│ ├── schema/ # YAML schema parsing
|
||||
│ ├── storage/ # MinIO file storage
|
||||
│ └── testutil/ # Test helpers
|
||||
├── web/ # React SPA (Vite + TypeScript)
|
||||
│ └── src/
|
||||
│ ├── api/ # API client and type definitions
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── context/ # Auth context provider
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── pages/ # Page components (Items, Projects, Schemas, Settings, Audit, Login)
|
||||
│ └── styles/ # Catppuccin Mocha theme and global styles
|
||||
├── migrations/ # Database migrations (11 files)
|
||||
├── schemas/ # Part numbering schemas (YAML)
|
||||
├── deployments/ # Docker Compose and systemd configs
|
||||
├── scripts/ # Deployment and setup scripts
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -95,12 +104,16 @@ The server provides the REST API and ODS endpoints consumed by these clients.
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [docs/AUTH.md](docs/AUTH.md) | Authentication system design |
|
||||
| [docs/AUTH_USER_GUIDE.md](docs/AUTH_USER_GUIDE.md) | User guide for login, tokens, and roles |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment guide |
|
||||
| [docs/SPECIFICATION.md](docs/SPECIFICATION.md) | Full design specification and API reference |
|
||||
| [docs/STATUS.md](docs/STATUS.md) | Implementation status |
|
||||
| [ROADMAP.md](ROADMAP.md) | Feature roadmap and gap analysis |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment guide |
|
||||
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration reference (all `config.yaml` options) |
|
||||
| [docs/AUTH.md](docs/AUTH.md) | Authentication system design |
|
||||
| [docs/AUTH_USER_GUIDE.md](docs/AUTH_USER_GUIDE.md) | User guide for login, tokens, and roles |
|
||||
| [docs/GAP_ANALYSIS.md](docs/GAP_ANALYSIS.md) | Gap analysis and revision control roadmap |
|
||||
| [docs/COMPONENT_AUDIT.md](docs/COMPONENT_AUDIT.md) | Component audit tool design |
|
||||
| [ROADMAP.md](ROADMAP.md) | Feature roadmap and SOLIDWORKS PDM comparison |
|
||||
| [frontend-spec.md](frontend-spec.md) | React SPA frontend specification |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { get, post, del } from '../../api/client';
|
||||
import type { Item, Project, Revision } from '../../api/types';
|
||||
import { useState, useEffect } from "react";
|
||||
import { get, post, del } from "../../api/client";
|
||||
import type { Item, Project, Revision } from "../../api/types";
|
||||
|
||||
interface MainTabProps {
|
||||
item: Item;
|
||||
@@ -9,8 +9,14 @@ interface MainTabProps {
|
||||
}
|
||||
|
||||
function formatDate(s: string) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
if (!s) return "—";
|
||||
return new Date(s).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
@@ -21,19 +27,23 @@ function formatFileSize(bytes: number) {
|
||||
}
|
||||
|
||||
export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
const [itemProjects, setItemProjects] = useState<string[]>([]);
|
||||
const [itemProjects, setItemProjects] = useState<Project[]>([]);
|
||||
const [allProjects, setAllProjects] = useState<Project[]>([]);
|
||||
const [latestRev, setLatestRev] = useState<Revision | null>(null);
|
||||
const [addProject, setAddProject] = useState('');
|
||||
const [addProject, setAddProject] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
get<string[]>(`/api/items/${encodeURIComponent(item.part_number)}/projects`)
|
||||
get<Project[]>(
|
||||
`/api/items/${encodeURIComponent(item.part_number)}/projects`,
|
||||
)
|
||||
.then(setItemProjects)
|
||||
.catch(() => setItemProjects([]));
|
||||
get<Project[]>('/api/projects')
|
||||
get<Project[]>("/api/projects")
|
||||
.then(setAllProjects)
|
||||
.catch(() => {});
|
||||
get<Revision[]>(`/api/items/${encodeURIComponent(item.part_number)}/revisions`)
|
||||
get<Revision[]>(
|
||||
`/api/items/${encodeURIComponent(item.part_number)}/revisions`,
|
||||
)
|
||||
.then((revs) => {
|
||||
if (revs.length > 0) setLatestRev(revs[revs.length - 1]!);
|
||||
})
|
||||
@@ -43,67 +53,144 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
const handleAddProject = async () => {
|
||||
if (!addProject) return;
|
||||
try {
|
||||
await post(`/api/items/${encodeURIComponent(item.part_number)}/projects`, { projects: [addProject] });
|
||||
setItemProjects((prev) => [...prev, addProject]);
|
||||
setAddProject('');
|
||||
await post(
|
||||
`/api/items/${encodeURIComponent(item.part_number)}/projects`,
|
||||
{ projects: [addProject] },
|
||||
);
|
||||
const proj = allProjects.find((p) => p.code === addProject);
|
||||
if (proj) setItemProjects((prev) => [...prev, proj]);
|
||||
setAddProject("");
|
||||
onReload();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to add project');
|
||||
alert(e instanceof Error ? e.message : "Failed to add project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProject = async (code: string) => {
|
||||
try {
|
||||
await del(`/api/items/${encodeURIComponent(item.part_number)}/projects/${encodeURIComponent(code)}`);
|
||||
setItemProjects((prev) => prev.filter((p) => p !== code));
|
||||
await del(
|
||||
`/api/items/${encodeURIComponent(item.part_number)}/projects/${encodeURIComponent(code)}`,
|
||||
);
|
||||
setItemProjects((prev) => prev.filter((p) => p.code !== code));
|
||||
onReload();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to remove project');
|
||||
alert(e instanceof Error ? e.message : "Failed to remove project");
|
||||
}
|
||||
};
|
||||
|
||||
const row = (label: string, value: React.ReactNode) => (
|
||||
<div style={{ display: 'flex', gap: '1rem', padding: '0.3rem 0', fontSize: '0.85rem' }}>
|
||||
<span style={{ width: 120, flexShrink: 0, color: 'var(--ctp-subtext0)' }}>{label}</span>
|
||||
<span style={{ color: 'var(--ctp-text)' }}>{value}</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1rem",
|
||||
padding: "0.3rem 0",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 120, flexShrink: 0, color: "var(--ctp-subtext0)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{row('Part Number', <span style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{item.part_number}</span>)}
|
||||
{row('Description', item.description)}
|
||||
{row('Type', item.item_type)}
|
||||
{row('Sourcing', item.sourcing_type || '—')}
|
||||
{item.sourcing_link && row('Source Link', <a href={item.sourcing_link} target="_blank" rel="noreferrer">{item.sourcing_link}</a>)}
|
||||
{item.standard_cost != null && row('Std Cost', `$${item.standard_cost.toFixed(2)}`)}
|
||||
{row('Revision', `Rev ${item.current_revision}`)}
|
||||
{row('Created', formatDate(item.created_at))}
|
||||
{row('Updated', formatDate(item.updated_at))}
|
||||
{row(
|
||||
"Part Number",
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: "var(--ctp-peach)",
|
||||
}}
|
||||
>
|
||||
{item.part_number}
|
||||
</span>,
|
||||
)}
|
||||
{row("Description", item.description)}
|
||||
{row("Type", item.item_type)}
|
||||
{row("Sourcing", item.sourcing_type || "—")}
|
||||
{item.sourcing_link &&
|
||||
row(
|
||||
"Source Link",
|
||||
<a href={item.sourcing_link} target="_blank" rel="noreferrer">
|
||||
{item.sourcing_link}
|
||||
</a>,
|
||||
)}
|
||||
{item.standard_cost != null &&
|
||||
row("Std Cost", `$${item.standard_cost.toFixed(2)}`)}
|
||||
{row("Revision", `Rev ${item.current_revision}`)}
|
||||
{row("Created", formatDate(item.created_at))}
|
||||
{row("Updated", formatDate(item.updated_at))}
|
||||
|
||||
{item.long_description && (
|
||||
<div style={{ marginTop: '0.75rem', padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', fontSize: '0.85rem' }}>
|
||||
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>Long Description</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{item.long_description}</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.75rem",
|
||||
padding: "0.5rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
borderRadius: "0.4rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.75rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Long Description
|
||||
</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{item.long_description}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Tags */}
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>Projects</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', alignItems: 'center' }}>
|
||||
{itemProjects.map((code) => (
|
||||
<span key={code} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
padding: '0.1rem 0.5rem', borderRadius: '1rem',
|
||||
backgroundColor: 'rgba(203,166,247,0.15)', color: 'var(--ctp-mauve)',
|
||||
fontSize: '0.75rem',
|
||||
}}>
|
||||
{code}
|
||||
<div style={{ marginTop: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.75rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{itemProjects.map((proj) => (
|
||||
<span
|
||||
key={proj.code}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.1rem 0.5rem",
|
||||
borderRadius: "1rem",
|
||||
backgroundColor: "rgba(203,166,247,0.15)",
|
||||
color: "var(--ctp-mauve)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{proj.code}
|
||||
{isEditor && (
|
||||
<button
|
||||
onClick={() => void handleRemoveProject(code)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--ctp-overlay0)', cursor: 'pointer', fontSize: '0.8rem', padding: 0 }}
|
||||
onClick={() => void handleRemoveProject(proj.code)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--ctp-overlay0)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -116,22 +203,36 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
value={addProject}
|
||||
onChange={(e) => setAddProject(e.target.value)}
|
||||
style={{
|
||||
padding: '0.1rem 0.3rem', fontSize: '0.75rem',
|
||||
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
||||
padding: "0.1rem 0.3rem",
|
||||
fontSize: "0.75rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
}}
|
||||
>
|
||||
<option value="">+</option>
|
||||
{allProjects
|
||||
.filter((p) => !itemProjects.includes(p.code))
|
||||
.map((p) => <option key={p.code} value={p.code}>{p.code}</option>)}
|
||||
.filter((p) => !itemProjects.some((ip) => ip.code === p.code))
|
||||
.map((p) => (
|
||||
<option key={p.code} value={p.code}>
|
||||
{p.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{addProject && (
|
||||
<button onClick={() => void handleAddProject()} style={{
|
||||
padding: '0.1rem 0.4rem', fontSize: '0.7rem', border: 'none',
|
||||
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)',
|
||||
borderRadius: '0.3rem', cursor: 'pointer',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => void handleAddProject()}
|
||||
style={{
|
||||
padding: "0.1rem 0.4rem",
|
||||
fontSize: "0.7rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
color: "var(--ctp-crust)",
|
||||
borderRadius: "0.3rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
@@ -142,21 +243,58 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
|
||||
{/* File Info */}
|
||||
{latestRev?.file_key && (
|
||||
<div style={{ marginTop: '0.75rem', padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem' }}>
|
||||
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>File Attachment (Rev {latestRev.revision_number})</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.85rem' }}>
|
||||
{latestRev.file_size != null && <span>{formatFileSize(latestRev.file_size)}</span>}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.75rem",
|
||||
padding: "0.5rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
borderRadius: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.75rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
File Attachment (Rev {latestRev.revision_number})
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{latestRev.file_size != null && (
|
||||
<span>{formatFileSize(latestRev.file_size)}</span>
|
||||
)}
|
||||
{latestRev.file_checksum && (
|
||||
<span title={latestRev.file_checksum} style={{ color: 'var(--ctp-overlay1)', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
<span
|
||||
title={latestRev.file_checksum}
|
||||
style={{
|
||||
color: "var(--ctp-overlay1)",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
SHA256: {latestRev.file_checksum.substring(0, 12)}...
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`; }}
|
||||
onClick={() => {
|
||||
window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`;
|
||||
}}
|
||||
style={{
|
||||
padding: '0.2rem 0.5rem', fontSize: '0.8rem', border: 'none',
|
||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)',
|
||||
borderRadius: '0.3rem', cursor: 'pointer',
|
||||
padding: "0.2rem 0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
borderRadius: "0.3rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Download
|
||||
|
||||
Reference in New Issue
Block a user