Initial implementation of inventree

A small IT asset inventory system built around a nested folder tree,
matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS,
SQLite storage (WAL, FKs, migrations), embedded templates/static assets,
single compiled binary.

- AJAX-driven inventory tree: lazy-loaded folder expansion with per-user
  persisted expand state, drag-and-drop move for folders/assets, safe
  delete-with-relocate-or-cascade confirmation, search with breadcrumbs.
- Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs,
  CSV import (with optional folder auto-create) and export.
- Admin-defined item types with custom fields (text/long_text/number/date/
  boolean/dropdown/url/ip_address).
- Configurable statuses, session-based auth (bcrypt, secure cookies),
  admin/user roles, user management.
- CLI: serve, version, database migrate, database backup; /healthz;
  graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
This commit is contained in:
Owen
2026-08-17 13:34:40 -05:00
commit 218687a1ee
71 changed files with 8102 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
// Command inventree runs the inventree IT asset inventory server, or one of
// its supporting subcommands (version, database migrate, database backup).
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"inventree/internal/database"
"inventree/internal/web"
)
// version is set at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
return runServe(args)
}
switch args[0] {
case "serve":
return runServe(args[1:])
case "version":
fmt.Println("inventree", version)
return nil
case "database":
if len(args) < 2 {
return fmt.Errorf("usage: inventree database <migrate|backup>")
}
switch args[1] {
case "migrate":
return runDatabaseMigrate(args[2:])
case "backup":
return runDatabaseBackup(args[2:])
default:
return fmt.Errorf("unknown database subcommand %q", args[1])
}
case "help", "-h", "--help":
printUsage()
return nil
default:
printUsage()
return fmt.Errorf("unknown command %q", args[0])
}
}
func printUsage() {
fmt.Println(`inventree - a small IT asset inventory system
Usage:
inventree [serve] Start the web server (default command)
inventree version Print the version
inventree database migrate Apply pending database migrations
inventree database backup [path] Write a consistent backup snapshot
Flags for "serve":
-addr string listen address (default ":8080")
-db string path to the SQLite database file (default "data/inventree.db")
-secure-cookies mark the session cookie Secure (enable when serving over HTTPS)`)
}
func dbPathFlag(args []string) (dbPath string, rest []string) {
dbPath = "data/inventree.db"
for i := 0; i < len(args); i++ {
if args[i] == "-db" && i+1 < len(args) {
dbPath = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return
}
func runDatabaseMigrate(args []string) error {
dbPath, _ := dbPathFlag(args)
if err := ensureDir(dbPath); err != nil {
return err
}
db, err := database.Open(dbPath)
if err != nil {
return err
}
defer db.Close()
fmt.Println("migrations applied to", dbPath)
return nil
}
func runDatabaseBackup(args []string) error {
dbPath, rest := dbPathFlag(args)
dest := fmt.Sprintf("backup-%s.db", time.Now().Format("20060102-150405"))
if len(rest) > 0 {
dest = rest[0]
}
if err := ensureDir(dbPath); err != nil {
return err
}
db, err := database.Open(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := ensureDir(dest); err != nil {
return err
}
if _, err := db.Exec(`VACUUM INTO ?`, dest); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
fmt.Println("backup written to", dest)
return nil
}
func ensureDir(path string) error {
dir := filepath.Dir(path)
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func runServe(args []string) error {
var (
addr = ":8080"
dbPath = "data/inventree.db"
secureCookies = false
)
for i := 0; i < len(args); i++ {
switch args[i] {
case "-addr":
if i+1 < len(args) {
addr = args[i+1]
i++
}
case "-db":
if i+1 < len(args) {
dbPath = args[i+1]
i++
}
case "-secure-cookies":
secureCookies = true
}
}
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := ensureDir(dbPath); err != nil {
return err
}
db, err := database.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()
app, err := web.NewApp(db, log, secureCookies)
if err != nil {
return fmt.Errorf("initialize app: %w", err)
}
stop := make(chan struct{})
app.StartSessionJanitor(stop)
defer close(stop)
srv := &http.Server{
Addr: addr,
Handler: app.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
go func() {
<-ctx.Done()
log.Info("shutting down")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error("graceful shutdown failed", "error", err)
}
}()
log.Info("inventree listening", "addr", addr, "db", dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
log.Info("server stopped")
return nil
}