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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user