No description
  • Rust 80.5%
  • HTML 12.8%
  • CSS 6.2%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Yarrith Devos 3b3cb1eb15 static: revalidate instead of a 1-hour max-age
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.
2026-10-03 11:40:49 +00:00
src static: revalidate instead of a 1-hour max-age 2026-10-03 11:40:49 +00:00
static theme: re-skin the UI to the ARP Collector mockup (CSS only) 2026-10-03 11:20:21 +00:00
templates Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
.containerignore Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
.gitignore Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
Cargo.lock perf: stop writing to the database on every request 2026-10-03 06:40:37 +00:00
Cargo.toml perf: stop writing to the database on every request 2026-10-03 06:40:37 +00:00
Containerfile Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
inventory.db Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
LICENSE Rust port of the site/vendor inventory demo 2026-10-02 21:24:32 +00:00
README.md perf: stop writing to the database on every request 2026-10-03 06:40:37 +00:00

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 shared on 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 the sites / vendors / categories / site_vendors tables) and the current database is snapshotted to a pre-restore-*.db first. 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). A pre-import-*.db snapshot 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-sftp crates instead of Python paramiko.
  • Data compatibility — the SQLite schema and the backup_config.json / backup_state.json formats 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, and generate_fields.py are 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) became profile.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 /static is served with a one-hour cache header. (The Python app re-ran init_db() — four CREATE TABLE IF NOT EXISTS, the category seed and a COMMIT — 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.