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,43 @@
|
||||
{{define "content"}}
|
||||
<div class="inventory-shell d-flex">
|
||||
<aside id="tree-pane" class="tree-pane border-end d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="search" id="tree-search" class="form-control" placeholder="Search inventory…">
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-expand-all" title="Expand all folders"><i class="bi bi-arrows-expand"></i> Expand</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="btn-collapse-all" title="Collapse all folders"><i class="bi bi-arrows-collapse"></i> Collapse</button>
|
||||
{{if .User.IsAdmin}}
|
||||
<button class="btn btn-sm btn-primary ms-auto" id="btn-new-root-folder" data-parent-id="" title="New top-level folder">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tree-scroll flex-fill overflow-auto">
|
||||
<ul class="tree list-unstyled mb-0" id="tree-root">
|
||||
<li class="tree-node tree-node-root" data-type="root">
|
||||
<div class="tree-row selected" data-type="root" tabindex="0">
|
||||
<span class="tree-toggle-spacer"></span>
|
||||
<i class="bi bi-house-door-fill tree-icon text-primary"></i>
|
||||
<span class="tree-label">Inventory</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<ul class="tree-children list-unstyled" id="top-level-children" data-expanded="true">
|
||||
{{template "tree_children" .Tree}}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="main-panel" class="main-panel flex-fill overflow-auto" data-context-type="root" data-context-id="">
|
||||
{{template "panel_root" .Root}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{template "modals" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,120 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 900px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .ItemType}}Edit Item Type{{else}}New Item Type{{end}}</h4>
|
||||
<a href="/item-types" class="btn btn-sm btn-outline-secondary ms-auto">Back to Item Types</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .ItemType}}/item-types/{{.ItemType.ID}}{{else}}/item-types{{end}}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .ItemType}}{{.ItemType.Name}}{{end}}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Icon <span class="text-body-secondary">(Bootstrap Icons class)</span></label>
|
||||
<input type="text" class="form-control" name="icon" value="{{if .ItemType}}{{.ItemType.Icon}}{{end}}" placeholder="bi-box-seam">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" name="description" rows="2">{{if .ItemType}}{{.ItemType.Description}}{{end}}</textarea>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Sort Order</label>
|
||||
<input type="number" class="form-control" name="sort_order" value="{{if .ItemType}}{{.ItemType.SortOrder}}{{else}}0{{end}}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .ItemType}}{{if .ItemType.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-3">Save</button>
|
||||
{{if .ItemType}}
|
||||
<button type="submit" formaction="/item-types/{{.ItemType.ID}}/delete" class="btn btn-outline-danger mt-3" onclick="return confirm('Delete this item type? This is only possible if no assets use it.')">Delete Item Type</button>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ItemType}}
|
||||
<h5 class="mb-2">Custom Fields</h5>
|
||||
<div class="card mb-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Type</th><th>Required</th><th>Default</th><th>Options</th><th>Order</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemType.Fields}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.FieldKey}}</code></td>
|
||||
<td>{{.FieldType.Label}}</td>
|
||||
<td>{{if .Required}}<i class="bi bi-check-lg text-success"></i>{{end}}</td>
|
||||
<td>{{.DefaultValue}}</td>
|
||||
<td class="text-body-secondary small">{{range .Options}}{{.}}<br>{{end}}</td>
|
||||
<td>{{.SortOrder}}</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary field-edit-btn"
|
||||
data-id="{{.ID}}" data-name="{{.Name}}" data-type="{{.FieldType}}"
|
||||
data-required="{{.Required}}" data-default="{{.DefaultValue}}"
|
||||
data-options="{{range $i, $o := .Options}}{{if $i}} {{end}}{{$o}}{{end}}" data-sort-order="{{.SortOrder}}">
|
||||
Edit
|
||||
</button>
|
||||
<form method="post" action="/item-types/{{$.ItemType.ID}}/fields/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this field? Existing values will be lost.')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if not .ItemType.Fields}}
|
||||
<tr><td colspan="8" class="text-body-secondary">No custom fields yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6 id="fieldFormTitle">Add Field</h6>
|
||||
<form method="post" action="/item-types/{{.ItemType.ID}}/fields" id="fieldForm" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" id="fieldFormName" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Type</label>
|
||||
<select class="form-select form-select-sm" name="field_type" id="fieldFormType">
|
||||
{{range .FieldTypes}}<option value="{{.}}">{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Default Value</label>
|
||||
<input type="text" class="form-control form-control-sm" name="default_value" id="fieldFormDefault">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Dropdown Options <span class="text-body-secondary">(one per line)</span></label>
|
||||
<textarea class="form-control form-control-sm" name="options" id="fieldFormOptions" rows="1"></textarea>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="required" id="fieldFormRequired">
|
||||
<label class="form-check-label small" for="fieldFormRequired">Required</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100" id="fieldFormSubmit">Add</button>
|
||||
</div>
|
||||
<input type="hidden" name="sort_order" id="fieldFormSortOrder" value="0">
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-link d-none" id="fieldFormCancel">Cancel edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.itemTypeFieldsBaseAction = "/item-types/{{.ItemType.ID}}/fields";</script>
|
||||
{{else}}
|
||||
<p class="text-body-secondary">Save the item type first to add custom fields.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Item Types</h4>
|
||||
<a href="/item-types/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-plus-lg"></i> New Item Type</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Icon</th><th>Name</th><th>Description</th><th>Custom Fields</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ItemTypes}}
|
||||
<tr>
|
||||
<td><i class="bi {{iconOr .Icon "bi-box-seam"}} fs-5"></i></td>
|
||||
<td><a href="/item-types/{{.ID}}/edit">{{.Name}}</a></td>
|
||||
<td class="text-body-secondary">{{.Description}}</td>
|
||||
<td>{{len .Fields}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td class="text-end"><a href="/item-types/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="text-body-secondary">No item types defined yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,86 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 800px">
|
||||
<h4 class="mb-3">Settings</h4>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-2 fw-semibold">General</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/settings/general" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Site Name</label>
|
||||
<input type="text" class="form-control" name="site_name" value="{{.SiteName}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Prefix</label>
|
||||
<input type="text" class="form-control" name="asset_id_prefix" value="{{.AssetPrefix}}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Asset ID Padding</label>
|
||||
<input type="number" class="form-control" name="asset_id_padding" value="{{.AssetPadding}}" min="1" max="12">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Statuses</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead><tr><th>Order</th><th>Preview</th><th>Name & Color</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range $i, $st := .Statuses}}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="up">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq $i 0}}disabled{{end}}><i class="bi bi-arrow-up"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/reorder" class="d-inline">
|
||||
<input type="hidden" name="dir" value="down">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" {{if eq (add $i 1) (len $.Statuses)}}disabled{{end}}><i class="bi bi-arrow-down"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="badge text-bg-{{$st.Color}}">{{$st.Name}}</span></td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}" class="d-flex gap-2 align-items-center">
|
||||
<input type="text" class="form-control form-control-sm" name="name" value="{{$st.Name}}" style="max-width:160px" required>
|
||||
<select class="form-select form-select-sm" name="color" style="max-width:130px">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}
|
||||
<option value="{{.}}" {{if eq . $st.Color}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/settings/statuses/{{$st.ID}}/delete" onsubmit="return confirm('Delete this status?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body border-top">
|
||||
<h6>Add Status</h6>
|
||||
<form method="post" action="/settings/statuses" class="d-flex gap-2 align-items-end">
|
||||
<div>
|
||||
<label class="form-label small mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" name="name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small mb-1">Color</label>
|
||||
<select class="form-select form-select-sm" name="color">
|
||||
{{range (list "primary" "secondary" "success" "danger" "warning" "info" "dark")}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3" style="max-width: 600px">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">{{if .EditUser}}Edit User{{else}}New User{{end}}</h4>
|
||||
<a href="/users" class="btn btn-sm btn-outline-secondary ms-auto">Back to Users</a>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{if .EditUser}}/users/{{.EditUser.ID}}{{else}}/users{{end}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" name="username" value="{{if .EditUser}}{{.EditUser.Username}}{{end}}" required autofocus>
|
||||
</div>
|
||||
{{if not .EditUser}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" required autocomplete="new-password">
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" name="role">
|
||||
<option value="user" {{if .EditUser}}{{if eq .EditUser.Role "user"}}selected{{end}}{{end}}>User</option>
|
||||
<option value="admin" {{if .EditUser}}{{if eq .EditUser.Role "admin"}}selected{{end}}{{end}}>Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="enabled" id="enabled" {{if .EditUser}}{{if .EditUser.Enabled}}checked{{end}}{{else}}checked{{end}}>
|
||||
<label class="form-check-label" for="enabled">Enabled</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
{{if .EditUser}}
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/delete" class="d-inline mt-2" onsubmit="return confirm('Delete this user?')">
|
||||
<button type="submit" class="btn btn-outline-danger">Delete User</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .EditUser}}
|
||||
<div class="card">
|
||||
<div class="card-header py-2 fw-semibold">Change Password</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/users/{{.EditUser.ID}}/password" class="row g-2 align-items-end">
|
||||
<div class="col">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">Set Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0">Users</h4>
|
||||
<a href="/users/new" class="btn btn-primary btn-sm ms-auto"><i class="bi bi-person-plus"></i> New User</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><a href="/users/{{.ID}}/edit">{{.Username}}</a></td>
|
||||
<td>{{if .IsAdmin}}<span class="badge text-bg-primary">Admin</span>{{else}}<span class="badge text-bg-secondary">User</span>{{end}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge text-bg-success">Enabled</span>{{else}}<span class="badge text-bg-secondary">Disabled</span>{{end}}</td>
|
||||
<td>{{fmtDateShort .CreatedAt}}</td>
|
||||
<td class="text-end"><a href="/users/{{.ID}}/edit" class="btn btn-sm btn-outline-secondary">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user