- Rust 80.5%
- HTML 12.8%
- CSS 6.2%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The perf change added 'Cache-Control: max-age=3600' for /static, which made the re-skin invisible in browsers that had already fetched style.css (the server was serving the new file, the client kept the old one for up to an hour). /static now sends 'no-cache': the browser may keep the file but must revalidate, and ServeDir answers Last-Modified revalidation with 304 Not Modified (0 bytes), so repeat loads stay cheap. Deploys are visible immediately again. |
||
| src | ||
| static | ||
| templates | ||
| .containerignore | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Containerfile | ||
| inventory.db | ||
| LICENSE | ||
| README.md | ||
Site Systems Inventory — Rust port
This is a Rust port of the Flask site/vendor inventory demo. It serves the same SQLite schema, templates, and static assets as the Python reference and is designed to be a drop-in replacement for the Flask application.
Run locally
You need a recent Rust toolchain (1.99 or newer) and the cargo build tool.
cargo run --release
Then open http://localhost:3000/.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
SVI_DB_PATH |
inventory.db |
Path to the SQLite database file. |
SVI_BACKUP_DIR |
backups/ |
Directory for backups, backup_config.json, and backup_state.json. |
SVI_PORT |
3000 |
TCP port the server binds to. |
TZ |
UTC |
Timezone used for backup timestamps and the scheduler. |
Run as a container (podman/docker)
Build the image from the project root:
podman build -t svi-demo .
podman run -p 3000:3000 svi-demo
Or with docker:
docker build -t svi-demo .
docker run -p 3000:3000 svi-demo
The demo SQLite database is baked into the image. Edits made through the UI live
only in that container's writable layer and are lost when the container is
removed. To persist the database across container recreation, mount a volume
over /app/inventory.db:
podman run -d --name svi-demo -p 3000:3000 \
-v svi-demo-db:/app/inventory.db \
svi-demo
Data model
| Table | Purpose |
|---|---|
sites |
One row per site — identity, address, and site-level fields (region, stray notes). |
vendors |
One row per vendor company (unique name). |
categories |
One row per category (i, tf, tc, nc, ac, …), with optional parent for sub-categories. |
site_vendors |
Junction: one row per (site, category, slot) holding the vendor + relationship fields (contract number, support contacts, brand/model, …). |
The contract number, support lines, etc. live on site_vendors because they belong
to the relationship, not to the vendor or site alone (the same vendor can have a
different contract at each site). Categories with two vendors (printers; badging
clock + badge supplier) use two rows distinguished by slot.
src/schema.rs and src/fields.rs hold the field-mapping that drives migration,
on-screen rendering, and export together, so the flat workbook layout and the
relational tables never drift.
Read mode vs. edit mode
Viewing a site (select it on the home page) is read-only — site info plus one card per system showing its vendor and details, and a single Edit button. All editing lives behind that button:
- The edit and New Site forms have, for each system, a Vendor box backed by a datalist of existing vendors — pick an existing one or type a new name (a new vendor row is created automatically; vendors left unused after an edit are cleaned up).
- Linking an existing vendor pre-fills its shared details. Choosing a vendor
fills that system's universal fields — product/brand-model, service level,
and support phone numbers (technical support, critical / non-critical
lines) — copied from the most complete site already using that vendor. Fields
that are unique per site — contract number, customer number, DSID/GDN
reference, support-contract reference, purchase year — aren't touched.
Universal fields are tagged
sharedon the form. - Delete lives on the edit form, not the read view.
- Nothing is persisted until you click Save Changes / Create Site.
Find a site
The Sites page (/sites) searches by name and works as a browser search
provider, e.g. /sites?q=damogran:
- A unique match (exact, or a single partial match) redirects straight to that site's read-only overview.
- Multiple matches show a table to pick from; no query lists all sites.
- Case-insensitive and partial.
Vendor lookup
The Vendors page (/vendors) lists all vendors (with usage counts); searching
shows each matching vendor and a table of every site/category that uses it, with
contract and support columns. Site names link straight to the site.
Rename a vendor everywhere
A vendor is a single row, so renaming (the ✎ action) updates all of its site/category links at once. If the new name already matches another vendor, the two are merged (links repointed, duplicate removed).
Edit a vendor's phone / support numbers
The ☎ Edit support numbers button auto-detects the phone numbers in a vendor's
records (Belgian formats and +32 …, ignoring bare contract/customer numbers) and
shows each with its global reach. Editing one replaces it everywhere it appears
across all site_vendors fields. A manual find/replace box handles the rest.
Export
Both exports rebuild the original flat 89-column workbook layout from the normalized data:
GET /export/xlsx→site_inventory.xlsx(sheet "Low Current", bold frozen header).GET /export/csv→site_inventory.csv(;-separated, UTF-8 with BOM).
Backup, restore & import
The Backup page (/backup) manages database backups and bulk data loads.
Every snapshot uses SQLite's online backup API, so it is a consistent copy
even while the app is writing. Backup files are named
inventory-YYYYMMDD-HHMMSS.db.
-
Scheduled / manual backup — enable a daily automatic backup at a chosen time, set a retention count (keep the N newest, older ones pruned locally and on SFTP), or use Back up now / Test connection for on-demand runs.
-
Destinations — a local folder (which may be an sshfs / network mount), or SFTP with username+password or an SSH private key.
-
Restore (
POST /backup/restore) — from a listed local backup or an uploaded.db. The source is validated (must be a SQLite DB with thesites/vendors/categories/site_vendorstables) and the current database is snapshotted to apre-restore-*.dbfirst. Filename restores are constrained to the backup folder (no path traversal). -
Import from Excel (
POST /backup/import) — upload a workbook shaped like the original "Low Current" sheet (or the app's own export). Columns are matched by header label; rows are matched to sites by name (existing updated, unknown added). Apre-import-*.dbsnapshot is taken first.Import updates matched sites from the whole row, so a column missing from the uploaded file blanks that field. Use a complete workbook.
Running in a container
Everything the feature writes at runtime lives under SVI_BACKUP_DIR —
backup_config.json, backup_state.json, the backup files, and the
pre-restore / pre-import safety snapshots. It defaults to backups/ next
to the binary; set SVI_BACKUP_DIR to relocate it onto a mounted volume so
config, credentials, run history, and backups all survive a rebuild.
Porting notes
- Web server — a single-process axum server replaces the Flask/gunicorn worker pool. Because there is only one process, the in-process daily backup scheduler runs exactly once.
- SFTP — implemented with the pure-Rust
russh/russh-sftpcrates instead of Pythonparamiko. - Data compatibility — the SQLite schema and the
backup_config.json/backup_state.jsonformats are identical to the Python app, so backups and configuration can be exchanged between the two. - Developer utilities — the Python-only scripts
seed_demo.py,migrate.py, andgenerate_fields.pyare not ported. The demo database (inventory.db) is committed and used directly. - Templates — the Jinja2 templates are used unchanged, except one line in
templates/index.html:profile.extra.get(key)becameprofile.extra[key](minijinja has no dict.get()method). Both forms are falsy for a missing key, and the rendered HTML is byte-identical to the Flask app's. - Performance — the schema is initialised once at startup and never on a
request path; each page render uses a single SQLite connection; the database is
opened in WAL mode, so reads never block on the daily-backup writer; and
/staticis served with a one-hour cache header. (The Python app re-raninit_db()— fourCREATE TABLE IF NOT EXISTS, the category seed and aCOMMIT— on every request, which cost ~20 ms per store call and serialised under concurrency.)
Files
| File | Purpose |
|---|---|
src/main.rs |
Axum server setup, routes, and middleware. |
src/schema.rs |
Normalized schema + the field-mapping (migration/render/export). |
src/fields.rs |
Flat 89-column order + labels; used for export and import matching. |
src/store.rs |
Data-access layer: schema DDL and all queries/CRUD. |
src/web.rs |
Route handlers. |
src/backup.rs |
Backup engine (local + SFTP), daily scheduler, restore, validation. |
src/importer.rs |
Excel → database import (header-matched, upsert by site name). |
src/templates.rs |
MiniJinja template engine setup. |
templates/ |
HTML templates (shared with the Python app). |
static/style.css |
Styling (shared with the Python app). |
inventory.db |
The live normalized demo database. |
Cargo.toml / Cargo.lock |
Rust package manifest and locked dependency resolution. |
Containerfile |
Multi-stage container build. |