Compare commits
38 Commits
issue-dedu
...
test-cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
414a5cf3d6 | ||
|
|
257e3d99ac | ||
|
|
384b137148 | ||
|
|
7c838bdf5e | ||
|
|
c9b081b8f8 | ||
| bc1149d4ba | |||
|
|
07c4aa1c28 | ||
|
|
679b730e74 | ||
|
|
b53ce94274 | ||
| 8316ac085c | |||
|
|
d5f1b4e587 | ||
|
|
f4a1c8004b | ||
|
|
a9614e704e | ||
|
|
289d488469 | ||
|
|
2585305590 | ||
| 65063c9ee7 | |||
|
|
1f7960db50 | ||
|
|
648c659e2b | ||
|
|
d4ea6d2739 | ||
| e20da25405 | |||
|
|
30bb3ee56e | ||
| a517a95912 | |||
| 6f1504021c | |||
| d93770c551 | |||
| 606316204d | |||
| 3d9ef9e99e | |||
| fb13795ef7 | |||
| 1c1cd144dc | |||
| 460b0f37fd | |||
| 73195be6a1 | |||
| 127836f7ce | |||
| a258152175 | |||
| efb3ccdfb5 | |||
| a80e99e500 | |||
| 485675b020 | |||
| a49680b274 | |||
| 32bc00caef | |||
| 2b7a9ae73a |
24
.env.example
24
.env.example
@@ -1,12 +1,26 @@
|
|||||||
# Silo Environment Configuration
|
# Silo Environment Configuration
|
||||||
# Copy this file to .env and update values as needed
|
# Copy to .env (or deployments/.env) and update values as needed.
|
||||||
|
# For automated setup, run: ./scripts/setup-docker.sh
|
||||||
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_PASSWORD=silodev
|
POSTGRES_PASSWORD=silodev
|
||||||
|
|
||||||
# MinIO
|
# MinIO
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=silominio
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=silominiosecret
|
||||||
|
|
||||||
# Silo API (optional overrides)
|
# OpenLDAP
|
||||||
# SILO_SERVER_PORT=8080
|
LDAP_ADMIN_PASSWORD=ldapadmin
|
||||||
|
LDAP_USERS=siloadmin
|
||||||
|
LDAP_PASSWORDS=siloadmin
|
||||||
|
|
||||||
|
# Silo Authentication
|
||||||
|
SILO_SESSION_SECRET=change-me-in-production
|
||||||
|
SILO_ADMIN_USERNAME=admin
|
||||||
|
SILO_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# Optional: OIDC (Keycloak)
|
||||||
|
# SILO_OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional: LDAP service account
|
||||||
|
# SILO_LDAP_BIND_PASSWORD=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ Thumbs.db
|
|||||||
# Config with secrets
|
# Config with secrets
|
||||||
config.yaml
|
config.yaml
|
||||||
*.env
|
*.env
|
||||||
|
deployments/config.docker.yaml
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -25,7 +25,7 @@ silo/
|
|||||||
│ ├── silo/ # CLI tool
|
│ ├── silo/ # CLI tool
|
||||||
│ └── silod/ # API server
|
│ └── silod/ # API server
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── api/ # HTTP handlers and routes (75 endpoints)
|
│ ├── api/ # HTTP handlers and routes (78 endpoints)
|
||||||
│ ├── auth/ # Authentication (local, LDAP, OIDC)
|
│ ├── auth/ # Authentication (local, LDAP, OIDC)
|
||||||
│ ├── config/ # Configuration loading
|
│ ├── config/ # Configuration loading
|
||||||
│ ├── db/ # PostgreSQL repositories
|
│ ├── db/ # PostgreSQL repositories
|
||||||
@@ -53,15 +53,20 @@ silo/
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions.
|
||||||
# Docker Compose (quickest)
|
|
||||||
cp config.example.yaml config.yaml
|
|
||||||
# Edit config.yaml with your database, MinIO, and auth settings
|
|
||||||
make docker-up
|
|
||||||
|
|
||||||
# Or manual setup
|
**Docker Compose (quickest — includes PostgreSQL, MinIO, OpenLDAP, and Silo):**
|
||||||
psql -h localhost -U silo -d silo -f migrations/*.sql
|
|
||||||
go run ./cmd/silod -config config.yaml
|
```bash
|
||||||
|
./scripts/setup-docker.sh
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development (local Go + Docker services):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-up # Start PostgreSQL + MinIO in Docker
|
||||||
|
make run # Run silo locally with Go
|
||||||
```
|
```
|
||||||
|
|
||||||
When auth is enabled, a default admin account is created on first startup using the credentials in `config.yaml` under `auth.local.default_admin_username` and `auth.local.default_admin_password`.
|
When auth is enabled, a default admin account is created on first startup using the credentials in `config.yaml` under `auth.local.default_admin_username` and `auth.local.default_admin_password`.
|
||||||
@@ -104,15 +109,16 @@ The server provides the REST API and ODS endpoints consumed by these clients.
|
|||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
|
| [docs/INSTALL.md](docs/INSTALL.md) | Installation guide (Docker Compose and daemon) |
|
||||||
| [docs/SPECIFICATION.md](docs/SPECIFICATION.md) | Full design specification and API reference |
|
| [docs/SPECIFICATION.md](docs/SPECIFICATION.md) | Full design specification and API reference |
|
||||||
| [docs/STATUS.md](docs/STATUS.md) | Implementation status |
|
| [docs/STATUS.md](docs/STATUS.md) | Implementation status |
|
||||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment guide |
|
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment and operations guide |
|
||||||
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration reference (all `config.yaml` options) |
|
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration reference (all `config.yaml` options) |
|
||||||
| [docs/AUTH.md](docs/AUTH.md) | Authentication system design |
|
| [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/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/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 |
|
| [docs/COMPONENT_AUDIT.md](docs/COMPONENT_AUDIT.md) | Component audit tool design |
|
||||||
| [ROADMAP.md](ROADMAP.md) | Feature roadmap and SOLIDWORKS PDM comparison |
|
| [docs/ROADMAP.md](docs/ROADMAP.md) | Platform roadmap, dependency tiers, and gap summary |
|
||||||
| [frontend-spec.md](frontend-spec.md) | React SPA frontend specification |
|
| [frontend-spec.md](frontend-spec.md) | React SPA frontend specification |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
536
ROADMAP.md
536
ROADMAP.md
@@ -1,536 +0,0 @@
|
|||||||
# Silo Roadmap
|
|
||||||
|
|
||||||
**Version:** 1.1
|
|
||||||
**Date:** February 2026
|
|
||||||
**Purpose:** Project inventory, SOLIDWORKS PDM gap analysis, and development roadmap
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Executive Summary](#executive-summary)
|
|
||||||
2. [Current Project Inventory](#current-project-inventory)
|
|
||||||
3. [SOLIDWORKS PDM Gap Analysis](#solidworks-pdm-gap-analysis)
|
|
||||||
4. [Feature Roadmap](#feature-roadmap)
|
|
||||||
5. [Implementation Phases](#implementation-phases)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Silo is an R&D-oriented item database and part management system. It provides configurable part number generation, revision tracking, BOM management, and file versioning through MinIO storage. CAD integration (FreeCAD workbench, LibreOffice Calc extension) is maintained in separate repositories ([silo-mod](https://git.kindred-systems.com/kindred/silo-mod), [silo-calc](https://git.kindred-systems.com/kindred/silo-calc)).
|
|
||||||
|
|
||||||
This document compares Silo's current capabilities against SOLIDWORKS PDM—the industry-leading product data management solution—to identify gaps and prioritize future development.
|
|
||||||
|
|
||||||
### Key Differentiators
|
|
||||||
|
|
||||||
| Aspect | Silo | SOLIDWORKS PDM |
|
|
||||||
|--------|------|----------------|
|
|
||||||
| **Target CAD** | FreeCAD / Kindred Create (open source) | SOLIDWORKS (proprietary) |
|
|
||||||
| **Part Numbering** | Schema-as-configuration (YAML) | Fixed format with some customization |
|
|
||||||
| **Licensing** | Open source / Kindred Proprietary | Commercial ($3,000-$10,000+ per seat) |
|
|
||||||
| **Storage** | PostgreSQL + MinIO (S3-compatible) | SQL Server + File Archive |
|
|
||||||
| **Philosophy** | R&D-oriented, lightweight | Enterprise-grade, comprehensive |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Project Inventory
|
|
||||||
|
|
||||||
### Implemented Features (MVP Complete)
|
|
||||||
|
|
||||||
#### Core Database System
|
|
||||||
- PostgreSQL schema with 11 migrations
|
|
||||||
- UUID-based identifiers throughout
|
|
||||||
- Soft delete support via `archived_at` timestamps
|
|
||||||
- Atomic sequence generation for part numbers
|
|
||||||
|
|
||||||
#### Part Number Generation
|
|
||||||
- YAML schema parser with validation
|
|
||||||
- Segment types: `string`, `enum`, `serial`, `constant`
|
|
||||||
- Scope templates for serial counters (e.g., `{category}`, `{project}`)
|
|
||||||
- Format templates for custom output
|
|
||||||
|
|
||||||
#### Item Management
|
|
||||||
- Full CRUD operations for items
|
|
||||||
- Item types: part, assembly, drawing, document, tooling, purchased, electrical, software
|
|
||||||
- Custom properties via JSONB storage
|
|
||||||
- Project tagging with many-to-many relationships
|
|
||||||
|
|
||||||
#### Revision Control
|
|
||||||
- Append-only revision history
|
|
||||||
- Revision metadata: properties, file reference, checksum, comment
|
|
||||||
- Status tracking: draft, review, released, obsolete
|
|
||||||
- Labels/tags per revision
|
|
||||||
- Revision comparison (diff)
|
|
||||||
- Rollback functionality
|
|
||||||
|
|
||||||
#### File Management
|
|
||||||
- MinIO integration with versioning
|
|
||||||
- File upload/download via REST API
|
|
||||||
- SHA256 checksums for integrity
|
|
||||||
- Storage path: `items/{partNumber}/rev{N}.FCStd`
|
|
||||||
|
|
||||||
#### Bill of Materials (BOM)
|
|
||||||
- Relationship types: component, alternate, reference
|
|
||||||
- Multi-level BOM (recursive expansion with configurable depth)
|
|
||||||
- Where-used queries (reverse parent lookup)
|
|
||||||
- BOM CSV and ODS export/import with cycle detection
|
|
||||||
- Reference designators for electronics
|
|
||||||
- Quantity tracking with units
|
|
||||||
- Revision-specific child linking
|
|
||||||
|
|
||||||
#### Project Management
|
|
||||||
- Project CRUD operations
|
|
||||||
- Unique project codes (2-10 characters)
|
|
||||||
- Item-to-project tagging
|
|
||||||
- Project-filtered queries
|
|
||||||
|
|
||||||
#### Data Import/Export
|
|
||||||
- CSV export with configurable properties
|
|
||||||
- CSV import with dry-run validation
|
|
||||||
- ODS spreadsheet import/export (items, BOMs, project sheets)
|
|
||||||
- Template generation for import formatting
|
|
||||||
|
|
||||||
#### API & Web Interface
|
|
||||||
- REST API with 75 endpoints
|
|
||||||
- Authentication: local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak
|
|
||||||
- Role-based access control (admin > editor > viewer)
|
|
||||||
- API token management (SHA-256 hashed)
|
|
||||||
- Session management (PostgreSQL-backed, 24h lifetime)
|
|
||||||
- CSRF protection (nosurf on web forms)
|
|
||||||
- Middleware: logging, CORS, recovery, request ID
|
|
||||||
- Web UI — React SPA (Vite + TypeScript, Catppuccin Mocha theme)
|
|
||||||
- Fuzzy search
|
|
||||||
- Health and readiness probes
|
|
||||||
|
|
||||||
#### Audit & Completeness
|
|
||||||
- Audit logging (database table with user/action/resource tracking)
|
|
||||||
- Item completeness scoring with weighted fields
|
|
||||||
- Category-specific property validation
|
|
||||||
- Tier classification (critical/low/partial/good/complete)
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
- YAML configuration with environment variable overrides
|
|
||||||
- Multi-schema support
|
|
||||||
- Docker Compose deployment ready
|
|
||||||
|
|
||||||
### Partially Implemented
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull sync operations are stubs |
|
|
||||||
| Date segment type | Not started | Schema parser placeholder exists |
|
|
||||||
| Part number validation | Not started | API accepts but doesn't validate format |
|
|
||||||
| Location hierarchy CRUD | Schema only | Tables exist, no API endpoints |
|
|
||||||
| Inventory tracking | Schema only | Tables exist, no API endpoints |
|
|
||||||
| Unit tests | Partial | 9 Go test files across api, db, ods, partnum, schema packages |
|
|
||||||
|
|
||||||
### Infrastructure Status
|
|
||||||
|
|
||||||
| Component | Status |
|
|
||||||
|-----------|--------|
|
|
||||||
| PostgreSQL | Running (psql.kindred.internal) |
|
|
||||||
| MinIO | Configured in Docker Compose |
|
|
||||||
| Silo API Server | Builds successfully |
|
|
||||||
| Docker Compose | Complete (dev and production) |
|
|
||||||
| systemd service | Unit file and env template ready |
|
|
||||||
| Deployment scripts | setup-host, deploy, init-db, setup-ipa-nginx |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SOLIDWORKS PDM Gap Analysis
|
|
||||||
|
|
||||||
This section compares Silo's capabilities against SOLIDWORKS PDM features. Gaps are categorized by priority and implementation complexity.
|
|
||||||
|
|
||||||
### Legend
|
|
||||||
- **Silo Status:** Full / Partial / None
|
|
||||||
- **Priority:** Critical / High / Medium / Low
|
|
||||||
- **Complexity:** Simple / Moderate / Complex
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. Version Control & Revision Management
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Check-in/check-out | Full pessimistic locking | None | High | Moderate |
|
|
||||||
| Version history | Complete with branching | Full (linear) | - | - |
|
|
||||||
| Revision labels | A, B, C or custom schemes | Full (custom labels) | - | - |
|
|
||||||
| Rollback/restore | Full | Full | - | - |
|
|
||||||
| Compare revisions | Visual + metadata diff | Metadata diff only | Medium | Complex |
|
|
||||||
| Get Latest Revision | One-click retrieval | Partial (API only) | Medium | Simple |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Silo lacks pessimistic locking (check-out), which is critical for multi-user CAD environments where file merging is impractical. Visual diff comparison would require FreeCAD integration for CAD file visualization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Workflow Management
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Custom workflows | Full visual designer | None | Critical | Complex |
|
|
||||||
| State transitions | Configurable with permissions | Basic (status field only) | Critical | Complex |
|
|
||||||
| Parallel approvals | Multiple approvers required | None | High | Complex |
|
|
||||||
| Automatic transitions | Timer/condition-based | None | Medium | Moderate |
|
|
||||||
| Email notifications | On state change | None | High | Moderate |
|
|
||||||
| ECO process | Built-in change management | None | High | Complex |
|
|
||||||
| Child state conditions | Block parent if children invalid | None | Medium | Moderate |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Workflow management is the largest functional gap. SOLIDWORKS PDM offers sophisticated state machines with parallel approvals, automatic transitions, and deep integration with engineering change processes. Silo currently has only a simple status field (draft/review/released/obsolete) with no transition rules or approval processes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. User Management & Security
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| User authentication | Windows AD, LDAP | Full (local, LDAP, OIDC) | - | - |
|
|
||||||
| Role-based permissions | Granular per folder/state | Partial (3-tier role model) | Medium | Moderate |
|
|
||||||
| Group management | Full | None | Medium | Moderate |
|
|
||||||
| Folder permissions | Read/write/delete per folder | None | Medium | Moderate |
|
|
||||||
| State permissions | Actions allowed per state | None | High | Moderate |
|
|
||||||
| Audit trail | Complete action logging | Full | - | - |
|
|
||||||
| Private files | Pre-check-in visibility control | None | Low | Simple |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Authentication is implemented with three backends (local, LDAP/FreeIPA, OIDC/Keycloak) and a 3-tier role model (admin > editor > viewer). Audit logging captures user actions. Remaining gaps: group management, folder-level permissions, and state-based permission rules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Search & Discovery
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Metadata search | Full with custom cards | Partial (API query params + fuzzy) | High | Moderate |
|
|
||||||
| Full-text content search | iFilters for Office, CAD | None | Medium | Complex |
|
|
||||||
| Quick search | Toolbar with history | Partial (fuzzy search API) | Medium | Simple |
|
|
||||||
| Saved searches | User-defined favorites | None | Medium | Simple |
|
|
||||||
| Advanced operators | AND, OR, NOT, wildcards | None | Medium | Simple |
|
|
||||||
| Multi-variable search | Search across multiple fields | None | Medium | Simple |
|
|
||||||
| Where-used search | Find all assemblies using part | Full | - | - |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Silo has API-level filtering, fuzzy search, and where-used queries. Remaining gaps: saved searches, advanced search operators, and a richer search UI. Content search (searching within CAD files) is not planned for the server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. BOM Management
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Single-level BOM | Yes | Full | - | - |
|
|
||||||
| Multi-level BOM | Indented/exploded views | Full (recursive, configurable depth) | - | - |
|
|
||||||
| BOM comparison | Between revisions | None | Medium | Moderate |
|
|
||||||
| BOM export | Excel, XML, ERP formats | Full (CSV, ODS) | - | - |
|
|
||||||
| BOM import | Bulk BOM loading | Full (CSV with upsert) | - | - |
|
|
||||||
| Calculated BOMs | Quantities rolled up | None | Medium | Moderate |
|
|
||||||
| Reference designators | Full support | Full | - | - |
|
|
||||||
| Alternate parts | Substitute tracking | Full | - | - |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Multi-level BOM retrieval (recursive CTE with configurable depth) and BOM export (CSV, ODS) are implemented. BOM import supports CSV with upsert and cycle detection. Remaining gap: BOM comparison between revisions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. CAD Integration
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Native CAD add-in | Deep SOLIDWORKS integration | FreeCAD workbench (silo-mod) | Medium | Complex |
|
|
||||||
| Property mapping | Bi-directional sync | Planned (silo-mod) | Medium | Moderate |
|
|
||||||
| Task pane | Embedded in CAD UI | Auth dock panel (silo-mod) | Medium | Complex |
|
|
||||||
| Lightweight components | Handle without full load | N/A | - | - |
|
|
||||||
| Drawing/model linking | Automatic association | Manual | Medium | Moderate |
|
|
||||||
| Multi-CAD support | Third-party formats | FreeCAD only | Low | - |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
CAD integration is maintained in separate repositories ([silo-mod](https://git.kindred-systems.com/kindred/silo-mod), [silo-calc](https://git.kindred-systems.com/kindred/silo-calc)). The Silo server provides the REST API endpoints consumed by those clients.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. External Integrations
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| ERP integration | SAP, Dynamics, etc. | Partial (Odoo stubs) | Medium | Complex |
|
|
||||||
| API access | Full COM/REST API | Full REST API (75 endpoints) | - | - |
|
|
||||||
| Dispatch scripts | Automation without coding | None | Medium | Moderate |
|
|
||||||
| Task scheduler | Background processing | None | Medium | Moderate |
|
|
||||||
| Email system | SMTP integration | None | High | Simple |
|
|
||||||
| Web portal | Browser access | Full (React SPA + auth) | - | - |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Silo has a comprehensive REST API (75 endpoints) and a full web UI with authentication. Odoo ERP integration has config/sync-log scaffolding but push/pull operations are stubs. Remaining gaps: email notifications, task scheduler, dispatch automation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Reporting & Analytics
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| Standard reports | Inventory, usage, activity | None | Medium | Moderate |
|
|
||||||
| Custom reports | User-defined queries | None | Medium | Moderate |
|
|
||||||
| Dashboard | Visual KPIs | None | Low | Moderate |
|
|
||||||
| Export formats | PDF, Excel, CSV | CSV and ODS | Medium | Simple |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
Reporting capabilities are absent. Basic reports (item counts, revision activity, where-used) would provide immediate value.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. File Handling
|
|
||||||
|
|
||||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
|
||||||
|---------|---------------|-------------|----------|------------|
|
|
||||||
| File versioning | Automatic | Full (MinIO) | - | - |
|
|
||||||
| File preview | Thumbnails, 3D preview | None | Medium | Complex |
|
|
||||||
| File conversion | PDF, DXF generation | None | Medium | Complex |
|
|
||||||
| Replication | Multi-site sync | None | Low | Complex |
|
|
||||||
| File copy with refs | Copy tree with references | None | Medium | Moderate |
|
|
||||||
|
|
||||||
**Gap Analysis:**
|
|
||||||
File storage works well. Thumbnail generation and file preview would significantly improve the web UI experience. Automatic conversion to PDF/DXF is valuable for sharing with non-CAD users.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Gap Summary by Priority
|
|
||||||
|
|
||||||
#### Completed (Previously Critical/High)
|
|
||||||
1. ~~**User authentication**~~ - Implemented: local, LDAP, OIDC
|
|
||||||
2. ~~**Role-based permissions**~~ - Implemented: 3-tier role model (admin/editor/viewer)
|
|
||||||
3. ~~**Audit trail**~~ - Implemented: audit_log table with completeness scoring
|
|
||||||
4. ~~**Where-used search**~~ - Implemented: reverse parent lookup API
|
|
||||||
5. ~~**Multi-level BOM API**~~ - Implemented: recursive expansion with configurable depth
|
|
||||||
6. ~~**BOM export**~~ - Implemented: CSV and ODS formats
|
|
||||||
|
|
||||||
#### Critical Gaps (Required for Team Use)
|
|
||||||
1. **Workflow engine** - State machines with transitions and approvals
|
|
||||||
2. **Check-out locking** - Pessimistic locking for CAD files
|
|
||||||
|
|
||||||
#### High Priority Gaps (Significant Value)
|
|
||||||
1. **Email notifications** - Alert users on state changes
|
|
||||||
2. **Web UI search** - Advanced search interface with saved searches
|
|
||||||
3. **Folder/state permissions** - Granular access control beyond role model
|
|
||||||
|
|
||||||
#### Medium Priority Gaps (Nice to Have)
|
|
||||||
1. **Saved searches** - Frequently used queries
|
|
||||||
2. **File preview/thumbnails** - Visual browsing
|
|
||||||
3. **Reporting** - Activity and inventory reports
|
|
||||||
4. **Scheduled tasks** - Background automation
|
|
||||||
5. **BOM comparison** - Revision diff for assemblies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Foundation (Current - Q2 2026)
|
|
||||||
*Complete MVP and stabilize core functionality*
|
|
||||||
|
|
||||||
| Feature | Description | Status |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| MinIO integration | File upload/download with versioning and checksums | Complete |
|
|
||||||
| Revision control | Rollback, comparison, status/labels | Complete |
|
|
||||||
| CSV import/export | Dry-run validation, template generation | Complete |
|
|
||||||
| ODS import/export | Items, BOMs, project sheets, templates | Complete |
|
|
||||||
| Project management | CRUD, many-to-many item tagging | Complete |
|
|
||||||
| Multi-level BOM | Recursive expansion, where-used, export | Complete |
|
|
||||||
| Authentication | Local, LDAP, OIDC with role-based access | Complete |
|
|
||||||
| Audit logging | Action logging, completeness scoring | Complete |
|
|
||||||
| Unit tests | Core API and database operations | Not Started |
|
|
||||||
| Date segment type | Support date-based part number segments | Not Started |
|
|
||||||
| Part number validation | Validate format on creation | Not Started |
|
|
||||||
| Location CRUD API | Expose location hierarchy via REST | Not Started |
|
|
||||||
| Inventory API | Expose inventory operations via REST | Not Started |
|
|
||||||
|
|
||||||
### Phase 2: Multi-User (Q2-Q3 2026)
|
|
||||||
*Enable team collaboration*
|
|
||||||
|
|
||||||
| Feature | Description | Status |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| LDAP authentication | Integrate with FreeIPA/Active Directory | **Complete** |
|
|
||||||
| OIDC authentication | Keycloak / OpenID Connect | **Complete** |
|
|
||||||
| Audit logging | Record all user actions with timestamps | **Complete** |
|
|
||||||
| Session management | Token-based and session-based API authentication | **Complete** |
|
|
||||||
| User/group management | Create, assign, manage users and groups | Not Started |
|
|
||||||
| Folder permissions | Read/write/delete per folder hierarchy | Not Started |
|
|
||||||
| Check-out locking | Pessimistic locks with timeout | Not Started |
|
|
||||||
|
|
||||||
### Phase 3: Workflow Engine (Q3-Q4 2026)
|
|
||||||
*Implement engineering change processes*
|
|
||||||
|
|
||||||
| Feature | Description | Complexity |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| Workflow designer | YAML-defined state machines | Complex |
|
|
||||||
| State transitions | Configurable transition rules | Complex |
|
|
||||||
| Transition permissions | Who can execute which transitions | Moderate |
|
|
||||||
| Single approvals | Basic approval workflow | Moderate |
|
|
||||||
| Parallel approvals | Multi-approver gates | Complex |
|
|
||||||
| Automatic transitions | Timer and condition-based | Complex |
|
|
||||||
| Email notifications | SMTP integration for alerts | Simple |
|
|
||||||
| Child state conditions | Block parent transitions | Moderate |
|
|
||||||
|
|
||||||
### Phase 4: Search & Discovery (Q4 2026 - Q1 2027)
|
|
||||||
*Improve findability and navigation*
|
|
||||||
|
|
||||||
| Feature | Description | Status |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| Where-used queries | Find parent assemblies | **Complete** |
|
|
||||||
| Fuzzy search | Quick search across items | **Complete** |
|
|
||||||
| Advanced search UI | Web interface with filters | Not Started |
|
|
||||||
| Search operators | AND, OR, NOT, wildcards | Not Started |
|
|
||||||
| Saved searches | User favorites | Not Started |
|
|
||||||
| Content search | Search within file content | Not Started |
|
|
||||||
|
|
||||||
### Phase 5: BOM & Reporting (Q1-Q2 2027)
|
|
||||||
*Enhanced BOM management and analytics*
|
|
||||||
|
|
||||||
| Feature | Description | Status |
|
|
||||||
|---------|-------------|--------|
|
|
||||||
| Multi-level BOM API | Recursive assembly retrieval | **Complete** |
|
|
||||||
| BOM export | CSV and ODS formats | **Complete** |
|
|
||||||
| BOM import | CSV with upsert and cycle detection | **Complete** |
|
|
||||||
| BOM comparison | Diff between revisions | Not Started |
|
|
||||||
| Standard reports | Activity, inventory, usage | Not Started |
|
|
||||||
| Custom queries | User-defined report builder | Not Started |
|
|
||||||
| Dashboard | Visual KPIs and metrics | Not Started |
|
|
||||||
|
|
||||||
### Phase 6: Advanced Features (Q2-Q4 2027)
|
|
||||||
*Enterprise capabilities*
|
|
||||||
|
|
||||||
| Feature | Description | Complexity |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| File preview | Thumbnail generation | Complex |
|
|
||||||
| File conversion | Auto-generate PDF/DXF | Complex |
|
|
||||||
| ERP integration | Adapter framework | Complex |
|
|
||||||
| Task scheduler | Background job processing | Moderate |
|
|
||||||
| Webhooks | Event notifications to external systems | Moderate |
|
|
||||||
| API rate limiting | Protect against abuse | Simple |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1 Detailed Tasks
|
|
||||||
|
|
||||||
#### 1.1 MinIO Integration -- COMPLETE
|
|
||||||
- [x] MinIO service configured in Docker Compose
|
|
||||||
- [x] File upload via REST API
|
|
||||||
- [x] File download via REST API (latest and by revision)
|
|
||||||
- [x] SHA256 checksums on upload
|
|
||||||
|
|
||||||
#### 1.2 Authentication & Authorization -- COMPLETE
|
|
||||||
- [x] Local authentication (bcrypt)
|
|
||||||
- [x] LDAP/FreeIPA authentication
|
|
||||||
- [x] OIDC/Keycloak authentication
|
|
||||||
- [x] Role-based access control (admin/editor/viewer)
|
|
||||||
- [x] API token management (SHA-256 hashed)
|
|
||||||
- [x] Session management (PostgreSQL-backed)
|
|
||||||
- [x] CSRF protection (nosurf)
|
|
||||||
- [x] Audit logging (database table)
|
|
||||||
|
|
||||||
#### 1.3 Multi-level BOM & Export -- COMPLETE
|
|
||||||
- [x] Recursive BOM expansion with configurable depth
|
|
||||||
- [x] Where-used reverse lookup
|
|
||||||
- [x] BOM CSV export/import with cycle detection
|
|
||||||
- [x] BOM ODS export
|
|
||||||
- [x] ODS item export/import/template
|
|
||||||
|
|
||||||
#### 1.4 Unit Test Suite
|
|
||||||
- [ ] Database connection and transaction tests
|
|
||||||
- [ ] Item CRUD operation tests
|
|
||||||
- [ ] Revision creation and retrieval tests
|
|
||||||
- [ ] Part number generation tests
|
|
||||||
- [ ] File upload/download tests
|
|
||||||
- [ ] CSV import/export tests
|
|
||||||
- [ ] API endpoint tests
|
|
||||||
|
|
||||||
#### 1.5 Missing Segment Types
|
|
||||||
- [ ] Implement date segment type
|
|
||||||
- [ ] Add strftime-style format support
|
|
||||||
|
|
||||||
#### 1.6 Location & Inventory APIs
|
|
||||||
- [ ] `GET /api/locations` - List locations
|
|
||||||
- [ ] `POST /api/locations` - Create location
|
|
||||||
- [ ] `GET /api/locations/{path}` - Get location
|
|
||||||
- [ ] `DELETE /api/locations/{path}` - Delete location
|
|
||||||
- [ ] `GET /api/inventory/{partNumber}` - Get inventory
|
|
||||||
- [ ] `POST /api/inventory/{partNumber}/adjust` - Adjust quantity
|
|
||||||
- [ ] `POST /api/inventory/{partNumber}/move` - Move between locations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
### Phase 1 (Foundation)
|
|
||||||
- All existing tests pass
|
|
||||||
- File upload/download works end-to-end
|
|
||||||
- FreeCAD users can checkout, modify, commit parts
|
|
||||||
|
|
||||||
### Phase 2 (Multi-User)
|
|
||||||
- 5+ concurrent users supported
|
|
||||||
- No data corruption under concurrent access
|
|
||||||
- Audit log captures all modifications
|
|
||||||
|
|
||||||
### Phase 3 (Workflow)
|
|
||||||
- Engineering change process completable in Silo
|
|
||||||
- Email notifications delivered reliably
|
|
||||||
- Workflow state visible in web UI
|
|
||||||
|
|
||||||
### Phase 4+ (Advanced)
|
|
||||||
- Search returns results in <2 seconds
|
|
||||||
- Where-used queries complete in <5 seconds
|
|
||||||
- BOM export matches assembly structure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
### SOLIDWORKS PDM Documentation
|
|
||||||
- [SOLIDWORKS PDM Product Page](https://www.solidworks.com/product/solidworks-pdm)
|
|
||||||
- [What's New in SOLIDWORKS PDM 2025](https://blogs.solidworks.com/solidworksblog/2024/10/whats-new-in-solidworks-pdm-2025.html)
|
|
||||||
- [Top 5 Enhancements in SOLIDWORKS PDM 2024](https://blogs.solidworks.com/solidworksblog/2023/10/top-5-enhancements-in-solidworks-pdm-2024.html)
|
|
||||||
- [SOLIDWORKS PDM Workflow Transitions](https://help.solidworks.com/2023/english/EnterprisePDM/Admin/c_workflow_transition.htm)
|
|
||||||
- [Ultimate Guide to SOLIDWORKS PDM Permissions](https://www.goengineer.com/blog/ultimate-guide-to-solidworks-pdm-permissions)
|
|
||||||
- [Searching in SOLIDWORKS PDM](https://help.solidworks.com/2021/english/EnterprisePDM/fileexplorer/c_searches.htm)
|
|
||||||
- [SOLIDWORKS PDM API Getting Started](https://3dswym.3dexperience.3ds.com/wiki/solidworks-news-info/getting-started-with-the-solidworks-pdm-api-solidpractices_gBCYaM75RgORBcpSO1m_Mw)
|
|
||||||
|
|
||||||
### Silo Documentation
|
|
||||||
- [Specification](docs/SPECIFICATION.md)
|
|
||||||
- [Development Status](docs/STATUS.md)
|
|
||||||
- [Deployment Guide](docs/DEPLOYMENT.md)
|
|
||||||
- [Gap Analysis](docs/GAP_ANALYSIS.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix: Feature Comparison Matrix
|
|
||||||
|
|
||||||
| Category | Feature | SW PDM Standard | SW PDM Pro | Silo Current | Silo Planned |
|
|
||||||
|----------|---------|-----------------|------------|--------------|--------------|
|
|
||||||
| **Version Control** | Check-in/out | Yes | Yes | No | Phase 2 |
|
|
||||||
| | Version history | Yes | Yes | Yes | - |
|
|
||||||
| | Rollback | Yes | Yes | Yes | - |
|
|
||||||
| | Revision labels/status | Yes | Yes | Yes | - |
|
|
||||||
| | Revision comparison | Yes | Yes | Yes (metadata) | - |
|
|
||||||
| **Workflow** | Custom workflows | Limited | Yes | No | Phase 3 |
|
|
||||||
| | Parallel approval | No | Yes | No | Phase 3 |
|
|
||||||
| | Notifications | No | Yes | No | Phase 3 |
|
|
||||||
| **Security** | User auth | Windows | Windows/LDAP | Yes (local, LDAP, OIDC) | - |
|
|
||||||
| | Permissions | Basic | Granular | Partial (role-based) | Phase 2 |
|
|
||||||
| | Audit trail | Basic | Full | Yes | - |
|
|
||||||
| **Search** | Metadata search | Yes | Yes | Partial (API + fuzzy) | Phase 4 |
|
|
||||||
| | Content search | No | Yes | No | Phase 4 |
|
|
||||||
| | Where-used | Yes | Yes | Yes | - |
|
|
||||||
| **BOM** | Single-level | Yes | Yes | Yes | - |
|
|
||||||
| | Multi-level | Yes | Yes | Yes (recursive) | - |
|
|
||||||
| | BOM export | Yes | Yes | Yes (CSV, ODS) | - |
|
|
||||||
| **Data** | CSV import/export | Yes | Yes | Yes | - |
|
|
||||||
| | ODS import/export | No | No | Yes | - |
|
|
||||||
| | Project management | Yes | Yes | Yes | - |
|
|
||||||
| **Integration** | API | Limited | Full | Full REST (75) | - |
|
|
||||||
| | ERP connectors | No | Yes | Partial (Odoo stubs) | Phase 6 |
|
|
||||||
| | Web access | No | Yes | Yes (React SPA + auth) | - |
|
|
||||||
| **Files** | Versioning | Yes | Yes | Yes | - |
|
|
||||||
| | Preview | Yes | Yes | No | Phase 6 |
|
|
||||||
| | Multi-site | No | Yes | No | Not Planned |
|
|
||||||
@@ -66,7 +66,7 @@ Token subcommands:
|
|||||||
silo token revoke <id> Revoke a token
|
silo token revoke <id> Revoke a token
|
||||||
|
|
||||||
Environment variables for API access:
|
Environment variables for API access:
|
||||||
SILO_API_URL Base URL of the Silo server (e.g., https://silo.kindred.internal)
|
SILO_API_URL Base URL of the Silo server (e.g., https://silo.example.internal)
|
||||||
SILO_API_TOKEN API token for authentication
|
SILO_API_TOKEN API token for authentication
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ server:
|
|||||||
# read_only: false # Reject all write operations; toggle at runtime with SIGUSR1
|
# read_only: false # Reject all write operations; toggle at runtime with SIGUSR1
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: "psql.kindred.internal"
|
host: "localhost" # Use "postgres" for Docker Compose
|
||||||
port: 5432
|
port: 5432
|
||||||
name: "silo"
|
name: "silo"
|
||||||
user: "silo"
|
user: "silo"
|
||||||
password: "" # Use SILO_DB_PASSWORD env var
|
password: "" # Use SILO_DB_PASSWORD env var
|
||||||
sslmode: "require"
|
sslmode: "require" # Use "disable" for Docker Compose (internal network)
|
||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
endpoint: "minio.kindred.internal:9000"
|
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose
|
||||||
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
||||||
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
||||||
bucket: "silo-files"
|
bucket: "silo-files"
|
||||||
use_ssl: true
|
use_ssl: true # Use false for Docker Compose (internal network)
|
||||||
region: "us-east-1"
|
region: "us-east-1"
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
@@ -53,7 +53,7 @@ auth:
|
|||||||
# LDAP / FreeIPA
|
# LDAP / FreeIPA
|
||||||
ldap:
|
ldap:
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "ldaps://ipa.kindred.internal"
|
url: "ldaps://ipa.example.internal"
|
||||||
base_dn: "dc=kindred,dc=internal"
|
base_dn: "dc=kindred,dc=internal"
|
||||||
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
||||||
# Optional service account for user search (omit for direct user bind)
|
# Optional service account for user search (omit for direct user bind)
|
||||||
@@ -77,10 +77,10 @@ auth:
|
|||||||
# OIDC / Keycloak
|
# OIDC / Keycloak
|
||||||
oidc:
|
oidc:
|
||||||
enabled: false
|
enabled: false
|
||||||
issuer_url: "https://keycloak.kindred.internal/realms/silo"
|
issuer_url: "https://keycloak.example.internal/realms/silo"
|
||||||
client_id: "silo"
|
client_id: "silo"
|
||||||
client_secret: "" # Use SILO_OIDC_CLIENT_SECRET env var
|
client_secret: "" # Use SILO_OIDC_CLIENT_SECRET env var
|
||||||
redirect_url: "https://silo.kindred.internal/auth/callback"
|
redirect_url: "https://silo.example.internal/auth/callback"
|
||||||
scopes: ["openid", "profile", "email"]
|
scopes: ["openid", "profile", "email"]
|
||||||
# Map Keycloak realm roles to Silo roles
|
# Map Keycloak realm roles to Silo roles
|
||||||
admin_role: "silo-admin"
|
admin_role: "silo-admin"
|
||||||
@@ -90,4 +90,4 @@ auth:
|
|||||||
# CORS origins (locked down when auth is enabled)
|
# CORS origins (locked down when auth is enabled)
|
||||||
cors:
|
cors:
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "https://silo.kindred.internal"
|
- "https://silo.example.internal"
|
||||||
|
|||||||
35
deployments/config.dev.yaml
Normal file
35
deployments/config.dev.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Silo Development Configuration
|
||||||
|
# Used by deployments/docker-compose.yaml — works with zero setup via `make docker-up`.
|
||||||
|
# For production Docker installs, run scripts/setup-docker.sh instead.
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
base_url: "http://localhost:8080"
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "postgres"
|
||||||
|
port: 5432
|
||||||
|
name: "silo"
|
||||||
|
user: "silo"
|
||||||
|
password: "${POSTGRES_PASSWORD:-silodev}"
|
||||||
|
sslmode: "disable"
|
||||||
|
max_connections: 10
|
||||||
|
|
||||||
|
storage:
|
||||||
|
endpoint: "minio:9000"
|
||||||
|
access_key: "${MINIO_ACCESS_KEY:-silominio}"
|
||||||
|
secret_key: "${MINIO_SECRET_KEY:-silominiosecret}"
|
||||||
|
bucket: "silo-files"
|
||||||
|
use_ssl: false
|
||||||
|
region: "us-east-1"
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
directory: "/etc/silo/schemas"
|
||||||
|
default: "kindred-rd"
|
||||||
|
|
||||||
|
freecad:
|
||||||
|
uri_scheme: "silo"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: false
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Silo Production Configuration
|
# Silo Production Configuration
|
||||||
# Single-binary deployment: silod serves API + React SPA
|
# Single-binary deployment: silod serves API + React SPA
|
||||||
#
|
#
|
||||||
# Layout on silo.kindred.internal:
|
# Layout on silo.example.internal:
|
||||||
# /opt/silo/bin/silod - server binary
|
# /opt/silo/bin/silod - server binary
|
||||||
# /opt/silo/web/dist/ - built React frontend (served automatically)
|
# /opt/silo/web/dist/ - built React frontend (served automatically)
|
||||||
# /opt/silo/schemas/ - part number schemas
|
# /opt/silo/schemas/ - part number schemas
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 8080
|
port: 8080
|
||||||
base_url: "https://silo.kindred.internal"
|
base_url: "https://silo.example.internal"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
host: "psql.kindred.internal"
|
host: "psql.example.internal"
|
||||||
port: 5432
|
port: 5432
|
||||||
name: "silo"
|
name: "silo"
|
||||||
user: "silo"
|
user: "silo"
|
||||||
@@ -30,7 +30,7 @@ database:
|
|||||||
max_connections: 20
|
max_connections: 20
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
endpoint: "minio.kindred.internal:9000"
|
endpoint: "minio.example.internal:9000"
|
||||||
access_key: "" # Set via SILO_MINIO_ACCESS_KEY
|
access_key: "" # Set via SILO_MINIO_ACCESS_KEY
|
||||||
secret_key: "" # Set via SILO_MINIO_SECRET_KEY
|
secret_key: "" # Set via SILO_MINIO_SECRET_KEY
|
||||||
bucket: "silo-files"
|
bucket: "silo-files"
|
||||||
@@ -53,7 +53,7 @@ auth:
|
|||||||
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
|
default_admin_password: "" # Set via SILO_ADMIN_PASSWORD
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
url: "ldaps://ipa.kindred.internal"
|
url: "ldaps://ipa.example.internal"
|
||||||
base_dn: "dc=kindred,dc=internal"
|
base_dn: "dc=kindred,dc=internal"
|
||||||
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
||||||
user_attr: "uid"
|
user_attr: "uid"
|
||||||
@@ -73,4 +73,4 @@ auth:
|
|||||||
enabled: false
|
enabled: false
|
||||||
cors:
|
cors:
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "https://silo.kindred.internal"
|
- "https://silo.example.internal"
|
||||||
|
|||||||
172
deployments/docker-compose.allinone.yaml
Normal file
172
deployments/docker-compose.allinone.yaml
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Silo All-in-One Stack
|
||||||
|
# PostgreSQL + MinIO + OpenLDAP + Silo API + Nginx (optional)
|
||||||
|
#
|
||||||
|
# Quick start:
|
||||||
|
# ./scripts/setup-docker.sh
|
||||||
|
# docker compose -f deployments/docker-compose.allinone.yaml up -d
|
||||||
|
#
|
||||||
|
# With nginx reverse proxy:
|
||||||
|
# docker compose -f deployments/docker-compose.allinone.yaml --profile nginx up -d
|
||||||
|
#
|
||||||
|
# View logs:
|
||||||
|
# docker compose -f deployments/docker-compose.allinone.yaml logs -f
|
||||||
|
#
|
||||||
|
# Stop:
|
||||||
|
# docker compose -f deployments/docker-compose.allinone.yaml down
|
||||||
|
#
|
||||||
|
# Stop and delete data:
|
||||||
|
# docker compose -f deployments/docker-compose.allinone.yaml down -v
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PostgreSQL 16
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: silo-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: silo
|
||||||
|
POSTGRES_USER: silo
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run ./scripts/setup-docker.sh first}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ../migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U silo -d silo"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- silo-net
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MinIO (S3-compatible object storage)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: silo-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?Run ./scripts/setup-docker.sh first}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?Run ./scripts/setup-docker.sh first}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- "9001:9001" # MinIO console (remove in hardened setups)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- silo-net
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OpenLDAP (user directory for LDAP authentication)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
openldap:
|
||||||
|
image: bitnami/openldap:2.6
|
||||||
|
container_name: silo-openldap
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LDAP_ROOT: "dc=silo,dc=local"
|
||||||
|
LDAP_ADMIN_USERNAME: "admin"
|
||||||
|
LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:?Run ./scripts/setup-docker.sh first}
|
||||||
|
LDAP_USERS: ${LDAP_USERS:-siloadmin}
|
||||||
|
LDAP_PASSWORDS: ${LDAP_PASSWORDS:?Run ./scripts/setup-docker.sh first}
|
||||||
|
LDAP_GROUP: "silo-users"
|
||||||
|
LDAP_USER_OU: "users"
|
||||||
|
LDAP_GROUP_OU: "groups"
|
||||||
|
volumes:
|
||||||
|
- openldap_data:/bitnami/openldap
|
||||||
|
- ./ldap:/docker-entrypoint-initdb.d:ro
|
||||||
|
ports:
|
||||||
|
- "1389:1389" # LDAP access for debugging (remove in hardened setups)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:1389 -b dc=silo,dc=local -D cn=admin,dc=silo,dc=local -w $${LDAP_ADMIN_PASSWORD} '(objectClass=organization)' >/dev/null 2>&1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- silo-net
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Silo API Server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
silo:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: build/package/Dockerfile
|
||||||
|
container_name: silo-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
openldap:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# These override values in config.docker.yaml via the Go config loader's
|
||||||
|
# direct env var support (see internal/config/config.go).
|
||||||
|
SILO_DB_HOST: postgres
|
||||||
|
SILO_DB_NAME: silo
|
||||||
|
SILO_DB_USER: silo
|
||||||
|
SILO_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
SILO_MINIO_ENDPOINT: minio:9000
|
||||||
|
SILO_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
||||||
|
SILO_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
|
||||||
|
ports:
|
||||||
|
- "${SILO_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- ../schemas:/etc/silo/schemas:ro
|
||||||
|
- ./config.docker.yaml:/etc/silo/config.yaml:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- silo-net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nginx reverse proxy (optional — enable with --profile nginx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: silo-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- nginx
|
||||||
|
depends_on:
|
||||||
|
silo:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# Uncomment to mount TLS certificates:
|
||||||
|
# - /path/to/cert.pem:/etc/nginx/ssl/cert.pem:ro
|
||||||
|
# - /path/to/key.pem:/etc/nginx/ssl/key.pem:ro
|
||||||
|
networks:
|
||||||
|
- silo-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
minio_data:
|
||||||
|
openldap_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
silo-net:
|
||||||
|
driver: bridge
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Production Docker Compose for Silo
|
# Production Docker Compose for Silo
|
||||||
# Uses external PostgreSQL (psql.kindred.internal) and MinIO (minio.kindred.internal)
|
# Uses external PostgreSQL (psql.example.internal) and MinIO (minio.example.internal)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# export SILO_DB_PASSWORD=<your-password>
|
# export SILO_DB_PASSWORD=<your-password>
|
||||||
@@ -15,23 +15,23 @@ services:
|
|||||||
container_name: silod
|
container_name: silod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database connection (psql.kindred.internal)
|
# Database connection (psql.example.internal)
|
||||||
SILO_DB_HOST: psql.kindred.internal
|
# Supported as direct env var overrides in the Go config loader:
|
||||||
SILO_DB_PORT: 5432
|
SILO_DB_HOST: psql.example.internal
|
||||||
SILO_DB_NAME: silo
|
SILO_DB_NAME: silo
|
||||||
SILO_DB_USER: silo
|
SILO_DB_USER: silo
|
||||||
SILO_DB_PASSWORD: ${SILO_DB_PASSWORD:?Database password required}
|
SILO_DB_PASSWORD: ${SILO_DB_PASSWORD:?Database password required}
|
||||||
SILO_DB_SSLMODE: require
|
# Note: SILO_DB_PORT and SILO_DB_SSLMODE are NOT supported as direct
|
||||||
|
# env var overrides. Set these in config.yaml instead, or use ${VAR}
|
||||||
|
# syntax in the YAML file. See docs/CONFIGURATION.md for details.
|
||||||
|
|
||||||
# MinIO storage (minio.kindred.internal)
|
# MinIO storage (minio.example.internal)
|
||||||
SILO_MINIO_ENDPOINT: minio.kindred.internal:9000
|
# Supported as direct env var overrides:
|
||||||
|
SILO_MINIO_ENDPOINT: minio.example.internal:9000
|
||||||
SILO_MINIO_ACCESS_KEY: ${SILO_MINIO_ACCESS_KEY:?MinIO access key required}
|
SILO_MINIO_ACCESS_KEY: ${SILO_MINIO_ACCESS_KEY:?MinIO access key required}
|
||||||
SILO_MINIO_SECRET_KEY: ${SILO_MINIO_SECRET_KEY:?MinIO secret key required}
|
SILO_MINIO_SECRET_KEY: ${SILO_MINIO_SECRET_KEY:?MinIO secret key required}
|
||||||
SILO_MINIO_BUCKET: silo-files
|
# Note: SILO_MINIO_BUCKET and SILO_MINIO_USE_SSL are NOT supported as
|
||||||
SILO_MINIO_USE_SSL: "true"
|
# direct env var overrides. Set these in config.yaml instead.
|
||||||
|
|
||||||
# Server settings
|
|
||||||
SILO_SERVER_BASE_URL: ${SILO_BASE_URL:-http://silo.kindred.internal:8080}
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ../schemas:/etc/silo/schemas:ro
|
- ../schemas:/etc/silo/schemas:ro
|
||||||
- ../configs/config.yaml:/etc/silo/config.yaml:ro
|
- ./config.dev.yaml:/etc/silo/config.yaml:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
36
deployments/ldap/memberof.ldif
Normal file
36
deployments/ldap/memberof.ldif
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Enable the memberOf overlay for OpenLDAP.
|
||||||
|
# When a user is added to a groupOfNames, their entry automatically
|
||||||
|
# gets a memberOf attribute pointing to the group DN.
|
||||||
|
# This is required for Silo's LDAP role mapping.
|
||||||
|
#
|
||||||
|
# Loaded automatically by bitnami/openldap from /docker-entrypoint-initdb.d/
|
||||||
|
|
||||||
|
dn: cn=module{0},cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcModuleLoad
|
||||||
|
olcModuleLoad: memberof
|
||||||
|
|
||||||
|
dn: olcOverlay=memberof,olcDatabase={2}mdb,cn=config
|
||||||
|
changetype: add
|
||||||
|
objectClass: olcOverlayConfig
|
||||||
|
objectClass: olcMemberOf
|
||||||
|
olcOverlay: memberof
|
||||||
|
olcMemberOfRefInt: TRUE
|
||||||
|
olcMemberOfDangling: ignore
|
||||||
|
olcMemberOfGroupOC: groupOfNames
|
||||||
|
olcMemberOfMemberAD: member
|
||||||
|
olcMemberOfMemberOfAD: memberOf
|
||||||
|
|
||||||
|
# Enable refint overlay to maintain referential integrity
|
||||||
|
# (removes memberOf when a user is removed from a group)
|
||||||
|
dn: cn=module{0},cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcModuleLoad
|
||||||
|
olcModuleLoad: refint
|
||||||
|
|
||||||
|
dn: olcOverlay=refint,olcDatabase={2}mdb,cn=config
|
||||||
|
changetype: add
|
||||||
|
objectClass: olcOverlayConfig
|
||||||
|
objectClass: olcRefintConfig
|
||||||
|
olcOverlay: refint
|
||||||
|
olcRefintAttribute: memberOf member
|
||||||
34
deployments/ldap/silo-groups.ldif
Normal file
34
deployments/ldap/silo-groups.ldif
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Create Silo role groups for LDAP-based access control.
|
||||||
|
# These groups map to Silo roles via auth.ldap.role_mapping in config.yaml.
|
||||||
|
#
|
||||||
|
# Group hierarchy:
|
||||||
|
# silo-admins -> admin role (full access)
|
||||||
|
# silo-users -> editor role (create/modify items)
|
||||||
|
# silo-viewers -> viewer role (read-only)
|
||||||
|
#
|
||||||
|
# The initial LDAP user (set via LDAP_USERS env var) is added to silo-admins.
|
||||||
|
# Additional users can be added with ldapadd or ldapmodify.
|
||||||
|
#
|
||||||
|
# Loaded automatically by bitnami/openldap from /docker-entrypoint-initdb.d/
|
||||||
|
# Note: This runs after the default tree is created (users/groups OUs exist).
|
||||||
|
|
||||||
|
# Admin group — initial user is a member
|
||||||
|
dn: cn=silo-admins,ou=groups,dc=silo,dc=local
|
||||||
|
objectClass: groupOfNames
|
||||||
|
cn: silo-admins
|
||||||
|
description: Silo administrators (full access)
|
||||||
|
member: cn=siloadmin,ou=users,dc=silo,dc=local
|
||||||
|
|
||||||
|
# Editor group
|
||||||
|
dn: cn=silo-users,ou=groups,dc=silo,dc=local
|
||||||
|
objectClass: groupOfNames
|
||||||
|
cn: silo-users
|
||||||
|
description: Silo editors (create and modify items)
|
||||||
|
member: cn=placeholder,ou=users,dc=silo,dc=local
|
||||||
|
|
||||||
|
# Viewer group
|
||||||
|
dn: cn=silo-viewers,ou=groups,dc=silo,dc=local
|
||||||
|
objectClass: groupOfNames
|
||||||
|
cn: silo-viewers
|
||||||
|
description: Silo viewers (read-only access)
|
||||||
|
member: cn=placeholder,ou=users,dc=silo,dc=local
|
||||||
44
deployments/nginx/nginx-nossl.conf
Normal file
44
deployments/nginx/nginx-nossl.conf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Silo Nginx Reverse Proxy — HTTP only (no TLS)
|
||||||
|
#
|
||||||
|
# Use this when TLS is terminated by an external load balancer or when
|
||||||
|
# running on a trusted internal network without HTTPS.
|
||||||
|
|
||||||
|
upstream silo_backend {
|
||||||
|
server silo:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://silo_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# SSE support
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
# File uploads (CAD files can be large)
|
||||||
|
client_max_body_size 100M;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "OK\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
deployments/nginx/nginx.conf
Normal file
103
deployments/nginx/nginx.conf
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Silo Nginx Reverse Proxy (Docker)
|
||||||
|
#
|
||||||
|
# HTTP reverse proxy with optional HTTPS. To enable TLS:
|
||||||
|
# 1. Uncomment the ssl server block below
|
||||||
|
# 2. Mount your certificate and key in docker-compose:
|
||||||
|
# volumes:
|
||||||
|
# - /path/to/cert.pem:/etc/nginx/ssl/cert.pem:ro
|
||||||
|
# - /path/to/key.pem:/etc/nginx/ssl/key.pem:ro
|
||||||
|
# 3. Uncomment the HTTP-to-HTTPS redirect in the port 80 block
|
||||||
|
|
||||||
|
upstream silo_backend {
|
||||||
|
server silo:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP server
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Uncomment the next line to redirect all HTTP traffic to HTTPS
|
||||||
|
# return 301 https://$host$request_uri;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://silo_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# SSE support
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
# File uploads (CAD files can be large)
|
||||||
|
client_max_body_size 100M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint for monitoring
|
||||||
|
location /nginx-health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "OK\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uncomment for HTTPS (mount certs in docker-compose volumes)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# listen [::]:443 ssl http2;
|
||||||
|
# server_name _;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
#
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||||
|
# ssl_prefer_server_ciphers off;
|
||||||
|
# ssl_session_timeout 1d;
|
||||||
|
# ssl_session_cache shared:SSL:10m;
|
||||||
|
# ssl_session_tickets off;
|
||||||
|
#
|
||||||
|
# # Security headers
|
||||||
|
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
# add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://silo_backend;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
#
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# proxy_set_header X-Forwarded-Host $host;
|
||||||
|
# proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
#
|
||||||
|
# proxy_set_header Connection "";
|
||||||
|
# proxy_buffering off;
|
||||||
|
#
|
||||||
|
# proxy_connect_timeout 60s;
|
||||||
|
# proxy_send_timeout 60s;
|
||||||
|
# proxy_read_timeout 300s;
|
||||||
|
#
|
||||||
|
# client_max_body_size 100M;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /nginx-health {
|
||||||
|
# access_log off;
|
||||||
|
# return 200 "OK\n";
|
||||||
|
# add_header Content-Type text/plain;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
# Copy to /etc/silo/silod.env and fill in values
|
# Copy to /etc/silo/silod.env and fill in values
|
||||||
# Permissions: chmod 600 /etc/silo/silod.env
|
# Permissions: chmod 600 /etc/silo/silod.env
|
||||||
|
|
||||||
# Database credentials (psql.kindred.internal)
|
# Database credentials (psql.example.internal)
|
||||||
# Database: silo, User: silo
|
# Database: silo, User: silo
|
||||||
SILO_DB_PASSWORD=
|
SILO_DB_PASSWORD=
|
||||||
|
|
||||||
# MinIO credentials (minio.kindred.internal)
|
# MinIO credentials (minio.example.internal)
|
||||||
# User: silouser
|
# User: silouser
|
||||||
SILO_MINIO_ACCESS_KEY=silouser
|
SILO_MINIO_ACCESS_KEY=silouser
|
||||||
SILO_MINIO_SECRET_KEY=
|
SILO_MINIO_SECRET_KEY=
|
||||||
@@ -23,4 +23,4 @@ SILO_ADMIN_PASSWORD=
|
|||||||
# SILO_LDAP_BIND_PASSWORD=
|
# SILO_LDAP_BIND_PASSWORD=
|
||||||
|
|
||||||
# Optional: Override server base URL
|
# Optional: Override server base URL
|
||||||
# SILO_SERVER_BASE_URL=http://silo.kindred.internal:8080
|
# SILO_SERVER_BASE_URL=http://silo.example.internal:8080
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ API tokens allow the FreeCAD plugin, scripts, and CI pipelines to authenticate w
|
|||||||
### Creating a Token (CLI)
|
### Creating a Token (CLI)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export SILO_API_URL=https://silo.kindred.internal
|
export SILO_API_URL=https://silo.example.internal
|
||||||
export SILO_API_TOKEN=silo_<your-existing-token>
|
export SILO_API_TOKEN=silo_<your-existing-token>
|
||||||
|
|
||||||
silo token create --name "CI pipeline"
|
silo token create --name "CI pipeline"
|
||||||
@@ -140,7 +140,7 @@ auth:
|
|||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
url: "ldaps://ipa.kindred.internal"
|
url: "ldaps://ipa.example.internal"
|
||||||
base_dn: "dc=kindred,dc=internal"
|
base_dn: "dc=kindred,dc=internal"
|
||||||
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
user_search_dn: "cn=users,cn=accounts,dc=kindred,dc=internal"
|
||||||
user_attr: "uid"
|
user_attr: "uid"
|
||||||
@@ -170,10 +170,10 @@ auth:
|
|||||||
|
|
||||||
oidc:
|
oidc:
|
||||||
enabled: true
|
enabled: true
|
||||||
issuer_url: "https://keycloak.kindred.internal/realms/silo"
|
issuer_url: "https://keycloak.example.internal/realms/silo"
|
||||||
client_id: "silo"
|
client_id: "silo"
|
||||||
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
|
client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET
|
||||||
redirect_url: "https://silo.kindred.internal/auth/callback"
|
redirect_url: "https://silo.example.internal/auth/callback"
|
||||||
scopes: ["openid", "profile", "email"]
|
scopes: ["openid", "profile", "email"]
|
||||||
admin_role: "silo-admin"
|
admin_role: "silo-admin"
|
||||||
editor_role: "silo-editor"
|
editor_role: "silo-editor"
|
||||||
@@ -186,7 +186,7 @@ auth:
|
|||||||
auth:
|
auth:
|
||||||
cors:
|
cors:
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "https://silo.kindred.internal"
|
- "https://silo.example.internal"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
@@ -254,4 +254,4 @@ UPDATE users SET password_hash = '<bcrypt-hash>', is_active = true WHERE usernam
|
|||||||
|
|
||||||
- Verify the token is set in FreeCAD preferences or `SILO_API_TOKEN`
|
- Verify the token is set in FreeCAD preferences or `SILO_API_TOKEN`
|
||||||
- Check the API URL points to the correct server
|
- Check the API URL points to the correct server
|
||||||
- Test with curl: `curl -H "Authorization: Bearer silo_..." https://silo.kindred.internal/api/items`
|
- Test with curl: `curl -H "Authorization: Bearer silo_..." https://silo.example.internal/api/items`
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Silo Production Deployment Guide
|
# Silo Production Deployment Guide
|
||||||
|
|
||||||
|
> **First-time setup?** See the [Installation Guide](INSTALL.md) for step-by-step
|
||||||
|
> instructions. This document covers ongoing maintenance and operations for an
|
||||||
|
> existing deployment.
|
||||||
|
|
||||||
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and MinIO services.
|
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and MinIO services.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -17,7 +21,7 @@ This guide covers deploying Silo to a dedicated VM using external PostgreSQL and
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ silo.kindred.internal │
|
│ silo.example.internal │
|
||||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
│ │ silod │ │
|
│ │ silod │ │
|
||||||
│ │ (Silo API Server) │ │
|
│ │ (Silo API Server) │ │
|
||||||
@@ -27,7 +31,7 @@ This guide covers deploying Silo to a dedicated VM using external PostgreSQL and
|
|||||||
│ │
|
│ │
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌─────────────────────────┐ ┌─────────────────────────────────┐
|
┌─────────────────────────┐ ┌─────────────────────────────────┐
|
||||||
│ psql.kindred.internal │ │ minio.kindred.internal │
|
│ psql.example.internal │ │ minio.example.internal │
|
||||||
│ PostgreSQL 16 │ │ MinIO S3 │
|
│ PostgreSQL 16 │ │ MinIO S3 │
|
||||||
│ :5432 │ │ :9000 (API) │
|
│ :5432 │ │ :9000 (API) │
|
||||||
│ │ │ :9001 (Console) │
|
│ │ │ :9001 (Console) │
|
||||||
@@ -40,8 +44,8 @@ The following external services are already configured:
|
|||||||
|
|
||||||
| Service | Host | Database/Bucket | User |
|
| Service | Host | Database/Bucket | User |
|
||||||
|---------|------|-----------------|------|
|
|---------|------|-----------------|------|
|
||||||
| PostgreSQL | psql.kindred.internal:5432 | silo | silo |
|
| PostgreSQL | psql.example.internal:5432 | silo | silo |
|
||||||
| MinIO | minio.kindred.internal:9000 | silo-files | silouser |
|
| MinIO | minio.example.internal:9000 | silo-files | silouser |
|
||||||
|
|
||||||
Migrations have been applied to the database.
|
Migrations have been applied to the database.
|
||||||
|
|
||||||
@@ -53,10 +57,10 @@ For a fresh VM, run these commands:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. SSH to the target host
|
# 1. SSH to the target host
|
||||||
ssh root@silo.kindred.internal
|
ssh root@silo.example.internal
|
||||||
|
|
||||||
# 2. Download and run setup script
|
# 2. Download and run setup script
|
||||||
curl -fsSL https://gitea.kindred.internal/kindred/silo-0062/raw/branch/main/scripts/setup-host.sh | bash
|
curl -fsSL https://git.kindred-systems.com/kindred/silo/raw/branch/main/scripts/setup-host.sh | bash
|
||||||
|
|
||||||
# 3. Configure credentials
|
# 3. Configure credentials
|
||||||
nano /etc/silo/silod.env
|
nano /etc/silo/silod.env
|
||||||
@@ -69,16 +73,16 @@ nano /etc/silo/silod.env
|
|||||||
|
|
||||||
## Initial Setup
|
## Initial Setup
|
||||||
|
|
||||||
Run the setup script once on `silo.kindred.internal` to prepare the host:
|
Run the setup script once on `silo.example.internal` to prepare the host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Option 1: If you have the repo locally
|
# Option 1: If you have the repo locally
|
||||||
scp scripts/setup-host.sh root@silo.kindred.internal:/tmp/
|
scp scripts/setup-host.sh root@silo.example.internal:/tmp/
|
||||||
ssh root@silo.kindred.internal 'bash /tmp/setup-host.sh'
|
ssh root@silo.example.internal 'bash /tmp/setup-host.sh'
|
||||||
|
|
||||||
# Option 2: Direct on the host
|
# Option 2: Direct on the host
|
||||||
ssh root@silo.kindred.internal
|
ssh root@silo.example.internal
|
||||||
curl -fsSL https://git.kindred.internal/kindred/silo/raw/branch/main/scripts/setup-host.sh -o /tmp/setup-host.sh
|
curl -fsSL https://git.kindred-systems.com/kindred/silo/raw/branch/main/scripts/setup-host.sh -o /tmp/setup-host.sh
|
||||||
bash /tmp/setup-host.sh
|
bash /tmp/setup-host.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,10 +104,10 @@ sudo nano /etc/silo/silod.env
|
|||||||
Fill in the values:
|
Fill in the values:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Database credentials (psql.kindred.internal)
|
# Database credentials (psql.example.internal)
|
||||||
SILO_DB_PASSWORD=your-database-password
|
SILO_DB_PASSWORD=your-database-password
|
||||||
|
|
||||||
# MinIO credentials (minio.kindred.internal)
|
# MinIO credentials (minio.example.internal)
|
||||||
SILO_MINIO_ACCESS_KEY=silouser
|
SILO_MINIO_ACCESS_KEY=silouser
|
||||||
SILO_MINIO_SECRET_KEY=your-minio-secret-key
|
SILO_MINIO_SECRET_KEY=your-minio-secret-key
|
||||||
```
|
```
|
||||||
@@ -114,10 +118,10 @@ Before deploying, verify connectivity to external services:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test PostgreSQL
|
# Test PostgreSQL
|
||||||
psql -h psql.kindred.internal -U silo -d silo -c 'SELECT 1'
|
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
|
||||||
|
|
||||||
# Test MinIO
|
# Test MinIO
|
||||||
curl -I http://minio.kindred.internal:9000/minio/health/live
|
curl -I http://minio.example.internal:9000/minio/health/live
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -129,7 +133,7 @@ curl -I http://minio.kindred.internal:9000/minio/health/live
|
|||||||
To deploy or update Silo, run the deploy script on the target host:
|
To deploy or update Silo, run the deploy script on the target host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@silo.kindred.internal
|
ssh root@silo.example.internal
|
||||||
/opt/silo/src/scripts/deploy.sh
|
/opt/silo/src/scripts/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -165,7 +169,7 @@ sudo /opt/silo/src/scripts/deploy.sh --status
|
|||||||
You can override the git repository URL and branch:
|
You can override the git repository URL and branch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export SILO_REPO_URL=https://git.kindred.internal/kindred/silo.git
|
export SILO_REPO_URL=https://git.kindred-systems.com/kindred/silo.git
|
||||||
export SILO_BRANCH=main
|
export SILO_BRANCH=main
|
||||||
sudo -E /opt/silo/src/scripts/deploy.sh
|
sudo -E /opt/silo/src/scripts/deploy.sh
|
||||||
```
|
```
|
||||||
@@ -247,7 +251,7 @@ curl http://localhost:8080/ready
|
|||||||
To update to the latest version:
|
To update to the latest version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@silo.kindred.internal
|
ssh root@silo.example.internal
|
||||||
/opt/silo/src/scripts/deploy.sh
|
/opt/silo/src/scripts/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -269,7 +273,7 @@ When new migrations are added, run them manually:
|
|||||||
ls -la /opt/silo/src/migrations/
|
ls -la /opt/silo/src/migrations/
|
||||||
|
|
||||||
# Run a specific migration
|
# Run a specific migration
|
||||||
psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/src/migrations/008_new_feature.sql
|
psql -h psql.example.internal -U silo -d silo -f /opt/silo/src/migrations/008_new_feature.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -303,13 +307,13 @@ psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/src/migrations/008_ne
|
|||||||
|
|
||||||
1. Test network connectivity:
|
1. Test network connectivity:
|
||||||
```bash
|
```bash
|
||||||
nc -zv psql.kindred.internal 5432
|
nc -zv psql.example.internal 5432
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Test credentials:
|
2. Test credentials:
|
||||||
```bash
|
```bash
|
||||||
source /etc/silo/silod.env
|
source /etc/silo/silod.env
|
||||||
PGPASSWORD=$SILO_DB_PASSWORD psql -h psql.kindred.internal -U silo -d silo -c 'SELECT 1'
|
PGPASSWORD=$SILO_DB_PASSWORD psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Check `pg_hba.conf` on PostgreSQL server allows connections from this host.
|
3. Check `pg_hba.conf` on PostgreSQL server allows connections from this host.
|
||||||
@@ -318,12 +322,12 @@ psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/src/migrations/008_ne
|
|||||||
|
|
||||||
1. Test network connectivity:
|
1. Test network connectivity:
|
||||||
```bash
|
```bash
|
||||||
nc -zv minio.kindred.internal 9000
|
nc -zv minio.example.internal 9000
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Test with curl:
|
2. Test with curl:
|
||||||
```bash
|
```bash
|
||||||
curl -I http://minio.kindred.internal:9000/minio/health/live
|
curl -I http://minio.example.internal:9000/minio/health/live
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Check SSL settings in config match MinIO setup:
|
3. Check SSL settings in config match MinIO setup:
|
||||||
@@ -340,8 +344,8 @@ curl -v http://localhost:8080/health
|
|||||||
curl -v http://localhost:8080/ready
|
curl -v http://localhost:8080/ready
|
||||||
|
|
||||||
# If ready fails but health passes, check external services
|
# If ready fails but health passes, check external services
|
||||||
psql -h psql.kindred.internal -U silo -d silo -c 'SELECT 1'
|
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
|
||||||
curl http://minio.kindred.internal:9000/minio/health/live
|
curl http://minio.example.internal:9000/minio/health/live
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Fails
|
### Build Fails
|
||||||
@@ -391,14 +395,14 @@ This script:
|
|||||||
getcert list
|
getcert list
|
||||||
```
|
```
|
||||||
|
|
||||||
2. The silo config is already updated to use `https://silo.kindred.internal` as base URL. Restart silo:
|
2. The silo config is already updated to use `https://silo.example.internal` as base URL. Restart silo:
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl restart silod
|
sudo systemctl restart silod
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Test the setup:
|
3. Test the setup:
|
||||||
```bash
|
```bash
|
||||||
curl https://silo.kindred.internal/health
|
curl https://silo.example.internal/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### Certificate Management
|
### Certificate Management
|
||||||
@@ -422,7 +426,7 @@ For clients to trust the Silo HTTPS certificate, they need the IPA CA:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download CA cert
|
# Download CA cert
|
||||||
curl -o /tmp/ipa-ca.crt https://ipa.kindred.internal/ipa/config/ca.crt
|
curl -o /tmp/ipa-ca.crt https://ipa.example.internal/ipa/config/ca.crt
|
||||||
|
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
sudo cp /tmp/ipa-ca.crt /usr/local/share/ca-certificates/ipa-ca.crt
|
sudo cp /tmp/ipa-ca.crt /usr/local/share/ca-certificates/ipa-ca.crt
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# Silo Gap Analysis and Revision Control Roadmap
|
# Silo Gap Analysis
|
||||||
|
|
||||||
**Date:** 2026-02-08
|
**Date:** 2026-02-13
|
||||||
**Status:** Analysis Complete (Updated)
|
**Status:** Analysis Complete (Updated)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
This document analyzes the current state of the Silo project against its specification, identifies documentation and feature gaps, and outlines a roadmap for enhanced revision control capabilities.
|
This document analyzes the current state of the Silo project against its specification and against SOLIDWORKS PDM (the industry-leading product data management solution). It identifies documentation gaps, feature gaps, and outlines a roadmap for enhanced revision control capabilities.
|
||||||
|
|
||||||
|
See [ROADMAP.md](ROADMAP.md) for the platform roadmap and dependency tier structure.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ This document analyzes the current state of the Silo project against its specifi
|
|||||||
| `docs/AUTH.md` | Authentication system design | Current |
|
| `docs/AUTH.md` | Authentication system design | Current |
|
||||||
| `docs/AUTH_USER_GUIDE.md` | User guide for login, tokens, and roles | Current |
|
| `docs/AUTH_USER_GUIDE.md` | User guide for login, tokens, and roles | Current |
|
||||||
| `docs/GAP_ANALYSIS.md` | Revision control roadmap | Current |
|
| `docs/GAP_ANALYSIS.md` | Revision control roadmap | Current |
|
||||||
| `ROADMAP.md` | Feature roadmap and SOLIDWORKS PDM comparison | Current |
|
| `docs/ROADMAP.md` | Platform roadmap and dependency tiers | Current |
|
||||||
| `frontend-spec.md` | React SPA frontend specification | Current |
|
| `frontend-spec.md` | React SPA frontend specification | Current |
|
||||||
|
|
||||||
### 1.2 Documentation Gaps (Priority Order)
|
### 1.2 Documentation Gaps (Priority Order)
|
||||||
@@ -365,7 +367,7 @@ internal/
|
|||||||
handlers.go # Items, schemas, projects, revisions
|
handlers.go # Items, schemas, projects, revisions
|
||||||
middleware.go # Auth middleware
|
middleware.go # Auth middleware
|
||||||
odoo_handlers.go # Odoo integration endpoints
|
odoo_handlers.go # Odoo integration endpoints
|
||||||
routes.go # Route registration (75 endpoints)
|
routes.go # Route registration (78 endpoints)
|
||||||
search.go # Fuzzy search
|
search.go # Fuzzy search
|
||||||
auth/
|
auth/
|
||||||
auth.go # Auth service: local, LDAP, OIDC
|
auth.go # Auth service: local, LDAP, OIDC
|
||||||
@@ -450,3 +452,163 @@ GET /api/releases/{name} # Get release details
|
|||||||
POST /api/releases/{name}/items # Add items to release
|
POST /api/releases/{name}/items # Add items to release
|
||||||
GET /api/items/{pn}/thumbnail/{rev} # Get thumbnail
|
GET /api/items/{pn}/thumbnail/{rev} # Get thumbnail
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: SOLIDWORKS PDM Comparison
|
||||||
|
|
||||||
|
This section compares Silo's capabilities against SOLIDWORKS PDM features. Gaps are categorized by priority and implementation complexity.
|
||||||
|
|
||||||
|
**Legend:** Silo Status = Full / Partial / None | Priority = Critical / High / Medium / Low | Complexity = Simple / Moderate / Complex
|
||||||
|
|
||||||
|
### C.1 Version Control & Revision Management
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Check-in/check-out | Full pessimistic locking | None | High | Moderate |
|
||||||
|
| Version history | Complete with branching | Full (linear) | - | - |
|
||||||
|
| Revision labels | A, B, C or custom schemes | Full (custom labels) | - | - |
|
||||||
|
| Rollback/restore | Full | Full | - | - |
|
||||||
|
| Compare revisions | Visual + metadata diff | Metadata diff only | Medium | Complex |
|
||||||
|
| Get Latest Revision | One-click retrieval | Partial (API only) | Medium | Simple |
|
||||||
|
|
||||||
|
Silo lacks pessimistic locking (check-out), which is critical for multi-user CAD environments where file merging is impractical. Visual diff comparison would require FreeCAD integration for CAD file visualization.
|
||||||
|
|
||||||
|
### C.2 Workflow Management
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Custom workflows | Full visual designer | None | Critical | Complex |
|
||||||
|
| State transitions | Configurable with permissions | Basic (status field only) | Critical | Complex |
|
||||||
|
| Parallel approvals | Multiple approvers required | None | High | Complex |
|
||||||
|
| Automatic transitions | Timer/condition-based | None | Medium | Moderate |
|
||||||
|
| Email notifications | On state change | None | High | Moderate |
|
||||||
|
| ECO process | Built-in change management | None | High | Complex |
|
||||||
|
| Child state conditions | Block parent if children invalid | None | Medium | Moderate |
|
||||||
|
|
||||||
|
Workflow management is the largest functional gap. SOLIDWORKS PDM offers sophisticated state machines with parallel approvals, automatic transitions, and deep integration with engineering change processes. Silo currently has only a simple status field (draft/review/released/obsolete) with no transition rules or approval processes.
|
||||||
|
|
||||||
|
### C.3 User Management & Security
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| User authentication | Windows AD, LDAP | Full (local, LDAP, OIDC) | - | - |
|
||||||
|
| Role-based permissions | Granular per folder/state | Partial (3-tier role model) | Medium | Moderate |
|
||||||
|
| Group management | Full | None | Medium | Moderate |
|
||||||
|
| Folder permissions | Read/write/delete per folder | None | Medium | Moderate |
|
||||||
|
| State permissions | Actions allowed per state | None | High | Moderate |
|
||||||
|
| Audit trail | Complete action logging | Full | - | - |
|
||||||
|
| Private files | Pre-check-in visibility control | None | Low | Simple |
|
||||||
|
|
||||||
|
Authentication is implemented with three backends (local, LDAP/FreeIPA, OIDC/Keycloak) and a 3-tier role model (admin > editor > viewer). Audit logging captures user actions. Remaining gaps: group management, folder-level permissions, and state-based permission rules.
|
||||||
|
|
||||||
|
### C.4 Search & Discovery
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Metadata search | Full with custom cards | Partial (API query params + fuzzy) | High | Moderate |
|
||||||
|
| Full-text content search | iFilters for Office, CAD | None | Medium | Complex |
|
||||||
|
| Quick search | Toolbar with history | Partial (fuzzy search API) | Medium | Simple |
|
||||||
|
| Saved searches | User-defined favorites | None | Medium | Simple |
|
||||||
|
| Advanced operators | AND, OR, NOT, wildcards | None | Medium | Simple |
|
||||||
|
| Multi-variable search | Search across multiple fields | None | Medium | Simple |
|
||||||
|
| Where-used search | Find all assemblies using part | Full | - | - |
|
||||||
|
|
||||||
|
Silo has API-level filtering, fuzzy search, and where-used queries. Remaining gaps: saved searches, advanced search operators, and a richer search UI. Content search (searching within CAD files) is not planned for the server.
|
||||||
|
|
||||||
|
### C.5 BOM Management
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Single-level BOM | Yes | Full | - | - |
|
||||||
|
| Multi-level BOM | Indented/exploded views | Full (recursive, configurable depth) | - | - |
|
||||||
|
| BOM comparison | Between revisions | None | Medium | Moderate |
|
||||||
|
| BOM export | Excel, XML, ERP formats | Full (CSV, ODS) | - | - |
|
||||||
|
| BOM import | Bulk BOM loading | Full (CSV with upsert) | - | - |
|
||||||
|
| Calculated BOMs | Quantities rolled up | None | Medium | Moderate |
|
||||||
|
| Reference designators | Full support | Full | - | - |
|
||||||
|
| Alternate parts | Substitute tracking | Full | - | - |
|
||||||
|
|
||||||
|
Multi-level BOM retrieval (recursive CTE with configurable depth) and BOM export (CSV, ODS) are implemented. BOM import supports CSV with upsert and cycle detection. Remaining gap: BOM comparison between revisions.
|
||||||
|
|
||||||
|
### C.6 CAD Integration
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Native CAD add-in | Deep SOLIDWORKS integration | FreeCAD workbench (silo-mod) | Medium | Complex |
|
||||||
|
| Property mapping | Bi-directional sync | Planned (silo-mod) | Medium | Moderate |
|
||||||
|
| Task pane | Embedded in CAD UI | Auth dock panel (silo-mod) | Medium | Complex |
|
||||||
|
| Lightweight components | Handle without full load | N/A | - | - |
|
||||||
|
| Drawing/model linking | Automatic association | Manual | Medium | Moderate |
|
||||||
|
| Multi-CAD support | Third-party formats | FreeCAD only | Low | - |
|
||||||
|
|
||||||
|
CAD integration is maintained in separate repositories ([silo-mod](https://git.kindred-systems.com/kindred/silo-mod), [silo-calc](https://git.kindred-systems.com/kindred/silo-calc)). The Silo server provides the REST API endpoints consumed by those clients.
|
||||||
|
|
||||||
|
### C.7 External Integrations
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| ERP integration | SAP, Dynamics, etc. | Partial (Odoo stubs) | Medium | Complex |
|
||||||
|
| API access | Full COM/REST API | Full REST API (78 endpoints) | - | - |
|
||||||
|
| Dispatch scripts | Automation without coding | None | Medium | Moderate |
|
||||||
|
| Task scheduler | Background processing | None | Medium | Moderate |
|
||||||
|
| Email system | SMTP integration | None | High | Simple |
|
||||||
|
| Web portal | Browser access | Full (React SPA + auth) | - | - |
|
||||||
|
|
||||||
|
Silo has a comprehensive REST API (78 endpoints) and a full web UI with authentication. Odoo ERP integration has config/sync-log scaffolding but push/pull operations are stubs. Remaining gaps: email notifications, task scheduler, dispatch automation.
|
||||||
|
|
||||||
|
### C.8 Reporting & Analytics
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| Standard reports | Inventory, usage, activity | None | Medium | Moderate |
|
||||||
|
| Custom reports | User-defined queries | None | Medium | Moderate |
|
||||||
|
| Dashboard | Visual KPIs | None | Low | Moderate |
|
||||||
|
| Export formats | PDF, Excel, CSV | CSV and ODS | Medium | Simple |
|
||||||
|
|
||||||
|
Reporting capabilities are absent. Basic reports (item counts, revision activity, where-used) would provide immediate value.
|
||||||
|
|
||||||
|
### C.9 File Handling
|
||||||
|
|
||||||
|
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||||
|
|---------|---------------|-------------|----------|------------|
|
||||||
|
| File versioning | Automatic | Full (MinIO) | - | - |
|
||||||
|
| File preview | Thumbnails, 3D preview | None | Medium | Complex |
|
||||||
|
| File conversion | PDF, DXF generation | None | Medium | Complex |
|
||||||
|
| Replication | Multi-site sync | None | Low | Complex |
|
||||||
|
| File copy with refs | Copy tree with references | None | Medium | Moderate |
|
||||||
|
|
||||||
|
File storage works well. Thumbnail generation and file preview would significantly improve the web UI experience. Automatic conversion to PDF/DXF is valuable for sharing with non-CAD users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix D: Feature Comparison Matrix
|
||||||
|
|
||||||
|
| Category | Feature | SW PDM Standard | SW PDM Pro | Silo Current | Silo Planned |
|
||||||
|
|----------|---------|-----------------|------------|--------------|--------------|
|
||||||
|
| **Version Control** | Check-in/out | Yes | Yes | No | Tier 1 |
|
||||||
|
| | Version history | Yes | Yes | Yes | - |
|
||||||
|
| | Rollback | Yes | Yes | Yes | - |
|
||||||
|
| | Revision labels/status | Yes | Yes | Yes | - |
|
||||||
|
| | Revision comparison | Yes | Yes | Yes (metadata) | - |
|
||||||
|
| **Workflow** | Custom workflows | Limited | Yes | No | Tier 4 |
|
||||||
|
| | Parallel approval | No | Yes | No | Tier 4 |
|
||||||
|
| | Notifications | No | Yes | No | Tier 1 |
|
||||||
|
| **Security** | User auth | Windows | Windows/LDAP | Yes (local, LDAP, OIDC) | - |
|
||||||
|
| | Permissions | Basic | Granular | Partial (role-based) | Tier 4 |
|
||||||
|
| | Audit trail | Basic | Full | Yes | - |
|
||||||
|
| **Search** | Metadata search | Yes | Yes | Partial (API + fuzzy) | Tier 0 |
|
||||||
|
| | Content search | No | Yes | No | Tier 2 |
|
||||||
|
| | Where-used | Yes | Yes | Yes | - |
|
||||||
|
| **BOM** | Single-level | Yes | Yes | Yes | - |
|
||||||
|
| | Multi-level | Yes | Yes | Yes (recursive) | - |
|
||||||
|
| | BOM export | Yes | Yes | Yes (CSV, ODS) | - |
|
||||||
|
| **Data** | CSV import/export | Yes | Yes | Yes | - |
|
||||||
|
| | ODS import/export | No | No | Yes | - |
|
||||||
|
| | Project management | Yes | Yes | Yes | - |
|
||||||
|
| **Integration** | API | Limited | Full | Full REST (78) | - |
|
||||||
|
| | ERP connectors | No | Yes | Partial (Odoo stubs) | Tier 6 |
|
||||||
|
| | Web access | No | Yes | Yes (React SPA + auth) | - |
|
||||||
|
| **Files** | Versioning | Yes | Yes | Yes | - |
|
||||||
|
| | Preview | Yes | Yes | No | Tier 2 |
|
||||||
|
| | Multi-site | No | Yes | No | Not Planned |
|
||||||
|
|||||||
518
docs/INSTALL.md
Normal file
518
docs/INSTALL.md
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
# Installing Silo
|
||||||
|
|
||||||
|
This guide covers two installation methods:
|
||||||
|
|
||||||
|
- **[Option A: Docker Compose](#option-a-docker-compose)** — self-contained stack with all services. Recommended for evaluation, small teams, and environments where Docker is the standard.
|
||||||
|
- **[Option B: Daemon Install](#option-b-daemon-install-systemd--external-services)** — systemd service with external PostgreSQL, MinIO, and optional LDAP/nginx. Recommended for production deployments integrated with existing infrastructure.
|
||||||
|
|
||||||
|
Both methods produce the same result: a running Silo server with a web UI, REST API, and authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Option A: Docker Compose](#option-a-docker-compose)
|
||||||
|
- [A.1 Prerequisites](#a1-prerequisites)
|
||||||
|
- [A.2 Clone the Repository](#a2-clone-the-repository)
|
||||||
|
- [A.3 Run the Setup Script](#a3-run-the-setup-script)
|
||||||
|
- [A.4 Start the Stack](#a4-start-the-stack)
|
||||||
|
- [A.5 Verify the Installation](#a5-verify-the-installation)
|
||||||
|
- [A.6 LDAP Users and Groups](#a6-ldap-users-and-groups)
|
||||||
|
- [A.7 Optional: Enable Nginx Reverse Proxy](#a7-optional-enable-nginx-reverse-proxy)
|
||||||
|
- [A.8 Stopping, Starting, and Upgrading](#a8-stopping-starting-and-upgrading)
|
||||||
|
- [Option B: Daemon Install (systemd + External Services)](#option-b-daemon-install-systemd--external-services)
|
||||||
|
- [B.1 Architecture Overview](#b1-architecture-overview)
|
||||||
|
- [B.2 Prerequisites](#b2-prerequisites)
|
||||||
|
- [B.3 Set Up External Services](#b3-set-up-external-services)
|
||||||
|
- [B.4 Prepare the Host](#b4-prepare-the-host)
|
||||||
|
- [B.5 Configure Credentials](#b5-configure-credentials)
|
||||||
|
- [B.6 Deploy](#b6-deploy)
|
||||||
|
- [B.7 Set Up Nginx and TLS](#b7-set-up-nginx-and-tls)
|
||||||
|
- [B.8 Verify the Installation](#b8-verify-the-installation)
|
||||||
|
- [B.9 Upgrading](#b9-upgrading)
|
||||||
|
- [Post-Install Configuration](#post-install-configuration)
|
||||||
|
- [Further Reading](#further-reading)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Regardless of which method you choose:
|
||||||
|
|
||||||
|
- **Git** to clone the repository
|
||||||
|
- A machine with at least **2 GB RAM** and **10 GB free disk**
|
||||||
|
- Network access to pull container images or download Go/Node toolchains
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option A: Docker Compose
|
||||||
|
|
||||||
|
A single Docker Compose file runs everything: PostgreSQL, MinIO, OpenLDAP, and Silo. An optional nginx container can be enabled for reverse proxying.
|
||||||
|
|
||||||
|
### A.1 Prerequisites
|
||||||
|
|
||||||
|
- [Docker Engine](https://docs.docker.com/engine/install/) 24+ with the [Compose plugin](https://docs.docker.com/compose/install/) (v2)
|
||||||
|
- `openssl` (used by the setup script to generate secrets)
|
||||||
|
|
||||||
|
Verify your installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version # Docker Engine 24+
|
||||||
|
docker compose version # Docker Compose v2+
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.2 Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.kindred-systems.com/kindred/silo.git
|
||||||
|
cd silo
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.3 Run the Setup Script
|
||||||
|
|
||||||
|
The setup script generates credentials and configuration files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for:
|
||||||
|
- Server domain (default: `localhost`)
|
||||||
|
- PostgreSQL password (auto-generated if you press Enter)
|
||||||
|
- MinIO credentials (auto-generated)
|
||||||
|
- OpenLDAP admin password and initial user (auto-generated)
|
||||||
|
- Silo local admin account (fallback when LDAP is unavailable)
|
||||||
|
|
||||||
|
For automated/CI environments, use non-interactive mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-docker.sh --non-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
The script writes two files:
|
||||||
|
- `deployments/.env` — secrets for Docker Compose
|
||||||
|
- `deployments/config.docker.yaml` — Silo server configuration
|
||||||
|
|
||||||
|
### A.4 Start the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for all services to become healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `silo-postgres`, `silo-minio`, `silo-openldap`, and `silo-api` all in a healthy state.
|
||||||
|
|
||||||
|
View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml logs -f
|
||||||
|
|
||||||
|
# Silo only
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml logs -f silo
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.5 Verify the Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Readiness check (includes database and storage connectivity)
|
||||||
|
curl http://localhost:8080/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8080 in your browser. Log in with either:
|
||||||
|
|
||||||
|
- **LDAP account**: the username and password shown by the setup script (default: `siloadmin`)
|
||||||
|
- **Local admin**: the local admin credentials shown by the setup script (default: `admin`)
|
||||||
|
|
||||||
|
The credentials were printed at the end of the setup script output and are stored in `deployments/.env`.
|
||||||
|
|
||||||
|
### A.6 LDAP Users and Groups
|
||||||
|
|
||||||
|
The Docker stack includes an OpenLDAP server with three preconfigured groups that map to Silo roles:
|
||||||
|
|
||||||
|
| LDAP Group | Silo Role | Access Level |
|
||||||
|
|------------|-----------|-------------|
|
||||||
|
| `cn=silo-admins,ou=groups,dc=silo,dc=local` | admin | Full access |
|
||||||
|
| `cn=silo-users,ou=groups,dc=silo,dc=local` | editor | Create and modify items |
|
||||||
|
| `cn=silo-viewers,ou=groups,dc=silo,dc=local` | viewer | Read-only |
|
||||||
|
|
||||||
|
The initial LDAP user (default: `siloadmin`) is added to `silo-admins`.
|
||||||
|
|
||||||
|
**Add a new LDAP user:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the host (using the exposed port)
|
||||||
|
ldapadd -x -H ldap://localhost:1389 \
|
||||||
|
-D "cn=admin,dc=silo,dc=local" \
|
||||||
|
-w "YOUR_LDAP_ADMIN_PASSWORD" << EOF
|
||||||
|
dn: cn=jdoe,ou=users,dc=silo,dc=local
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: jdoe
|
||||||
|
sn: Doe
|
||||||
|
userPassword: changeme
|
||||||
|
mail: jdoe@example.com
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add a user to a group:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapmodify -x -H ldap://localhost:1389 \
|
||||||
|
-D "cn=admin,dc=silo,dc=local" \
|
||||||
|
-w "YOUR_LDAP_ADMIN_PASSWORD" << EOF
|
||||||
|
dn: cn=silo-users,ou=groups,dc=silo,dc=local
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=jdoe,ou=users,dc=silo,dc=local
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**List all users:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldap://localhost:1389 \
|
||||||
|
-b "ou=users,dc=silo,dc=local" \
|
||||||
|
-D "cn=admin,dc=silo,dc=local" \
|
||||||
|
-w "YOUR_LDAP_ADMIN_PASSWORD" "(objectClass=inetOrgPerson)" cn mail memberOf
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.7 Optional: Enable Nginx Reverse Proxy
|
||||||
|
|
||||||
|
To place nginx in front of Silo (for TLS termination or to serve on port 80):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml --profile nginx up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
By default nginx listens on ports 80 and 443 and proxies to the Silo container. The configuration is at `deployments/nginx/nginx.conf`.
|
||||||
|
|
||||||
|
**To enable HTTPS**, edit `deployments/docker-compose.allinone.yaml` and uncomment the TLS certificate volume mounts in the `nginx` service, then uncomment the HTTPS server block in `deployments/nginx/nginx.conf`. See the comments in those files for details.
|
||||||
|
|
||||||
|
If you already have your own reverse proxy or load balancer, skip the nginx profile and point your proxy at port 8080.
|
||||||
|
|
||||||
|
### A.8 Stopping, Starting, and Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop the stack (data is preserved in Docker volumes)
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml down
|
||||||
|
|
||||||
|
# Start again
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml up -d
|
||||||
|
|
||||||
|
# Stop and delete all data (WARNING: destroys database, files, and LDAP data)
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**To upgrade to a newer version:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd silo
|
||||||
|
git pull
|
||||||
|
docker compose -f deployments/docker-compose.allinone.yaml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Silo container is rebuilt from the updated source. Database migrations in `migrations/` are applied automatically on container startup via the PostgreSQL init mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option B: Daemon Install (systemd + External Services)
|
||||||
|
|
||||||
|
This method runs Silo as a systemd service on a dedicated host, connecting to externally managed PostgreSQL, MinIO, and optionally LDAP services.
|
||||||
|
|
||||||
|
### B.1 Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Silo Host │
|
||||||
|
│ ┌────────────────┐ │
|
||||||
|
HTTPS (443) ──►│ │ nginx │ │
|
||||||
|
│ └───────┬────────┘ │
|
||||||
|
│ │ :8080 │
|
||||||
|
│ ┌───────▼────────┐ │
|
||||||
|
│ │ silod │ │
|
||||||
|
│ │ (API server) │ │
|
||||||
|
│ └──┬─────────┬───┘ │
|
||||||
|
└─────┼─────────┼──────┘
|
||||||
|
│ │
|
||||||
|
┌───────────▼──┐ ┌───▼──────────────┐
|
||||||
|
│ PostgreSQL 16│ │ MinIO (S3) │
|
||||||
|
│ :5432 │ │ :9000 API │
|
||||||
|
└──────────────┘ │ :9001 Console │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.2 Prerequisites
|
||||||
|
|
||||||
|
- Linux host (Debian/Ubuntu or RHEL/Fedora/AlmaLinux)
|
||||||
|
- Root or sudo access
|
||||||
|
- Network access to your PostgreSQL and MinIO servers
|
||||||
|
|
||||||
|
The setup script installs Go and other build dependencies automatically.
|
||||||
|
|
||||||
|
### B.3 Set Up External Services
|
||||||
|
|
||||||
|
#### PostgreSQL 16
|
||||||
|
|
||||||
|
Install PostgreSQL and create the Silo database:
|
||||||
|
|
||||||
|
- [PostgreSQL downloads](https://www.postgresql.org/download/)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After installing PostgreSQL, create the database and user:
|
||||||
|
sudo -u postgres createuser silo
|
||||||
|
sudo -u postgres createdb -O silo silo
|
||||||
|
sudo -u postgres psql -c "ALTER USER silo WITH PASSWORD 'your-password';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure the Silo host can connect (check `pg_hba.conf` on the PostgreSQL server).
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h YOUR_PG_HOST -U silo -d silo -c 'SELECT 1'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MinIO
|
||||||
|
|
||||||
|
Install MinIO and create a bucket and service account:
|
||||||
|
|
||||||
|
- [MinIO quickstart](https://min.io/docs/minio/linux/index.html)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the MinIO client (mc):
|
||||||
|
mc alias set local http://YOUR_MINIO_HOST:9000 minioadmin minioadmin
|
||||||
|
mc mb local/silo-files
|
||||||
|
mc admin user add local silouser YOUR_MINIO_SECRET
|
||||||
|
mc admin policy attach local readwrite --user silouser
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://YOUR_MINIO_HOST:9000/minio/health/live
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LDAP / FreeIPA (Optional)
|
||||||
|
|
||||||
|
For LDAP authentication, you need an LDAP server with user and group entries. Options:
|
||||||
|
|
||||||
|
- [FreeIPA](https://www.freeipa.org/page/Quick_Start_Guide) — full identity management (recommended for organizations already using it)
|
||||||
|
- [OpenLDAP](https://www.openldap.org/doc/admin26/) — lightweight LDAP server
|
||||||
|
|
||||||
|
Silo needs:
|
||||||
|
- A base DN (e.g., `dc=example,dc=com`)
|
||||||
|
- Users under a known OU (e.g., `cn=users,cn=accounts,dc=example,dc=com`)
|
||||||
|
- Groups that map to Silo roles (`admin`, `editor`, `viewer`)
|
||||||
|
- The `memberOf` overlay enabled (so user entries have `memberOf` attributes)
|
||||||
|
|
||||||
|
See [CONFIGURATION.md — LDAP](CONFIGURATION.md#ldap--freeipa) for the full LDAP configuration reference.
|
||||||
|
|
||||||
|
### B.4 Prepare the Host
|
||||||
|
|
||||||
|
Run the setup script on the target host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and run the script
|
||||||
|
scp scripts/setup-host.sh root@YOUR_HOST:/tmp/
|
||||||
|
ssh root@YOUR_HOST 'bash /tmp/setup-host.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/setup-host.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Installs dependencies (git, Go 1.24)
|
||||||
|
2. Creates the `silo` system user
|
||||||
|
3. Creates directories (`/opt/silo`, `/etc/silo`)
|
||||||
|
4. Clones the repository
|
||||||
|
5. Creates the environment file template
|
||||||
|
|
||||||
|
To override the default service hostnames:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SILO_DB_HOST=db.example.com SILO_MINIO_HOST=s3.example.com sudo -E bash scripts/setup-host.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.5 Configure Credentials
|
||||||
|
|
||||||
|
Edit the environment file with your service credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/silo/silod.env
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
SILO_DB_PASSWORD=your-database-password
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
SILO_MINIO_ACCESS_KEY=silouser
|
||||||
|
SILO_MINIO_SECRET_KEY=your-minio-secret
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
SILO_SESSION_SECRET=generate-a-long-random-string
|
||||||
|
SILO_ADMIN_USERNAME=admin
|
||||||
|
SILO_ADMIN_PASSWORD=your-admin-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a session secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the server configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/silo/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `database.host`, `storage.endpoint`, `server.base_url`, and authentication settings for your environment. See [CONFIGURATION.md](CONFIGURATION.md) for all options.
|
||||||
|
|
||||||
|
### B.6 Deploy
|
||||||
|
|
||||||
|
Run the deploy script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Pulls latest code from git
|
||||||
|
2. Builds the `silod` binary and React frontend
|
||||||
|
3. Installs files to `/opt/silo` and `/etc/silo`
|
||||||
|
4. Runs database migrations
|
||||||
|
5. Installs and starts the systemd service
|
||||||
|
|
||||||
|
Deploy options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Skip git pull (use current checkout)
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh --no-pull
|
||||||
|
|
||||||
|
# Skip build (use existing binary)
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh --no-build
|
||||||
|
|
||||||
|
# Just restart the service
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh --restart-only
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh --status
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the target host or database host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SILO_DEPLOY_TARGET=silo.example.com SILO_DB_HOST=db.example.com sudo -E scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.7 Set Up Nginx and TLS
|
||||||
|
|
||||||
|
#### With FreeIPA (automated)
|
||||||
|
|
||||||
|
If your organization uses FreeIPA, the included script handles nginx setup, IPA enrollment, and certificate issuance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/silo/src/scripts/setup-ipa-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Override the hostname if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SILO_HOSTNAME=silo.example.com sudo -E /opt/silo/src/scripts/setup-ipa-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script installs nginx, enrolls the host in FreeIPA, requests a TLS certificate from the IPA CA (auto-renewed by certmonger), and configures nginx as an HTTPS reverse proxy.
|
||||||
|
|
||||||
|
#### Manual nginx setup
|
||||||
|
|
||||||
|
Install nginx and create a config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install nginx # or: sudo dnf install nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the template at `deployments/nginx/nginx.conf` as a starting point. Copy it to `/etc/nginx/sites-available/silo`, update the `server_name` and certificate paths, then enable it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/silo /etc/nginx/sites-enabled/silo
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
After enabling HTTPS, update `server.base_url` in `/etc/silo/config.yaml` to use `https://` and restart Silo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart silod
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.8 Verify the Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service status
|
||||||
|
sudo systemctl status silod
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Readiness check
|
||||||
|
curl http://localhost:8080/ready
|
||||||
|
|
||||||
|
# Follow logs
|
||||||
|
sudo journalctl -u silod -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your configured base URL in a browser and log in.
|
||||||
|
|
||||||
|
### B.9 Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code and redeploy
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh
|
||||||
|
|
||||||
|
# Or deploy a specific version
|
||||||
|
cd /opt/silo/src
|
||||||
|
git fetch --all --tags
|
||||||
|
git checkout v1.2.3
|
||||||
|
sudo /opt/silo/src/scripts/deploy.sh --no-pull
|
||||||
|
```
|
||||||
|
|
||||||
|
New database migrations are applied automatically during deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Install Configuration
|
||||||
|
|
||||||
|
After a successful installation:
|
||||||
|
|
||||||
|
- **Authentication**: Configure LDAP, OIDC, or local auth backends. See [CONFIGURATION.md — Authentication](CONFIGURATION.md#authentication).
|
||||||
|
- **Schemas**: Part numbering schemas are loaded from YAML files. See the `schemas/` directory and [CONFIGURATION.md — Schemas](CONFIGURATION.md#schemas).
|
||||||
|
- **Read-only mode**: Toggle write protection at runtime with `kill -USR1 $(pidof silod)` or by setting `server.read_only: true` in the config.
|
||||||
|
- **Ongoing maintenance**: See [DEPLOYMENT.md](DEPLOYMENT.md) for service management, log viewing, troubleshooting, and the security checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [CONFIGURATION.md](CONFIGURATION.md) | Complete `config.yaml` reference |
|
||||||
|
| [DEPLOYMENT.md](DEPLOYMENT.md) | Operations guide: maintenance, troubleshooting, security |
|
||||||
|
| [AUTH.md](AUTH.md) | Authentication system design |
|
||||||
|
| [AUTH_USER_GUIDE.md](AUTH_USER_GUIDE.md) | User guide for login, tokens, and roles |
|
||||||
|
| [SPECIFICATION.md](SPECIFICATION.md) | Full design specification and API reference |
|
||||||
|
| [STATUS.md](STATUS.md) | Implementation status |
|
||||||
|
| [GAP_ANALYSIS.md](GAP_ANALYSIS.md) | Gap analysis and revision control roadmap |
|
||||||
|
| [COMPONENT_AUDIT.md](COMPONENT_AUDIT.md) | Component audit tool design |
|
||||||
445
docs/ROADMAP.md
Normal file
445
docs/ROADMAP.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# Silo Platform Roadmap
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Date:** February 2026
|
||||||
|
|
||||||
|
Silo is the server component of the Kindred ecosystem. Its core function is storing and version-controlling engineering data (parts, assemblies, BOMs). This roadmap describes the expansion of Silo from a PDM server into a modular platform -- comparable to how Gitea/GitHub extend Git hosting with Actions, Wikis, Packages, and webhooks.
|
||||||
|
|
||||||
|
For a detailed comparison against SOLIDWORKS PDM, see [GAP_ANALYSIS.md](GAP_ANALYSIS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
- **Modular architecture.** Every capability beyond core PDM is a module. Modules register against a central API endpoint registry and declare their menu entries, views, dependencies, and routes via a module manifest.
|
||||||
|
- **Odoo-aligned UX.** The web UI follows Odoo's navigation patterns: a top-level app launcher grid, breadcrumb navigation (`Module > List > Record > Sub-view`), and standard view types (list, form, kanban, calendar, pivot). This alignment provides a familiar experience for shops already using Odoo as their ERP, and a clean integration path for those who adopt it later.
|
||||||
|
- **Open by default.** Silo and all modules are open-source. Enterprise customers can fork, extend, and self-host. Developer tools for building and distributing custom Create forks are available to everyone, not just Kindred.
|
||||||
|
- **Odoo as reference ERP.** For shops on Odoo, a bridge module syncs Silo data to Odoo models (`mrp.bom`, `mrp.production`, `quality.check`, etc.). For shops on other ERPs, the open API serves as a documented integration surface. Silo's web UI is fully self-sufficient with no ERP dependency required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Foundational Contracts
|
||||||
|
|
||||||
|
### The .kc File Format
|
||||||
|
|
||||||
|
Silo introduces the `.kc` file format as an enhanced superset of FreeCAD's `.fcstd`. Both are ZIP bundles. A `.kc` file contains everything an `.fcstd` does, plus a `silo/` directory with platform metadata.
|
||||||
|
|
||||||
|
#### Standard FCStd contents (preserved as-is)
|
||||||
|
|
||||||
|
- `Document.xml`, `GuiDocument.xml`
|
||||||
|
- BREP geometry files (`.brp`)
|
||||||
|
- `thumbnails/`
|
||||||
|
|
||||||
|
#### Added .kc entries
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `silo/manifest.json` | Silo instance origin, part UUID, revision hash, .kc schema version |
|
||||||
|
| `silo/metadata.json` | Custom schema field values, tags, lifecycle state |
|
||||||
|
| `silo/history.json` | Local revision log (lightweight; full history is server-side) |
|
||||||
|
| `silo/approvals.json` | ECO/approval state snapshot |
|
||||||
|
| `silo/dependencies.json` | Assembly link references by Silo UUID (not filepath) |
|
||||||
|
| `silo/macros/` | Embedded macro references or inline scripts bound to this part |
|
||||||
|
| `silo/inspection/` | GD&T annotations, tolerance data, CMM linkage metadata |
|
||||||
|
| `silo/thumbnails/` | Silo-generated renderings (separate from FreeCAD's built-in thumbnail) |
|
||||||
|
|
||||||
|
#### Interoperability
|
||||||
|
|
||||||
|
- **FCStd -> Silo:** On import, the `silo/` directory is generated with defaults. A UUID is assigned and the user is prompted for schema fields.
|
||||||
|
- **Silo -> FCStd:** On export, the `silo/` directory is stripped. The remaining contents are a valid `.fcstd`.
|
||||||
|
- **Round-trip safety:** FreeCAD ignores the `silo/` directory on save, so there is no risk of FreeCAD corrupting Silo metadata.
|
||||||
|
- **Schema versioning:** `silo/manifest.json` carries a format version for forward-compatible migrations.
|
||||||
|
|
||||||
|
### Module Manifest
|
||||||
|
|
||||||
|
Each module ships a manifest declaring its integration surface:
|
||||||
|
|
||||||
|
```
|
||||||
|
id, name, version, description
|
||||||
|
dependencies (other module IDs)
|
||||||
|
menu_entries (app launcher icon, label, route)
|
||||||
|
view_declarations (list, form, kanban, etc.)
|
||||||
|
api_routes (REST endpoints the module registers)
|
||||||
|
hooks (events the module listens to or emits)
|
||||||
|
permissions (required roles/scopes)
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact format (JSON, TOML, or Python-based a la Odoo's `__manifest__.py`) is TBD. The contract is: a module is anything that provides a valid manifest and registers against the endpoint registry.
|
||||||
|
|
||||||
|
### Web UI Shell
|
||||||
|
|
||||||
|
The Silo web application provides the chrome that all modules render within.
|
||||||
|
|
||||||
|
- **App launcher:** Top-level grid of installed module icons. Driven by the API endpoint registry -- only enabled modules appear. Disabled modules show greyed with an "Enable" action for discoverability.
|
||||||
|
- **Breadcrumbs:** Every view follows `Module > List > Record > Sub-view`. Consistent across all modules.
|
||||||
|
- **View types:** List, form, kanban, calendar, pivot/reporting. Modules declare supported views in their manifest.
|
||||||
|
- **Schema-driven forms:** The user-customizable schema engine maps directly to form views, enabling end-users to define part metadata fields through the web UI without code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Tiers
|
||||||
|
|
||||||
|
Modules are organized into tiers based on what they depend on. Lower tiers must be stable before higher tiers are built.
|
||||||
|
|
||||||
|
### Tier 0 -- Foundation
|
||||||
|
|
||||||
|
Everything depends on these. They define what Silo *is*.
|
||||||
|
|
||||||
|
| Component | Description | Status |
|
||||||
|
|-----------|-------------|--------|
|
||||||
|
| **Core Silo** | Part/assembly storage, version control, auth, base REST API | Complete |
|
||||||
|
| **.kc Format Spec** | File format contract between Create and Silo | Not Started |
|
||||||
|
| **API Endpoint Registry** | Module discovery, dynamic UI rendering, health checks | Not Started |
|
||||||
|
| **Web UI Shell** | App launcher, breadcrumbs, view framework, module rendering | Partial |
|
||||||
|
| **Python Scripting Engine** | Server-side hook execution, module extension point | Not Started |
|
||||||
|
| **Job Queue Infrastructure** | Redis/NATS shared async service for all compute modules | Not Started |
|
||||||
|
|
||||||
|
### Tier 1 -- Core Services
|
||||||
|
|
||||||
|
Broad downstream dependencies. These should be built early because retrofitting is painful.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **Headless Create** | API-driven FreeCAD instance for file manipulation, geometry queries, format conversion, rendering | Core Silo, Job Queue | Not Started |
|
||||||
|
| **Notifications & Subscriptions** | Per-part watch lists, lifecycle event hooks, webhook delivery | Core Silo, Registry | Not Started |
|
||||||
|
| **Audit Trail / Compliance** | ITAR, ISO 9001, AS9100 traceability; module-level event journaling | Core Silo | Partial |
|
||||||
|
|
||||||
|
### Tier 2 -- File Intelligence & Collaboration
|
||||||
|
|
||||||
|
High-visibility features. Mostly low-hanging fruit once Tier 1 is solid.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **Intelligent FCStd Diffing** | XML-based structural diff of .kc bundles | Headless Create | Not Started |
|
||||||
|
| **Thumbnail Generation** | Auto-rendered part/assembly previews | Headless Create | Not Started |
|
||||||
|
| **Macro Store** | Shared macro library across Create instances | Core Silo, Registry | Not Started |
|
||||||
|
| **Theme & Addon Manager** | Centralized distribution of UI themes and workbench addons | Core Silo, Registry | Not Started |
|
||||||
|
| **User-Customizable Schemas** | End-user defined part/form metadata via web UI | Core Silo, Scripting Engine | Not Started |
|
||||||
|
|
||||||
|
### Tier 3 -- Compute
|
||||||
|
|
||||||
|
Heavy async workloads. All route through the shared job queue.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **Batch Jobs (CPU/GPU)** | FEA, CFD, rendering, bulk export | Job Queue, Headless Create | Not Started |
|
||||||
|
| **AI Broker** | LLM tasks (Ollama), GNN constraint optimization, appearance AI | Job Queue | Not Started |
|
||||||
|
| **Reporting & Analytics** | Part reuse, revision frequency, compute usage dashboards, cost roll-ups | Audit Trail, Core Silo | Not Started |
|
||||||
|
|
||||||
|
### Tier 4 -- Engineering Workflow
|
||||||
|
|
||||||
|
Process modules that formalize how engineering work moves through an organization.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **Approval / ECO Workflow** | Engineering change orders, multi-stage review gates, digital signatures | Notifications, Audit Trail, Schemas | Not Started |
|
||||||
|
| **Shop Floor Drawing Distribution** | Controlled push-to-production drawings; web-based appliance displays on the floor | Headless Create, Approval Workflow | Not Started |
|
||||||
|
| **Import/Export Bridge** | STEP, IGES, 3MF connectors; SOLIDWORKS migration tooling; ERP adapters | Headless Create | Not Started |
|
||||||
|
| **Multi-tenant / Org Management** | Org boundaries, role-based permissioning, storage quotas | Core Auth, Audit Trail | Not Started |
|
||||||
|
|
||||||
|
### Tier 5 -- Manufacturing & Quality
|
||||||
|
|
||||||
|
Deep domain modules. Heavy spec work required independent of software dependencies.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **MES Module** | Manufacturing execution -- internal module or bridge to external MES | Approval Workflow, Schemas, Shop Floor Drawings | Not Started |
|
||||||
|
| **Quality / Tolerance Stackup** | Inspection data ingestion, CMM device linking, statistical tolerance analysis, material mapping | Schemas, Import Bridge | Not Started |
|
||||||
|
| **Inspection Plan Generator** | Auto-generate CMM programs or inspection checklists from GD&T drawings | Headless Create, Quality Module | Not Started |
|
||||||
|
| **BIM Inventory / Receiving** | Live facility model with real-time inventory location, explorable in a custom BIM-MES workbench in Create | Custom BIM-MES Workbench, Schemas, Notifications | Not Started |
|
||||||
|
|
||||||
|
### Tier 6 -- Platform & Ecosystem
|
||||||
|
|
||||||
|
Modules that serve the broader community and long-horizon use cases.
|
||||||
|
|
||||||
|
| Module | Description | Depends On | Status |
|
||||||
|
|--------|-------------|------------|--------|
|
||||||
|
| **Developer Tools** | Managed Gitea instance for in-house Create fork development; CI/CD to build and distribute fork updates to configured clients | Tier 0-1 stability | Not Started |
|
||||||
|
| **Digital Twin Sync** | Live sensor data mapped onto BIM/assembly models; operational monitoring | BIM Inventory, Reporting | Not Started |
|
||||||
|
| **ERP Adapters (Odoo, SAP, etc.)** | Bidirectional sync of parts, BOMs, ECOs, production orders to external ERP | Import/Export Bridge, MES, Schemas | Partial (Odoo stubs) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Near-Term Priorities
|
||||||
|
|
||||||
|
These are the concrete tasks that map to Tier 0 completion and the first steps into Tier 1. They replace the older Phase 1-6 calendar-based timelines.
|
||||||
|
|
||||||
|
### Tier 0 Completion
|
||||||
|
|
||||||
|
Complete MVP and stabilize core functionality.
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| Unit test suite | Core API, database, partnum, file, CSV/ODS handler tests | Complete (137 tests) |
|
||||||
|
| Date segment type | Implement `date` segment with strftime-style formatting | Complete (#79) |
|
||||||
|
| Part number validation | Validate format against schema on creation | Complete (#80) |
|
||||||
|
| Location CRUD API | Expose location hierarchy via REST | Not Started (#81) |
|
||||||
|
| Inventory API | Expose inventory operations via REST | Not Started (#82) |
|
||||||
|
|
||||||
|
**Success metrics:**
|
||||||
|
- All existing tests pass
|
||||||
|
- File upload/download works end-to-end
|
||||||
|
- FreeCAD users can checkout, modify, commit parts
|
||||||
|
|
||||||
|
### Multi-User Enablement
|
||||||
|
|
||||||
|
Enable team collaboration (feeds into Tier 1 and Tier 4).
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| Check-out locking | Pessimistic locks with timeout | Not Started (#87) |
|
||||||
|
| User/group management | Create, assign, manage users and groups | Not Started (#88) |
|
||||||
|
| Folder permissions | Read/write/delete per folder/project hierarchy | Not Started (#89) |
|
||||||
|
|
||||||
|
**Success metrics:**
|
||||||
|
- 5+ concurrent users supported
|
||||||
|
- No data corruption under concurrent access
|
||||||
|
- Audit log captures all modifications
|
||||||
|
|
||||||
|
### Workflow Engine
|
||||||
|
|
||||||
|
Implement engineering change processes (Tier 4: Approval/ECO Workflow).
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| Workflow designer | YAML-defined state machines | Not Started |
|
||||||
|
| State transitions | Configurable transition rules with permissions | Not Started |
|
||||||
|
| Approval workflows | Single and parallel approver gates | Not Started |
|
||||||
|
| Email notifications | SMTP integration for alerts on state changes | Not Started |
|
||||||
|
|
||||||
|
**Success metrics:**
|
||||||
|
- Engineering change process completable in Silo
|
||||||
|
- Email notifications delivered reliably
|
||||||
|
- Workflow state visible in web UI
|
||||||
|
|
||||||
|
### Search & Discovery
|
||||||
|
|
||||||
|
Improve findability and navigation (Tier 0 Web UI Shell).
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| Advanced search UI | Web interface with filters and operators | Not Started (#90) |
|
||||||
|
| Saved searches | User-defined query favorites | Not Started (#91) |
|
||||||
|
|
||||||
|
**Success metrics:**
|
||||||
|
- Search returns results in <2 seconds
|
||||||
|
- Where-used queries complete in <5 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap Summary
|
||||||
|
|
||||||
|
For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_ANALYSIS.md#appendix-c-solidworks-pdm-comparison).
|
||||||
|
|
||||||
|
### Completed (Previously Critical/High)
|
||||||
|
|
||||||
|
1. ~~User authentication~~ -- local, LDAP, OIDC
|
||||||
|
2. ~~Role-based permissions~~ -- 3-tier role model (admin/editor/viewer)
|
||||||
|
3. ~~Audit trail~~ -- audit_log table with completeness scoring
|
||||||
|
4. ~~Where-used search~~ -- reverse parent lookup API
|
||||||
|
5. ~~Multi-level BOM API~~ -- recursive expansion with configurable depth
|
||||||
|
6. ~~BOM export~~ -- CSV and ODS formats
|
||||||
|
|
||||||
|
### Critical Gaps (Required for Team Use)
|
||||||
|
|
||||||
|
1. **Workflow engine** -- state machines with transitions and approvals
|
||||||
|
2. **Check-out locking** -- pessimistic locking for CAD files
|
||||||
|
|
||||||
|
### High Priority Gaps (Significant Value)
|
||||||
|
|
||||||
|
1. **Email notifications** -- alert users on state changes
|
||||||
|
2. **Web UI search** -- advanced search interface with saved searches
|
||||||
|
3. **Folder/state permissions** -- granular access control beyond role model
|
||||||
|
|
||||||
|
### Medium Priority Gaps (Nice to Have)
|
||||||
|
|
||||||
|
1. **Saved searches** -- frequently used queries
|
||||||
|
2. **File preview/thumbnails** -- visual browsing
|
||||||
|
3. **Reporting** -- activity and inventory reports
|
||||||
|
4. **Scheduled tasks** -- background automation
|
||||||
|
5. **BOM comparison** -- revision diff for assemblies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Notes
|
||||||
|
|
||||||
|
- **Headless Create** is the single highest-leverage Tier 1 item. It unblocks diffing, thumbnails, batch export, drawing distribution, and inspection plan generation.
|
||||||
|
- **Audit Trail** is unglamorous but critical to build early. Retrofitting compliance logging after modules ship is expensive and error-prone.
|
||||||
|
- **Tier 2** delivers visible, demo-able value quickly -- diffing, thumbnails, and the macro store are features users immediately understand.
|
||||||
|
- **Tiers 5-6** carry heavy domain complexity. They need detailed specification and industry consultation well before implementation begins.
|
||||||
|
- The **.kc format** and **module manifest** are the two foundational contracts. Getting these right determines how cleanly everything above them composes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Module manifest format** -- JSON, TOML, or Python-based? Tradeoffs between simplicity and expressiveness.
|
||||||
|
2. **.kc thumbnail policy** -- Single canonical thumbnail vs. multi-view renders. Impacts file size and generation cost.
|
||||||
|
3. **Job queue technology** -- Redis Streams vs. NATS. Redis is already in the stack; NATS offers better pub/sub semantics for event-driven modules.
|
||||||
|
4. **Headless Create deployment** -- Sidecar container per Silo instance, or pool of workers behind the job queue?
|
||||||
|
5. **BIM-MES workbench scope** -- How much of FreeCAD BIM is reusable vs. needs to be purpose-built for inventory/facility modeling?
|
||||||
|
6. **Offline .kc workflow** -- How much of the `silo/` metadata is authoritative when disconnected? Reconciliation strategy on reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Current Project Inventory
|
||||||
|
|
||||||
|
### Implemented Features (MVP Complete)
|
||||||
|
|
||||||
|
#### Core Database System
|
||||||
|
- PostgreSQL schema with 13 migrations
|
||||||
|
- UUID-based identifiers throughout
|
||||||
|
- Soft delete support via `archived_at` timestamps
|
||||||
|
- Atomic sequence generation for part numbers
|
||||||
|
|
||||||
|
#### Part Number Generation
|
||||||
|
- YAML schema parser with validation
|
||||||
|
- Segment types: `string`, `enum`, `serial`, `constant`
|
||||||
|
- Scope templates for serial counters (e.g., `{category}`, `{project}`)
|
||||||
|
- Format templates for custom output
|
||||||
|
|
||||||
|
#### Item Management
|
||||||
|
- Full CRUD operations for items
|
||||||
|
- Item types: part, assembly, drawing, document, tooling, purchased, electrical, software
|
||||||
|
- Custom properties via JSONB storage
|
||||||
|
- Project tagging with many-to-many relationships
|
||||||
|
|
||||||
|
#### Revision Control
|
||||||
|
- Append-only revision history
|
||||||
|
- Revision metadata: properties, file reference, checksum, comment
|
||||||
|
- Status tracking: draft, review, released, obsolete
|
||||||
|
- Labels/tags per revision
|
||||||
|
- Revision comparison (diff)
|
||||||
|
- Rollback functionality
|
||||||
|
|
||||||
|
#### File Management
|
||||||
|
- MinIO integration with versioning
|
||||||
|
- File upload/download via REST API
|
||||||
|
- SHA256 checksums for integrity
|
||||||
|
- Storage path: `items/{partNumber}/rev{N}.FCStd`
|
||||||
|
|
||||||
|
#### Bill of Materials (BOM)
|
||||||
|
- Relationship types: component, alternate, reference
|
||||||
|
- Multi-level BOM (recursive expansion with configurable depth)
|
||||||
|
- Where-used queries (reverse parent lookup)
|
||||||
|
- BOM CSV and ODS export/import with cycle detection
|
||||||
|
- Reference designators for electronics
|
||||||
|
- Quantity tracking with units
|
||||||
|
- Revision-specific child linking
|
||||||
|
|
||||||
|
#### Project Management
|
||||||
|
- Project CRUD operations
|
||||||
|
- Unique project codes (2-10 characters)
|
||||||
|
- Item-to-project tagging
|
||||||
|
- Project-filtered queries
|
||||||
|
|
||||||
|
#### Data Import/Export
|
||||||
|
- CSV export with configurable properties
|
||||||
|
- CSV import with dry-run validation
|
||||||
|
- ODS spreadsheet import/export (items, BOMs, project sheets)
|
||||||
|
- Template generation for import formatting
|
||||||
|
|
||||||
|
#### API & Web Interface
|
||||||
|
- REST API with 78 endpoints
|
||||||
|
- Authentication: local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak
|
||||||
|
- Role-based access control (admin > editor > viewer)
|
||||||
|
- API token management (SHA-256 hashed)
|
||||||
|
- Session management (PostgreSQL-backed, 24h lifetime)
|
||||||
|
- CSRF protection (nosurf on web forms)
|
||||||
|
- Middleware: logging, CORS, recovery, request ID
|
||||||
|
- Web UI -- React SPA (Vite + TypeScript, Catppuccin Mocha theme)
|
||||||
|
- Fuzzy search
|
||||||
|
- Health and readiness probes
|
||||||
|
|
||||||
|
#### Audit & Completeness
|
||||||
|
- Audit logging (database table with user/action/resource tracking)
|
||||||
|
- Item completeness scoring with weighted fields
|
||||||
|
- Category-specific property validation
|
||||||
|
- Tier classification (critical/low/partial/good/complete)
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
- YAML configuration with environment variable overrides
|
||||||
|
- Multi-schema support
|
||||||
|
- Docker Compose deployment ready
|
||||||
|
|
||||||
|
### Partially Implemented
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull sync operations are stubs |
|
||||||
|
| Date segment type | Complete | strftime-style formatting via Go time layout (#79) |
|
||||||
|
| Part number validation | Complete | Validates against schema on creation (#80) |
|
||||||
|
| Location hierarchy CRUD | Schema only | Tables exist, no API endpoints (#81) |
|
||||||
|
| Inventory tracking | Schema only | Tables exist, no API endpoints (#82) |
|
||||||
|
| Unit tests | Complete | 137 tests across 20 files covering api, db, ods, partnum, schema packages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Phase 1 Detailed Tasks
|
||||||
|
|
||||||
|
### 1.1 MinIO Integration -- COMPLETE
|
||||||
|
- [x] MinIO service configured in Docker Compose
|
||||||
|
- [x] File upload via REST API
|
||||||
|
- [x] File download via REST API (latest and by revision)
|
||||||
|
- [x] SHA256 checksums on upload
|
||||||
|
|
||||||
|
### 1.2 Authentication & Authorization -- COMPLETE
|
||||||
|
- [x] Local authentication (bcrypt)
|
||||||
|
- [x] LDAP/FreeIPA authentication
|
||||||
|
- [x] OIDC/Keycloak authentication
|
||||||
|
- [x] Role-based access control (admin/editor/viewer)
|
||||||
|
- [x] API token management (SHA-256 hashed)
|
||||||
|
- [x] Session management (PostgreSQL-backed)
|
||||||
|
- [x] CSRF protection (nosurf)
|
||||||
|
- [x] Audit logging (database table)
|
||||||
|
|
||||||
|
### 1.3 Multi-level BOM & Export -- COMPLETE
|
||||||
|
- [x] Recursive BOM expansion with configurable depth
|
||||||
|
- [x] Where-used reverse lookup
|
||||||
|
- [x] BOM CSV export/import with cycle detection
|
||||||
|
- [x] BOM ODS export
|
||||||
|
- [x] ODS item export/import/template
|
||||||
|
|
||||||
|
### 1.4 Unit Test Suite -- COMPLETE
|
||||||
|
- [x] Database connection and transaction tests
|
||||||
|
- [x] Item CRUD operation tests (including edge cases: duplicate keys, pagination, search)
|
||||||
|
- [x] Revision creation, retrieval, compare, rollback tests
|
||||||
|
- [x] Part number generation tests (including date segments, validation)
|
||||||
|
- [x] File upload/download handler tests
|
||||||
|
- [x] CSV import/export tests (dry-run, commit, BOM export)
|
||||||
|
- [x] ODS import/export tests (export, template, project sheet)
|
||||||
|
- [x] API endpoint tests (revisions, schemas, audit, auth tokens)
|
||||||
|
- [x] Item file CRUD tests
|
||||||
|
- [x] BOM handler tests (get, flat, cost, add, delete)
|
||||||
|
|
||||||
|
### 1.5 Missing Segment Types -- COMPLETE
|
||||||
|
- [x] Implement date segment type
|
||||||
|
- [x] Add strftime-style format support
|
||||||
|
|
||||||
|
### 1.6 Location & Inventory APIs
|
||||||
|
- [ ] `GET /api/locations` - List locations
|
||||||
|
- [ ] `POST /api/locations` - Create location
|
||||||
|
- [ ] `GET /api/locations/{path}` - Get location
|
||||||
|
- [ ] `DELETE /api/locations/{path}` - Delete location
|
||||||
|
- [ ] `GET /api/inventory/{partNumber}` - Get inventory
|
||||||
|
- [ ] `POST /api/inventory/{partNumber}/adjust` - Adjust quantity
|
||||||
|
- [ ] `POST /api/inventory/{partNumber}/move` - Move between locations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: References
|
||||||
|
|
||||||
|
### SOLIDWORKS PDM Documentation
|
||||||
|
- [SOLIDWORKS PDM Product Page](https://www.solidworks.com/product/solidworks-pdm)
|
||||||
|
- [What's New in SOLIDWORKS PDM 2025](https://blogs.solidworks.com/solidworksblog/2024/10/whats-new-in-solidworks-pdm-2025.html)
|
||||||
|
- [Top 5 Enhancements in SOLIDWORKS PDM 2024](https://blogs.solidworks.com/solidworksblog/2023/10/top-5-enhancements-in-solidworks-pdm-2024.html)
|
||||||
|
- [SOLIDWORKS PDM Workflow Transitions](https://help.solidworks.com/2023/english/EnterprisePDM/Admin/c_workflow_transition.htm)
|
||||||
|
- [Ultimate Guide to SOLIDWORKS PDM Permissions](https://www.goengineer.com/blog/ultimate-guide-to-solidworks-pdm-permissions)
|
||||||
|
- [Searching in SOLIDWORKS PDM](https://help.solidworks.com/2021/english/EnterprisePDM/fileexplorer/c_searches.htm)
|
||||||
|
- [SOLIDWORKS PDM API Getting Started](https://3dswym.3dexperience.3ds.com/wiki/solidworks-news-info/getting-started-with-the-solidworks-pdm-api-solidpractices_gBCYaM75RgORBcpSO1m_Mw)
|
||||||
|
|
||||||
|
### Silo Documentation
|
||||||
|
- [Specification](SPECIFICATION.md)
|
||||||
|
- [Development Status](STATUS.md)
|
||||||
|
- [Deployment Guide](DEPLOYMENT.md)
|
||||||
|
- [Gap Analysis](GAP_ANALYSIS.md)
|
||||||
@@ -37,7 +37,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
|||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Silo Server (silod) │
|
│ Silo Server (silod) │
|
||||||
│ - REST API (75 endpoints) │
|
│ - REST API (78 endpoints) │
|
||||||
│ - Authentication (local, LDAP, OIDC) │
|
│ - Authentication (local, LDAP, OIDC) │
|
||||||
│ - Schema parsing and validation │
|
│ - Schema parsing and validation │
|
||||||
│ - Part number generation engine │
|
│ - Part number generation engine │
|
||||||
@@ -50,7 +50,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
|||||||
▼ ▼
|
▼ ▼
|
||||||
┌─────────────────────────┐ ┌─────────────────────────────┐
|
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||||
│ PostgreSQL │ │ MinIO │
|
│ PostgreSQL │ │ MinIO │
|
||||||
│ (psql.kindred.internal)│ │ - File storage │
|
│ (psql.example.internal)│ │ - File storage │
|
||||||
│ - Item metadata │ │ - Versioned objects │
|
│ - Item metadata │ │ - Versioned objects │
|
||||||
│ - Relationships │ │ - Thumbnails │
|
│ - Relationships │ │ - Thumbnails │
|
||||||
│ - Revision history │ │ │
|
│ - Revision history │ │ │
|
||||||
@@ -63,7 +63,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
|||||||
|
|
||||||
| Component | Technology | Notes |
|
| Component | Technology | Notes |
|
||||||
|-----------|------------|-------|
|
|-----------|------------|-------|
|
||||||
| Database | PostgreSQL 16 | Existing instance at psql.kindred.internal |
|
| Database | PostgreSQL 16 | Existing instance at psql.example.internal |
|
||||||
| File Storage | MinIO | S3-compatible, versioning enabled |
|
| File Storage | MinIO | S3-compatible, versioning enabled |
|
||||||
| CLI & API Server | Go (1.24) | chi/v5 router, pgx/v5 driver, zerolog |
|
| CLI & API Server | Go (1.24) | chi/v5 router, pgx/v5 driver, zerolog |
|
||||||
| Authentication | Multi-backend | Local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak |
|
| Authentication | Multi-backend | Local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak |
|
||||||
@@ -598,7 +598,7 @@ See [AUTH.md](AUTH.md) for full architecture details and [AUTH_USER_GUIDE.md](AU
|
|||||||
|
|
||||||
## 11. API Design
|
## 11. API Design
|
||||||
|
|
||||||
### 11.1 REST Endpoints (75 Implemented)
|
### 11.1 REST Endpoints (78 Implemented)
|
||||||
|
|
||||||
```
|
```
|
||||||
# Health (no auth)
|
# Health (no auth)
|
||||||
@@ -615,6 +615,9 @@ GET /auth/callback # OIDC callback
|
|||||||
# Public API (no auth required)
|
# Public API (no auth required)
|
||||||
GET /api/auth/config # Auth backend configuration (for login UI)
|
GET /api/auth/config # Auth backend configuration (for login UI)
|
||||||
|
|
||||||
|
# Server-Sent Events (require auth)
|
||||||
|
GET /api/events # SSE stream for real-time updates
|
||||||
|
|
||||||
# Auth API (require auth)
|
# Auth API (require auth)
|
||||||
GET /api/auth/me # Current authenticated user
|
GET /api/auth/me # Current authenticated user
|
||||||
GET /api/auth/tokens # List user's API tokens
|
GET /api/auth/tokens # List user's API tokens
|
||||||
@@ -627,7 +630,7 @@ POST /api/uploads/presign # Get presigned MinI
|
|||||||
# Schemas (read: viewer, write: editor)
|
# Schemas (read: viewer, write: editor)
|
||||||
GET /api/schemas # List all schemas
|
GET /api/schemas # List all schemas
|
||||||
GET /api/schemas/{name} # Get schema details
|
GET /api/schemas/{name} # Get schema details
|
||||||
GET /api/schemas/{name}/properties # Get property schema for category
|
GET /api/schemas/{name}/form # Get form descriptor (field groups, widgets, category picker)
|
||||||
POST /api/schemas/{name}/segments/{segment}/values # Add enum value [editor]
|
POST /api/schemas/{name}/segments/{segment}/values # Add enum value [editor]
|
||||||
PUT /api/schemas/{name}/segments/{segment}/values/{code} # Update enum value [editor]
|
PUT /api/schemas/{name}/segments/{segment}/values/{code} # Update enum value [editor]
|
||||||
DELETE /api/schemas/{name}/segments/{segment}/values/{code} # Delete enum value [editor]
|
DELETE /api/schemas/{name}/segments/{segment}/values/{code} # Delete enum value [editor]
|
||||||
@@ -644,6 +647,7 @@ DELETE /api/projects/{code} # Delete project [ed
|
|||||||
# Items (read: viewer, write: editor)
|
# Items (read: viewer, write: editor)
|
||||||
GET /api/items # List/filter items
|
GET /api/items # List/filter items
|
||||||
GET /api/items/search # Fuzzy search
|
GET /api/items/search # Fuzzy search
|
||||||
|
GET /api/items/by-uuid/{uuid} # Get item by UUID
|
||||||
GET /api/items/export.csv # Export items to CSV
|
GET /api/items/export.csv # Export items to CSV
|
||||||
GET /api/items/template.csv # CSV import template
|
GET /api/items/template.csv # CSV import template
|
||||||
GET /api/items/export.ods # Export items to ODS
|
GET /api/items/export.ods # Export items to ODS
|
||||||
@@ -689,6 +693,7 @@ GET /api/items/{partNumber}/bom/export.csv # Export BOM as CSV
|
|||||||
GET /api/items/{partNumber}/bom/export.ods # Export BOM as ODS
|
GET /api/items/{partNumber}/bom/export.ods # Export BOM as ODS
|
||||||
POST /api/items/{partNumber}/bom # Add BOM entry [editor]
|
POST /api/items/{partNumber}/bom # Add BOM entry [editor]
|
||||||
POST /api/items/{partNumber}/bom/import # Import BOM from CSV [editor]
|
POST /api/items/{partNumber}/bom/import # Import BOM from CSV [editor]
|
||||||
|
POST /api/items/{partNumber}/bom/merge # Merge BOM from ODS with conflict resolution [editor]
|
||||||
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
|
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
|
||||||
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
|
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
|
||||||
|
|
||||||
@@ -734,11 +739,11 @@ POST /api/inventory/{partNumber}/move
|
|||||||
|
|
||||||
### 12.1 Implemented
|
### 12.1 Implemented
|
||||||
|
|
||||||
- [x] PostgreSQL database schema (11 migrations)
|
- [x] PostgreSQL database schema (13 migrations)
|
||||||
- [x] YAML schema parser for part numbering
|
- [x] YAML schema parser for part numbering
|
||||||
- [x] Part number generation engine
|
- [x] Part number generation engine
|
||||||
- [x] CLI tool (`cmd/silo`)
|
- [x] CLI tool (`cmd/silo`)
|
||||||
- [x] API server (`cmd/silod`) with 75 endpoints
|
- [x] API server (`cmd/silod`) with 78 endpoints
|
||||||
- [x] MinIO integration for file storage with versioning
|
- [x] MinIO integration for file storage with versioning
|
||||||
- [x] BOM relationships (component, alternate, reference)
|
- [x] BOM relationships (component, alternate, reference)
|
||||||
- [x] Multi-level BOM (recursive expansion with configurable depth)
|
- [x] Multi-level BOM (recursive expansion with configurable depth)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
|-----------|--------|-------|
|
|-----------|--------|-------|
|
||||||
| PostgreSQL schema | Complete | 11 migrations applied |
|
| PostgreSQL schema | Complete | 13 migrations applied |
|
||||||
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
|
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
|
||||||
| Part number generator | Complete | Scoped sequences, category-based format |
|
| Part number generator | Complete | Scoped sequences, category-based format |
|
||||||
| API server (`silod`) | Complete | 75 REST endpoints via chi/v5 |
|
| API server (`silod`) | Complete | 78 REST endpoints via chi/v5 |
|
||||||
| CLI tool (`silo`) | Complete | Item registration and management |
|
| CLI tool (`silo`) | Complete | Item registration and management |
|
||||||
| MinIO file storage | Complete | Upload, download, versioning, checksums |
|
| MinIO file storage | Complete | Upload, download, versioning, checksums |
|
||||||
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
|
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
|
||||||
@@ -55,7 +55,7 @@ FreeCAD workbench and LibreOffice Calc extension are maintained in separate repo
|
|||||||
|
|
||||||
| Service | Host | Status |
|
| Service | Host | Status |
|
||||||
|---------|------|--------|
|
|---------|------|--------|
|
||||||
| PostgreSQL | psql.kindred.internal:5432 | Running |
|
| PostgreSQL | psql.example.internal:5432 | Running |
|
||||||
| MinIO | localhost:9000 (API) / :9001 (console) | Configured |
|
| MinIO | localhost:9000 (API) / :9001 (console) | Configured |
|
||||||
| Silo API | localhost:8080 | Builds successfully |
|
| Silo API | localhost:8080 | Builds successfully |
|
||||||
|
|
||||||
@@ -92,5 +92,7 @@ The schema defines 170 category codes across 10 groups:
|
|||||||
| 007_revision_status.sql | Revision status and labels |
|
| 007_revision_status.sql | Revision status and labels |
|
||||||
| 008_odoo_integration.sql | Odoo ERP integration tables (integrations, sync_log) |
|
| 008_odoo_integration.sql | Odoo ERP integration tables (integrations, sync_log) |
|
||||||
| 009_auth.sql | Authentication system (users, api_tokens, sessions, audit_log, user tracking columns) |
|
| 009_auth.sql | Authentication system (users, api_tokens, sessions, audit_log, user tracking columns) |
|
||||||
| 010_item_extended_fields.sql | Extended item fields (sourcing_type, sourcing_link, standard_cost, long_description) |
|
| 010_item_extended_fields.sql | Extended item fields (sourcing_type, long_description) |
|
||||||
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
|
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
|
||||||
|
| 012_bom_source.sql | BOM entry source tracking |
|
||||||
|
| 013_move_cost_sourcing_to_props.sql | Move sourcing_link and standard_cost from item columns to revision properties |
|
||||||
|
|||||||
515
docs/STYLE.md
Normal file
515
docs/STYLE.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# Silo Style Guide
|
||||||
|
|
||||||
|
> Living reference for the Silo web UI. All modules must follow these conventions to maintain visual consistency across the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color System
|
||||||
|
|
||||||
|
Silo uses the [Catppuccin Mocha](https://github.com/catppuccin/catppuccin) palette exclusively. All colors are referenced via CSS custom properties defined at `:root`.
|
||||||
|
|
||||||
|
### Palette
|
||||||
|
|
||||||
|
```
|
||||||
|
--ctp-rosewater: #f5e0dc
|
||||||
|
--ctp-flamingo: #f2cdcd
|
||||||
|
--ctp-pink: #f5c2e7
|
||||||
|
--ctp-mauve: #cba6f7
|
||||||
|
--ctp-red: #f38ba8
|
||||||
|
--ctp-maroon: #eba0ac
|
||||||
|
--ctp-peach: #fab387
|
||||||
|
--ctp-yellow: #f9e2af
|
||||||
|
--ctp-green: #a6e3a1
|
||||||
|
--ctp-teal: #94e2d5
|
||||||
|
--ctp-sky: #89dceb
|
||||||
|
--ctp-sapphire: #74c7ec
|
||||||
|
--ctp-blue: #89b4fa
|
||||||
|
--ctp-lavender: #b4befe
|
||||||
|
--ctp-text: #cdd6f4
|
||||||
|
--ctp-subtext1: #bac2de
|
||||||
|
--ctp-subtext0: #a6adc8
|
||||||
|
--ctp-overlay2: #9399b2
|
||||||
|
--ctp-overlay1: #7f849c
|
||||||
|
--ctp-overlay0: #6c7086
|
||||||
|
--ctp-surface2: #585b70
|
||||||
|
--ctp-surface1: #45475a
|
||||||
|
--ctp-surface0: #313244
|
||||||
|
--ctp-base: #1e1e2e
|
||||||
|
--ctp-mantle: #181825
|
||||||
|
--ctp-crust: #11111b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Roles
|
||||||
|
|
||||||
|
| Role | Token | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Page background | `--ctp-base` | Main content area |
|
||||||
|
| Panel background | `--ctp-mantle` | Sidebars, detail panes, headers |
|
||||||
|
| Inset/input background | `--ctp-crust` | Form inputs, code blocks, drop zones |
|
||||||
|
| Primary accent | `--ctp-mauve` | Primary buttons, active states, links, selection highlights |
|
||||||
|
| Secondary accent | `--ctp-blue` | Informational highlights, secondary actions |
|
||||||
|
| Success | `--ctp-green` | Confirmations, positive status |
|
||||||
|
| Warning | `--ctp-yellow` | Caution states, pending actions |
|
||||||
|
| Danger | `--ctp-red` | Destructive actions, errors, required indicators |
|
||||||
|
| Informational | `--ctp-teal` | Auto-generated metadata, system-assigned values |
|
||||||
|
| Body text | `--ctp-text` | Primary content |
|
||||||
|
| Secondary text | `--ctp-subtext1` | Descriptions, timestamps |
|
||||||
|
| Muted text | `--ctp-overlay1` | Placeholders, disabled states |
|
||||||
|
| Borders | `--ctp-surface0` | Dividers, panel edges |
|
||||||
|
| Hover borders | `--ctp-surface1` | Interactive element borders, row separators |
|
||||||
|
| Focus ring | `rgba(203, 166, 247, 0.25)` | `box-shadow` on focused inputs (mauve at 25%) |
|
||||||
|
|
||||||
|
### Accent Usage for Data Types
|
||||||
|
|
||||||
|
| Data type | Color | Token |
|
||||||
|
|-----------|-------|-------|
|
||||||
|
| Assembly | `--ctp-mauve` | Badge, icon tint |
|
||||||
|
| Part | `--ctp-green` | Badge, icon tint |
|
||||||
|
| Document | `--ctp-blue` | Badge, icon tint |
|
||||||
|
| Purchased | `--ctp-peach` | Badge, icon tint |
|
||||||
|
| Phantom | `--ctp-overlay1` | Badge, icon tint |
|
||||||
|
|
||||||
|
These mappings are used anywhere item types appear: list badges, detail pane headers, BOM entries, tree views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Scale
|
||||||
|
|
||||||
|
| Role | Size | Weight | Token/Color | Transform |
|
||||||
|
|------|------|--------|-------------|-----------|
|
||||||
|
| Page title | 1.1rem | 600 | `--ctp-text` | None |
|
||||||
|
| Section header | 11px | 600 | `--ctp-overlay0` | Uppercase, `letter-spacing: 0.06em` |
|
||||||
|
| Form label | 11px | 600 | `--ctp-overlay1` | Uppercase, `letter-spacing: 0.05em` |
|
||||||
|
| Body text | 13px | 400 | `--ctp-text` | None |
|
||||||
|
| Table cell | 12px | 400 | `--ctp-text` | None |
|
||||||
|
| Caption / metadata | 11px | 400 | `--ctp-subtext0` | None |
|
||||||
|
| Badge text | 10px | 600 | Varies | Uppercase |
|
||||||
|
| Breadcrumb segment | 13px | 500 | `--ctp-subtext1` | None |
|
||||||
|
| Breadcrumb active | 13px | 600 | `--ctp-text` | None |
|
||||||
|
|
||||||
|
### Font Stack
|
||||||
|
|
||||||
|
```css
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
No external font dependencies. System fonts ensure fast rendering and native feel across platforms.
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Never use font sizes below 10px.
|
||||||
|
- Use `font-weight: 600` for emphasis instead of bold (700). Reserve 700 for page titles only when extra weight is needed.
|
||||||
|
- `text-transform: uppercase` is reserved for section headers, form labels, and badges. Never uppercase body text or descriptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Base unit: **4px**. All spacing values are multiples of 4.
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `xs` | 4px (0.25rem) | Tight gaps: icon-to-label, tag internal padding |
|
||||||
|
| `sm` | 8px (0.5rem) | Compact spacing: between related fields, badge padding |
|
||||||
|
| `md` | 12px (0.75rem) | Standard: form group gaps, sidebar section padding |
|
||||||
|
| `lg` | 16px (1rem) | Section separation, card padding |
|
||||||
|
| `xl` | 24px (1.5rem) | Page-level padding, major section breaks |
|
||||||
|
| `2xl` | 32px (2rem) | Page horizontal padding |
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
- **Page padding:** `1.5rem 2rem` (24px vertical, 32px horizontal)
|
||||||
|
- **Sidebar section padding:** `1rem 1.25rem`
|
||||||
|
- **Form grid gap:** `1.25rem 1.5rem` (row gap × column gap)
|
||||||
|
- **Table row height:** 36px minimum (padding included)
|
||||||
|
- **Table cell padding:** `0.4rem 0.75rem`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
|
||||||
|
Every module page follows the same shell:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Top Nav (52px) │
|
||||||
|
├──────────┬──────────────────────────────────────┤
|
||||||
|
│ App Menu │ Page Header (58px) │
|
||||||
|
│ (icons) ├──────────────────────┬───────────────┤
|
||||||
|
│ │ Content Area │ Detail Pane │
|
||||||
|
│ │ │ (360px) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
└──────────┴──────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Top nav:** `52px` height, `--ctp-mantle` background, `1px solid --ctp-surface0` bottom border.
|
||||||
|
- **App menu sidebar:** Icon strip on the left. Module icons, tooltips on hover. Active module highlighted with `--ctp-mauve` indicator.
|
||||||
|
- **Page header:** `58px` height, `--ctp-mantle` background. Contains page title (with module icon), action buttons right-aligned.
|
||||||
|
- **Content area:** `--ctp-base` background. Scrollable. Contains list views, kanban boards, or other primary content.
|
||||||
|
- **Detail pane:** `360px` fixed width, `--ctp-mantle` background, `1px solid --ctp-surface0` left border. Appears on record selection.
|
||||||
|
|
||||||
|
### Grid Patterns
|
||||||
|
|
||||||
|
**Two-column form:**
|
||||||
|
```css
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.25rem 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
```
|
||||||
|
|
||||||
|
**List + detail:**
|
||||||
|
```css
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 360px;
|
||||||
|
min-height: calc(100vh - 52px - 58px);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
Not currently required. Silo targets desktop browsers on engineering workstations. If mobile support is added later, breakpoints will be defined at `768px` and `1024px`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
Four tiers. All buttons share a base style:
|
||||||
|
|
||||||
|
```css
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tier | Name | Background | Border | Text | Hover |
|
||||||
|
|------|------|-----------|--------|------|-------|
|
||||||
|
| Primary | `.btn-primary` | `--ctp-mauve` | `--ctp-mauve` | `--ctp-crust` | `--ctp-lavender` bg + border |
|
||||||
|
| Secondary | `.btn` (default) | `--ctp-surface0` | `--ctp-surface1` | `--ctp-text` | `--ctp-surface1` bg, `--ctp-overlay0` border |
|
||||||
|
| Ghost | `.btn-ghost` | transparent | transparent | `--ctp-subtext0` | `--ctp-surface0` bg, `--ctp-text` text |
|
||||||
|
| Danger | `.btn-danger` | transparent | `--ctp-surface1` | `--ctp-red` | `rgba(243, 139, 168, 0.1)` bg, `--ctp-red` border |
|
||||||
|
|
||||||
|
Primary is used once per visible context (the main action). All other actions use secondary or ghost. Danger is only for destructive actions and always requires confirmation.
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
Used for type indicators, status labels, and tags.
|
||||||
|
|
||||||
|
```css
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
```
|
||||||
|
|
||||||
|
Badges use a translucent background derived from their accent color:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Example: assembly badge */
|
||||||
|
background: rgba(203, 166, 247, 0.15); /* --ctp-mauve at 15% */
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard badge colors follow the [accent usage table](#accent-usage-for-data-types). Status badges:
|
||||||
|
|
||||||
|
| Status | Color |
|
||||||
|
|--------|-------|
|
||||||
|
| Active / Released | `--ctp-green` |
|
||||||
|
| Draft / In Progress | `--ctp-blue` |
|
||||||
|
| Review / Pending | `--ctp-yellow` |
|
||||||
|
| Obsolete / Rejected | `--ctp-red` |
|
||||||
|
| Locked | `--ctp-overlay1` |
|
||||||
|
|
||||||
|
### Form Inputs
|
||||||
|
|
||||||
|
All inputs share a base style:
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--ctp-crust);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
```
|
||||||
|
|
||||||
|
| State | Border | Shadow |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| Default | `--ctp-surface1` | None |
|
||||||
|
| Hover | `--ctp-overlay0` | None |
|
||||||
|
| Focus | `--ctp-mauve` | `0 0 0 0.2rem rgba(203, 166, 247, 0.25)` |
|
||||||
|
| Error | `--ctp-red` | `0 0 0 0.2rem rgba(243, 139, 168, 0.15)` |
|
||||||
|
| Disabled | `--ctp-surface0` | None, `opacity: 0.5` |
|
||||||
|
|
||||||
|
Placeholder text: `--ctp-overlay0`. Labels sit above inputs (never inline or floating).
|
||||||
|
|
||||||
|
### Tag Input
|
||||||
|
|
||||||
|
Used for multi-value fields (projects, tags):
|
||||||
|
|
||||||
|
```css
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--ctp-crust);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
```
|
||||||
|
|
||||||
|
Individual tags use the badge pattern: `rgba(accent, 0.15)` background with accent text. Remove button (×) at `opacity: 0.6`, `1.0` on hover.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
```css
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
```
|
||||||
|
|
||||||
|
| Element | Style |
|
||||||
|
|---------|-------|
|
||||||
|
| Header row | `background: --ctp-mantle`, `font-size: 11px`, uppercase, `--ctp-overlay1` text |
|
||||||
|
| Body row | `border-bottom: 1px solid --ctp-surface0` |
|
||||||
|
| Row hover | `background: --ctp-surface0` |
|
||||||
|
| Row selected | `background: rgba(203, 166, 247, 0.08)` |
|
||||||
|
| Cell padding | `0.4rem 0.75rem` |
|
||||||
|
| Text columns | Left-aligned |
|
||||||
|
| Number columns | Right-aligned |
|
||||||
|
| Date columns | Right-aligned |
|
||||||
|
| Action columns | Center-aligned |
|
||||||
|
|
||||||
|
Row actions use icon buttons (not text links). Icons at 14px, `--ctp-overlay1` default, `--ctp-text` on hover.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
Used in detail panes and module sub-views:
|
||||||
|
|
||||||
|
```css
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--ctp-surface0);
|
||||||
|
```
|
||||||
|
|
||||||
|
| State | Style |
|
||||||
|
|-------|-------|
|
||||||
|
| Default | `padding: 0.5rem 1rem`, `--ctp-subtext0` text, no border |
|
||||||
|
| Hover | `--ctp-text` text |
|
||||||
|
| Active | `--ctp-text` text, `font-weight: 600`, `border-bottom: 2px solid --ctp-mauve` (overlaps container border) |
|
||||||
|
|
||||||
|
### Section Dividers
|
||||||
|
|
||||||
|
Used to visually group form fields:
|
||||||
|
|
||||||
|
```css
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-column: 1 / -1; /* span full form grid */
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
```
|
||||||
|
|
||||||
|
Contains a label (`11px`, uppercase, `--ctp-overlay0`) and a horizontal line (`flex: 1`, `1px solid --ctp-surface0`).
|
||||||
|
|
||||||
|
### Sidebar Sections
|
||||||
|
|
||||||
|
Stacked vertically within detail panes:
|
||||||
|
|
||||||
|
```css
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Last section has no bottom border. Section titles follow the section header typography (11px, uppercase, `--ctp-overlay0`).
|
||||||
|
|
||||||
|
### Tooltips
|
||||||
|
|
||||||
|
Appear on hover after a 300ms delay. Position: above the target element by default, flip below if insufficient space.
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
box-shadow: 0 4px 12px rgba(17, 17, 27, 0.4);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breadcrumbs
|
||||||
|
|
||||||
|
Module navigation breadcrumbs:
|
||||||
|
|
||||||
|
```
|
||||||
|
Module Name > List View > Record Name > Sub-view
|
||||||
|
```
|
||||||
|
|
||||||
|
Separator: `>` character in `--ctp-overlay0`. Segments are clickable links in `--ctp-subtext1`. Active (final) segment is `--ctp-text` at `font-weight: 600`.
|
||||||
|
|
||||||
|
### Dropdowns / Selects
|
||||||
|
|
||||||
|
Follow the input base style. The dropdown menu:
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(17, 17, 27, 0.5);
|
||||||
|
padding: 0.25rem;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
```
|
||||||
|
|
||||||
|
Menu items:
|
||||||
|
|
||||||
|
```css
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
cursor: pointer;
|
||||||
|
```
|
||||||
|
|
||||||
|
Hover: `background: --ctp-surface1`. Selected: `background: rgba(203, 166, 247, 0.12)`, `color: --ctp-mauve`, `font-weight: 600`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Use [Lucide](https://lucide.dev) icons. Size: 14px for inline/table contexts, 16px for buttons and navigation, 20px for page headers and empty states.
|
||||||
|
|
||||||
|
Stroke width: 1.5px (Lucide default). Color inherits from parent text color unless explicitly set.
|
||||||
|
|
||||||
|
Do not mix icon libraries. If Lucide does not have a suitable icon, request one be added or create a custom SVG following Lucide's 24×24 grid and stroke conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transitions & Animation
|
||||||
|
|
||||||
|
All interactive state changes use `transition: all 0.15s ease`. This applies to hover, focus, active, and open/close states.
|
||||||
|
|
||||||
|
No entrance animations on page load. Content renders immediately. Skeleton loaders are acceptable for async data using a pulsing `--ctp-surface0` → `--ctp-surface1` gradient.
|
||||||
|
|
||||||
|
Dropdown menus and tooltips appear instantly (no slide/fade). Collapse/expand panels (if used) transition `max-height` at `0.2s ease`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Styling Implementation
|
||||||
|
|
||||||
|
Silo's React frontend uses **inline `React.CSSProperties` objects** with `var(--ctp-*)` token references. This is the project convention and must not be changed.
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- No CSS modules, no Tailwind, no external CSS-in-JS libraries.
|
||||||
|
- Styles are defined as `const` objects at the top of each component file.
|
||||||
|
- Shared style patterns (button base, input base) can be extracted to a `styles/` directory as exported `CSSProperties` objects.
|
||||||
|
- Use `as const` or `as React.CSSProperties` for type safety.
|
||||||
|
- Pseudo-classes (`:hover`, `:focus`) require state-driven inline styles or a thin CSS file for the base pseudo-class rules.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 360px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
|
||||||
|
sidebar: {
|
||||||
|
background: 'var(--ctp-mantle)',
|
||||||
|
borderLeft: '1px solid var(--ctp-surface0)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
overflowY: 'auto' as const,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pseudo-class CSS
|
||||||
|
|
||||||
|
A single `silo-base.css` file provides pseudo-class rules that cannot be expressed inline:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Hover, focus, and active states for core interactive elements */
|
||||||
|
.silo-input:hover { border-color: var(--ctp-overlay0); }
|
||||||
|
.silo-input:focus { border-color: var(--ctp-mauve); box-shadow: 0 0 0 0.2rem rgba(203, 166, 247, 0.25); }
|
||||||
|
.silo-btn:hover { /* per-tier overrides */ }
|
||||||
|
.silo-row:hover { background: var(--ctp-surface0); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Components apply the corresponding class names alongside their inline styles. This is the only place class-based styling is used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do / Don't
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `var(--ctp-*)` for every color | Hardcode hex values |
|
||||||
|
| Use the 4px spacing scale | Use arbitrary padding/margins |
|
||||||
|
| Use Lucide icons at standard sizes | Mix icon libraries |
|
||||||
|
| Use inline `CSSProperties` | Use CSS modules or Tailwind |
|
||||||
|
| One primary button per visible context | Multiple competing primary buttons |
|
||||||
|
| Use translucent accent backgrounds for badges | Use solid bright backgrounds for badges |
|
||||||
|
| Use icon buttons for row-level table actions | Use text links in table rows |
|
||||||
|
| Define styles as `const` at file top | Inline style objects in JSX |
|
||||||
|
| Show tooltips on icon-only buttons | Leave icon buttons unlabeled |
|
||||||
|
| Use section dividers to group form fields | Use cards or borders around field groups |
|
||||||
|
| Follow the breadcrumb pattern for navigation | Use nested tab bars |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: CSS Custom Properties Block
|
||||||
|
|
||||||
|
Paste this at the root of the application stylesheet:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ctp-rosewater: #f5e0dc;
|
||||||
|
--ctp-flamingo: #f2cdcd;
|
||||||
|
--ctp-pink: #f5c2e7;
|
||||||
|
--ctp-mauve: #cba6f7;
|
||||||
|
--ctp-red: #f38ba8;
|
||||||
|
--ctp-maroon: #eba0ac;
|
||||||
|
--ctp-peach: #fab387;
|
||||||
|
--ctp-yellow: #f9e2af;
|
||||||
|
--ctp-green: #a6e3a1;
|
||||||
|
--ctp-teal: #94e2d5;
|
||||||
|
--ctp-sky: #89dceb;
|
||||||
|
--ctp-sapphire: #74c7ec;
|
||||||
|
--ctp-blue: #89b4fa;
|
||||||
|
--ctp-lavender: #b4befe;
|
||||||
|
--ctp-text: #cdd6f4;
|
||||||
|
--ctp-subtext1: #bac2de;
|
||||||
|
--ctp-subtext0: #a6adc8;
|
||||||
|
--ctp-overlay2: #9399b2;
|
||||||
|
--ctp-overlay1: #7f849c;
|
||||||
|
--ctp-overlay0: #6c7086;
|
||||||
|
--ctp-surface2: #585b70;
|
||||||
|
--ctp-surface1: #45475a;
|
||||||
|
--ctp-surface0: #313244;
|
||||||
|
--ctp-base: #1e1e2e;
|
||||||
|
--ctp-mantle: #181825;
|
||||||
|
--ctp-crust: #11111b;
|
||||||
|
}
|
||||||
|
```
|
||||||
339
frontend-spec.md
339
frontend-spec.md
@@ -1,6 +1,6 @@
|
|||||||
# Silo Frontend Specification
|
# Silo Frontend Specification
|
||||||
|
|
||||||
Current as of 2026-02-08. Documents the React + Vite + TypeScript frontend (migration from Go templates is complete).
|
Current as of 2026-02-11. Documents the React + Vite + TypeScript frontend (migration from Go templates is complete).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ web/
|
|||||||
│ └── AuthContext.tsx AuthProvider with login/logout/refresh methods
|
│ └── AuthContext.tsx AuthProvider with login/logout/refresh methods
|
||||||
├── hooks/
|
├── hooks/
|
||||||
│ ├── useAuth.ts Context consumer hook
|
│ ├── useAuth.ts Context consumer hook
|
||||||
|
│ ├── useFormDescriptor.ts Fetches form descriptor from /api/schemas/{name}/form (replaces useCategories)
|
||||||
│ ├── useItems.ts Items fetching with search, filters, pagination, debounce
|
│ ├── useItems.ts Items fetching with search, filters, pagination, debounce
|
||||||
│ └── useLocalStorage.ts Typed localStorage persistence hook
|
│ └── useLocalStorage.ts Typed localStorage persistence hook
|
||||||
├── styles/
|
├── styles/
|
||||||
@@ -271,63 +272,81 @@ Vite dev server runs on port 5173 with proxy config in `vite.config.ts` forwardi
|
|||||||
|
|
||||||
## New Frontend Tasks
|
## New Frontend Tasks
|
||||||
|
|
||||||
# CreateItemPane Redesign Specification
|
# CreateItemPane — Schema-Driven Dynamic Form
|
||||||
|
|
||||||
**Date**: 2026-02-06
|
**Date**: 2026-02-10
|
||||||
**Scope**: Replace existing `CreateItemPane.tsx` with a two-column layout, multi-stage category picker, file attachment via MinIO, and full use of screen real estate.
|
**Scope**: `CreateItemPane.tsx` renders a dynamic form driven entirely by the form descriptor API (`GET /api/schemas/{name}/form`). All field groups, field types, widgets, and category-specific fields are defined in YAML and resolved server-side.
|
||||||
**Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern.
|
**Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
The pane uses a CSS Grid two-column layout instead of the current single-column form:
|
Single-column scrollable form with a green header bar. Field groups are rendered dynamically from the form descriptor. Category-specific field groups appear after global groups when a category is selected.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────┬──────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ Header: "New Item" [green bar] Cancel │ Create │ │
|
│ Header: "New Item" [green bar] Cancel │ Create │
|
||||||
├──────────────────────────────────────────────────────┤ │
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
│ │ Auto- │
|
│ │
|
||||||
│ ── Identity ────────────────────────────────────── │ assigned │
|
│ Category * [Domain buttons: F C R S E M T A P X] │
|
||||||
│ [Part Number *] [Type * v] │ metadata │
|
│ [Subcategory search + filtered list] │
|
||||||
│ [Description ] │ │
|
│ │
|
||||||
│ Category * [Domain │ Group │ Subtype ] │──────────────│
|
│ ── Identity ────────────────────────────────────────────────────── │
|
||||||
│ Mechanical│ Structural│ Bracket │ │ │
|
│ [Type * (auto-derived from category)] [Description ] │
|
||||||
│ Electrical│ Bearings │ Plate │ │ Attachments │
|
│ │
|
||||||
│ ... │ ... │ ... │ │ ┌─ ─ ─ ─ ┐ │
|
│ ── Sourcing ────────────────────────────────────────────────────── │
|
||||||
│ ── Sourcing ────────────────────────────────────── │ │ Drop │ │
|
│ [Sourcing Type v] [Manufacturer] [MPN] [Supplier] [SPN] │
|
||||||
│ [Sourcing Type v] [Standard Cost $ ] │ │ zone │ │
|
│ [Sourcing Link] │
|
||||||
│ [Unit of Measure v] [Sourcing Link ] │ └─ ─ ─ ─ ┘ │
|
│ │
|
||||||
│ │ file.FCStd │
|
│ ── Cost & Lead Time ────────────────────────────────────────────── │
|
||||||
│ ── Details ─────────────────────────────────────── │ drawing.pdf │
|
│ [Standard Cost $] [Lead Time Days] [Min Order Qty] │
|
||||||
│ [Long Description ] │ │
|
│ │
|
||||||
│ [Projects: [tag][tag] type to search... ] │──────────────│
|
│ ── Status ──────────────────────────────────────────────────────── │
|
||||||
│ │ Thumbnail │
|
│ [Lifecycle Status v] [RoHS Compliant ☐] [Country of Origin] │
|
||||||
│ │ [preview] │
|
│ │
|
||||||
└──────────────────────────────────────────────────────┴──────────────┘
|
│ ── Details ─────────────────────────────────────────────────────── │
|
||||||
|
│ [Long Description ] │
|
||||||
|
│ [Projects: [tag][tag] type to search... ] │
|
||||||
|
│ [Notes ] │
|
||||||
|
│ │
|
||||||
|
│ ── Fastener Specifications (category-specific) ─────────────────── │
|
||||||
|
│ [Material] [Finish] [Thread Size] [Head Type] [Drive Type] ... │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Grid definition: `grid-template-columns: 1fr 320px`. The left column scrolls independently if content overflows. The right sidebar is a flex column with sections separated by `--ctp-surface1` borders.
|
## Data Source — Form Descriptor API
|
||||||
|
|
||||||
|
All form structure is fetched from `GET /api/schemas/kindred-rd/form`, which returns:
|
||||||
|
|
||||||
|
- `category_picker`: Multi-stage picker config (domain → subcategory)
|
||||||
|
- `item_fields`: Definitions for item-level fields (description, item_type, sourcing_type, etc.)
|
||||||
|
- `field_groups`: Ordered groups with resolved field metadata (Identity, Sourcing, Cost, Status, Details)
|
||||||
|
- `category_field_groups`: Per-category-prefix groups (e.g., Fastener Specifications for `F` prefix)
|
||||||
|
- `field_overrides`: Widget hints (currency, url, select, checkbox)
|
||||||
|
|
||||||
|
The YAML schema (`schemas/kindred-rd.yaml`) is the single source of truth. Adding a new field or category in YAML propagates to all clients with no code changes.
|
||||||
|
|
||||||
## File Location
|
## File Location
|
||||||
|
|
||||||
`web/src/components/items/CreateItemPane.tsx` (replaces existing file)
|
`web/src/components/items/CreateItemPane.tsx`
|
||||||
|
|
||||||
New supporting files:
|
Supporting files:
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage category selector |
|
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
|
||||||
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
||||||
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
||||||
| `web/src/hooks/useCategories.ts` | Fetches category tree from schema data |
|
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
|
||||||
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
||||||
|
|
||||||
## Component Breakdown
|
## Component Breakdown
|
||||||
|
|
||||||
### CreateItemPane
|
### CreateItemPane
|
||||||
|
|
||||||
Top-level orchestrator. Manages form state, submission, and layout.
|
Top-level orchestrator. Renders dynamic form from the form descriptor.
|
||||||
|
|
||||||
**Props** (unchanged interface):
|
**Props** (unchanged interface):
|
||||||
|
|
||||||
@@ -341,68 +360,64 @@ interface CreateItemPaneProps {
|
|||||||
**State**:
|
**State**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const [form, setForm] = useState<CreateItemForm>({
|
const { descriptor, categories, loading } = useFormDescriptor();
|
||||||
part_number: '',
|
const [category, setCategory] = useState(''); // selected category code, e.g. "F01"
|
||||||
item_type: 'part',
|
const [fields, setFields] = useState<Record<string, string>>({}); // all field values keyed by name
|
||||||
description: '',
|
|
||||||
category_path: [], // e.g. ['Mechanical', 'Structural', 'Bracket']
|
|
||||||
sourcing_type: 'manufactured',
|
|
||||||
standard_cost: '',
|
|
||||||
unit_of_measure: 'ea',
|
|
||||||
sourcing_link: '',
|
|
||||||
long_description: '',
|
|
||||||
project_ids: [],
|
|
||||||
});
|
|
||||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
||||||
const [thumbnail, setThumbnail] = useState<PendingAttachment | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A single `fields` record holds all form values (both item-level and property fields). The `ITEM_LEVEL_FIELDS` set (`description`, `item_type`, `sourcing_type`, `long_description`) determines which fields go into the top-level request vs. the `properties` map on submission.
|
||||||
|
|
||||||
|
**Auto-derivation**: When a category is selected, `item_type` is automatically set based on the `derived_from_category` mapping in the form descriptor (e.g., category prefix `A` → `assembly`, `T` → `tooling`, default → `part`).
|
||||||
|
|
||||||
|
**Dynamic rendering**: A `renderField()` function maps each field's `widget` type to the appropriate input:
|
||||||
|
|
||||||
|
| Widget | Rendered As |
|
||||||
|
|--------|-------------|
|
||||||
|
| `text` | `<input type="text">` |
|
||||||
|
| `number` | `<input type="number">` |
|
||||||
|
| `textarea` | `<textarea>` |
|
||||||
|
| `select` | `<select>` with `<option>` elements from `field.options` |
|
||||||
|
| `checkbox` | `<input type="checkbox">` |
|
||||||
|
| `currency` | `<input type="number">` with currency prefix (e.g., "$") |
|
||||||
|
| `url` | `<input type="url">` |
|
||||||
|
| `tag_input` | `TagInput` component with search endpoint |
|
||||||
|
|
||||||
**Submission flow**:
|
**Submission flow**:
|
||||||
|
|
||||||
1. Validate required fields (part_number, item_type, category_path length === 3).
|
1. Validate required fields (category must be selected).
|
||||||
2. `POST /api/items` with form data → returns created `Item` with UUID.
|
2. Split `fields` into item-level fields and properties using `ITEM_LEVEL_FIELDS`.
|
||||||
3. For each attachment in `attachments[]`, call the file association endpoint: `POST /api/items/{id}/files` with the MinIO object key returned from upload.
|
3. `POST /api/items` with `{ part_number: '', item_type, description, sourcing_type, long_description, category, properties: {...} }`.
|
||||||
4. If thumbnail exists, `PUT /api/items/{id}/thumbnail` with the object key.
|
4. Call `onCreated(item)`.
|
||||||
5. Call `onCreated(item)`.
|
|
||||||
|
|
||||||
If step 2 fails, show error banner. If file association fails, show warning but still navigate (item was created, files can be re-attached).
|
**Header bar**: Green (`--ctp-green` background, `--ctp-crust` text). "New Item" title on left, Cancel and Create Item buttons on right.
|
||||||
|
|
||||||
**Header bar**: Green (`--ctp-green` background, `--ctp-crust` text) per existing create-pane convention. "New Item" title on left, Cancel (ghost button) and Create Item (primary button, `--ctp-green` bg) on right.
|
|
||||||
|
|
||||||
### CategoryPicker
|
### CategoryPicker
|
||||||
|
|
||||||
Three-column scrollable list for hierarchical category selection.
|
Multi-stage category selector driven by the form descriptor's `category_picker.stages` config.
|
||||||
|
|
||||||
**Props**:
|
**Props**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface CategoryPickerProps {
|
interface CategoryPickerProps {
|
||||||
value: string[]; // current selection path, e.g. ['Mechanical', 'Structural']
|
value: string; // selected category code, e.g. "F01"
|
||||||
onChange: (path: string[]) => void;
|
onChange: (code: string) => void;
|
||||||
categories: CategoryNode[]; // top-level nodes
|
categories: Record<string, string>; // flat code → description map
|
||||||
}
|
stages?: CategoryPickerStage[]; // from form descriptor
|
||||||
|
|
||||||
interface CategoryNode {
|
|
||||||
name: string;
|
|
||||||
children?: CategoryNode[];
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rendering**: Three side-by-side `<div>` columns inside a container with `border: 1px solid var(--ctp-surface1)` and `border-radius: 0.4rem`. Each column has:
|
**Rendering**: Two-stage selection:
|
||||||
|
|
||||||
- A sticky header row (10px uppercase, `--ctp-overlay0` text, `--ctp-mantle` background) labeling the tier. Labels come from the schema definition if available, otherwise "Level 1", "Level 2", "Level 3".
|
1. **Domain row**: Horizontal row of buttons, one per domain from `stages[0].values` (F=Fasteners, C=Fluid Fittings, etc.). Selected domain has mauve highlight.
|
||||||
- A scrollable list of options. Each option is a `<div>` row, 28px height, `0.85rem` font. Hover: `--ctp-surface0` background. Selected: translucent mauve background (`rgba(203, 166, 247, 0.12)`), `--ctp-mauve` text, weight 600.
|
2. **Subcategory list**: Filtered list of categories matching the selected domain prefix. Includes a search input for filtering. Each row shows code and description.
|
||||||
- If a node has children, show a `›` chevron on the right side of the row.
|
|
||||||
|
|
||||||
Column 1 always shows all top-level nodes. Column 2 shows children of the selected Column 1 node. Column 3 shows children of the selected Column 2 node. If nothing is selected in a column, the next column shows an empty state with muted text: "Select a [tier name]".
|
If no `stages` prop is provided, falls back to a flat searchable list of all categories.
|
||||||
|
|
||||||
Below the picker, render a breadcrumb trail: `Mechanical › Structural › Bracket` in `--ctp-mauve` with `›` separators in `--ctp-overlay0`. Only show segments that are selected.
|
Below the picker, the selected category is shown as a breadcrumb: `Fasteners › F01 — Hex Cap Screw` in `--ctp-mauve`.
|
||||||
|
|
||||||
**Data source**: Categories are derived from schemas. The `useCategories` hook calls `GET /api/schemas` and transforms the response into a `CategoryNode[]` tree. The exact mapping depends on how schemas define category hierarchies — if schemas don't currently support hierarchical categories, this requires a backend addition (see Backend Changes section).
|
**Data source**: Categories come from `useFormDescriptor()` which derives them from the `category_picker` stages and `values_by_domain` in the form descriptor response.
|
||||||
|
|
||||||
**Max height**: 180px per column with `overflow-y: auto`.
|
|
||||||
|
|
||||||
### FileDropZone
|
### FileDropZone
|
||||||
|
|
||||||
@@ -478,17 +493,17 @@ The dropdown is an absolutely-positioned `<div>` below the input container, `--c
|
|||||||
|
|
||||||
**For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`.
|
**For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`.
|
||||||
|
|
||||||
### useCategories Hook
|
### useFormDescriptor Hook
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function useCategories(): {
|
function useFormDescriptor(schemaName = "kindred-rd"): {
|
||||||
categories: CategoryNode[];
|
descriptor: FormDescriptor | null;
|
||||||
|
categories: Record<string, string>; // flat code → description map derived from descriptor
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Fetches `GET /api/schemas` on mount and transforms into a category tree. Caches in a module-level variable so repeated renders don't refetch. If the API doesn't currently support hierarchical categories, this returns a flat list as a single-tier picker until the backend is extended.
|
Fetches `GET /api/schemas/{name}/form` on mount. Caches the result in a module-level variable so repeated renders/mounts don't refetch. Derives a flat `categories` map from the `category_picker` stages and `values_by_domain` in the response. Replaces the old `useCategories` hook (deleted).
|
||||||
|
|
||||||
### useFileUpload Hook
|
### useFileUpload Hook
|
||||||
|
|
||||||
@@ -542,30 +557,32 @@ const styles = {
|
|||||||
|
|
||||||
## Form Sections
|
## Form Sections
|
||||||
|
|
||||||
The form is visually divided by section headers. Each header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`). Sections span `grid-column: 1 / -1`.
|
Form sections are rendered dynamically from the `field_groups` array in the form descriptor. Each section header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`).
|
||||||
|
|
||||||
| Section | Fields |
|
**Global field groups** (from `ui.field_groups` in YAML):
|
||||||
|---------|--------|
|
|
||||||
| Identity | Part Number*, Type*, Description, Category* |
|
|
||||||
| Sourcing | Sourcing Type, Standard Cost, Unit of Measure, Sourcing Link |
|
|
||||||
| Details | Long Description, Projects |
|
|
||||||
|
|
||||||
## Sidebar Sections
|
| Group Key | Label | Fields |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| identity | Identity | item_type, description |
|
||||||
|
| sourcing | Sourcing | sourcing_type, manufacturer, manufacturer_pn, supplier, supplier_pn, sourcing_link |
|
||||||
|
| cost | Cost & Lead Time | standard_cost, lead_time_days, minimum_order_qty |
|
||||||
|
| status | Status | lifecycle_status, rohs_compliant, country_of_origin |
|
||||||
|
| details | Details | long_description, projects, notes |
|
||||||
|
|
||||||
The right sidebar is divided into three sections with `borderBottom: 1px solid var(--ctp-surface0)`:
|
**Category-specific field groups** (from `ui.category_field_groups` in YAML, shown when a category is selected):
|
||||||
|
|
||||||
**Auto-assigned metadata**: Read-only key-value rows showing:
|
| Prefix | Group | Example Fields |
|
||||||
- UUID: "On create" in `--ctp-teal` italic
|
|--------|-------|----------------|
|
||||||
- Revision: "A" (hardcoded initial)
|
| F | Fastener Specifications | material, finish, thread_size, head_type, drive_type, ... |
|
||||||
- Created By: current user's display name from `useAuth()`
|
| C | Fitting Specifications | material, connection_type, size_1, pressure_rating, ... |
|
||||||
|
| R | Motion Specifications | bearing_type, bore_diameter, load_rating, ... |
|
||||||
|
| ... | ... | (one group per category prefix, defined in YAML) |
|
||||||
|
|
||||||
**Attachments**: `FileDropZone` component. Takes `flex: 1` to fill available space.
|
Note: `sourcing_link` and `standard_cost` are revision properties (stored in the `properties` JSONB), not item-level DB columns. They were migrated from item-level fields in PR #1 (migration 013).
|
||||||
|
|
||||||
**Thumbnail**: A 4:3 aspect ratio placeholder box (`--ctp-crust` bg, `--ctp-surface0` border) with centered text "Generated from CAD file or upload manually". Clicking opens file picker filtered to images. If a thumbnail is uploaded, show it as an `<img>` with `object-fit: cover`.
|
|
||||||
|
|
||||||
## Backend Changes
|
## Backend Changes
|
||||||
|
|
||||||
Items 1-3 and 5 below are implemented (migration `011_item_files.sql`, `internal/api/file_handlers.go`). Item 4 (hierarchical categories) remains open.
|
Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by the form descriptor's multi-stage category picker.
|
||||||
|
|
||||||
### 1. Presigned Upload URL -- IMPLEMENTED
|
### 1. Presigned Upload URL -- IMPLEMENTED
|
||||||
|
|
||||||
@@ -597,33 +614,14 @@ Response: 204
|
|||||||
|
|
||||||
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column.
|
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column.
|
||||||
|
|
||||||
### 4. Hierarchical Categories -- NOT IMPLEMENTED
|
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)
|
||||||
|
|
||||||
If schemas don't currently support a hierarchical category tree, one of these approaches:
|
Resolved by the schema-driven form descriptor (`GET /api/schemas/{name}/form`). The YAML schema's `ui.category_picker` section defines multi-stage selection:
|
||||||
|
|
||||||
**Option A — Schema-driven**: Add a `category_tree` JSON column to the `schemas` table that defines the hierarchy. The `GET /api/schemas` response already returns schemas; the frontend transforms this into the picker tree.
|
- **Stage 1 (domain)**: Groups categories by first character of category code (F=Fasteners, C=Fluid Fittings, etc.). Values defined in `ui.category_picker.stages[0].values`.
|
||||||
|
- **Stage 2 (subcategory)**: Auto-derived by the Go backend's `ValuesByDomain()` method, which groups the category enum values by their first character.
|
||||||
|
|
||||||
**Option B — Dedicated table**:
|
No separate `categories` table is needed — the existing schema enum values are the single source of truth. Adding a new category code to the YAML propagates to the picker automatically.
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE categories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
parent_id UUID REFERENCES categories(id),
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE(parent_id, name)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
With endpoints:
|
|
||||||
```
|
|
||||||
GET /api/categories → flat list with parent_id, frontend builds tree
|
|
||||||
POST /api/categories → { name, parent_id? }
|
|
||||||
PUT /api/categories/{id} → { name, sort_order }
|
|
||||||
DELETE /api/categories/{id} → cascade check
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation**: Option B is more flexible and keeps categories as a first-class entity. The three-tier picker doesn't need to be limited to exactly three levels — it can render as many columns as the deepest category path, but three is the practical default (Domain → Group → Subtype).
|
|
||||||
|
|
||||||
### 5. Database Schema Addition -- IMPLEMENTED
|
### 5. Database Schema Addition -- IMPLEMENTED
|
||||||
|
|
||||||
@@ -641,46 +639,89 @@ CREATE TABLE item_files (
|
|||||||
CREATE INDEX idx_item_files_item ON item_files(item_id);
|
CREATE INDEX idx_item_files_item ON item_files(item_id);
|
||||||
|
|
||||||
ALTER TABLE items ADD COLUMN thumbnail_key TEXT;
|
ALTER TABLE items ADD COLUMN thumbnail_key TEXT;
|
||||||
ALTER TABLE items ADD COLUMN category_id UUID REFERENCES categories(id);
|
|
||||||
ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured';
|
ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured';
|
||||||
ALTER TABLE items ADD COLUMN sourcing_link TEXT;
|
|
||||||
ALTER TABLE items ADD COLUMN standard_cost NUMERIC(12,2);
|
|
||||||
ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea';
|
ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea';
|
||||||
ALTER TABLE items ADD COLUMN long_description TEXT;
|
ALTER TABLE items ADD COLUMN long_description TEXT;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implementation Order
|
## Implementation Order
|
||||||
|
|
||||||
1. **TagInput component** — reusable, no backend changes needed, uses existing projects API.
|
1. **[DONE] Deduplicate sourcing_link/standard_cost** — Migrated from item-level DB columns to revision properties (migration 013). Removed from Go structs, API types, frontend types.
|
||||||
2. **CategoryPicker component** — start with flat/mock data, wire to real API after backend adds categories.
|
2. **[DONE] Form descriptor API** — Added `ui` section to YAML, Go structs + validation, `GET /api/schemas/{name}/form` endpoint.
|
||||||
3. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint first.
|
3. **[DONE] useFormDescriptor hook** — Replaces `useCategories`, fetches and caches form descriptor.
|
||||||
4. **CreateItemPane rewrite** — compose the above into the two-column layout.
|
4. **[DONE] CategoryPicker rewrite** — Multi-stage domain/subcategory picker driven by form descriptor.
|
||||||
5. **Backend: categories table + endpoints** — unblocks real category data.
|
5. **[DONE] CreateItemPane rewrite** — Dynamic form rendering from field groups, widget-based field rendering.
|
||||||
6. **Backend: presigned uploads + item_files** — unblocks file attachments.
|
6. **TagInput component** — reusable, no backend changes needed, uses existing projects API.
|
||||||
7. **Backend: items table migration** — adds new columns (sourcing_type, standard_cost, etc.).
|
7. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint (already implemented).
|
||||||
|
|
||||||
Steps 1-2 can start immediately. Steps 5-7 can run in parallel once specified. Step 4 ties it all together.
|
## Types Added
|
||||||
|
|
||||||
## Types to Add
|
The following types were added to `web/src/api/types.ts` for the form descriptor system:
|
||||||
|
|
||||||
Add to `web/src/api/types.ts`:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Categories
|
// Form descriptor types (from GET /api/schemas/{name}/form)
|
||||||
interface Category {
|
interface FormFieldDescriptor {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
parent_id: string | null;
|
type: string;
|
||||||
sort_order: number;
|
widget: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: string;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
|
options?: string[];
|
||||||
|
currency?: string;
|
||||||
|
derived_from_category?: Record<string, string>;
|
||||||
|
search_endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryNode {
|
interface FormFieldGroup {
|
||||||
name: string;
|
key: string;
|
||||||
id: string;
|
label: string;
|
||||||
children?: CategoryNode[];
|
order: number;
|
||||||
|
fields: FormFieldDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// File uploads
|
interface CategoryPickerStage {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
values_by_domain?: Record<string, Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryPickerDescriptor {
|
||||||
|
style: string;
|
||||||
|
stages: CategoryPickerStage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFieldDef {
|
||||||
|
type: string;
|
||||||
|
widget: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
default?: string;
|
||||||
|
options?: string[];
|
||||||
|
derived_from_category?: Record<string, string>;
|
||||||
|
search_endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldOverride {
|
||||||
|
widget?: string;
|
||||||
|
currency?: string;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormDescriptor {
|
||||||
|
schema_name: string;
|
||||||
|
format: string;
|
||||||
|
category_picker: CategoryPickerDescriptor;
|
||||||
|
item_fields: Record<string, ItemFieldDef>;
|
||||||
|
field_groups: FormFieldGroup[];
|
||||||
|
category_field_groups: Record<string, FormFieldGroup[]>;
|
||||||
|
field_overrides: Record<string, FieldOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File uploads (unchanged)
|
||||||
interface PresignRequest {
|
interface PresignRequest {
|
||||||
filename: string;
|
filename: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
@@ -703,20 +744,6 @@ interface ItemFile {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended create request
|
|
||||||
interface CreateItemRequest {
|
|
||||||
part_number: string;
|
|
||||||
item_type: 'part' | 'assembly' | 'document';
|
|
||||||
description?: string;
|
|
||||||
category_id?: string;
|
|
||||||
sourcing_type?: 'manufactured' | 'purchased' | 'phantom';
|
|
||||||
standard_cost?: number;
|
|
||||||
unit_of_measure?: string;
|
|
||||||
sourcing_link?: string;
|
|
||||||
long_description?: string;
|
|
||||||
project_ids?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending upload (frontend only, not an API type)
|
// Pending upload (frontend only, not an API type)
|
||||||
interface PendingAttachment {
|
interface PendingAttachment {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -726,3 +753,5 @@ interface PendingAttachment {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: `sourcing_link` and `standard_cost` have been removed from the `Item`, `CreateItemRequest`, and `UpdateItemRequest` interfaces — they are now stored as revision properties and rendered dynamically from the form descriptor.
|
||||||
|
|||||||
106
internal/api/audit_handlers_test.go
Normal file
106
internal/api/audit_handlers_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuditRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/audit/completeness", s.HandleAuditCompleteness)
|
||||||
|
r.Get("/api/audit/completeness/{partNumber}", s.HandleAuditItemDetail)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuditCompletenessEmpty(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newAuditRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/audit/completeness", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuditCompleteness(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newAuditRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "AUD-001", "audit item 1", nil)
|
||||||
|
createItemDirect(t, s, "AUD-002", "audit item 2", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/audit/completeness", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
// Should have items array
|
||||||
|
items, ok := resp["items"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("response missing 'items' key")
|
||||||
|
}
|
||||||
|
itemList, ok := items.([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("'items' is not an array")
|
||||||
|
}
|
||||||
|
if len(itemList) < 2 {
|
||||||
|
t.Errorf("expected at least 2 audit items, got %d", len(itemList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuditItemDetail(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newAuditRouter(s)
|
||||||
|
|
||||||
|
cost := 50.0
|
||||||
|
createItemDirect(t, s, "AUDDET-001", "audit detail item", &cost)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/audit/completeness/AUDDET-001", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["part_number"] != "AUDDET-001" {
|
||||||
|
t.Errorf("part_number: got %v, want %q", resp["part_number"], "AUDDET-001")
|
||||||
|
}
|
||||||
|
if _, ok := resp["score"]; !ok {
|
||||||
|
t.Error("response missing 'score' field")
|
||||||
|
}
|
||||||
|
if _, ok := resp["tier"]; !ok {
|
||||||
|
t.Error("response missing 'tier' field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuditItemDetailNotFound(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newAuditRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/audit/completeness/NOPE-999", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
206
internal/api/auth_handlers_test.go
Normal file
206
internal/api/auth_handlers_test.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/auth"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
|
"github.com/kindredsystems/silo/internal/testutil"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAuthTestServer creates a Server with a real auth service (for token tests).
|
||||||
|
func newAuthTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
pool := testutil.MustConnectTestPool(t)
|
||||||
|
database := db.NewFromPool(pool)
|
||||||
|
users := db.NewUserRepository(database)
|
||||||
|
tokens := db.NewTokenRepository(database)
|
||||||
|
authSvc := auth.NewService(zerolog.Nop(), users, tokens)
|
||||||
|
broker := NewBroker(zerolog.Nop())
|
||||||
|
state := NewServerState(zerolog.Nop(), nil, broker)
|
||||||
|
return NewServer(
|
||||||
|
zerolog.Nop(),
|
||||||
|
database,
|
||||||
|
map[string]*schema.Schema{},
|
||||||
|
"", // schemasDir
|
||||||
|
nil, // storage
|
||||||
|
authSvc, // authService
|
||||||
|
nil, // sessionManager
|
||||||
|
nil, // oidcBackend
|
||||||
|
nil, // authConfig
|
||||||
|
broker,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTestUser creates a user in the DB and returns their ID.
|
||||||
|
func ensureTestUser(t *testing.T, s *Server, username string) string {
|
||||||
|
t.Helper()
|
||||||
|
u := &db.User{
|
||||||
|
Username: username,
|
||||||
|
DisplayName: "Test " + username,
|
||||||
|
Email: username + "@test.local",
|
||||||
|
AuthSource: "local",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
users := db.NewUserRepository(s.db)
|
||||||
|
if err := users.Upsert(context.Background(), u); err != nil {
|
||||||
|
t.Fatalf("upserting user: %v", err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/auth/me", s.HandleGetCurrentUser)
|
||||||
|
r.Post("/api/auth/tokens", s.HandleCreateToken)
|
||||||
|
r.Get("/api/auth/tokens", s.HandleListTokens)
|
||||||
|
r.Delete("/api/auth/tokens/{id}", s.HandleRevokeToken)
|
||||||
|
r.Get("/api/auth/config", s.HandleAuthConfig)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCurrentUser(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newAuthRouter(s)
|
||||||
|
|
||||||
|
req := authRequest(httptest.NewRequest("GET", "/api/auth/me", nil))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["username"] != "testadmin" {
|
||||||
|
t.Errorf("username: got %v, want %q", resp["username"], "testadmin")
|
||||||
|
}
|
||||||
|
if resp["role"] != "admin" {
|
||||||
|
t.Errorf("role: got %v, want %q", resp["role"], "admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetCurrentUserUnauth(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newAuthRouter(s)
|
||||||
|
|
||||||
|
// No auth context
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthConfig(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newAuthRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/auth/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
// With nil oidc and nil authConfig, both should be false
|
||||||
|
if resp["oidc_enabled"] != false {
|
||||||
|
t.Errorf("oidc_enabled: got %v, want false", resp["oidc_enabled"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreateAndListTokens(t *testing.T) {
|
||||||
|
s := newAuthTestServer(t)
|
||||||
|
router := newAuthRouter(s)
|
||||||
|
|
||||||
|
// Create a user in the DB so token generation can associate
|
||||||
|
userID := ensureTestUser(t, s, "tokenuser")
|
||||||
|
|
||||||
|
// Inject user with the DB-assigned ID
|
||||||
|
u := &auth.User{
|
||||||
|
ID: userID,
|
||||||
|
Username: "tokenuser",
|
||||||
|
DisplayName: "Test tokenuser",
|
||||||
|
Role: auth.RoleAdmin,
|
||||||
|
AuthSource: "local",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
body := `{"name":"test-token"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/auth/tokens", strings.NewReader(body))
|
||||||
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create token status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var createResp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil {
|
||||||
|
t.Fatalf("decoding create response: %v", err)
|
||||||
|
}
|
||||||
|
if createResp["token"] == nil || createResp["token"] == "" {
|
||||||
|
t.Error("expected token in response")
|
||||||
|
}
|
||||||
|
tokenID, _ := createResp["id"].(string)
|
||||||
|
|
||||||
|
// List tokens
|
||||||
|
req = httptest.NewRequest("GET", "/api/auth/tokens", nil)
|
||||||
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list tokens status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens []map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil {
|
||||||
|
t.Fatalf("decoding list response: %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 1 {
|
||||||
|
t.Errorf("expected 1 token, got %d", len(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke token
|
||||||
|
req = httptest.NewRequest("DELETE", "/api/auth/tokens/"+tokenID, nil)
|
||||||
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("revoke token status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// List again — should be empty
|
||||||
|
req = httptest.NewRequest("GET", "/api/auth/tokens", nil)
|
||||||
|
req = req.WithContext(auth.ContextWithUser(req.Context(), u))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &tokens)
|
||||||
|
if len(tokens) != 0 {
|
||||||
|
t.Errorf("expected 0 tokens after revoke, got %d", len(tokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,15 @@ func newTestRouter(s *Server) http.Handler {
|
|||||||
func createItemDirect(t *testing.T, s *Server, pn, desc string, cost *float64) {
|
func createItemDirect(t *testing.T, s *Server, pn, desc string, cost *float64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
item := &db.Item{
|
item := &db.Item{
|
||||||
PartNumber: pn,
|
PartNumber: pn,
|
||||||
ItemType: "part",
|
ItemType: "part",
|
||||||
Description: desc,
|
Description: desc,
|
||||||
StandardCost: cost,
|
|
||||||
}
|
}
|
||||||
if err := s.items.Create(context.Background(), item, nil); err != nil {
|
var props map[string]any
|
||||||
|
if cost != nil {
|
||||||
|
props = map[string]any{"standard_cost": *cost}
|
||||||
|
}
|
||||||
|
if err := s.items.Create(context.Background(), item, props); err != nil {
|
||||||
t.Fatalf("creating item %s: %v", pn, err)
|
t.Fatalf("creating item %s: %v", pn, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
254
internal/api/csv_handlers_test.go
Normal file
254
internal/api/csv_handlers_test.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
|
"github.com/kindredsystems/silo/internal/testutil"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findSchemasDir walks upward to find the project root and returns
|
||||||
|
// the path to the schemas/ directory.
|
||||||
|
func findSchemasDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getting working directory: %v", err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return filepath.Join(dir, "schemas")
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
t.Fatal("could not find project root")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestServerWithSchemas creates a Server backed by a real test DB with schemas loaded.
|
||||||
|
func newTestServerWithSchemas(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
pool := testutil.MustConnectTestPool(t)
|
||||||
|
database := db.NewFromPool(pool)
|
||||||
|
broker := NewBroker(zerolog.Nop())
|
||||||
|
state := NewServerState(zerolog.Nop(), nil, broker)
|
||||||
|
schemasDir := findSchemasDir(t)
|
||||||
|
schemas, err := schema.LoadAll(schemasDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loading schemas: %v", err)
|
||||||
|
}
|
||||||
|
return NewServer(
|
||||||
|
zerolog.Nop(),
|
||||||
|
database,
|
||||||
|
schemas,
|
||||||
|
schemasDir,
|
||||||
|
nil, // storage
|
||||||
|
nil, // authService
|
||||||
|
nil, // sessionManager
|
||||||
|
nil, // oidcBackend
|
||||||
|
nil, // authConfig
|
||||||
|
broker,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCSVRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/items/export.csv", s.HandleExportCSV)
|
||||||
|
r.Get("/api/items/template.csv", s.HandleCSVTemplate)
|
||||||
|
r.Post("/api/items/import", s.HandleImportCSV)
|
||||||
|
r.Get("/api/items/{partNumber}/bom/export.csv", s.HandleExportBOMCSV)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleExportCSVEmpty(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/export.csv", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/csv") {
|
||||||
|
t.Errorf("content-type: got %q, want text/csv", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have header row only
|
||||||
|
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Errorf("expected 1 line (header only), got %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleExportCSVWithItems(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "CSV-001", "first csv item", nil)
|
||||||
|
createItemDirect(t, s, "CSV-002", "second csv item", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/export.csv", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
|
||||||
|
// header + 2 data rows
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Errorf("expected 3 lines (header + 2 rows), got %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCSVTemplate(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/template.csv?schema=kindred-rd", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/csv") {
|
||||||
|
t.Errorf("content-type: got %q, want text/csv", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain at least "category" and "description" columns
|
||||||
|
header := strings.Split(strings.TrimSpace(w.Body.String()), "\n")[0]
|
||||||
|
if !strings.Contains(header, "category") {
|
||||||
|
t.Error("template header missing 'category' column")
|
||||||
|
}
|
||||||
|
if !strings.Contains(header, "description") {
|
||||||
|
t.Error("template header missing 'description' column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// csvMultipartBody creates a multipart form body with a CSV file and optional form fields.
|
||||||
|
func csvMultipartBody(t *testing.T, csvContent string, fields map[string]string) (*bytes.Buffer, string) {
|
||||||
|
t.Helper()
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file", "import.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating form file: %v", err)
|
||||||
|
}
|
||||||
|
io.WriteString(part, csvContent)
|
||||||
|
|
||||||
|
for k, v := range fields {
|
||||||
|
writer.WriteField(k, v)
|
||||||
|
}
|
||||||
|
writer.Close()
|
||||||
|
return body, writer.FormDataContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleImportCSVDryRun(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
csv := "category,description\nF01,Dry run widget\nF01,Dry run gadget\n"
|
||||||
|
body, contentType := csvMultipartBody(t, csv, map[string]string{"dry_run": "true"})
|
||||||
|
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result CSVImportResult
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if result.TotalRows != 2 {
|
||||||
|
t.Errorf("total_rows: got %d, want 2", result.TotalRows)
|
||||||
|
}
|
||||||
|
// Dry run should not create items
|
||||||
|
if len(result.CreatedItems) != 0 {
|
||||||
|
t.Errorf("dry run should not create items, got %d", len(result.CreatedItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleImportCSVCommit(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
csv := "category,description\nF01,Committed widget\n"
|
||||||
|
body, contentType := csvMultipartBody(t, csv, nil)
|
||||||
|
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result CSVImportResult
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if result.SuccessCount != 1 {
|
||||||
|
t.Errorf("success_count: got %d, want 1", result.SuccessCount)
|
||||||
|
}
|
||||||
|
if len(result.CreatedItems) != 1 {
|
||||||
|
t.Errorf("created_items: got %d, want 1", len(result.CreatedItems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleExportBOMCSV(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newCSVRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "BOMCSV-P", "parent", nil)
|
||||||
|
createItemDirect(t, s, "BOMCSV-C", "child", nil)
|
||||||
|
addBOMDirect(t, s, "BOMCSV-P", "BOMCSV-C", 3)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/BOMCSV-P/bom/export.csv", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/csv") {
|
||||||
|
t.Errorf("content-type: got %q, want text/csv", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
|
||||||
|
// header + 1 BOM entry
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Errorf("expected 2 lines (header + 1 row), got %d", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
186
internal/api/file_handlers_test.go
Normal file
186
internal/api/file_handlers_test.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newFileRouter creates a chi router with file-related routes for testing.
|
||||||
|
func newFileRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/items/{partNumber}", func(r chi.Router) {
|
||||||
|
r.Get("/files", s.HandleListItemFiles)
|
||||||
|
r.Post("/files", s.HandleAssociateItemFile)
|
||||||
|
r.Delete("/files/{fileId}", s.HandleDeleteItemFile)
|
||||||
|
r.Put("/thumbnail", s.HandleSetItemThumbnail)
|
||||||
|
r.Post("/file", s.HandleUploadFile)
|
||||||
|
r.Get("/file/{revision}", s.HandleDownloadFile)
|
||||||
|
})
|
||||||
|
r.Post("/api/uploads/presign", s.HandlePresignUpload)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFileDirect creates a file record directly via the DB for test setup.
|
||||||
|
func createFileDirect(t *testing.T, s *Server, itemID, filename string) *db.ItemFile {
|
||||||
|
t.Helper()
|
||||||
|
f := &db.ItemFile{
|
||||||
|
ItemID: itemID,
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Size: 1024,
|
||||||
|
ObjectKey: "items/" + itemID + "/files/" + filename,
|
||||||
|
}
|
||||||
|
if err := s.itemFiles.Create(context.Background(), f); err != nil {
|
||||||
|
t.Fatalf("creating file %s: %v", filename, err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListItemFiles(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "FAPI-001", "file list item", nil)
|
||||||
|
item, _ := s.items.GetByPartNumber(context.Background(), "FAPI-001")
|
||||||
|
|
||||||
|
createFileDirect(t, s, item.ID, "drawing.pdf")
|
||||||
|
createFileDirect(t, s, item.ID, "model.step")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/FAPI-001/files", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []itemFileResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Errorf("expected 2 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListItemFilesNotFound(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/NONEXISTENT/files", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteItemFile(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "FDEL-API-001", "delete file item", nil)
|
||||||
|
item, _ := s.items.GetByPartNumber(context.Background(), "FDEL-API-001")
|
||||||
|
f := createFileDirect(t, s, item.ID, "removable.bin")
|
||||||
|
|
||||||
|
req := authRequest(httptest.NewRequest("DELETE", "/api/items/FDEL-API-001/files/"+f.ID, nil))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteItemFileCrossItem(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
// Create two items, attach file to item A
|
||||||
|
createItemDirect(t, s, "CROSS-A", "item A", nil)
|
||||||
|
createItemDirect(t, s, "CROSS-B", "item B", nil)
|
||||||
|
itemA, _ := s.items.GetByPartNumber(context.Background(), "CROSS-A")
|
||||||
|
f := createFileDirect(t, s, itemA.ID, "belongs-to-a.pdf")
|
||||||
|
|
||||||
|
// Try to delete via item B — should fail
|
||||||
|
req := authRequest(httptest.NewRequest("DELETE", "/api/items/CROSS-B/files/"+f.ID, nil))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePresignUploadNoStorage(t *testing.T) {
|
||||||
|
s := newTestServer(t) // storage is nil
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
body := `{"filename":"test.bin","content_type":"application/octet-stream","size":1024}`
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/uploads/presign", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUploadFileNoStorage(t *testing.T) {
|
||||||
|
s := newTestServer(t) // storage is nil
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "UPNS-001", "upload no storage", nil)
|
||||||
|
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/UPNS-001/file", strings.NewReader("fake")))
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAssociateFileNoStorage(t *testing.T) {
|
||||||
|
s := newTestServer(t) // storage is nil
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "ASSNS-001", "associate no storage", nil)
|
||||||
|
|
||||||
|
body := `{"object_key":"uploads/tmp/abc/test.bin","filename":"test.bin"}`
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/ASSNS-001/files", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSetThumbnailNoStorage(t *testing.T) {
|
||||||
|
s := newTestServer(t) // storage is nil
|
||||||
|
router := newFileRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "THNS-001", "thumbnail no storage", nil)
|
||||||
|
|
||||||
|
body := `{"object_key":"uploads/tmp/abc/thumb.png"}`
|
||||||
|
req := authRequest(httptest.NewRequest("PUT", "/api/items/THNS-001/thumbnail", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -621,6 +621,12 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.partgen.Validate(partNumber, schemaName); err != nil {
|
||||||
|
s.logger.Error().Err(err).Str("part_number", partNumber).Msg("generated part number failed validation")
|
||||||
|
writeError(w, http.StatusInternalServerError, "validation_failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
item = &db.Item{
|
item = &db.Item{
|
||||||
PartNumber: partNumber,
|
PartNumber: partNumber,
|
||||||
ItemType: itemType,
|
ItemType: itemType,
|
||||||
|
|||||||
90
internal/api/ods_handlers_test.go
Normal file
90
internal/api/ods_handlers_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/kindredsystems/silo/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newODSRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/items/export.ods", s.HandleExportODS)
|
||||||
|
r.Get("/api/items/template.ods", s.HandleODSTemplate)
|
||||||
|
r.Post("/api/items/import.ods", s.HandleImportODS)
|
||||||
|
r.Get("/api/projects/{code}/sheet.ods", s.HandleProjectSheetODS)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleExportODS(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newODSRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "ODS-001", "ods export item", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/export.ods", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/vnd.oasis.opendocument.spreadsheet") {
|
||||||
|
t.Errorf("content-type: got %q, want ODS type", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ODS is a ZIP file — first 2 bytes should be PK
|
||||||
|
body := w.Body.Bytes()
|
||||||
|
if len(body) < 2 || body[0] != 'P' || body[1] != 'K' {
|
||||||
|
t.Error("response body does not start with PK (ZIP magic)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleODSTemplate(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newODSRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/template.ods?schema=kindred-rd", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/vnd.oasis.opendocument.spreadsheet") {
|
||||||
|
t.Errorf("content-type: got %q, want ODS type", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleProjectSheetODS(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newODSRouter(s)
|
||||||
|
|
||||||
|
// Create project and item
|
||||||
|
ctx := httptest.NewRequest("GET", "/", nil).Context()
|
||||||
|
proj := &db.Project{Code: "ODSPR", Name: "ODS Project"}
|
||||||
|
s.projects.Create(ctx, proj)
|
||||||
|
createItemDirect(t, s, "ODSPR-001", "project sheet item", nil)
|
||||||
|
item, _ := s.items.GetByPartNumber(ctx, "ODSPR-001")
|
||||||
|
s.projects.AddItemToProject(ctx, item.ID, proj.ID)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/projects/ODSPR/sheet.ods", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := w.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "application/vnd.oasis.opendocument.spreadsheet") {
|
||||||
|
t.Errorf("content-type: got %q, want ODS type", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/api/revision_handlers_test.go
Normal file
222
internal/api/revision_handlers_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRevisionRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/items/{partNumber}", func(r chi.Router) {
|
||||||
|
r.Get("/revisions", s.HandleListRevisions)
|
||||||
|
r.Get("/revisions/compare", s.HandleCompareRevisions)
|
||||||
|
r.Get("/revisions/{revision}", s.HandleGetRevision)
|
||||||
|
r.Post("/revisions", s.HandleCreateRevision)
|
||||||
|
r.Patch("/revisions/{revision}", s.HandleUpdateRevision)
|
||||||
|
r.Post("/revisions/{revision}/rollback", s.HandleRollbackRevision)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListRevisions(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REV-API-001", "revision list", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/REV-API-001/revisions", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var revisions []RevisionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &revisions); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if len(revisions) != 1 {
|
||||||
|
t.Errorf("expected 1 revision (initial), got %d", len(revisions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListRevisionsNotFound(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/NOEXIST/revisions", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetRevision(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REVGET-001", "get revision", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/REVGET-001/revisions/1", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var rev RevisionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if rev.RevisionNumber != 1 {
|
||||||
|
t.Errorf("revision_number: got %d, want 1", rev.RevisionNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetRevisionNotFound(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REVNF-001", "rev not found", nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/items/REVNF-001/revisions/99", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCreateRevision(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REVCR-001", "create revision", nil)
|
||||||
|
|
||||||
|
body := `{"properties":{"material":"steel"},"comment":"added material"}`
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/REVCR-001/revisions", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var rev RevisionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if rev.RevisionNumber != 2 {
|
||||||
|
t.Errorf("revision_number: got %d, want 2", rev.RevisionNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateRevision(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REVUP-001", "update revision", nil)
|
||||||
|
|
||||||
|
body := `{"status":"released","labels":["production"]}`
|
||||||
|
req := authRequest(httptest.NewRequest("PATCH", "/api/items/REVUP-001/revisions/1", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var rev RevisionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if rev.Status != "released" {
|
||||||
|
t.Errorf("status: got %q, want %q", rev.Status, "released")
|
||||||
|
}
|
||||||
|
if len(rev.Labels) != 1 || rev.Labels[0] != "production" {
|
||||||
|
t.Errorf("labels: got %v, want [production]", rev.Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCompareRevisions(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
// Create item with properties, then create second revision with changed properties
|
||||||
|
cost := 10.0
|
||||||
|
createItemDirect(t, s, "REVCMP-001", "compare revisions", &cost)
|
||||||
|
|
||||||
|
body := `{"properties":{"standard_cost":20,"material":"aluminum"},"comment":"updated cost"}`
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/REVCMP-001/revisions", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare rev 1 vs rev 2
|
||||||
|
req = httptest.NewRequest("GET", "/api/items/REVCMP-001/revisions/compare?from=1&to=2", nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var diff RevisionDiffResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &diff); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if diff.FromRevision != 1 || diff.ToRevision != 2 {
|
||||||
|
t.Errorf("revisions: got from=%d to=%d, want from=1 to=2", diff.FromRevision, diff.ToRevision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRollbackRevision(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
router := newRevisionRouter(s)
|
||||||
|
|
||||||
|
createItemDirect(t, s, "REVRB-001", "rollback test", nil)
|
||||||
|
|
||||||
|
// Create rev 2
|
||||||
|
body := `{"properties":{"version":"v2"},"comment":"version 2"}`
|
||||||
|
req := authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback to rev 1 — should create rev 3
|
||||||
|
body = `{"comment":"rolling back"}`
|
||||||
|
req = authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions/1/rollback", strings.NewReader(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var rev RevisionResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if rev.RevisionNumber != 3 {
|
||||||
|
t.Errorf("revision_number: got %d, want 3", rev.RevisionNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
internal/api/schema_handlers_test.go
Normal file
100
internal/api/schema_handlers_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSchemaRouter(s *Server) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/schemas", s.HandleListSchemas)
|
||||||
|
r.Get("/api/schemas/{name}", s.HandleGetSchema)
|
||||||
|
r.Get("/api/schemas/{name}/form", s.HandleGetFormDescriptor)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListSchemas(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newSchemaRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemas", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemas []map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &schemas); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if len(schemas) == 0 {
|
||||||
|
t.Error("expected at least 1 schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSchema(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newSchemaRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemas/kindred-rd", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var schema map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &schema); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
if schema["name"] != "kindred-rd" {
|
||||||
|
t.Errorf("name: got %v, want %q", schema["name"], "kindred-rd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetSchemaNotFound(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newSchemaRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemas/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetFormDescriptor(t *testing.T) {
|
||||||
|
s := newTestServerWithSchemas(t)
|
||||||
|
router := newSchemaRouter(s)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/schemas/kindred-rd/form", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var form map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &form); err != nil {
|
||||||
|
t.Fatalf("decoding response: %v", err)
|
||||||
|
}
|
||||||
|
// Form descriptor should have fields
|
||||||
|
if _, ok := form["fields"]; !ok {
|
||||||
|
// Some schemas may use "categories" or "segments" instead
|
||||||
|
if _, ok := form["categories"]; !ok {
|
||||||
|
if _, ok := form["segments"]; !ok {
|
||||||
|
t.Error("form descriptor missing fields/categories/segments key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/db/item_files_test.go
Normal file
121
internal/db/item_files_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestItemFileCreate(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
fileRepo := NewItemFileRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "FILE-001", ItemType: "part", Description: "file test"}
|
||||||
|
if err := itemRepo.Create(ctx, item, nil); err != nil {
|
||||||
|
t.Fatalf("Create item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &ItemFile{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: "drawing.pdf",
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
Size: 12345,
|
||||||
|
ObjectKey: "items/FILE-001/files/abc/drawing.pdf",
|
||||||
|
}
|
||||||
|
if err := fileRepo.Create(ctx, f); err != nil {
|
||||||
|
t.Fatalf("Create file: %v", err)
|
||||||
|
}
|
||||||
|
if f.ID == "" {
|
||||||
|
t.Error("expected file ID to be set")
|
||||||
|
}
|
||||||
|
if f.CreatedAt.IsZero() {
|
||||||
|
t.Error("expected created_at to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemFileListByItem(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
fileRepo := NewItemFileRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "FLIST-001", ItemType: "part", Description: "file list test"}
|
||||||
|
itemRepo.Create(ctx, item, nil)
|
||||||
|
|
||||||
|
for i, name := range []string{"a.pdf", "b.step"} {
|
||||||
|
fileRepo.Create(ctx, &ItemFile{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: name,
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Size: int64(i * 1000),
|
||||||
|
ObjectKey: "items/FLIST-001/files/" + name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := fileRepo.ListByItem(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByItem: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Errorf("expected 2 files, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemFileGet(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
fileRepo := NewItemFileRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "FGET-001", ItemType: "part", Description: "file get test"}
|
||||||
|
itemRepo.Create(ctx, item, nil)
|
||||||
|
|
||||||
|
f := &ItemFile{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: "model.FCStd",
|
||||||
|
ContentType: "application/x-freecad",
|
||||||
|
Size: 99999,
|
||||||
|
ObjectKey: "items/FGET-001/files/xyz/model.FCStd",
|
||||||
|
}
|
||||||
|
fileRepo.Create(ctx, f)
|
||||||
|
|
||||||
|
got, err := fileRepo.Get(ctx, f.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Filename != "model.FCStd" {
|
||||||
|
t.Errorf("filename: got %q, want %q", got.Filename, "model.FCStd")
|
||||||
|
}
|
||||||
|
if got.Size != 99999 {
|
||||||
|
t.Errorf("size: got %d, want %d", got.Size, 99999)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemFileDelete(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
fileRepo := NewItemFileRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "FDEL-001", ItemType: "part", Description: "file delete test"}
|
||||||
|
itemRepo.Create(ctx, item, nil)
|
||||||
|
|
||||||
|
f := &ItemFile{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Filename: "temp.bin",
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Size: 100,
|
||||||
|
ObjectKey: "items/FDEL-001/files/tmp/temp.bin",
|
||||||
|
}
|
||||||
|
fileRepo.Create(ctx, f)
|
||||||
|
|
||||||
|
if err := fileRepo.Delete(ctx, f.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fileRepo.Get(ctx, f.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error after delete, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
281
internal/db/items_edge_test.go
Normal file
281
internal/db/items_edge_test.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestItemCreateDuplicatePartNumber(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "first"}
|
||||||
|
if err := repo.Create(ctx, item, nil); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dup := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "duplicate"}
|
||||||
|
err := repo.Create(ctx, dup, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for duplicate part number, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "23505") && !strings.Contains(err.Error(), "duplicate") {
|
||||||
|
t.Errorf("expected duplicate key error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemDelete(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "HDEL-001", ItemType: "part", Description: "hard delete"}
|
||||||
|
if err := repo.Create(ctx, item, nil); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Delete(ctx, item.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetByID(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByID after delete: %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Error("expected nil after hard delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemListPagination(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
item := &Item{
|
||||||
|
PartNumber: fmt.Sprintf("PAGE-%04d", i),
|
||||||
|
ItemType: "part",
|
||||||
|
Description: fmt.Sprintf("page item %d", i),
|
||||||
|
}
|
||||||
|
if err := repo.Create(ctx, item, nil); err != nil {
|
||||||
|
t.Fatalf("Create #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch page of 2 with offset 2
|
||||||
|
items, err := repo.List(ctx, ListOptions{Limit: 2, Offset: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Errorf("expected 2 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItemListSearch(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
repo.Create(ctx, &Item{PartNumber: "SRCH-001", ItemType: "part", Description: "alpha widget"}, nil)
|
||||||
|
repo.Create(ctx, &Item{PartNumber: "SRCH-002", ItemType: "part", Description: "beta gadget"}, nil)
|
||||||
|
repo.Create(ctx, &Item{PartNumber: "SRCH-003", ItemType: "part", Description: "alpha gizmo"}, nil)
|
||||||
|
|
||||||
|
items, err := repo.List(ctx, ListOptions{Search: "alpha"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Errorf("expected 2 items matching 'alpha', got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionStatusUpdate(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "STAT-001", ItemType: "part", Description: "status test"}
|
||||||
|
if err := repo.Create(ctx, item, map[string]any{"v": 1}); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "released"
|
||||||
|
if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, &status, nil); err != nil {
|
||||||
|
t.Fatalf("UpdateRevisionStatus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rev, err := repo.GetRevision(ctx, item.ID, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRevision: %v", err)
|
||||||
|
}
|
||||||
|
if rev.Status != "released" {
|
||||||
|
t.Errorf("status: got %q, want %q", rev.Status, "released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionLabelsUpdate(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "LBL-001", ItemType: "part", Description: "label test"}
|
||||||
|
if err := repo.Create(ctx, item, nil); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{"prototype", "urgent"}
|
||||||
|
if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, nil, labels); err != nil {
|
||||||
|
t.Fatalf("UpdateRevisionStatus: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rev, err := repo.GetRevision(ctx, item.ID, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRevision: %v", err)
|
||||||
|
}
|
||||||
|
if len(rev.Labels) != 2 {
|
||||||
|
t.Errorf("labels count: got %d, want 2", len(rev.Labels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionCompare(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "CMP-001", ItemType: "part", Description: "compare test"}
|
||||||
|
if err := repo.Create(ctx, item, map[string]any{"color": "red", "weight": 10}); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rev 2: change color, remove weight, add size
|
||||||
|
repo.CreateRevision(ctx, &Revision{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Properties: map[string]any{"color": "blue", "size": "large"},
|
||||||
|
})
|
||||||
|
|
||||||
|
diff, err := repo.CompareRevisions(ctx, item.ID, 1, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareRevisions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(diff.Added) == 0 {
|
||||||
|
t.Error("expected added fields (size)")
|
||||||
|
}
|
||||||
|
if len(diff.Removed) == 0 {
|
||||||
|
t.Error("expected removed fields (weight)")
|
||||||
|
}
|
||||||
|
if len(diff.Changed) == 0 {
|
||||||
|
t.Error("expected changed fields (color)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionRollback(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
repo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "RBK-001", ItemType: "part", Description: "rollback test"}
|
||||||
|
if err := repo.Create(ctx, item, map[string]any{"version": "original"}); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rev 2: change property
|
||||||
|
repo.CreateRevision(ctx, &Revision{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Properties: map[string]any{"version": "modified"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rollback to rev 1 — should create rev 3
|
||||||
|
comment := "rollback to rev 1"
|
||||||
|
rev3, err := repo.CreateRevisionFromExisting(ctx, item.ID, 1, comment, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRevisionFromExisting: %v", err)
|
||||||
|
}
|
||||||
|
if rev3.RevisionNumber != 3 {
|
||||||
|
t.Errorf("revision number: got %d, want 3", rev3.RevisionNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rev 3 should have rev 1's properties
|
||||||
|
got, err := repo.GetRevision(ctx, item.ID, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRevision: %v", err)
|
||||||
|
}
|
||||||
|
if got.Properties["version"] != "original" {
|
||||||
|
t.Errorf("rolled back version: got %v, want %q", got.Properties["version"], "original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectItemAssociationsByCode(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
projRepo := NewProjectRepository(database)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
proj := &Project{Code: "BYTAG", Name: "Tag Project"}
|
||||||
|
projRepo.Create(ctx, proj)
|
||||||
|
|
||||||
|
item := &Item{PartNumber: "TAG-001", ItemType: "part", Description: "taggable"}
|
||||||
|
itemRepo.Create(ctx, item, nil)
|
||||||
|
|
||||||
|
// Tag by code
|
||||||
|
if err := projRepo.AddItemToProjectByCode(ctx, item.ID, "BYTAG"); err != nil {
|
||||||
|
t.Fatalf("AddItemToProjectByCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, err := projRepo.GetProjectsForItem(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProjectsForItem: %v", err)
|
||||||
|
}
|
||||||
|
if len(projects) != 1 {
|
||||||
|
t.Fatalf("expected 1 project, got %d", len(projects))
|
||||||
|
}
|
||||||
|
if projects[0].Code != "BYTAG" {
|
||||||
|
t.Errorf("project code: got %q, want %q", projects[0].Code, "BYTAG")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untag by code
|
||||||
|
if err := projRepo.RemoveItemFromProjectByCode(ctx, item.ID, "BYTAG"); err != nil {
|
||||||
|
t.Fatalf("RemoveItemFromProjectByCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projects, _ = projRepo.GetProjectsForItem(ctx, item.ID)
|
||||||
|
if len(projects) != 0 {
|
||||||
|
t.Errorf("expected 0 projects after removal, got %d", len(projects))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListByProject(t *testing.T) {
|
||||||
|
database := mustConnectTestDB(t)
|
||||||
|
projRepo := NewProjectRepository(database)
|
||||||
|
itemRepo := NewItemRepository(database)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
proj := &Project{Code: "FILT", Name: "Filter Project"}
|
||||||
|
projRepo.Create(ctx, proj)
|
||||||
|
|
||||||
|
// Create 3 items, tag only 2
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
item := &Item{
|
||||||
|
PartNumber: fmt.Sprintf("FILT-%04d", i),
|
||||||
|
ItemType: "part",
|
||||||
|
Description: fmt.Sprintf("filter item %d", i),
|
||||||
|
}
|
||||||
|
itemRepo.Create(ctx, item, nil)
|
||||||
|
if i < 2 {
|
||||||
|
projRepo.AddItemToProjectByCode(ctx, item.ID, "FILT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := itemRepo.List(ctx, ListOptions{Project: "FILT"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List with project filter: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Errorf("expected 2 items in project FILT, got %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
)
|
)
|
||||||
@@ -99,8 +100,11 @@ func (g *Generator) resolveSegment(
|
|||||||
return g.formatSerial(seg, next), nil
|
return g.formatSerial(seg, next), nil
|
||||||
|
|
||||||
case "date":
|
case "date":
|
||||||
// TODO: implement date formatting
|
layout := seg.Value
|
||||||
return "", fmt.Errorf("date segments not yet implemented")
|
if layout == "" {
|
||||||
|
layout = "20060102"
|
||||||
|
}
|
||||||
|
return time.Now().UTC().Format(layout), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unknown segment type: %s", seg.Type)
|
return "", fmt.Errorf("unknown segment type: %s", seg.Type)
|
||||||
@@ -174,7 +178,84 @@ func (g *Generator) Validate(partNumber string, schemaName string) error {
|
|||||||
return fmt.Errorf("unknown schema: %s", schemaName)
|
return fmt.Errorf("unknown schema: %s", schemaName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: parse part number and validate each segment
|
parts := strings.Split(partNumber, s.Separator)
|
||||||
_ = s
|
if len(parts) != len(s.Segments) {
|
||||||
|
return fmt.Errorf("expected %d segments, got %d", len(s.Segments), len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, seg := range s.Segments {
|
||||||
|
val := parts[i]
|
||||||
|
if err := g.validateSegment(&seg, val); err != nil {
|
||||||
|
return fmt.Errorf("segment %s: %w", seg.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSegment checks that a single segment value is valid.
|
||||||
|
func (g *Generator) validateSegment(seg *schema.Segment, val string) error {
|
||||||
|
switch seg.Type {
|
||||||
|
case "constant":
|
||||||
|
if val != seg.Value {
|
||||||
|
return fmt.Errorf("expected %q, got %q", seg.Value, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enum":
|
||||||
|
if _, ok := seg.Values[val]; !ok {
|
||||||
|
return fmt.Errorf("invalid enum value: %s", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "string":
|
||||||
|
if seg.Length > 0 && len(val) != seg.Length {
|
||||||
|
return fmt.Errorf("value must be exactly %d characters", seg.Length)
|
||||||
|
}
|
||||||
|
if seg.MinLength > 0 && len(val) < seg.MinLength {
|
||||||
|
return fmt.Errorf("value must be at least %d characters", seg.MinLength)
|
||||||
|
}
|
||||||
|
if seg.MaxLength > 0 && len(val) > seg.MaxLength {
|
||||||
|
return fmt.Errorf("value must be at most %d characters", seg.MaxLength)
|
||||||
|
}
|
||||||
|
if seg.Case == "upper" && val != strings.ToUpper(val) {
|
||||||
|
return fmt.Errorf("value must be uppercase")
|
||||||
|
}
|
||||||
|
if seg.Case == "lower" && val != strings.ToLower(val) {
|
||||||
|
return fmt.Errorf("value must be lowercase")
|
||||||
|
}
|
||||||
|
if seg.Validation.Pattern != "" {
|
||||||
|
re := regexp.MustCompile(seg.Validation.Pattern)
|
||||||
|
if !re.MatchString(val) {
|
||||||
|
msg := seg.Validation.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "serial":
|
||||||
|
if seg.Length > 0 && len(val) != seg.Length {
|
||||||
|
return fmt.Errorf("value must be exactly %d characters", seg.Length)
|
||||||
|
}
|
||||||
|
for _, ch := range val {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return fmt.Errorf("serial must be numeric")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
layout := seg.Value
|
||||||
|
if layout == "" {
|
||||||
|
layout = "20060102"
|
||||||
|
}
|
||||||
|
expected := time.Now().UTC().Format(layout)
|
||||||
|
if len(val) != len(expected) {
|
||||||
|
return fmt.Errorf("date segment length mismatch: expected %d, got %d", len(expected), len(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown segment type: %s", seg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package partnum
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kindredsystems/silo/internal/schema"
|
"github.com/kindredsystems/silo/internal/schema"
|
||||||
)
|
)
|
||||||
@@ -165,3 +167,199 @@ func TestGenerateConstantSegment(t *testing.T) {
|
|||||||
t.Errorf("got %q, want %q", pn, "KS-0001")
|
t.Errorf("got %q, want %q", pn, "KS-0001")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateDateSegmentDefault(t *testing.T) {
|
||||||
|
s := &schema.Schema{
|
||||||
|
Name: "date-test",
|
||||||
|
Version: 1,
|
||||||
|
Separator: "-",
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{Name: "date", Type: "date"},
|
||||||
|
{Name: "serial", Type: "serial", Length: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"date-test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
pn, err := gen.Generate(context.Background(), Input{
|
||||||
|
SchemaName: "date-test",
|
||||||
|
Values: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default format: YYYYMMDD-NNN
|
||||||
|
want := time.Now().UTC().Format("20060102") + "-001"
|
||||||
|
if pn != want {
|
||||||
|
t.Errorf("got %q, want %q", pn, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDateSegmentCustomFormat(t *testing.T) {
|
||||||
|
s := &schema.Schema{
|
||||||
|
Name: "date-custom",
|
||||||
|
Version: 1,
|
||||||
|
Separator: "-",
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{Name: "date", Type: "date", Value: "0601"},
|
||||||
|
{Name: "serial", Type: "serial", Length: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"date-custom": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
pn, err := gen.Generate(context.Background(), Input{
|
||||||
|
SchemaName: "date-custom",
|
||||||
|
Values: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format "0601" produces YYMM
|
||||||
|
if matched, _ := regexp.MatchString(`^\d{4}-\d{4}$`, pn); !matched {
|
||||||
|
t.Errorf("got %q, want pattern YYMM-NNNN", pn)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := time.Now().UTC().Format("0601") + "-0001"
|
||||||
|
if pn != want {
|
||||||
|
t.Errorf("got %q, want %q", pn, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation tests ---
|
||||||
|
|
||||||
|
func TestValidateBasic(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("F01-0001", "test"); err != nil {
|
||||||
|
t.Fatalf("expected valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateWrongSegmentCount(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("F01-0001-EXTRA", "test"); err == nil {
|
||||||
|
t.Fatal("expected error for wrong segment count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInvalidEnum(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("ZZZ-0001", "test"); err == nil {
|
||||||
|
t.Fatal("expected error for invalid enum value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNonNumericSerial(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("F01-ABCD", "test"); err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric serial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSerialWrongLength(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("F01-01", "test"); err == nil {
|
||||||
|
t.Fatal("expected error for wrong serial length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConstantSegment(t *testing.T) {
|
||||||
|
s := &schema.Schema{
|
||||||
|
Name: "const-val",
|
||||||
|
Version: 1,
|
||||||
|
Separator: "-",
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{Name: "prefix", Type: "constant", Value: "KS"},
|
||||||
|
{Name: "serial", Type: "serial", Length: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"const-val": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("KS-0001", "const-val"); err != nil {
|
||||||
|
t.Fatalf("expected valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if err := gen.Validate("XX-0001", "const-val"); err == nil {
|
||||||
|
t.Fatal("expected error for wrong constant value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUnknownSchema(t *testing.T) {
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{}, &mockSeqStore{})
|
||||||
|
|
||||||
|
if err := gen.Validate("F01-0001", "nonexistent"); err == nil {
|
||||||
|
t.Fatal("expected error for unknown schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDateSegment(t *testing.T) {
|
||||||
|
s := &schema.Schema{
|
||||||
|
Name: "date-val",
|
||||||
|
Version: 1,
|
||||||
|
Separator: "-",
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{Name: "date", Type: "date"},
|
||||||
|
{Name: "serial", Type: "serial", Length: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"date-val": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("20060102")
|
||||||
|
if err := gen.Validate(today+"-001", "date-val"); err != nil {
|
||||||
|
t.Fatalf("expected valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if err := gen.Validate("20-001", "date-val"); err == nil {
|
||||||
|
t.Fatal("expected error for wrong date length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGeneratedOutput(t *testing.T) {
|
||||||
|
s := testSchema()
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"test": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
pn, err := gen.Generate(context.Background(), Input{
|
||||||
|
SchemaName: "test",
|
||||||
|
Values: map[string]string{"category": "F01"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate error: %v", err)
|
||||||
|
}
|
||||||
|
if err := gen.Validate(pn, "test"); err != nil {
|
||||||
|
t.Fatalf("generated part number %q failed validation: %v", pn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDateSegmentYearOnly(t *testing.T) {
|
||||||
|
s := &schema.Schema{
|
||||||
|
Name: "date-year",
|
||||||
|
Version: 1,
|
||||||
|
Separator: "-",
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{Name: "year", Type: "date", Value: "2006"},
|
||||||
|
{Name: "serial", Type: "serial", Length: 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
gen := NewGenerator(map[string]*schema.Schema{"date-year": s}, &mockSeqStore{})
|
||||||
|
|
||||||
|
pn, err := gen.Generate(context.Background(), Input{
|
||||||
|
SchemaName: "date-year",
|
||||||
|
Values: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := time.Now().UTC().Format("2006") + "-0001"
|
||||||
|
if pn != want {
|
||||||
|
t.Errorf("got %q, want %q", pn, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Deploy Silo to silo.kindred.internal
|
# Deploy Silo to a target host
|
||||||
#
|
#
|
||||||
# Usage: ./scripts/deploy.sh [host]
|
# Usage: ./scripts/deploy.sh [host]
|
||||||
# host defaults to silo.kindred.internal
|
# host defaults to SILO_DEPLOY_TARGET env var, or silo.example.internal
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - SSH access to the target host
|
# - SSH access to the target host
|
||||||
# - /etc/silo/silod.env must exist on target with credentials filled in
|
# - /etc/silo/silod.env must exist on target with credentials filled in
|
||||||
# - PostgreSQL reachable from target at psql.kindred.internal
|
# - PostgreSQL reachable from target (set SILO_DB_HOST to override)
|
||||||
# - MinIO reachable from target at minio.kindred.internal
|
# - MinIO reachable from target (set SILO_MINIO_HOST to override)
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# SILO_DEPLOY_TARGET - target host (default: silo.example.internal)
|
||||||
|
# SILO_DB_HOST - PostgreSQL host (default: psql.example.internal)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET="${1:-silo.kindred.internal}"
|
TARGET="${1:-${SILO_DEPLOY_TARGET:-silo.example.internal}}"
|
||||||
|
DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
|
||||||
DEPLOY_DIR="/opt/silo"
|
DEPLOY_DIR="/opt/silo"
|
||||||
CONFIG_DIR="/etc/silo"
|
CONFIG_DIR="/etc/silo"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -104,7 +109,7 @@ echo " Files installed to $DEPLOY_DIR"
|
|||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
echo "[6/6] Running migrations and starting service..."
|
echo "[6/6] Running migrations and starting service..."
|
||||||
ssh "$TARGET" bash -s <<'REMOTE'
|
ssh "$TARGET" DB_HOST="$DB_HOST" bash -s <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DEPLOY_DIR="/opt/silo"
|
DEPLOY_DIR="/opt/silo"
|
||||||
@@ -123,14 +128,14 @@ if command -v psql &>/dev/null && [ -n "${SILO_DB_PASSWORD:-}" ]; then
|
|||||||
for f in "$DEPLOY_DIR/migrations/"*.sql; do
|
for f in "$DEPLOY_DIR/migrations/"*.sql; do
|
||||||
echo " $(basename "$f")"
|
echo " $(basename "$f")"
|
||||||
PGPASSWORD="$SILO_DB_PASSWORD" psql \
|
PGPASSWORD="$SILO_DB_PASSWORD" psql \
|
||||||
-h psql.kindred.internal -p 5432 \
|
-h "$DB_HOST" -p 5432 \
|
||||||
-U silo -d silo \
|
-U silo -d silo \
|
||||||
-f "$f" -q 2>&1 | grep -v "already exists" || true
|
-f "$f" -q 2>&1 | grep -v "already exists" || true
|
||||||
done
|
done
|
||||||
echo " Migrations complete."
|
echo " Migrations complete."
|
||||||
else
|
else
|
||||||
echo " WARNING: psql not available or SILO_DB_PASSWORD not set, skipping migrations."
|
echo " WARNING: psql not available or SILO_DB_PASSWORD not set, skipping migrations."
|
||||||
echo " Run migrations manually: PGPASSWORD=... psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/migrations/NNN_name.sql"
|
echo " Run migrations manually: PGPASSWORD=... psql -h $DB_HOST -U silo -d silo -f /opt/silo/migrations/NNN_name.sql"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start service
|
# Start service
|
||||||
|
|||||||
344
scripts/setup-docker.sh
Executable file
344
scripts/setup-docker.sh
Executable file
@@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Silo Docker Setup Script
|
||||||
|
# Generates .env and config.docker.yaml for the all-in-one Docker Compose stack.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/setup-docker.sh # interactive
|
||||||
|
# ./scripts/setup-docker.sh --non-interactive # use defaults / env vars
|
||||||
|
# ./scripts/setup-docker.sh --domain silo.example.com
|
||||||
|
# ./scripts/setup-docker.sh --with-nginx
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# deployments/.env - Docker Compose environment variables
|
||||||
|
# deployments/config.docker.yaml - Silo server configuration
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors (disabled if not a terminal)
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
else
|
||||||
|
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DOMAIN="localhost"
|
||||||
|
NON_INTERACTIVE=false
|
||||||
|
WITH_NGINX=false
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="${SCRIPT_DIR}/.."
|
||||||
|
OUTPUT_DIR="${PROJECT_DIR}/deployments"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parse arguments
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--non-interactive) NON_INTERACTIVE=true; shift ;;
|
||||||
|
--domain) DOMAIN="$2"; shift 2 ;;
|
||||||
|
--with-nginx) WITH_NGINX=true; shift ;;
|
||||||
|
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --non-interactive Use defaults and env vars, no prompts"
|
||||||
|
echo " --domain DOMAIN Server hostname (default: localhost)"
|
||||||
|
echo " --with-nginx Print instructions for the nginx profile"
|
||||||
|
echo " --output-dir DIR Output directory (default: ./deployments)"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) log_error "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
generate_secret() {
|
||||||
|
local len="${1:-32}"
|
||||||
|
openssl rand -hex "$len" 2>/dev/null \
|
||||||
|
|| head -c "$len" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
local var_name="$1" prompt_text="$2" default="$3"
|
||||||
|
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||||
|
eval "$var_name=\"$default\""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local input
|
||||||
|
read -r -p "$(echo -e "${BOLD}${prompt_text}${NC} [${default}]: ")" input
|
||||||
|
eval "$var_name=\"${input:-$default}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_secret() {
|
||||||
|
local var_name="$1" prompt_text="$2" default="$3"
|
||||||
|
if [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||||
|
eval "$var_name=\"$default\""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local input
|
||||||
|
read -r -p "$(echo -e "${BOLD}${prompt_text}${NC} [auto-generated]: ")" input
|
||||||
|
eval "$var_name=\"${input:-$default}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Banner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Silo Docker Setup${NC}"
|
||||||
|
echo "Generates configuration for the all-in-one Docker Compose stack."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check for existing files
|
||||||
|
if [[ -f "${OUTPUT_DIR}/.env" ]]; then
|
||||||
|
log_warn "deployments/.env already exists."
|
||||||
|
if [[ "$NON_INTERACTIVE" == "false" ]]; then
|
||||||
|
read -r -p "Overwrite? [y/N]: " overwrite
|
||||||
|
if [[ "${overwrite,,}" != "y" ]]; then
|
||||||
|
log_info "Aborted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gather configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log_info "Gathering configuration..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Domain / base URL
|
||||||
|
prompt DOMAIN "Server domain" "$DOMAIN"
|
||||||
|
|
||||||
|
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||||
|
BASE_URL="http://${DOMAIN}"
|
||||||
|
elif [[ "$DOMAIN" == "localhost" ]]; then
|
||||||
|
BASE_URL="http://localhost:8080"
|
||||||
|
else
|
||||||
|
BASE_URL="http://${DOMAIN}:8080"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
PG_PASSWORD_DEFAULT="$(generate_secret 16)"
|
||||||
|
prompt_secret POSTGRES_PASSWORD "PostgreSQL password" "$PG_PASSWORD_DEFAULT"
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_AK_DEFAULT="$(generate_secret 10)"
|
||||||
|
MINIO_SK_DEFAULT="$(generate_secret 16)"
|
||||||
|
prompt_secret MINIO_ACCESS_KEY "MinIO access key" "$MINIO_AK_DEFAULT"
|
||||||
|
prompt_secret MINIO_SECRET_KEY "MinIO secret key" "$MINIO_SK_DEFAULT"
|
||||||
|
|
||||||
|
# OpenLDAP
|
||||||
|
LDAP_ADMIN_PW_DEFAULT="$(generate_secret 16)"
|
||||||
|
prompt_secret LDAP_ADMIN_PASSWORD "LDAP admin password" "$LDAP_ADMIN_PW_DEFAULT"
|
||||||
|
prompt LDAP_USERS "LDAP initial username" "siloadmin"
|
||||||
|
LDAP_USER_PW_DEFAULT="$(generate_secret 12)"
|
||||||
|
prompt_secret LDAP_PASSWORDS "LDAP initial user password" "$LDAP_USER_PW_DEFAULT"
|
||||||
|
|
||||||
|
# Session secret
|
||||||
|
SESSION_SECRET="$(generate_secret 32)"
|
||||||
|
|
||||||
|
# Silo local admin (fallback when LDAP is unavailable)
|
||||||
|
prompt SILO_ADMIN_USERNAME "Silo local admin username" "admin"
|
||||||
|
ADMIN_PW_DEFAULT="$(generate_secret 12)"
|
||||||
|
prompt_secret SILO_ADMIN_PASSWORD "Silo local admin password" "$ADMIN_PW_DEFAULT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write .env
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log_info "Writing ${OUTPUT_DIR}/.env ..."
|
||||||
|
|
||||||
|
cat > "${OUTPUT_DIR}/.env" << EOF
|
||||||
|
# Generated by setup-docker.sh on $(date +%Y-%m-%d)
|
||||||
|
# Used by: docker compose -f deployments/docker-compose.allinone.yaml
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||||
|
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||||
|
|
||||||
|
# OpenLDAP
|
||||||
|
LDAP_ADMIN_PASSWORD=${LDAP_ADMIN_PASSWORD}
|
||||||
|
LDAP_USERS=${LDAP_USERS}
|
||||||
|
LDAP_PASSWORDS=${LDAP_PASSWORDS}
|
||||||
|
|
||||||
|
# Silo
|
||||||
|
SILO_SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
SILO_ADMIN_USERNAME=${SILO_ADMIN_USERNAME}
|
||||||
|
SILO_ADMIN_PASSWORD=${SILO_ADMIN_PASSWORD}
|
||||||
|
SILO_BASE_URL=${BASE_URL}
|
||||||
|
|
||||||
|
# Uncomment if using OIDC (Keycloak)
|
||||||
|
# SILO_OIDC_CLIENT_SECRET=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${OUTPUT_DIR}/.env"
|
||||||
|
log_success "deployments/.env written"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write config.docker.yaml
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log_info "Writing ${OUTPUT_DIR}/config.docker.yaml ..."
|
||||||
|
|
||||||
|
# Note: Values wrapped in ${VAR} (inside the single-quoted YAMLEOF blocks)
|
||||||
|
# are NOT expanded by bash — they are written literally into the YAML file
|
||||||
|
# and expanded at runtime by the Go config loader via os.ExpandEnv().
|
||||||
|
# The base_url and cors origin use the bash variable directly since
|
||||||
|
# SILO_SERVER_BASE_URL is not a supported direct override in the Go loader.
|
||||||
|
{
|
||||||
|
cat << 'YAMLEOF'
|
||||||
|
# Silo Configuration — Docker Compose (all-in-one)
|
||||||
|
# Generated by scripts/setup-docker.sh
|
||||||
|
#
|
||||||
|
# Values using ${VAR} syntax are expanded from environment variables at
|
||||||
|
# startup. Direct env var overrides (SILO_DB_PASSWORD, etc.) take precedence
|
||||||
|
# over YAML values — see docs/CONFIGURATION.md for the full reference.
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
YAMLEOF
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
base_url: "${BASE_URL}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat << 'YAMLEOF'
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "postgres"
|
||||||
|
port: 5432
|
||||||
|
name: "silo"
|
||||||
|
user: "silo"
|
||||||
|
password: "${SILO_DB_PASSWORD}"
|
||||||
|
sslmode: "disable"
|
||||||
|
max_connections: 10
|
||||||
|
|
||||||
|
storage:
|
||||||
|
endpoint: "minio:9000"
|
||||||
|
access_key: "${SILO_MINIO_ACCESS_KEY}"
|
||||||
|
secret_key: "${SILO_MINIO_SECRET_KEY}"
|
||||||
|
bucket: "silo-files"
|
||||||
|
use_ssl: false
|
||||||
|
region: "us-east-1"
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
directory: "/etc/silo/schemas"
|
||||||
|
default: "kindred-rd"
|
||||||
|
|
||||||
|
freecad:
|
||||||
|
uri_scheme: "silo"
|
||||||
|
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
session_secret: "${SILO_SESSION_SECRET}"
|
||||||
|
|
||||||
|
# Local accounts (fallback when LDAP is unavailable)
|
||||||
|
local:
|
||||||
|
enabled: true
|
||||||
|
default_admin_username: "${SILO_ADMIN_USERNAME}"
|
||||||
|
default_admin_password: "${SILO_ADMIN_PASSWORD}"
|
||||||
|
|
||||||
|
# OpenLDAP (provided by the Docker Compose stack)
|
||||||
|
ldap:
|
||||||
|
enabled: true
|
||||||
|
url: "ldap://openldap:1389"
|
||||||
|
base_dn: "dc=silo,dc=local"
|
||||||
|
user_search_dn: "ou=users,dc=silo,dc=local"
|
||||||
|
user_attr: "cn"
|
||||||
|
email_attr: "mail"
|
||||||
|
display_attr: "cn"
|
||||||
|
group_attr: "memberOf"
|
||||||
|
role_mapping:
|
||||||
|
admin:
|
||||||
|
- "cn=silo-admins,ou=groups,dc=silo,dc=local"
|
||||||
|
editor:
|
||||||
|
- "cn=silo-users,ou=groups,dc=silo,dc=local"
|
||||||
|
viewer:
|
||||||
|
- "cn=silo-viewers,ou=groups,dc=silo,dc=local"
|
||||||
|
tls_skip_verify: false
|
||||||
|
|
||||||
|
oidc:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
cors:
|
||||||
|
allowed_origins:
|
||||||
|
YAMLEOF
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
- "${BASE_URL}"
|
||||||
|
EOF
|
||||||
|
} > "${OUTPUT_DIR}/config.docker.yaml"
|
||||||
|
|
||||||
|
log_success "deployments/config.docker.yaml written"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}============================================${NC}"
|
||||||
|
echo -e "${BOLD}Setup complete!${NC}"
|
||||||
|
echo -e "${BOLD}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Generated files:"
|
||||||
|
echo " deployments/.env - secrets and credentials"
|
||||||
|
echo " deployments/config.docker.yaml - server configuration"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Credentials:${NC}"
|
||||||
|
echo " PostgreSQL: silo / ${POSTGRES_PASSWORD}"
|
||||||
|
echo " MinIO: ${MINIO_ACCESS_KEY} / ${MINIO_SECRET_KEY}"
|
||||||
|
echo " MinIO Console: http://localhost:9001"
|
||||||
|
echo " LDAP Admin: cn=admin,dc=silo,dc=local / ${LDAP_ADMIN_PASSWORD}"
|
||||||
|
echo " LDAP User: ${LDAP_USERS} / ${LDAP_PASSWORDS}"
|
||||||
|
echo " Silo Admin: ${SILO_ADMIN_USERNAME} / ${SILO_ADMIN_PASSWORD} (local fallback)"
|
||||||
|
echo " Base URL: ${BASE_URL}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Next steps:${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " # Start the stack"
|
||||||
|
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||||
|
echo " docker compose -f deployments/docker-compose.allinone.yaml --profile nginx up -d"
|
||||||
|
else
|
||||||
|
echo " docker compose -f deployments/docker-compose.allinone.yaml up -d"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo " # Check status"
|
||||||
|
echo " docker compose -f deployments/docker-compose.allinone.yaml ps"
|
||||||
|
echo ""
|
||||||
|
echo " # View logs"
|
||||||
|
echo " docker compose -f deployments/docker-compose.allinone.yaml logs -f silo"
|
||||||
|
echo ""
|
||||||
|
echo " # Open in browser"
|
||||||
|
echo " ${BASE_URL}"
|
||||||
|
echo ""
|
||||||
|
echo " # Log in with LDAP: ${LDAP_USERS} / <password above>"
|
||||||
|
echo " # Or local admin: ${SILO_ADMIN_USERNAME} / <password above>"
|
||||||
|
echo ""
|
||||||
|
if [[ "$WITH_NGINX" != "true" ]]; then
|
||||||
|
echo " To add nginx later:"
|
||||||
|
echo " docker compose -f deployments/docker-compose.allinone.yaml --profile nginx up -d"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
echo "Save these credentials somewhere safe. The passwords in deployments/.env"
|
||||||
|
echo "are the source of truth for the running stack."
|
||||||
|
echo ""
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Silo Host Setup Script
|
# Silo Host Setup Script
|
||||||
# Run this once on silo.kindred.internal to prepare for deployment
|
# Run this once on silo.example.internal to prepare for deployment
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# sudo ./setup-host.sh
|
# sudo ./setup-host.sh
|
||||||
@@ -24,11 +24,13 @@ BLUE='\033[0;34m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
REPO_URL="${SILO_REPO_URL:-https://gitea.kindred.internal/kindred/silo-0062.git}"
|
REPO_URL="${SILO_REPO_URL:-https://git.kindred-systems.com/kindred/silo.git}"
|
||||||
REPO_BRANCH="${SILO_BRANCH:-main}"
|
REPO_BRANCH="${SILO_BRANCH:-main}"
|
||||||
INSTALL_DIR="/opt/silo"
|
INSTALL_DIR="/opt/silo"
|
||||||
CONFIG_DIR="/etc/silo"
|
CONFIG_DIR="/etc/silo"
|
||||||
GO_VERSION="1.23.0"
|
GO_VERSION="1.24.0"
|
||||||
|
DB_HOST="${SILO_DB_HOST:-psql.example.internal}"
|
||||||
|
MINIO_HOST="${SILO_MINIO_HOST:-minio.example.internal}"
|
||||||
|
|
||||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
@@ -155,21 +157,28 @@ log_success "Directories created"
|
|||||||
ENV_FILE="${CONFIG_DIR}/silod.env"
|
ENV_FILE="${CONFIG_DIR}/silod.env"
|
||||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
log_info "Creating environment file..."
|
log_info "Creating environment file..."
|
||||||
cat > "${ENV_FILE}" << 'EOF'
|
cat > "${ENV_FILE}" << EOF
|
||||||
# Silo daemon environment variables
|
# Silo daemon environment variables
|
||||||
# Fill in the values below
|
# Fill in the values below
|
||||||
|
|
||||||
# Database credentials (psql.kindred.internal)
|
# Database credentials (${DB_HOST})
|
||||||
# Database: silo, User: silo
|
# Database: silo, User: silo
|
||||||
SILO_DB_PASSWORD=
|
SILO_DB_PASSWORD=
|
||||||
|
|
||||||
# MinIO credentials (minio.kindred.internal)
|
# MinIO credentials (${MINIO_HOST})
|
||||||
# User: silouser
|
# User: silouser
|
||||||
SILO_MINIO_ACCESS_KEY=silouser
|
SILO_MINIO_ACCESS_KEY=silouser
|
||||||
SILO_MINIO_SECRET_KEY=
|
SILO_MINIO_SECRET_KEY=
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
# Session secret (required when auth is enabled)
|
||||||
|
SILO_SESSION_SECRET=
|
||||||
|
# Default admin account (created on first startup if both are set)
|
||||||
|
SILO_ADMIN_USERNAME=admin
|
||||||
|
SILO_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Optional overrides
|
# Optional overrides
|
||||||
# SILO_SERVER_BASE_URL=http://silo.kindred.internal:8080
|
# SILO_SERVER_BASE_URL=http://\$(hostname -f):8080
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "${ENV_FILE}"
|
chmod 600 "${ENV_FILE}"
|
||||||
chown root:silo "${ENV_FILE}"
|
chown root:silo "${ENV_FILE}"
|
||||||
@@ -214,10 +223,10 @@ echo "1. Edit ${ENV_FILE} and fill in credentials:"
|
|||||||
echo " sudo nano ${ENV_FILE}"
|
echo " sudo nano ${ENV_FILE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "2. Verify database connectivity:"
|
echo "2. Verify database connectivity:"
|
||||||
echo " psql -h psql.kindred.internal -U silo -d silo -c 'SELECT 1'"
|
echo " psql -h ${DB_HOST} -U silo -d silo -c 'SELECT 1'"
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Verify MinIO connectivity:"
|
echo "3. Verify MinIO connectivity:"
|
||||||
echo " curl -I http://minio.kindred.internal:9000/minio/health/live"
|
echo " curl -I http://${MINIO_HOST}:9000/minio/health/live"
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. Run the deployment:"
|
echo "4. Run the deployment:"
|
||||||
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
|
echo " sudo ${INSTALL_DIR}/src/scripts/deploy.sh"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
# sudo ./scripts/setup-ipa-nginx.sh
|
# sudo ./scripts/setup-ipa-nginx.sh
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - FreeIPA server at ipa.kindred.internal
|
# - FreeIPA server at ipa.example.internal
|
||||||
# - DNS configured for silo.kindred.internal
|
# - DNS configured for the silo host (set SILO_HOSTNAME to override default)
|
||||||
# - Admin credentials for IPA enrollment
|
# - Admin credentials for IPA enrollment
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -21,12 +21,12 @@ BLUE='\033[0;34m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
IPA_SERVER="${IPA_SERVER:-ipa.kindred.internal}"
|
IPA_SERVER="${IPA_SERVER:-ipa.example.internal}"
|
||||||
IPA_DOMAIN="${IPA_DOMAIN:-kindred.internal}"
|
IPA_DOMAIN="${IPA_DOMAIN:-example.internal}"
|
||||||
IPA_REALM="${IPA_REALM:-KINDRED.INTERNAL}"
|
IPA_REALM="${IPA_REALM:-KINDRED.INTERNAL}"
|
||||||
HOSTNAME="silo.kindred.internal"
|
SILO_HOSTNAME="${SILO_HOSTNAME:-silo.example.internal}"
|
||||||
CERT_DIR="/etc/ssl/silo"
|
CERT_DIR="/etc/ssl/silo"
|
||||||
SILO_PORT=8080
|
SILO_PORT="${SILO_PORT:-8080}"
|
||||||
|
|
||||||
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
log_success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
@@ -77,8 +77,8 @@ log_success "Packages installed"
|
|||||||
#
|
#
|
||||||
# Step 2: Set hostname
|
# Step 2: Set hostname
|
||||||
#
|
#
|
||||||
log_info "Setting hostname to ${HOSTNAME}..."
|
log_info "Setting hostname to ${SILO_HOSTNAME}..."
|
||||||
hostnamectl set-hostname "${HOSTNAME}"
|
hostnamectl set-hostname "${SILO_HOSTNAME}"
|
||||||
log_success "Hostname set"
|
log_success "Hostname set"
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -95,7 +95,7 @@ else
|
|||||||
--server="${IPA_SERVER}" \
|
--server="${IPA_SERVER}" \
|
||||||
--domain="${IPA_DOMAIN}" \
|
--domain="${IPA_DOMAIN}" \
|
||||||
--realm="${IPA_REALM}" \
|
--realm="${IPA_REALM}" \
|
||||||
--hostname="${HOSTNAME}" \
|
--hostname="${SILO_HOSTNAME}" \
|
||||||
--mkhomedir \
|
--mkhomedir \
|
||||||
--enable-dns-updates \
|
--enable-dns-updates \
|
||||||
--unattended \
|
--unattended \
|
||||||
@@ -105,7 +105,7 @@ else
|
|||||||
--server="${IPA_SERVER}" \
|
--server="${IPA_SERVER}" \
|
||||||
--domain="${IPA_DOMAIN}" \
|
--domain="${IPA_DOMAIN}" \
|
||||||
--realm="${IPA_REALM}" \
|
--realm="${IPA_REALM}" \
|
||||||
--hostname="${HOSTNAME}" \
|
--hostname="${SILO_HOSTNAME}" \
|
||||||
--mkhomedir \
|
--mkhomedir \
|
||||||
--enable-dns-updates
|
--enable-dns-updates
|
||||||
}
|
}
|
||||||
@@ -135,9 +135,9 @@ else
|
|||||||
ipa-getcert request \
|
ipa-getcert request \
|
||||||
-f "${CERT_DIR}/silo.crt" \
|
-f "${CERT_DIR}/silo.crt" \
|
||||||
-k "${CERT_DIR}/silo.key" \
|
-k "${CERT_DIR}/silo.key" \
|
||||||
-K "HTTP/${HOSTNAME}" \
|
-K "HTTP/${SILO_HOSTNAME}" \
|
||||||
-D "${HOSTNAME}" \
|
-D "${SILO_HOSTNAME}" \
|
||||||
-N "CN=${HOSTNAME}" \
|
-N "CN=${SILO_HOSTNAME}" \
|
||||||
-C "systemctl reload nginx"
|
-C "systemctl reload nginx"
|
||||||
|
|
||||||
log_info "Waiting for certificate to be issued..."
|
log_info "Waiting for certificate to be issued..."
|
||||||
@@ -186,14 +186,14 @@ if [[ -f /etc/nginx/sites-enabled/default ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create silo nginx config
|
# Create silo nginx config
|
||||||
cat > /etc/nginx/sites-available/silo << 'NGINX_EOF'
|
cat > /etc/nginx/sites-available/silo << NGINX_EOF
|
||||||
# Silo API Server - Nginx Reverse Proxy Configuration
|
# Silo API Server - Nginx Reverse Proxy Configuration
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
# Redirect HTTP to HTTPS
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name silo.kindred.internal;
|
server_name ${SILO_HOSTNAME};
|
||||||
|
|
||||||
# Allow certmonger/ACME challenges
|
# Allow certmonger/ACME challenges
|
||||||
location /.well-known/ {
|
location /.well-known/ {
|
||||||
@@ -201,7 +201,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
return 301 https://$server_name$request_uri;
|
return 301 https://\\$server_name\\$request_uri;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +209,11 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
server_name silo.kindred.internal;
|
server_name ${SILO_HOSTNAME};
|
||||||
|
|
||||||
# SSL certificates (managed by certmonger/IPA)
|
# SSL certificates (managed by certmonger/IPA)
|
||||||
ssl_certificate /etc/ssl/silo/silo.crt;
|
ssl_certificate ${CERT_DIR}/silo.crt;
|
||||||
ssl_certificate_key /etc/ssl/silo/silo.key;
|
ssl_certificate_key ${CERT_DIR}/silo.key;
|
||||||
|
|
||||||
# SSL configuration
|
# SSL configuration
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
@@ -226,7 +226,7 @@ server {
|
|||||||
# OCSP stapling
|
# OCSP stapling
|
||||||
ssl_stapling on;
|
ssl_stapling on;
|
||||||
ssl_stapling_verify on;
|
ssl_stapling_verify on;
|
||||||
ssl_trusted_certificate /etc/ssl/silo/ca.crt;
|
ssl_trusted_certificate ${CERT_DIR}/ca.crt;
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
@@ -240,19 +240,19 @@ server {
|
|||||||
|
|
||||||
# Proxy settings
|
# Proxy settings
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:${SILO_PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# Headers
|
# Headers
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host \\$host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP \\$remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto \\$scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host \\$host;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port \\$server_port;
|
||||||
|
|
||||||
# WebSocket support (for future use)
|
# WebSocket support (for future use)
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade \\$http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
# Timeouts
|
# Timeouts
|
||||||
@@ -343,14 +343,14 @@ echo " getcert list"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "2. Update silo config to use correct base URL:"
|
echo "2. Update silo config to use correct base URL:"
|
||||||
echo " sudo nano /etc/silo/config.yaml"
|
echo " sudo nano /etc/silo/config.yaml"
|
||||||
echo " # Change base_url to: https://silo.kindred.internal"
|
echo " # Change base_url to: https://${SILO_HOSTNAME}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "3. Restart silo service:"
|
echo "3. Restart silo service:"
|
||||||
echo " sudo systemctl restart silod"
|
echo " sudo systemctl restart silod"
|
||||||
echo ""
|
echo ""
|
||||||
echo "4. Test the setup:"
|
echo "4. Test the setup:"
|
||||||
echo " curl -k https://silo.kindred.internal/health"
|
echo " curl -k https://${SILO_HOSTNAME}/health"
|
||||||
echo " curl https://silo.kindred.internal/health # after trusting IPA CA"
|
echo " curl https://${SILO_HOSTNAME}/health # after trusting IPA CA"
|
||||||
echo ""
|
echo ""
|
||||||
echo "5. Trust IPA CA on client machines:"
|
echo "5. Trust IPA CA on client machines:"
|
||||||
echo " # The CA cert is at: ${CERT_DIR}/ca.crt"
|
echo " # The CA cert is at: ${CERT_DIR}/ca.crt"
|
||||||
|
|||||||
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "silo-web",
|
"name": "silo-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -1499,6 +1500,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.564.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz",
|
||||||
|
"integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function AppShell() {
|
|||||||
padding: "var(--d-nav-py) var(--d-nav-px)",
|
padding: "var(--d-nav-py) var(--d-nav-px)",
|
||||||
borderRadius: "var(--d-nav-radius)",
|
borderRadius: "var(--d-nav-radius)",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
transition: "all 0.2s",
|
transition: "all 0.15s ease",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
@@ -113,9 +113,9 @@ export function AppShell() {
|
|||||||
onClick={toggleDensity}
|
onClick={toggleDensity}
|
||||||
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
|
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
background: "var(--ctp-surface0)",
|
background: "var(--ctp-surface0)",
|
||||||
@@ -130,7 +130,7 @@ export function AppShell() {
|
|||||||
onClick={logout}
|
onClick={logout}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.35rem 0.75rem",
|
padding: "0.35rem 0.75rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,76 +25,95 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
};
|
};
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === "Escape") onClose();
|
||||||
};
|
};
|
||||||
const handleScroll = () => onClose();
|
const handleScroll = () => onClose();
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
document.addEventListener('keydown', handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
window.addEventListener("scroll", handleScroll, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClick);
|
document.removeEventListener("mousedown", handleClick);
|
||||||
document.removeEventListener('keydown', handleKey);
|
document.removeEventListener("keydown", handleKey);
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
window.removeEventListener("scroll", handleScroll, true);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Clamp position to viewport
|
// Clamp position to viewport
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
left: Math.min(x, window.innerWidth - 220),
|
left: Math.min(x, window.innerWidth - 220),
|
||||||
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
|
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
backgroundColor: 'var(--ctp-surface0)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.5rem',
|
borderRadius: "0.5rem",
|
||||||
padding: '0.25rem 0',
|
padding: "0.25rem 0",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={style}>
|
<div ref={ref} style={style}>
|
||||||
{items.map((item, i) =>
|
{items.map((item, i) =>
|
||||||
item.divider ? (
|
item.divider ? (
|
||||||
<div key={i} style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.25rem 0' }} />
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--ctp-surface1)",
|
||||||
|
margin: "0.25rem 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.onToggle) item.onToggle();
|
if (item.onToggle) item.onToggle();
|
||||||
else if (item.onClick) { item.onClick(); onClose(); }
|
else if (item.onClick) {
|
||||||
|
item.onClick();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.5rem',
|
gap: "0.5rem",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
padding: '0.35rem 0.75rem',
|
padding: "0.35rem 0.75rem",
|
||||||
background: 'none',
|
background: "none",
|
||||||
border: 'none',
|
border: "none",
|
||||||
color: item.disabled ? 'var(--ctp-overlay0)' : 'var(--ctp-text)',
|
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
|
||||||
fontSize: '0.85rem',
|
fontSize: "var(--font-body)",
|
||||||
cursor: item.disabled ? 'default' : 'pointer',
|
cursor: item.disabled ? "default" : "pointer",
|
||||||
textAlign: 'left',
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!item.disabled) e.currentTarget.style.backgroundColor = 'var(--ctp-surface1)';
|
if (!item.disabled)
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.checked !== undefined && (
|
{item.checked !== undefined && (
|
||||||
<span style={{
|
<span
|
||||||
width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
style={{
|
||||||
border: '1px solid var(--ctp-overlay0)', borderRadius: 3,
|
width: 16,
|
||||||
backgroundColor: item.checked ? 'var(--ctp-mauve)' : 'transparent',
|
height: 16,
|
||||||
color: item.checked ? 'var(--ctp-crust)' : 'transparent',
|
display: "inline-flex",
|
||||||
fontSize: '0.7rem', fontWeight: 700, flexShrink: 0,
|
alignItems: "center",
|
||||||
}}>
|
justifyContent: "center",
|
||||||
{item.checked ? '✓' : ''}
|
border: "1px solid var(--ctp-overlay0)",
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: item.checked
|
||||||
|
? "var(--ctp-mauve)"
|
||||||
|
: "transparent",
|
||||||
|
color: item.checked ? "var(--ctp-crust)" : "transparent",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.checked ? <Check size={14} /> : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
export interface TagOption {
|
export interface TagOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,34 +13,45 @@ interface TagInputProps {
|
|||||||
searchFn: (query: string) => Promise<TagOption[]>;
|
searchFn: (query: string) => Promise<TagOption[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) {
|
export function TagInput({
|
||||||
const [query, setQuery] = useState('');
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
searchFn,
|
||||||
|
}: TagInputProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<TagOption[]>([]);
|
const [results, setResults] = useState<TagOption[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [highlighted, setHighlighted] = useState(0);
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(q: string) => {
|
(q: string) => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
if (q.trim() === '') {
|
if (q.trim() === "") {
|
||||||
// Show all results when input is empty but focused
|
// Show all results when input is empty but focused
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
searchFn('').then((opts) => {
|
searchFn("")
|
||||||
setResults(opts.filter((o) => !value.includes(o.id)));
|
.then((opts) => {
|
||||||
setHighlighted(0);
|
setResults(opts.filter((o) => !value.includes(o.id)));
|
||||||
}).catch(() => setResults([]));
|
setHighlighted(0);
|
||||||
|
})
|
||||||
|
.catch(() => setResults([]));
|
||||||
}, 100);
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
searchFn(q).then((opts) => {
|
searchFn(q)
|
||||||
setResults(opts.filter((o) => !value.includes(o.id)));
|
.then((opts) => {
|
||||||
setHighlighted(0);
|
setResults(opts.filter((o) => !value.includes(o.id)));
|
||||||
}).catch(() => setResults([]));
|
setHighlighted(0);
|
||||||
|
})
|
||||||
|
.catch(() => setResults([]));
|
||||||
}, 200);
|
}, 200);
|
||||||
},
|
},
|
||||||
[searchFn, value],
|
[searchFn, value],
|
||||||
@@ -53,17 +65,20 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
// Close on click outside
|
// Close on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handler);
|
document.addEventListener("mousedown", handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const select = (id: string) => {
|
const select = (id: string) => {
|
||||||
onChange([...value, id]);
|
onChange([...value, id]);
|
||||||
setQuery('');
|
setQuery("");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
@@ -73,22 +88,22 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Backspace' && query === '' && value.length > 0) {
|
if (e.key === "Backspace" && query === "" && value.length > 0) {
|
||||||
onChange(value.slice(0, -1));
|
onChange(value.slice(0, -1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open || results.length === 0) return;
|
if (!open || results.length === 0) return;
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setHighlighted((h) => (h + 1) % results.length);
|
setHighlighted((h) => (h + 1) % results.length);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setHighlighted((h) => (h - 1 + results.length) % results.length);
|
setHighlighted((h) => (h - 1 + results.length) % results.length);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (results[highlighted]) select(results[highlighted].id);
|
if (results[highlighted]) select(results[highlighted].id);
|
||||||
}
|
}
|
||||||
@@ -99,19 +114,19 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
for (const r of results) labelMap.current.set(r.id, r.label);
|
for (const r of results) labelMap.current.set(r.id, r.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
<div ref={containerRef} style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.25rem',
|
gap: "0.25rem",
|
||||||
padding: '0.25rem 0.5rem',
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: 'var(--ctp-base)',
|
backgroundColor: "var(--ctp-base)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.3rem',
|
borderRadius: "0.375rem",
|
||||||
cursor: 'text',
|
cursor: "text",
|
||||||
minHeight: '1.8rem',
|
minHeight: "1.8rem",
|
||||||
}}
|
}}
|
||||||
onClick={() => inputRef.current?.focus()}
|
onClick={() => inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
@@ -119,14 +134,14 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: "inline-flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.25rem',
|
gap: "0.25rem",
|
||||||
padding: '0.1rem 0.5rem',
|
padding: "0.15rem 0.5rem",
|
||||||
borderRadius: '1rem',
|
borderRadius: "1rem",
|
||||||
backgroundColor: 'rgba(203,166,247,0.15)',
|
backgroundColor: "rgba(203,166,247,0.15)",
|
||||||
color: 'var(--ctp-mauve)',
|
color: "var(--ctp-mauve)",
|
||||||
fontSize: '0.75rem',
|
fontSize: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelMap.current.get(id) ?? id}
|
{labelMap.current.get(id) ?? id}
|
||||||
@@ -137,16 +152,16 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
remove(id);
|
remove(id);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: "none",
|
||||||
border: 'none',
|
border: "none",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
color: 'var(--ctp-mauve)',
|
color: "var(--ctp-mauve)",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
fontSize: '0.8rem',
|
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
|
display: "inline-flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -166,30 +181,30 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
placeholder={value.length === 0 ? placeholder : undefined}
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: '4rem',
|
minWidth: "4rem",
|
||||||
border: 'none',
|
border: "none",
|
||||||
outline: 'none',
|
outline: "none",
|
||||||
background: 'transparent',
|
background: "transparent",
|
||||||
color: 'var(--ctp-text)',
|
color: "var(--ctp-text)",
|
||||||
fontSize: '0.85rem',
|
fontSize: "var(--font-body)",
|
||||||
padding: '0.1rem 0',
|
padding: "0.15rem 0",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{open && results.length > 0 && (
|
{open && results.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '100%',
|
top: "100%",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
marginTop: '0.2rem',
|
marginTop: "0.25rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.3rem',
|
borderRadius: "0.375rem",
|
||||||
maxHeight: '160px',
|
maxHeight: "160px",
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{results.map((opt, i) => (
|
{results.map((opt, i) => (
|
||||||
@@ -201,15 +216,15 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHighlighted(i)}
|
onMouseEnter={() => setHighlighted(i)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.25rem 0.5rem',
|
padding: "0.25rem 0.5rem",
|
||||||
height: '28px',
|
height: "28px",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
fontSize: '0.8rem',
|
fontSize: "var(--font-table)",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
color: 'var(--ctp-text)',
|
color: "var(--ctp-text)",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
i === highlighted ? 'var(--ctp-surface1)' : 'transparent',
|
i === highlighted ? "var(--ctp-surface1)" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function AuditDetailPanel({
|
|||||||
fontFamily: "'JetBrains Mono', monospace",
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
color: "var(--ctp-peach)",
|
color: "var(--ctp-peach)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "1rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{audit.part_number}
|
{audit.part_number}
|
||||||
@@ -252,7 +252,7 @@ export function AuditDetailPanel({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
width: `${Math.min(audit.score * 100, 100)}%`,
|
width: `${Math.min(audit.score * 100, 100)}%`,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
transition: "width 0.3s, background-color 0.3s",
|
transition: "all 0.15s ease",
|
||||||
borderRadius: "0 3px 3px 0",
|
borderRadius: "0 3px 3px 0",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -263,7 +263,7 @@ export function AuditDetailPanel({
|
|||||||
style={{
|
style={{
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
@@ -274,7 +274,7 @@ export function AuditDetailPanel({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
borderBottom: "1px solid var(--ctp-surface0)",
|
borderBottom: "1px solid var(--ctp-surface0)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -361,8 +361,8 @@ function FieldGroup({
|
|||||||
<div style={{ marginBottom: "0.75rem" }}>
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "0.3rem 1rem",
|
padding: "0.25rem 1rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
@@ -424,7 +424,7 @@ function FieldRow({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "0.3rem 1rem",
|
padding: "0.25rem 1rem",
|
||||||
borderLeft: `3px solid ${borderColor}`,
|
borderLeft: `3px solid ${borderColor}`,
|
||||||
marginLeft: "0.5rem",
|
marginLeft: "0.5rem",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
@@ -434,7 +434,7 @@ function FieldRow({
|
|||||||
style={{
|
style={{
|
||||||
width: 140,
|
width: 140,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontSize: "0.78rem",
|
fontSize: "var(--font-table)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
}}
|
}}
|
||||||
title={`Weight: ${field.weight}`}
|
title={`Weight: ${field.weight}`}
|
||||||
@@ -445,7 +445,7 @@ function FieldRow({
|
|||||||
style={{
|
style={{
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
fontSize: "0.65rem",
|
fontSize: "var(--font-xs)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
*
|
*
|
||||||
@@ -456,7 +456,7 @@ function FieldRow({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
|
color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
|
||||||
fontStyle: field.filled ? "normal" : "italic",
|
fontStyle: field.filled ? "normal" : "italic",
|
||||||
}}
|
}}
|
||||||
@@ -477,10 +477,10 @@ function FieldRow({
|
|||||||
placeholder="---"
|
placeholder="---"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: "0.2rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-surface0)",
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
@@ -492,10 +492,10 @@ function FieldRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closeBtnStyle: React.CSSProperties = {
|
const closeBtnStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export function AuditSummaryBar({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
transition: "opacity 0.2s",
|
transition: "all 0.15s ease",
|
||||||
outline: isActive ? "2px solid var(--ctp-text)" : "none",
|
outline: isActive ? "2px solid var(--ctp-text)" : "none",
|
||||||
outlineOffset: -2,
|
outlineOffset: -2,
|
||||||
}}
|
}}
|
||||||
@@ -71,16 +71,12 @@ export function AuditSummaryBar({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "1.5rem",
|
gap: "1.5rem",
|
||||||
marginTop: "0.4rem",
|
marginTop: "0.4rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>{summary.total_items} items</span>
|
||||||
{summary.total_items} items
|
<span>Avg score: {(summary.avg_score * 100).toFixed(1)}%</span>
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Avg score: {(summary.avg_score * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
{summary.manufactured_without_bom > 0 && (
|
{summary.manufactured_without_bom > 0 && (
|
||||||
<span style={{ color: "var(--ctp-red)" }}>
|
<span style={{ color: "var(--ctp-red)" }}>
|
||||||
{summary.manufactured_without_bom} manufactured without BOM
|
{summary.manufactured_without_bom} manufactured without BOM
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function AuditTable({
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -85,9 +85,9 @@ export function AuditTable({
|
|||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? "var(--ctp-surface1)"
|
? "rgba(203, 166, 247, 0.08)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
transition: "background-color 0.15s",
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isSelected)
|
if (!isSelected)
|
||||||
@@ -154,7 +154,7 @@ const thStyle: React.CSSProperties = {
|
|||||||
padding: "var(--d-th-py) var(--d-th-px)",
|
padding: "var(--d-th-py) var(--d-th-px)",
|
||||||
fontSize: "var(--d-th-font)",
|
fontSize: "var(--d-th-font)",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Download } from "lucide-react";
|
||||||
import { get, post, put, del } from "../../api/client";
|
import { get, post, put, del } from "../../api/client";
|
||||||
import type { BOMEntry } from "../../api/types";
|
import type { BOMEntry } from "../../api/types";
|
||||||
|
|
||||||
@@ -117,11 +118,11 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
backgroundColor: "var(--ctp-base)",
|
backgroundColor: "var(--ctp-base)",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
};
|
};
|
||||||
@@ -225,7 +226,9 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
|
<span
|
||||||
|
style={{ fontSize: "var(--font-body)", color: "var(--ctp-subtext1)" }}
|
||||||
|
>
|
||||||
{entries.length} entries
|
{entries.length} entries
|
||||||
</span>
|
</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
@@ -233,9 +236,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
|
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
|
||||||
}}
|
}}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Export CSV
|
<Download size={14} /> Export CSV
|
||||||
</button>
|
</button>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button
|
<button
|
||||||
@@ -244,9 +252,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
setEditIdx(null);
|
setEditIdx(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
}}
|
}}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+ Add
|
<Plus size={14} /> Add
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,9 +267,9 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
{isEditor && assemblyCount > 0 && (
|
{isEditor && assemblyCount > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "0.35rem 0.6rem",
|
padding: "0.35rem 0.5rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "rgba(148,226,213,0.1)",
|
backgroundColor: "rgba(148,226,213,0.1)",
|
||||||
border: "1px solid rgba(148,226,213,0.3)",
|
border: "1px solid rgba(148,226,213,0.3)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
@@ -274,7 +287,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
borderCollapse: "collapse",
|
borderCollapse: "collapse",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -403,12 +416,12 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
padding: "0.3rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -422,9 +435,10 @@ const tdStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
const toolBtnStyle: React.CSSProperties = {
|
const toolBtnStyle: React.CSSProperties = {
|
||||||
padding: "0.25rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -436,14 +450,17 @@ const actionBtnStyle: React.CSSProperties = {
|
|||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
padding: "0.1rem 0.3rem",
|
fontWeight: 500,
|
||||||
|
padding: "0.15rem 0.25rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBtnStyle: React.CSSProperties = {
|
const saveBtnStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-green)",
|
backgroundColor: "var(--ctp-green)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -451,9 +468,9 @@ const saveBtnStyle: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sourceBadgeBase: React.CSSProperties = {
|
const sourceBadgeBase: React.CSSProperties = {
|
||||||
padding: "0.1rem 0.4rem",
|
padding: "0.15rem 0.4rem",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -470,10 +487,11 @@ const manualBadge: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelBtnStyle: React.CSSProperties = {
|
const cancelBtnStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ export function CategoryPicker({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
backgroundColor: isActive
|
backgroundColor: isActive
|
||||||
? "rgba(203,166,247,0.2)"
|
? "rgba(203,166,247,0.2)"
|
||||||
@@ -107,7 +107,7 @@ export function CategoryPicker({
|
|||||||
color: isActive
|
color: isActive
|
||||||
? "var(--ctp-mauve)"
|
? "var(--ctp-mauve)"
|
||||||
: "var(--ctp-subtext0)",
|
: "var(--ctp-subtext0)",
|
||||||
transition: "background-color 0.1s",
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||||
@@ -134,7 +134,7 @@ export function CategoryPicker({
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.4rem 0.5rem",
|
padding: "0.4rem 0.5rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
backgroundColor: "var(--ctp-mantle)",
|
backgroundColor: "var(--ctp-mantle)",
|
||||||
@@ -152,7 +152,7 @@ export function CategoryPicker({
|
|||||||
padding: "0.75rem",
|
padding: "0.75rem",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select a domain to see categories
|
Select a domain to see categories
|
||||||
@@ -163,7 +163,7 @@ export function CategoryPicker({
|
|||||||
padding: "0.75rem",
|
padding: "0.75rem",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No categories found
|
No categories found
|
||||||
@@ -180,15 +180,15 @@ export function CategoryPicker({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
padding: "0.3rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? "rgba(203,166,247,0.12)"
|
? "rgba(203,166,247,0.12)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
|
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
|
||||||
fontWeight: isSelected ? 600 : 400,
|
fontWeight: isSelected ? 600 : 400,
|
||||||
transition: "background-color 0.1s",
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isSelected)
|
if (!isSelected)
|
||||||
@@ -228,7 +228,7 @@ export function CategoryPicker({
|
|||||||
{value && categories[value] && (
|
{value && categories[value] && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "0.3rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
borderTop: "1px solid var(--ctp-surface0)",
|
borderTop: "1px solid var(--ctp-surface0)",
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-green)",
|
color: "var(--ctp-green)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
New Item
|
New Item
|
||||||
@@ -400,13 +400,19 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
|||||||
/>
|
/>
|
||||||
) : thumbnailFile?.uploadStatus === "uploading" ? (
|
) : thumbnailFile?.uploadStatus === "uploading" ? (
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
|
style={{
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
color: "var(--ctp-subtext0)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Uploading... {thumbnailFile.uploadProgress}%
|
Uploading... {thumbnailFile.uploadProgress}%
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
|
style={{
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
color: "var(--ctp-subtext0)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Click to upload
|
Click to upload
|
||||||
</span>
|
</span>
|
||||||
@@ -453,6 +459,7 @@ function renderField(
|
|||||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||||
<FormGroup label={field.label}>
|
<FormGroup label={field.label}>
|
||||||
<textarea
|
<textarea
|
||||||
|
className="silo-input"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||||
@@ -467,6 +474,7 @@ function renderField(
|
|||||||
return (
|
return (
|
||||||
<FormGroup key={field.name} label={field.label}>
|
<FormGroup key={field.name} label={field.label}>
|
||||||
<select
|
<select
|
||||||
|
className="silo-input"
|
||||||
value={value || (field.default != null ? String(field.default) : "")}
|
value={value || (field.default != null ? String(field.default) : "")}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -486,6 +494,7 @@ function renderField(
|
|||||||
return (
|
return (
|
||||||
<FormGroup key={field.name} label={field.label}>
|
<FormGroup key={field.name} label={field.label}>
|
||||||
<select
|
<select
|
||||||
|
className="silo-input"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -505,6 +514,7 @@ function renderField(
|
|||||||
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
label={`${field.label}${field.currency ? ` (${field.currency})` : ""}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={value}
|
value={value}
|
||||||
@@ -521,6 +531,7 @@ function renderField(
|
|||||||
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
<div key={field.name} style={{ gridColumn: "1 / -1" }}>
|
||||||
<FormGroup label={field.label}>
|
<FormGroup label={field.label}>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="url"
|
type="url"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
@@ -541,6 +552,7 @@ function renderField(
|
|||||||
return (
|
return (
|
||||||
<FormGroup key={field.name} label={field.label}>
|
<FormGroup key={field.name} label={field.label}>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type={inputType}
|
type={inputType}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
@@ -565,7 +577,7 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
@@ -602,7 +614,7 @@ function SidebarSection({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
@@ -623,7 +635,7 @@ function MetaRow({ label, value }: { label: string; value: string }) {
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
padding: "0.15rem 0",
|
padding: "0.15rem 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -641,13 +653,13 @@ function FormGroup({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: "0.6rem" }}>
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
marginBottom: "0.2rem",
|
marginBottom: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -670,10 +682,11 @@ const headerStyle: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const actionBtnStyle: React.CSSProperties = {
|
const actionBtnStyle: React.CSSProperties = {
|
||||||
padding: "0.3rem 0.75rem",
|
padding: "0.25rem 0.75rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
@@ -683,17 +696,19 @@ const cancelBtnStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
padding: "0.2rem 0.4rem",
|
fontWeight: 500,
|
||||||
|
padding: "0.25rem 0.4rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.35rem 0.5rem",
|
padding: "0.35rem 0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
backgroundColor: "var(--ctp-base)",
|
backgroundColor: "var(--ctp-base)",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
@@ -708,7 +723,7 @@ const errorStyle: React.CSSProperties = {
|
|||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
backgroundColor: "rgba(243,139,168,0.1)",
|
backgroundColor: "rgba(243,139,168,0.1)",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { del } from '../../api/client';
|
import { del } from "../../api/client";
|
||||||
|
|
||||||
interface DeleteItemPaneProps {
|
interface DeleteItemPaneProps {
|
||||||
partNumber: string;
|
partNumber: string;
|
||||||
@@ -7,7 +7,11 @@ interface DeleteItemPaneProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPaneProps) {
|
export function DeleteItemPane({
|
||||||
|
partNumber,
|
||||||
|
onDeleted,
|
||||||
|
onCancel,
|
||||||
|
}: DeleteItemPaneProps) {
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -18,59 +22,133 @@ export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPa
|
|||||||
await del(`/api/items/${encodeURIComponent(partNumber)}`);
|
await del(`/api/items/${encodeURIComponent(partNumber)}`);
|
||||||
onDeleted();
|
onDeleted();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to delete item');
|
setError(e instanceof Error ? e.message : "Failed to delete item");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
style={{
|
||||||
padding: '0.5rem 0.75rem',
|
display: "flex",
|
||||||
borderBottom: '1px solid var(--ctp-surface1)',
|
alignItems: "center",
|
||||||
backgroundColor: 'var(--ctp-mantle)',
|
gap: "0.75rem",
|
||||||
flexShrink: 0,
|
padding: "0.5rem 0.75rem",
|
||||||
}}>
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
<span style={{ color: 'var(--ctp-red)', fontWeight: 600, fontSize: '0.9rem' }}>Delete Item</span>
|
backgroundColor: "var(--ctp-mantle)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "var(--font-body)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Item
|
||||||
|
</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
<button onClick={onCancel} style={headerBtnStyle}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '1rem' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
gap: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem 1rem', borderRadius: '0.3rem', fontSize: '0.85rem', width: '100%', textAlign: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
backgroundColor: "rgba(243,139,168,0.1)",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
fontSize: "var(--font-body)",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<p style={{ fontSize: '0.9rem', color: 'var(--ctp-text)', marginBottom: '0.5rem' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--font-body)",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Permanently delete item
|
Permanently delete item
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)', fontSize: '1.1rem', fontWeight: 600 }}>
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
color: "var(--ctp-peach)",
|
||||||
|
fontSize: "var(--font-title)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{partNumber}
|
{partNumber}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ color: 'var(--ctp-subtext0)', fontSize: '0.85rem', textAlign: 'center', maxWidth: 300 }}>
|
<p
|
||||||
This will permanently remove this item, all its revisions, BOM entries, and file attachments. This action cannot be undone.
|
style={{
|
||||||
|
color: "var(--ctp-subtext0)",
|
||||||
|
fontSize: "var(--font-body)",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This will permanently remove this item, all its revisions, BOM
|
||||||
|
entries, and file attachments. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
<div style={{ display: "flex", gap: "0.75rem", marginTop: "0.5rem" }}>
|
||||||
<button onClick={onCancel} style={{
|
<button
|
||||||
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
|
onClick={onCancel}
|
||||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
|
style={{
|
||||||
}}>
|
padding: "0.5rem 1.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => void handleDelete()} disabled={deleting} style={{
|
<button
|
||||||
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
|
onClick={() => void handleDelete()}
|
||||||
backgroundColor: 'var(--ctp-red)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
disabled={deleting}
|
||||||
opacity: deleting ? 0.6 : 1,
|
style={{
|
||||||
}}>
|
padding: "0.5rem 1.25rem",
|
||||||
{deleting ? 'Deleting...' : 'Delete Permanently'}
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "var(--ctp-red)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: deleting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : "Delete Permanently"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,6 +157,12 @@ export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headerBtnStyle: React.CSSProperties = {
|
const headerBtnStyle: React.CSSProperties = {
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
background: "none",
|
||||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "0.25rem 0.4rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function EditItemPane({
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-blue)",
|
color: "var(--ctp-blue)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit {partNumber}
|
Edit {partNumber}
|
||||||
@@ -89,10 +89,11 @@ export function EditItemPane({
|
|||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.3rem 0.75rem",
|
padding: "0.25rem 0.75rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-blue)",
|
backgroundColor: "var(--ctp-blue)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -113,9 +114,9 @@ export function EditItemPane({
|
|||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
backgroundColor: "rgba(243,139,168,0.1)",
|
backgroundColor: "rgba(243,139,168,0.1)",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
@@ -124,6 +125,7 @@ export function EditItemPane({
|
|||||||
|
|
||||||
<FormGroup label="Part Number">
|
<FormGroup label="Part Number">
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
value={pn}
|
value={pn}
|
||||||
onChange={(e) => setPN(e.target.value)}
|
onChange={(e) => setPN(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -132,6 +134,7 @@ export function EditItemPane({
|
|||||||
|
|
||||||
<FormGroup label="Type">
|
<FormGroup label="Type">
|
||||||
<select
|
<select
|
||||||
|
className="silo-input"
|
||||||
value={itemType}
|
value={itemType}
|
||||||
onChange={(e) => setItemType(e.target.value)}
|
onChange={(e) => setItemType(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -145,6 +148,7 @@ export function EditItemPane({
|
|||||||
|
|
||||||
<FormGroup label="Description">
|
<FormGroup label="Description">
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -153,6 +157,7 @@ export function EditItemPane({
|
|||||||
|
|
||||||
<FormGroup label="Sourcing Type">
|
<FormGroup label="Sourcing Type">
|
||||||
<select
|
<select
|
||||||
|
className="silo-input"
|
||||||
value={sourcingType}
|
value={sourcingType}
|
||||||
onChange={(e) => setSourcingType(e.target.value)}
|
onChange={(e) => setSourcingType(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@@ -166,6 +171,7 @@ export function EditItemPane({
|
|||||||
|
|
||||||
<FormGroup label="Long Description">
|
<FormGroup label="Long Description">
|
||||||
<textarea
|
<textarea
|
||||||
|
className="silo-input"
|
||||||
value={longDescription}
|
value={longDescription}
|
||||||
onChange={(e) => setLongDescription(e.target.value)}
|
onChange={(e) => setLongDescription(e.target.value)}
|
||||||
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
|
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
|
||||||
@@ -184,13 +190,13 @@ function FormGroup({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: "0.6rem" }}>
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
marginBottom: "0.2rem",
|
marginBottom: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -203,10 +209,10 @@ function FormGroup({
|
|||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.35rem 0.5rem",
|
padding: "0.35rem 0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
backgroundColor: "var(--ctp-base)",
|
backgroundColor: "var(--ctp-base)",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,6 +221,8 @@ const headerBtnStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
padding: "0.2rem 0.4rem",
|
fontWeight: 500,
|
||||||
|
padding: "0.25rem 0.4rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ export function FileDropZone({
|
|||||||
padding: "1.25rem",
|
padding: "1.25rem",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
backgroundColor: dragOver
|
backgroundColor: dragOver ? "rgba(203,166,247,0.05)" : "transparent",
|
||||||
? "rgba(203,166,247,0.05)"
|
transition: "all 0.15s ease",
|
||||||
: "transparent",
|
|
||||||
transition: "border-color 0.15s, background-color 0.15s",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
|
<div
|
||||||
|
style={{ fontSize: "var(--font-body)", color: "var(--ctp-subtext1)" }}
|
||||||
|
>
|
||||||
Drop files here or{" "}
|
Drop files here or{" "}
|
||||||
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
|
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
|
||||||
browse
|
browse
|
||||||
@@ -87,7 +87,7 @@ export function FileDropZone({
|
|||||||
{accept && (
|
{accept && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
color: "var(--ctp-overlay0)",
|
color: "var(--ctp-overlay0)",
|
||||||
marginTop: "0.25rem",
|
marginTop: "0.25rem",
|
||||||
}}
|
}}
|
||||||
@@ -113,7 +113,11 @@ export function FileDropZone({
|
|||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div style={{ marginTop: "0.5rem" }}>
|
<div style={{ marginTop: "0.5rem" }}>
|
||||||
{files.map((att, i) => (
|
{files.map((att, i) => (
|
||||||
<FileRow key={i} attachment={att} onRemove={() => onFileRemoved(i)} />
|
<FileRow
|
||||||
|
key={i}
|
||||||
|
attachment={att}
|
||||||
|
onRemove={() => onFileRemoved(i)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -139,8 +143,8 @@ function FileRow({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
padding: "0.3rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -149,13 +153,13 @@ function FileRow({
|
|||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: "0.6rem",
|
fontSize: "var(--font-xs)",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -168,7 +172,7 @@ function FileRow({
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
@@ -177,7 +181,9 @@ function FileRow({
|
|||||||
>
|
>
|
||||||
{attachment.file.name}
|
{attachment.file.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "0.7rem", color: "var(--ctp-overlay0)" }}>
|
<div
|
||||||
|
style={{ fontSize: "var(--font-sm)", color: "var(--ctp-overlay0)" }}
|
||||||
|
>
|
||||||
{formatSize(attachment.file.size)}
|
{formatSize(attachment.file.size)}
|
||||||
{attachment.uploadStatus === "error" && (
|
{attachment.uploadStatus === "error" && (
|
||||||
<span style={{ color: "var(--ctp-red)", marginLeft: "0.5rem" }}>
|
<span style={{ color: "var(--ctp-red)", marginLeft: "0.5rem" }}>
|
||||||
@@ -202,7 +208,7 @@ function FileRow({
|
|||||||
width: `${attachment.uploadProgress}%`,
|
width: `${attachment.uploadProgress}%`,
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
transition: "width 0.15s",
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +219,7 @@ function FileRow({
|
|||||||
{attachment.uploadStatus === "complete" ? (
|
{attachment.uploadStatus === "complete" ? (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
color: "var(--ctp-green)",
|
color: "var(--ctp-green)",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
@@ -231,11 +237,11 @@ function FileRow({
|
|||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
|
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
|
||||||
padding: "0 0.2rem",
|
padding: "0 0.2rem",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transition: "color 0.15s",
|
transition: "all 0.15s ease",
|
||||||
}}
|
}}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ImportItemsPane({
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-yellow)",
|
color: "var(--ctp-yellow)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Import Items (CSV)
|
Import Items (CSV)
|
||||||
@@ -90,9 +90,9 @@ export function ImportItemsPane({
|
|||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
backgroundColor: "rgba(243,139,168,0.1)",
|
backgroundColor: "rgba(243,139,168,0.1)",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
@@ -102,7 +102,7 @@ export function ImportItemsPane({
|
|||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.75rem",
|
||||||
}}
|
}}
|
||||||
@@ -120,7 +120,10 @@ export function ImportItemsPane({
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/api/items/template.csv"
|
href="/api/items/template.csv"
|
||||||
style={{ color: "var(--ctp-sapphire)", fontSize: "0.8rem" }}
|
style={{
|
||||||
|
color: "var(--ctp-sapphire)",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Download CSV template
|
Download CSV template
|
||||||
</a>
|
</a>
|
||||||
@@ -149,7 +152,7 @@ export function ImportItemsPane({
|
|||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file ? file.name : "Choose CSV file..."}
|
{file ? file.name : "Choose CSV file..."}
|
||||||
@@ -162,7 +165,7 @@ export function ImportItemsPane({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.4rem",
|
gap: "0.4rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.75rem",
|
||||||
}}
|
}}
|
||||||
@@ -185,9 +188,10 @@ export function ImportItemsPane({
|
|||||||
disabled={!file || importing}
|
disabled={!file || importing}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-yellow)",
|
backgroundColor: "var(--ctp-yellow)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -202,9 +206,10 @@ export function ImportItemsPane({
|
|||||||
disabled={importing || (result?.error_count ?? 0) > 0}
|
disabled={importing || (result?.error_count ?? 0) > 0}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
backgroundColor: "var(--ctp-green)",
|
backgroundColor: "var(--ctp-green)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -223,7 +228,7 @@ export function ImportItemsPane({
|
|||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
backgroundColor: "var(--ctp-surface0)",
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
@@ -257,7 +262,7 @@ export function ImportItemsPane({
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
padding: "0.1rem 0",
|
padding: "0.15rem 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Row {err.row}
|
Row {err.row}
|
||||||
@@ -289,6 +294,8 @@ const headerBtnStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
padding: "0.2rem 0.4rem",
|
fontWeight: 500,
|
||||||
|
padding: "0.25rem 0.4rem",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { get } from "../../api/client";
|
import { get } from "../../api/client";
|
||||||
import type { Item } from "../../api/types";
|
import type { Item } from "../../api/types";
|
||||||
import { MainTab } from "./MainTab";
|
import { MainTab } from "./MainTab";
|
||||||
@@ -64,9 +65,11 @@ export function ItemDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, { bg: string; color: string }> = {
|
const typeColors: Record<string, { bg: string; color: string }> = {
|
||||||
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
part: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
||||||
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
assembly: { bg: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
|
||||||
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
|
document: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
||||||
|
purchased: { bg: "rgba(250,179,135,0.2)", color: "var(--ctp-peach)" },
|
||||||
|
phantom: { bg: "rgba(127,132,156,0.2)", color: "var(--ctp-overlay1)" },
|
||||||
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
||||||
};
|
};
|
||||||
const tc = typeColors[item.item_type] ?? {
|
const tc = typeColors[item.item_type] ?? {
|
||||||
@@ -93,16 +96,16 @@ export function ItemDetail({
|
|||||||
fontFamily: "'JetBrains Mono', monospace",
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
color: "var(--ctp-peach)",
|
color: "var(--ctp-peach)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.part_number}
|
{item.part_number}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: "0.1rem 0.5rem",
|
padding: "0.15rem 0.5rem",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
backgroundColor: tc.bg,
|
backgroundColor: tc.bg,
|
||||||
color: tc.color,
|
color: tc.color,
|
||||||
@@ -129,9 +132,13 @@ export function ItemDetail({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{ ...headerBtnStyle, fontSize: "1rem" }}
|
style={{
|
||||||
|
...headerBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +158,7 @@ export function ItemDetail({
|
|||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderBottom:
|
borderBottom:
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
@@ -197,6 +204,6 @@ const headerBtnStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
padding: "0.2rem 0.4rem",
|
padding: "0.25rem 0.4rem",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import type { Item } from "../../api/types";
|
import type { Item } from "../../api/types";
|
||||||
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
||||||
|
|
||||||
@@ -49,9 +50,11 @@ interface ItemTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, { bg: string; color: string }> = {
|
const typeColors: Record<string, { bg: string; color: string }> = {
|
||||||
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
part: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
||||||
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
assembly: { bg: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
|
||||||
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
|
document: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
||||||
|
purchased: { bg: "rgba(250,179,135,0.2)", color: "var(--ctp-peach)" },
|
||||||
|
phantom: { bg: "rgba(127,132,156,0.2)", color: "var(--ctp-overlay1)" },
|
||||||
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@ export function ItemTable({
|
|||||||
padding: "var(--d-th-py) var(--d-th-px)",
|
padding: "var(--d-th-py) var(--d-th-px)",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "var(--d-th-font)",
|
fontSize: "var(--d-th-font)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
@@ -189,8 +192,18 @@ export function ItemTable({
|
|||||||
>
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
{sortKey === col.key && (
|
{sortKey === col.key && (
|
||||||
<span style={{ marginLeft: 4 }}>
|
<span
|
||||||
{sortDir === "asc" ? "▲" : "▼"}
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
display: "inline-flex",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortDir === "asc" ? (
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
@@ -201,7 +214,7 @@ export function ItemTable({
|
|||||||
{sortedItems.map((item, idx) => {
|
{sortedItems.map((item, idx) => {
|
||||||
const isSelected = item.part_number === selectedPN;
|
const isSelected = item.part_number === selectedPN;
|
||||||
const rowBg = isSelected
|
const rowBg = isSelected
|
||||||
? "var(--ctp-surface1)"
|
? "rgba(203, 166, 247, 0.08)"
|
||||||
: idx % 2 === 0
|
: idx % 2 === 0
|
||||||
? "var(--ctp-base)"
|
? "var(--ctp-base)"
|
||||||
: "var(--ctp-surface0)";
|
: "var(--ctp-surface0)";
|
||||||
@@ -255,7 +268,7 @@ export function ItemTable({
|
|||||||
<td key={col.key} style={tdStyle}>
|
<td key={col.key} style={tdStyle}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: "0.1rem 0.5rem",
|
padding: "0.15rem 0.5rem",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
@@ -383,7 +396,8 @@ const actionBtnStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
padding: "0.15rem 0.4rem",
|
padding: "0.15rem 0.4rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Columns2, Rows2, Plus, Download, Upload } from "lucide-react";
|
||||||
import { get } from "../../api/client";
|
import { get } from "../../api/client";
|
||||||
import type { Project } from "../../api/types";
|
import type { Project } from "../../api/types";
|
||||||
import type { ItemFilters } from "../../hooks/useItems";
|
import type { ItemFilters } from "../../hooks/useItems";
|
||||||
@@ -37,9 +38,10 @@ export function ItemsToolbar({
|
|||||||
onClick={() => onFilterChange({ searchScope: scope })}
|
onClick={() => onFilterChange({ searchScope: scope })}
|
||||||
style={{
|
style={{
|
||||||
padding: "var(--d-input-py) var(--d-input-px)",
|
padding: "var(--d-input-py) var(--d-input-px)",
|
||||||
fontSize: "var(--d-input-font)",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
filters.searchScope === scope
|
filters.searchScope === scope
|
||||||
@@ -126,20 +128,42 @@ export function ItemsToolbar({
|
|||||||
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
|
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
|
||||||
}
|
}
|
||||||
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{layout === "horizontal" ? "⬌" : "⬍"}
|
{layout === "horizontal" ? <Columns2 size={14} /> : <Rows2 size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
|
<button
|
||||||
Export
|
onClick={onExport}
|
||||||
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
title="Export CSV"
|
||||||
|
>
|
||||||
|
<Download size={14} /> Export
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Import (editor only) */}
|
{/* Import (editor only) */}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
|
<button
|
||||||
Import
|
onClick={onImport}
|
||||||
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
title="Import CSV"
|
||||||
|
>
|
||||||
|
<Upload size={14} /> Import
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -151,9 +175,12 @@ export function ItemsToolbar({
|
|||||||
...toolBtnStyle,
|
...toolBtnStyle,
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ New
|
<Plus size={14} /> New
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +200,9 @@ const toolBtnStyle: React.CSSProperties = {
|
|||||||
padding: "var(--d-input-py) var(--d-input-px)",
|
padding: "var(--d-input-py) var(--d-input-px)",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "var(--d-input-font)",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { get, post, del } from "../../api/client";
|
import { get, post, del } from "../../api/client";
|
||||||
import type { Item, Project, Revision } from "../../api/types";
|
import type { Item, Project, Revision } from "../../api/types";
|
||||||
|
|
||||||
@@ -83,8 +84,8 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "1rem",
|
gap: "1rem",
|
||||||
padding: "0.3rem 0",
|
padding: "0.25rem 0",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ width: 120, flexShrink: 0, color: "var(--ctp-subtext0)" }}>
|
<span style={{ width: 120, flexShrink: 0, color: "var(--ctp-subtext0)" }}>
|
||||||
@@ -134,7 +135,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
backgroundColor: "var(--ctp-surface0)",
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -176,7 +177,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
padding: "0.1rem 0.5rem",
|
padding: "0.15rem 0.5rem",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
backgroundColor: "rgba(203,166,247,0.15)",
|
backgroundColor: "rgba(203,166,247,0.15)",
|
||||||
color: "var(--ctp-mauve)",
|
color: "var(--ctp-mauve)",
|
||||||
@@ -192,11 +193,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
border: "none",
|
border: "none",
|
||||||
color: "var(--ctp-overlay0)",
|
color: "var(--ctp-overlay0)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
display: "inline-flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -207,11 +208,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
value={addProject}
|
value={addProject}
|
||||||
onChange={(e) => setAddProject(e.target.value)}
|
onChange={(e) => setAddProject(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.1rem 0.3rem",
|
padding: "0.15rem 0.25rem",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
backgroundColor: "var(--ctp-surface0)",
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -228,12 +229,12 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void handleAddProject()}
|
onClick={() => void handleAddProject()}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.1rem 0.4rem",
|
padding: "0.15rem 0.4rem",
|
||||||
fontSize: "0.7rem",
|
fontSize: "var(--font-sm)",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -269,7 +270,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{latestRev.file_size != null && (
|
{latestRev.file_size != null && (
|
||||||
@@ -292,12 +293,12 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`;
|
window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`;
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { post } from '../../api/client';
|
import { X, Plus } from "lucide-react";
|
||||||
import type { Item } from '../../api/types';
|
import { post } from "../../api/client";
|
||||||
|
import type { Item } from "../../api/types";
|
||||||
|
|
||||||
interface PropertiesTabProps {
|
interface PropertiesTabProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -8,24 +9,24 @@ interface PropertiesTabProps {
|
|||||||
isEditor: boolean;
|
isEditor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'form' | 'json';
|
type Mode = "form" | "json";
|
||||||
|
|
||||||
interface PropRow {
|
interface PropRow {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
type: 'string' | 'number' | 'boolean';
|
type: "string" | "number" | "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectType(v: unknown): PropRow['type'] {
|
function detectType(v: unknown): PropRow["type"] {
|
||||||
if (typeof v === 'number') return 'number';
|
if (typeof v === "number") return "number";
|
||||||
if (typeof v === 'boolean') return 'boolean';
|
if (typeof v === "boolean") return "boolean";
|
||||||
return 'string';
|
return "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRows(props: Record<string, unknown>): PropRow[] {
|
function toRows(props: Record<string, unknown>): PropRow[] {
|
||||||
return Object.entries(props).map(([key, value]) => ({
|
return Object.entries(props).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
value: String(value ?? ''),
|
value: String(value ?? ""),
|
||||||
type: detectType(value),
|
type: detectType(value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -35,17 +36,26 @@ function fromRows(rows: PropRow[]): Record<string, unknown> {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.key.trim()) continue;
|
if (!row.key.trim()) continue;
|
||||||
switch (row.type) {
|
switch (row.type) {
|
||||||
case 'number': obj[row.key] = Number(row.value) || 0; break;
|
case "number":
|
||||||
case 'boolean': obj[row.key] = row.value === 'true'; break;
|
obj[row.key] = Number(row.value) || 0;
|
||||||
default: obj[row.key] = row.value;
|
break;
|
||||||
|
case "boolean":
|
||||||
|
obj[row.key] = row.value === "true";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
obj[row.key] = row.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) {
|
export function PropertiesTab({
|
||||||
|
item,
|
||||||
|
onReload,
|
||||||
|
isEditor,
|
||||||
|
}: PropertiesTabProps) {
|
||||||
const props = item.properties ?? {};
|
const props = item.properties ?? {};
|
||||||
const [mode, setMode] = useState<Mode>('form');
|
const [mode, setMode] = useState<Mode>("form");
|
||||||
const [rows, setRows] = useState<PropRow[]>(toRows(props));
|
const [rows, setRows] = useState<PropRow[]>(toRows(props));
|
||||||
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
|
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
@@ -62,18 +72,20 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
setRows(toRows(parsed));
|
setRows(toRows(parsed));
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
|
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchMode = (m: Mode) => {
|
const switchMode = (m: Mode) => {
|
||||||
if (m === 'json') syncFormToJson();
|
if (m === "json") syncFormToJson();
|
||||||
else syncJsonToForm();
|
else syncJsonToForm();
|
||||||
setMode(m);
|
setMode(m);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRow = (idx: number, field: keyof PropRow, value: string) => {
|
const updateRow = (idx: number, field: keyof PropRow, value: string) => {
|
||||||
setRows((prev) => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r));
|
setRows((prev) =>
|
||||||
|
prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRow = (idx: number) => {
|
const removeRow = (idx: number) => {
|
||||||
@@ -81,72 +93,112 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
setRows((prev) => [...prev, { key: '', value: '', type: 'string' }]);
|
setRows((prev) => [...prev, { key: "", value: "", type: "string" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
let properties: Record<string, unknown>;
|
let properties: Record<string, unknown>;
|
||||||
if (mode === 'json') {
|
if (mode === "json") {
|
||||||
try {
|
try {
|
||||||
properties = JSON.parse(jsonText) as Record<string, unknown>;
|
properties = JSON.parse(jsonText) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
setJsonError('Invalid JSON');
|
setJsonError("Invalid JSON");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
properties = fromRows(rows);
|
properties = fromRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = prompt('Revision comment (optional):') ?? '';
|
const comment = prompt("Revision comment (optional):") ?? "";
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(item.part_number)}/revisions`, { properties, comment });
|
await post(
|
||||||
|
`/api/items/${encodeURIComponent(item.part_number)}/revisions`,
|
||||||
|
{ properties, comment },
|
||||||
|
);
|
||||||
onReload();
|
onReload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Failed to save properties');
|
alert(e instanceof Error ? e.message : "Failed to save properties");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
|
padding: "0.25rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
fontSize: "var(--font-table)",
|
||||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
backgroundColor: "var(--ctp-base)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem', alignItems: 'center' }}>
|
<div
|
||||||
<button onClick={() => switchMode('form')} style={mode === 'form' ? activeTabBtn : tabBtn}>Form</button>
|
style={{
|
||||||
<button onClick={() => switchMode('json')} style={mode === 'json' ? activeTabBtn : tabBtn}>JSON</button>
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => switchMode("form")}
|
||||||
|
style={mode === "form" ? activeTabBtn : tabBtn}
|
||||||
|
>
|
||||||
|
Form
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchMode("json")}
|
||||||
|
style={mode === "json" ? activeTabBtn : tabBtn}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={() => void handleSave()} disabled={saving} style={{
|
<button
|
||||||
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
onClick={() => void handleSave()}
|
||||||
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
disabled={saving}
|
||||||
opacity: saving ? 0.6 : 1,
|
style={{
|
||||||
}}>
|
padding: "0.25rem 0.75rem",
|
||||||
{saving ? 'Saving...' : 'Save (New Revision)'}
|
fontSize: "var(--font-table)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save (New Revision)"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'form' ? (
|
{mode === "form" ? (
|
||||||
<div>
|
<div>
|
||||||
{rows.map((row, idx) => (
|
{rows.map((row, idx) => (
|
||||||
<div key={idx} style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.25rem', alignItems: 'center' }}>
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={row.key}
|
value={row.key}
|
||||||
onChange={(e) => updateRow(idx, 'key', e.target.value)}
|
onChange={(e) => updateRow(idx, "key", e.target.value)}
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
style={{ ...inputStyle, width: 140 }}
|
style={{ ...inputStyle, width: 140 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={row.type}
|
value={row.type}
|
||||||
onChange={(e) => updateRow(idx, 'type', e.target.value)}
|
onChange={(e) => updateRow(idx, "type", e.target.value)}
|
||||||
style={{ ...inputStyle, width: 80 }}
|
style={{ ...inputStyle, width: 80 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
>
|
>
|
||||||
@@ -154,44 +206,90 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
<option value="number">num</option>
|
<option value="number">num</option>
|
||||||
<option value="boolean">bool</option>
|
<option value="boolean">bool</option>
|
||||||
</select>
|
</select>
|
||||||
{row.type === 'boolean' ? (
|
{row.type === "boolean" ? (
|
||||||
<select value={row.value} onChange={(e) => updateRow(idx, 'value', e.target.value)} style={{ ...inputStyle, flex: 1 }} disabled={!isEditor}>
|
<select
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(idx, "value", e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
disabled={!isEditor}
|
||||||
|
>
|
||||||
<option value="true">true</option>
|
<option value="true">true</option>
|
||||||
<option value="false">false</option>
|
<option value="false">false</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={row.type === 'number' ? 'number' : 'text'}
|
type={row.type === "number" ? "number" : "text"}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
onChange={(e) => updateRow(idx, 'value', e.target.value)}
|
onChange={(e) => updateRow(idx, "value", e.target.value)}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={() => removeRow(idx)} style={{ background: 'none', border: 'none', color: 'var(--ctp-red)', cursor: 'pointer', fontSize: '0.9rem' }}>×</button>
|
<button
|
||||||
|
onClick={() => removeRow(idx)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={addRow} style={{ ...tabBtn, marginTop: '0.25rem' }}>+ Add Property</button>
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
style={{
|
||||||
|
...tabBtn,
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add Property
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
value={jsonText}
|
value={jsonText}
|
||||||
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
|
onChange={(e) => {
|
||||||
|
setJsonText(e.target.value);
|
||||||
|
setJsonError(null);
|
||||||
|
}}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', minHeight: 200, padding: '0.5rem',
|
width: "100%",
|
||||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
|
minHeight: 200,
|
||||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
padding: "0.5rem",
|
||||||
borderRadius: '0.4rem', color: 'var(--ctp-text)', resize: 'vertical',
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
backgroundColor: "var(--ctp-base)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.4rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
resize: "vertical",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{jsonError && <div style={{ color: 'var(--ctp-red)', fontSize: '0.8rem', marginTop: '0.25rem' }}>{jsonError}</div>}
|
{jsonError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jsonError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,11 +297,17 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabBtn: React.CSSProperties = {
|
const tabBtn: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
|
fontSize: "var(--font-table)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabBtn: React.CSSProperties = {
|
const activeTabBtn: React.CSSProperties = {
|
||||||
...tabBtn,
|
...tabBtn,
|
||||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-mauve)',
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-mauve)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { get, post } from '../../api/client';
|
import { Download } from "lucide-react";
|
||||||
import type { Revision, RevisionComparison } from '../../api/types';
|
import { get, post } from "../../api/client";
|
||||||
|
import type { Revision, RevisionComparison } from "../../api/types";
|
||||||
|
|
||||||
interface RevisionsTabProps {
|
interface RevisionsTabProps {
|
||||||
partNumber: string;
|
partNumber: string;
|
||||||
@@ -8,28 +9,35 @@ interface RevisionsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: 'var(--ctp-overlay1)',
|
draft: "var(--ctp-overlay1)",
|
||||||
review: 'var(--ctp-yellow)',
|
review: "var(--ctp-yellow)",
|
||||||
released: 'var(--ctp-green)',
|
released: "var(--ctp-green)",
|
||||||
obsolete: 'var(--ctp-red)',
|
obsolete: "var(--ctp-red)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(s: string) {
|
function formatDate(s: string) {
|
||||||
if (!s) return '';
|
if (!s) return "";
|
||||||
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
return new Date(s).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
||||||
const [revisions, setRevisions] = useState<Revision[]>([]);
|
const [revisions, setRevisions] = useState<Revision[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [compareFrom, setCompareFrom] = useState('');
|
const [compareFrom, setCompareFrom] = useState("");
|
||||||
const [compareTo, setCompareTo] = useState('');
|
const [compareTo, setCompareTo] = useState("");
|
||||||
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
|
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
|
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
|
||||||
.then((r) => { setRevisions(r); setLoading(false); })
|
.then((r) => {
|
||||||
|
setRevisions(r);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,97 +47,177 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
if (!compareFrom || !compareTo) return;
|
if (!compareFrom || !compareTo) return;
|
||||||
try {
|
try {
|
||||||
const result = await get<RevisionComparison>(
|
const result = await get<RevisionComparison>(
|
||||||
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`,
|
||||||
);
|
);
|
||||||
setComparison(result);
|
setComparison(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Compare failed');
|
alert(e instanceof Error ? e.message : "Compare failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (rev: number, status: string) => {
|
const handleStatusChange = async (rev: number, status: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`, {
|
await fetch(
|
||||||
method: 'PATCH',
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{
|
||||||
credentials: 'include',
|
method: "PATCH",
|
||||||
body: JSON.stringify({ status }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
},
|
||||||
|
);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Status update failed');
|
alert(e instanceof Error ? e.message : "Status update failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRollback = async (rev: number) => {
|
const handleRollback = async (rev: number) => {
|
||||||
if (!confirm(`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`)) return;
|
if (
|
||||||
const comment = prompt('Rollback comment:') ?? `Rollback to rev ${rev}`;
|
!confirm(
|
||||||
|
`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const comment = prompt("Rollback comment:") ?? `Rollback to rev ${rev}`;
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`, { comment });
|
await post(
|
||||||
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
|
||||||
|
{ comment },
|
||||||
|
);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Rollback failed');
|
alert(e instanceof Error ? e.message : "Rollback failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading revisions...</div>;
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)" }}>Loading revisions...</div>
|
||||||
|
);
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
const selectStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
|
padding: "0.25rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
|
fontSize: "var(--font-table)",
|
||||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Compare controls */}
|
{/* Compare controls */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
|
<div
|
||||||
<select value={compareFrom} onChange={(e) => setCompareFrom(e.target.value)} style={selectStyle}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={compareFrom}
|
||||||
|
onChange={(e) => setCompareFrom(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
<option value="">From rev...</option>
|
<option value="">From rev...</option>
|
||||||
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
|
{revisions.map((r) => (
|
||||||
|
<option key={r.id} value={r.revision_number}>
|
||||||
|
Rev {r.revision_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={compareTo} onChange={(e) => setCompareTo(e.target.value)} style={selectStyle}>
|
<select
|
||||||
|
value={compareTo}
|
||||||
|
onChange={(e) => setCompareTo(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
<option value="">To rev...</option>
|
<option value="">To rev...</option>
|
||||||
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
|
{revisions.map((r) => (
|
||||||
|
<option key={r.id} value={r.revision_number}>
|
||||||
|
Rev {r.revision_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button onClick={() => void handleCompare()} disabled={!compareFrom || !compareTo} style={{
|
<button
|
||||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
onClick={() => void handleCompare()}
|
||||||
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
disabled={!compareFrom || !compareTo}
|
||||||
opacity: (!compareFrom || !compareTo) ? 0.5 : 1,
|
style={{
|
||||||
}}>
|
padding: "0.25rem 0.5rem",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: !compareFrom || !compareTo ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Compare
|
Compare
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compare results */}
|
{/* Compare results */}
|
||||||
{comparison && (
|
{comparison && (
|
||||||
<div style={{
|
<div
|
||||||
padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem',
|
style={{
|
||||||
fontSize: '0.8rem', marginBottom: '0.75rem', fontFamily: "'JetBrains Mono', monospace",
|
padding: "0.5rem",
|
||||||
}}>
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
borderRadius: "0.4rem",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{comparison.status_changed && (
|
{comparison.status_changed && (
|
||||||
<div>Status: <span style={{ color: 'var(--ctp-red)' }}>{comparison.status_changed.from}</span> → <span style={{ color: 'var(--ctp-green)' }}>{comparison.status_changed.to}</span></div>
|
<div>
|
||||||
|
Status:{" "}
|
||||||
|
<span style={{ color: "var(--ctp-red)" }}>
|
||||||
|
{comparison.status_changed.from}
|
||||||
|
</span>{" "}
|
||||||
|
→{" "}
|
||||||
|
<span style={{ color: "var(--ctp-green)" }}>
|
||||||
|
{comparison.status_changed.to}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comparison.file_changed && (
|
||||||
|
<div style={{ color: "var(--ctp-yellow)" }}>File changed</div>
|
||||||
)}
|
)}
|
||||||
{comparison.file_changed && <div style={{ color: 'var(--ctp-yellow)' }}>File changed</div>}
|
|
||||||
{Object.entries(comparison.added).map(([k, v]) => (
|
{Object.entries(comparison.added).map(([k, v]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-green)' }}>+ {k}: {String(v)}</div>
|
<div key={k} style={{ color: "var(--ctp-green)" }}>
|
||||||
|
+ {k}: {String(v)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.entries(comparison.removed).map(([k, v]) => (
|
{Object.entries(comparison.removed).map(([k, v]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-red)' }}>- {k}: {String(v)}</div>
|
<div key={k} style={{ color: "var(--ctp-red)" }}>
|
||||||
|
- {k}: {String(v)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.entries(comparison.changed).map(([k, c]) => (
|
{Object.entries(comparison.changed).map(([k, c]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-yellow)' }}>~ {k}: {String(c.from)} → {String(c.to)}</div>
|
<div key={k} style={{ color: "var(--ctp-yellow)" }}>
|
||||||
|
~ {k}: {String(c.from)} → {String(c.to)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{!comparison.status_changed && !comparison.file_changed &&
|
{!comparison.status_changed &&
|
||||||
Object.keys(comparison.added).length === 0 && Object.keys(comparison.removed).length === 0 &&
|
!comparison.file_changed &&
|
||||||
Object.keys(comparison.changed).length === 0 && (
|
Object.keys(comparison.added).length === 0 &&
|
||||||
<div style={{ color: 'var(--ctp-subtext0)' }}>No differences</div>
|
Object.keys(comparison.removed).length === 0 &&
|
||||||
)}
|
Object.keys(comparison.changed).length === 0 && (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)" }}>No differences</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Revisions table */}
|
{/* Revisions table */}
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>Rev</th>
|
<th style={thStyle}>Rev</th>
|
||||||
@@ -143,17 +231,32 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{revisions.map((rev, idx) => (
|
{revisions.map((rev, idx) => (
|
||||||
<tr key={rev.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
|
<tr
|
||||||
|
key={rev.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td style={tdStyle}>{rev.revision_number}</td>
|
<td style={tdStyle}>{rev.revision_number}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
{isEditor ? (
|
{isEditor ? (
|
||||||
<select
|
<select
|
||||||
value={rev.status}
|
value={rev.status}
|
||||||
onChange={(e) => void handleStatusChange(rev.revision_number, e.target.value)}
|
onChange={(e) =>
|
||||||
|
void handleStatusChange(
|
||||||
|
rev.revision_number,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.1rem 0.3rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.3rem',
|
padding: "0.15rem 0.25rem",
|
||||||
backgroundColor: 'transparent', color: statusColors[rev.status] ?? 'var(--ctp-text)',
|
fontSize: "0.75rem",
|
||||||
cursor: 'pointer',
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="draft">draft</option>
|
<option value="draft">draft</option>
|
||||||
@@ -162,27 +265,58 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
<option value="obsolete">obsolete</option>
|
<option value="obsolete">obsolete</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: statusColors[rev.status] ?? 'var(--ctp-text)' }}>{rev.status}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rev.status}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
|
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
|
||||||
<td style={tdStyle}>{rev.created_by ?? '—'}</td>
|
<td style={tdStyle}>{rev.created_by ?? "—"}</td>
|
||||||
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{rev.comment ?? ''}</td>
|
<td
|
||||||
|
style={{
|
||||||
|
...tdStyle,
|
||||||
|
maxWidth: 150,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rev.comment ?? ""}
|
||||||
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
{rev.file_key ? (
|
{rev.file_key ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`; }}
|
onClick={() => {
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--ctp-sapphire)', cursor: 'pointer', fontSize: '0.8rem' }}
|
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-sapphire)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
↓
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
) : '—'}
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleRollback(rev.revision_number)}
|
onClick={() => void handleRollback(rev.revision_number)}
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--ctp-peach)', cursor: 'pointer', fontSize: '0.75rem' }}
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-peach)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
title="Rollback to this revision"
|
title="Rollback to this revision"
|
||||||
>
|
>
|
||||||
Rollback
|
Rollback
|
||||||
@@ -198,10 +332,18 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
|
padding: "0.25rem 0.5rem",
|
||||||
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
textAlign: "left",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "var(--font-sm)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { get } from '../../api/client';
|
import { get } from "../../api/client";
|
||||||
import type { WhereUsedEntry } from '../../api/types';
|
import type { WhereUsedEntry } from "../../api/types";
|
||||||
|
|
||||||
interface WhereUsedTabProps {
|
interface WhereUsedTabProps {
|
||||||
partNumber: string;
|
partNumber: string;
|
||||||
@@ -12,20 +12,35 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
get<WhereUsedEntry[]>(`/api/items/${encodeURIComponent(partNumber)}/bom/where-used`)
|
get<WhereUsedEntry[]>(
|
||||||
|
`/api/items/${encodeURIComponent(partNumber)}/bom/where-used`,
|
||||||
|
)
|
||||||
.then(setEntries)
|
.then(setEntries)
|
||||||
.catch(() => setEntries([]))
|
.catch(() => setEntries([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [partNumber]);
|
}, [partNumber]);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading where-used...</div>;
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)" }}>Loading where-used...</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div style={{ color: 'var(--ctp-subtext0)', padding: '1rem' }}>Not used in any assemblies.</div>;
|
return (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)", padding: "1rem" }}>
|
||||||
|
Not used in any assemblies.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "var(--font-table)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>Parent PN</th>
|
<th style={thStyle}>Parent PN</th>
|
||||||
@@ -36,13 +51,25 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e, idx) => (
|
{entries.map((e, idx) => (
|
||||||
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
|
<tr
|
||||||
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
|
key={e.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
...tdStyle,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
color: "var(--ctp-peach)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{e.parent_part_number}
|
{e.parent_part_number}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{e.parent_description}</td>
|
<td style={tdStyle}>{e.parent_description}</td>
|
||||||
<td style={tdStyle}>{e.rel_type}</td>
|
<td style={tdStyle}>{e.rel_type}</td>
|
||||||
<td style={tdStyle}>{e.quantity ?? '—'}</td>
|
<td style={tdStyle}>{e.quantity ?? "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -51,10 +78,18 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
|
padding: "0.25rem 0.5rem",
|
||||||
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
textAlign: "left",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "var(--font-sm)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function AuditPage() {
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Error: {error}
|
Error: {error}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function ItemsPage() {
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
padding: "0.5rem",
|
padding: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Error: {error}
|
Error: {error}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function LoginPage() {
|
|||||||
<div style={formGroupStyle}>
|
<div style={formGroupStyle}>
|
||||||
<label style={labelStyle}>Username</label>
|
<label style={labelStyle}>Username</label>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@@ -55,6 +56,7 @@ export function LoginPage() {
|
|||||||
<div style={formGroupStyle}>
|
<div style={formGroupStyle}>
|
||||||
<label style={labelStyle}>Password</label>
|
<label style={labelStyle}>Password</label>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -76,7 +78,7 @@ export function LoginPage() {
|
|||||||
style={{
|
style={{
|
||||||
padding: "0 1rem",
|
padding: "0 1rem",
|
||||||
color: "var(--ctp-overlay0)",
|
color: "var(--ctp-overlay0)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
or
|
or
|
||||||
@@ -121,7 +123,7 @@ const titleStyle: React.CSSProperties = {
|
|||||||
const subtitleStyle: React.CSSProperties = {
|
const subtitleStyle: React.CSSProperties = {
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
marginBottom: "2rem",
|
marginBottom: "2rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ const errorStyle: React.CSSProperties = {
|
|||||||
padding: "0.75rem 1rem",
|
padding: "0.75rem 1rem",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
marginBottom: "1rem",
|
marginBottom: "1rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const formGroupStyle: React.CSSProperties = {
|
const formGroupStyle: React.CSSProperties = {
|
||||||
@@ -144,7 +146,7 @@ const labelStyle: React.CSSProperties = {
|
|||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -154,7 +156,7 @@ const inputStyle: React.CSSProperties = {
|
|||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "1rem",
|
fontSize: "var(--font-body)",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,9 +164,9 @@ const btnPrimaryStyle: React.CSSProperties = {
|
|||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.75rem 1.5rem",
|
padding: "0.75rem 1.5rem",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.375rem",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: "1rem",
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
@@ -187,9 +189,9 @@ const btnOidcStyle: React.CSSProperties = {
|
|||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.75rem 1.5rem",
|
padding: "0.75rem 1.5rem",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.375rem",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: "1rem",
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-blue)",
|
backgroundColor: "var(--ctp-blue)",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback, type FormEvent } from "react";
|
import { useEffect, useState, useCallback, type FormEvent } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Plus, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { get, post, put, del } from "../api/client";
|
import { get, post, put, del } from "../api/client";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import type {
|
import type {
|
||||||
@@ -180,7 +181,17 @@ export function ProjectsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortArrow = (key: typeof sortKey) =>
|
const sortArrow = (key: typeof sortKey) =>
|
||||||
sortKey === key ? (sortAsc ? " \u25B2" : " \u25BC") : "";
|
sortKey === key ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
display: "inline-flex",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortAsc ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
|
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
|
||||||
@@ -199,8 +210,16 @@ export function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<h2>Projects ({projects.length})</h2>
|
<h2>Projects ({projects.length})</h2>
|
||||||
{isEditor && mode === "list" && (
|
{isEditor && mode === "list" && (
|
||||||
<button onClick={openCreate} style={btnPrimaryStyle}>
|
<button
|
||||||
+ New Project
|
onClick={openCreate}
|
||||||
|
style={{
|
||||||
|
...btnPrimaryStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> New Project
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,6 +254,7 @@ export function ProjectsPage() {
|
|||||||
Code (2-10 characters, uppercase)
|
Code (2-10 characters, uppercase)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={formCode}
|
value={formCode}
|
||||||
onChange={(e) => setFormCode(e.target.value)}
|
onChange={(e) => setFormCode(e.target.value)}
|
||||||
@@ -249,6 +269,7 @@ export function ProjectsPage() {
|
|||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
<label style={labelStyle}>Name</label>
|
<label style={labelStyle}>Name</label>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={formName}
|
value={formName}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
@@ -259,6 +280,7 @@ export function ProjectsPage() {
|
|||||||
<div style={fieldStyle}>
|
<div style={fieldStyle}>
|
||||||
<label style={labelStyle}>Description</label>
|
<label style={labelStyle}>Description</label>
|
||||||
<input
|
<input
|
||||||
|
className="silo-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={formDesc}
|
value={formDesc}
|
||||||
onChange={(e) => setFormDesc(e.target.value)}
|
onChange={(e) => setFormDesc(e.target.value)}
|
||||||
@@ -316,7 +338,7 @@ export function ProjectsPage() {
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
@@ -443,43 +465,45 @@ export function ProjectsPage() {
|
|||||||
// Styles
|
// Styles
|
||||||
const btnPrimaryStyle: React.CSSProperties = {
|
const btnPrimaryStyle: React.CSSProperties = {
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnSecondaryStyle: React.CSSProperties = {
|
const btnSecondaryStyle: React.CSSProperties = {
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.85rem",
|
fontWeight: 500,
|
||||||
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnDangerStyle: React.CSSProperties = {
|
const btnDangerStyle: React.CSSProperties = {
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-red)",
|
backgroundColor: "var(--ctp-red)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnSmallStyle: React.CSSProperties = {
|
const btnSmallStyle: React.CSSProperties = {
|
||||||
padding: "0.3rem 0.6rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.8rem",
|
fontWeight: 500,
|
||||||
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -496,7 +520,7 @@ const formHeaderStyle: React.CSSProperties = {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const formCloseStyle: React.CSSProperties = {
|
const formCloseStyle: React.CSSProperties = {
|
||||||
@@ -504,8 +528,9 @@ const formCloseStyle: React.CSSProperties = {
|
|||||||
border: "none",
|
border: "none",
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
|
borderRadius: "0.375rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorBannerStyle: React.CSSProperties = {
|
const errorBannerStyle: React.CSSProperties = {
|
||||||
@@ -515,7 +540,7 @@ const errorBannerStyle: React.CSSProperties = {
|
|||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.75rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fieldStyle: React.CSSProperties = {
|
const fieldStyle: React.CSSProperties = {
|
||||||
@@ -527,7 +552,7 @@ const labelStyle: React.CSSProperties = {
|
|||||||
marginBottom: "0.35rem",
|
marginBottom: "0.35rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -537,7 +562,7 @@ const inputStyle: React.CSSProperties = {
|
|||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -552,9 +577,9 @@ const thStyle: React.CSSProperties = {
|
|||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -564,5 +589,5 @@ const thStyle: React.CSSProperties = {
|
|||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: "0.35rem 0.75rem",
|
padding: "0.35rem 0.75rem",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { ChevronRight, ChevronDown, Plus } from "lucide-react";
|
||||||
import { get, post, put, del } from "../api/client";
|
import { get, post, put, del } from "../api/client";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import type { Schema, SchemaSegment } from "../api/types";
|
import type { Schema, SchemaSegment } from "../api/types";
|
||||||
@@ -282,10 +283,13 @@ function SchemaCard({
|
|||||||
color: "var(--ctp-sapphire)",
|
color: "var(--ctp-sapphire)",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}{" "}
|
||||||
{schema.segments.length})
|
View Segments ({schema.segments.length})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded &&
|
{isExpanded &&
|
||||||
@@ -381,7 +385,7 @@ function SegmentBlock({
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{segment.description}
|
{segment.description}
|
||||||
@@ -415,7 +419,9 @@ function SegmentBlock({
|
|||||||
return (
|
return (
|
||||||
<tr key={code}>
|
<tr key={code}>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<code style={{ fontSize: "0.85rem" }}>{code}</code>
|
<code style={{ fontSize: "var(--font-body)" }}>
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<form
|
<form
|
||||||
@@ -436,6 +442,7 @@ function SegmentBlock({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
|
className="silo-input"
|
||||||
style={inlineInputStyle}
|
style={inlineInputStyle}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -478,13 +485,15 @@ function SegmentBlock({
|
|||||||
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
|
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
|
||||||
>
|
>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<code style={{ fontSize: "0.85rem" }}>{code}</code>
|
<code style={{ fontSize: "var(--font-body)" }}>
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete this value?
|
Delete this value?
|
||||||
@@ -526,7 +535,9 @@ function SegmentBlock({
|
|||||||
return (
|
return (
|
||||||
<tr key={code}>
|
<tr key={code}>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<code style={{ fontSize: "0.85rem" }}>{code}</code>
|
<code style={{ fontSize: "var(--font-body)" }}>
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{desc}</td>
|
<td style={tdStyle}>{desc}</td>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
@@ -573,6 +584,7 @@ function SegmentBlock({
|
|||||||
}
|
}
|
||||||
placeholder="Code"
|
placeholder="Code"
|
||||||
required
|
required
|
||||||
|
className="silo-input"
|
||||||
style={inlineInputStyle}
|
style={inlineInputStyle}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -597,6 +609,7 @@ function SegmentBlock({
|
|||||||
}
|
}
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
required
|
required
|
||||||
|
className="silo-input"
|
||||||
style={inlineInputStyle}
|
style={inlineInputStyle}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -639,9 +652,15 @@ function SegmentBlock({
|
|||||||
!(isThisSegment(editState) && editState!.mode === "add") && (
|
!(isThisSegment(editState) && editState!.mode === "add") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onStartAdd(schemaName, segment.name)}
|
onClick={() => onStartAdd(schemaName, segment.name)}
|
||||||
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }}
|
style={{
|
||||||
|
...btnTinyPrimaryStyle,
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+ Add Value
|
<Plus size={14} /> Add Value
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -661,7 +680,7 @@ const codeStyle: React.CSSProperties = {
|
|||||||
background: "var(--ctp-surface1)",
|
background: "var(--ctp-surface1)",
|
||||||
padding: "0.25rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const segmentStyle: React.CSSProperties = {
|
const segmentStyle: React.CSSProperties = {
|
||||||
@@ -691,37 +710,38 @@ const thStyle: React.CSSProperties = {
|
|||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: "0.3rem 0.75rem",
|
padding: "0.25rem 0.75rem",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnTinyStyle: React.CSSProperties = {
|
const btnTinyStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnTinyPrimaryStyle: React.CSSProperties = {
|
const btnTinyPrimaryStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -731,7 +751,7 @@ const inlineInputStyle: React.CSSProperties = {
|
|||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function SettingsPage() {
|
|||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
padding: "0.15rem 0.5rem",
|
padding: "0.15rem 0.5rem",
|
||||||
borderRadius: "1rem",
|
borderRadius: "1rem",
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
...roleBadgeStyles[user.role],
|
...roleBadgeStyles[user.role],
|
||||||
}}
|
}}
|
||||||
@@ -137,7 +137,7 @@ export function SettingsPage() {
|
|||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
marginBottom: "1.25rem",
|
marginBottom: "1.25rem",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
API tokens allow the FreeCAD plugin and scripts to authenticate with
|
API tokens allow the FreeCAD plugin and scripts to authenticate with
|
||||||
@@ -175,7 +175,7 @@ export function SettingsPage() {
|
|||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -194,6 +194,7 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => setTokenName(e.target.value)}
|
onChange={(e) => setTokenName(e.target.value)}
|
||||||
placeholder="e.g., FreeCAD workstation"
|
placeholder="e.g., FreeCAD workstation"
|
||||||
required
|
required
|
||||||
|
className="silo-input"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +212,7 @@ export function SettingsPage() {
|
|||||||
{tokensLoading ? (
|
{tokensLoading ? (
|
||||||
<p style={mutedStyle}>Loading tokens...</p>
|
<p style={mutedStyle}>Loading tokens...</p>
|
||||||
) : tokensError ? (
|
) : tokensError ? (
|
||||||
<p style={{ color: "var(--ctp-red)", fontSize: "0.85rem" }}>
|
<p style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
|
||||||
{tokensError}
|
{tokensError}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -331,7 +332,7 @@ const cardStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
const cardTitleStyle: React.CSSProperties = {
|
const cardTitleStyle: React.CSSProperties = {
|
||||||
marginBottom: "1rem",
|
marginBottom: "1rem",
|
||||||
fontSize: "1.1rem",
|
fontSize: "var(--font-title)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const dlStyle: React.CSSProperties = {
|
const dlStyle: React.CSSProperties = {
|
||||||
@@ -343,12 +344,12 @@ const dlStyle: React.CSSProperties = {
|
|||||||
const dtStyle: React.CSSProperties = {
|
const dtStyle: React.CSSProperties = {
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ddStyle: React.CSSProperties = {
|
const ddStyle: React.CSSProperties = {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutedStyle: React.CSSProperties = {
|
const mutedStyle: React.CSSProperties = {
|
||||||
@@ -370,7 +371,7 @@ const tokenDisplayStyle: React.CSSProperties = {
|
|||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
color: "var(--ctp-peach)",
|
color: "var(--ctp-peach)",
|
||||||
wordBreak: "break-all",
|
wordBreak: "break-all",
|
||||||
};
|
};
|
||||||
@@ -388,7 +389,7 @@ const labelStyle: React.CSSProperties = {
|
|||||||
marginBottom: "0.35rem",
|
marginBottom: "0.35rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-subtext1)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -398,18 +399,18 @@ const inputStyle: React.CSSProperties = {
|
|||||||
border: "1px solid var(--ctp-surface1)",
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.4rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.9rem",
|
fontSize: "var(--font-body)",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnPrimaryStyle: React.CSSProperties = {
|
const btnPrimaryStyle: React.CSSProperties = {
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
};
|
};
|
||||||
@@ -418,55 +419,60 @@ const btnCopyStyle: React.CSSProperties = {
|
|||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
background: "var(--ctp-surface1)",
|
background: "var(--ctp-surface1)",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.4rem",
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnDismissStyle: React.CSSProperties = {
|
const btnDismissStyle: React.CSSProperties = {
|
||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
color: "var(--ctp-subtext0)",
|
color: "var(--ctp-subtext0)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnDangerStyle: React.CSSProperties = {
|
const btnDangerStyle: React.CSSProperties = {
|
||||||
background: "rgba(243, 139, 168, 0.15)",
|
background: "rgba(243, 139, 168, 0.15)",
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: "0.3rem 0.6rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.3rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnRevokeConfirmStyle: React.CSSProperties = {
|
const btnRevokeConfirmStyle: React.CSSProperties = {
|
||||||
background: "var(--ctp-red)",
|
background: "var(--ctp-red)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnTinyStyle: React.CSSProperties = {
|
const btnTinyStyle: React.CSSProperties = {
|
||||||
padding: "0.2rem 0.5rem",
|
padding: "0.25rem 0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.375rem",
|
||||||
border: "none",
|
border: "none",
|
||||||
backgroundColor: "var(--ctp-surface1)",
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
color: "var(--ctp-text)",
|
color: "var(--ctp-text)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorStyle: React.CSSProperties = {
|
const errorStyle: React.CSSProperties = {
|
||||||
color: "var(--ctp-red)",
|
color: "var(--ctp-red)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
marginTop: "0.25rem",
|
marginTop: "0.25rem",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,9 +480,9 @@ const thStyle: React.CSSProperties = {
|
|||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
color: "var(--ctp-subtext1)",
|
color: "var(--ctp-overlay1)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "0.8rem",
|
fontSize: "var(--font-table)",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
};
|
};
|
||||||
@@ -484,5 +490,5 @@ const thStyle: React.CSSProperties = {
|
|||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: "0.4rem 0.75rem",
|
padding: "0.4rem 0.75rem",
|
||||||
borderBottom: "1px solid var(--ctp-surface1)",
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
fontSize: "0.85rem",
|
fontSize: "var(--font-body)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +1,55 @@
|
|||||||
@import './theme.css';
|
@import "./theme.css";
|
||||||
|
@import "./silo-base.css";
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family:
|
||||||
background-color: var(--ctp-base);
|
-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
color: var(--ctp-text);
|
background-color: var(--ctp-base);
|
||||||
line-height: 1.6;
|
color: var(--ctp-text);
|
||||||
min-height: 100vh;
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--ctp-sapphire);
|
color: var(--ctp-sapphire);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--ctp-sky);
|
color: var(--ctp-sky);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--ctp-mantle);
|
background: var(--ctp-mantle);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--ctp-surface1);
|
background: var(--ctp-surface1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--ctp-surface2);
|
background: var(--ctp-surface2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Monospace */
|
/* Monospace */
|
||||||
code, pre, .mono {
|
code,
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
pre,
|
||||||
|
.mono {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
14
web/src/styles/silo-base.css
Normal file
14
web/src/styles/silo-base.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* Focus and hover states for form inputs */
|
||||||
|
.silo-input {
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.silo-input:hover {
|
||||||
|
border-color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.silo-input:focus {
|
||||||
|
border-color: var(--ctp-mauve);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(203, 166, 247, 0.25);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@
|
|||||||
--ctp-crust: #11111b;
|
--ctp-crust: #11111b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Font scale ── */
|
||||||
|
:root {
|
||||||
|
--font-title: 1.1rem; /* page titles */
|
||||||
|
--font-body: 0.8125rem; /* 13px — body text, breadcrumbs */
|
||||||
|
--font-table: 0.75rem; /* 12px — table cells, inputs, buttons */
|
||||||
|
--font-sm: 0.6875rem; /* 11px — section headers, labels, captions */
|
||||||
|
--font-xs: 0.625rem; /* 10px — badges (minimum) */
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Density: comfortable (default) ── */
|
/* ── Density: comfortable (default) ── */
|
||||||
[data-density="comfortable"],
|
[data-density="comfortable"],
|
||||||
:root {
|
:root {
|
||||||
@@ -39,24 +48,24 @@
|
|||||||
--d-nav-px: 0.75rem;
|
--d-nav-px: 0.75rem;
|
||||||
--d-nav-radius: 0.4rem;
|
--d-nav-radius: 0.4rem;
|
||||||
--d-user-gap: 0.6rem;
|
--d-user-gap: 0.6rem;
|
||||||
--d-user-font: 0.85rem;
|
--d-user-font: var(--font-body);
|
||||||
|
|
||||||
--d-th-py: 0.35rem;
|
--d-th-py: 0.35rem;
|
||||||
--d-th-px: 0.75rem;
|
--d-th-px: 0.75rem;
|
||||||
--d-th-font: 0.75rem;
|
--d-th-font: var(--font-table);
|
||||||
--d-td-py: 0.25rem;
|
--d-td-py: 0.25rem;
|
||||||
--d-td-px: 0.75rem;
|
--d-td-px: 0.75rem;
|
||||||
--d-td-font: 0.85rem;
|
--d-td-font: var(--font-body);
|
||||||
|
|
||||||
--d-toolbar-gap: 0.5rem;
|
--d-toolbar-gap: 0.5rem;
|
||||||
--d-toolbar-py: 0.5rem;
|
--d-toolbar-py: 0.5rem;
|
||||||
--d-toolbar-mb: 0.35rem;
|
--d-toolbar-mb: 0.35rem;
|
||||||
--d-input-py: 0.35rem;
|
--d-input-py: 0.35rem;
|
||||||
--d-input-px: 0.6rem;
|
--d-input-px: 0.6rem;
|
||||||
--d-input-font: 0.85rem;
|
--d-input-font: var(--font-body);
|
||||||
|
|
||||||
--d-footer-h: 28px;
|
--d-footer-h: 28px;
|
||||||
--d-footer-font: 0.75rem;
|
--d-footer-font: var(--font-table);
|
||||||
--d-footer-px: 2rem;
|
--d-footer-px: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,23 +79,23 @@
|
|||||||
--d-nav-px: 0.5rem;
|
--d-nav-px: 0.5rem;
|
||||||
--d-nav-radius: 0.3rem;
|
--d-nav-radius: 0.3rem;
|
||||||
--d-user-gap: 0.35rem;
|
--d-user-gap: 0.35rem;
|
||||||
--d-user-font: 0.8rem;
|
--d-user-font: var(--font-table);
|
||||||
|
|
||||||
--d-th-py: 0.2rem;
|
--d-th-py: 0.2rem;
|
||||||
--d-th-px: 0.5rem;
|
--d-th-px: 0.5rem;
|
||||||
--d-th-font: 0.7rem;
|
--d-th-font: var(--font-sm);
|
||||||
--d-td-py: 0.125rem;
|
--d-td-py: 0.125rem;
|
||||||
--d-td-px: 0.5rem;
|
--d-td-px: 0.5rem;
|
||||||
--d-td-font: 0.8rem;
|
--d-td-font: var(--font-table);
|
||||||
|
|
||||||
--d-toolbar-gap: 0.35rem;
|
--d-toolbar-gap: 0.35rem;
|
||||||
--d-toolbar-py: 0.25rem;
|
--d-toolbar-py: 0.25rem;
|
||||||
--d-toolbar-mb: 0.15rem;
|
--d-toolbar-mb: 0.15rem;
|
||||||
--d-input-py: 0.2rem;
|
--d-input-py: 0.2rem;
|
||||||
--d-input-px: 0.4rem;
|
--d-input-px: 0.4rem;
|
||||||
--d-input-font: 0.8rem;
|
--d-input-font: var(--font-table);
|
||||||
|
|
||||||
--d-footer-h: 24px;
|
--d-footer-h: 24px;
|
||||||
--d-footer-font: 0.7rem;
|
--d-footer-font: var(--font-sm);
|
||||||
--d-footer-px: 1.25rem;
|
--d-footer-px: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user