Initial commit: Go + HTMX network mapping tool

- Database schema (SQLite) with migrations
- Data models: racks, devices, device models, ports, connections
- CRUD store layer in internal/db/
- Connection tracing service
- HTTP handlers for overview, rack, device, model, connection, wall sockets
- HTML templates with HTMX for connection modal
- Dark-themed CSS
- Vanilla JS for modal overlay management
master
Joca 2026-06-03 23:11:15 -03:00
commit a0f05791f8
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
34 changed files with 3029 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/wireplanner
/data.db
/uploads/*
!.gitkeep

BIN
data.db-shm Normal file

Binary file not shown.

BIN
data.db-wal Normal file

Binary file not shown.

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module lostcavewireplanner
go 1.26.3
require github.com/mattn/go-sqlite3 v1.14.44

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=

192
internal/db/connections.go Normal file
View File

@ -0,0 +1,192 @@
package db
import (
"database/sql"
"fmt"
"lostcavewireplanner/internal/models"
)
func (s *Store) ConnectionTypeGetAll() ([]models.ConnectionType, error) {
rows, err := s.DB.Query(`SELECT id, name FROM connection_types ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var types []models.ConnectionType
for rows.Next() {
var ct models.ConnectionType
if err := rows.Scan(&ct.ID, &ct.Name); err != nil {
return nil, err
}
types = append(types, ct)
}
return types, rows.Err()
}
func (s *Store) ConnectionGetByPortID(portID int64) (*models.Connection, error) {
c := &models.Connection{}
err := s.DB.QueryRow(`SELECT id, connection_type_id, label_1, label_2, color, port_id_1, port_id_2, created_at, updated_at FROM connections WHERE port_id_1 = ? OR port_id_2 = ?`, portID, portID).
Scan(&c.ID, &c.ConnectionTypeID, &c.Label1, &c.Label2, &c.Color, &c.PortID1, &c.PortID2, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.populateConnection(c)
return c, nil
}
func (s *Store) ConnectionGetByPortIDExcluding(portID int64, excludeConnID int64) (*models.Connection, error) {
c := &models.Connection{}
err := s.DB.QueryRow(`SELECT id, connection_type_id, label_1, label_2, color, port_id_1, port_id_2, created_at, updated_at FROM connections WHERE (port_id_1 = ? OR port_id_2 = ?) AND id != ?`, portID, portID, excludeConnID).
Scan(&c.ID, &c.ConnectionTypeID, &c.Label1, &c.Label2, &c.Color, &c.PortID1, &c.PortID2, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.populateConnection(c)
return c, nil
}
func (s *Store) ConnectionGetByID(id int64) (*models.Connection, error) {
c := &models.Connection{}
err := s.DB.QueryRow(`SELECT id, connection_type_id, label_1, label_2, color, port_id_1, port_id_2, created_at, updated_at FROM connections WHERE id = ?`, id).
Scan(&c.ID, &c.ConnectionTypeID, &c.Label1, &c.Label2, &c.Color, &c.PortID1, &c.PortID2, &c.CreatedAt, &c.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
s.populateConnection(c)
return c, nil
}
func (s *Store) ConnectionGetAllForRack(rackID int64) ([]models.Connection, error) {
rows, err := s.DB.Query(`
SELECT c.id, c.connection_type_id, c.label_1, c.label_2, c.color, c.port_id_1, c.port_id_2, c.created_at, c.updated_at
FROM connections c
WHERE (port_id_1 IN (SELECT dp.id FROM device_ports dp JOIN devices d ON d.id = dp.device_id WHERE d.rack_id = ?))
OR (port_id_2 IN (SELECT dp.id FROM device_ports dp JOIN devices d ON d.id = dp.device_id WHERE d.rack_id = ?))`, rackID, rackID)
if err != nil {
return nil, err
}
defer rows.Close()
var conns []models.Connection
for rows.Next() {
var c models.Connection
if err := rows.Scan(&c.ID, &c.ConnectionTypeID, &c.Label1, &c.Label2, &c.Color, &c.PortID1, &c.PortID2, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
s.populateConnection(&c)
conns = append(conns, c)
}
return conns, rows.Err()
}
func (s *Store) ConnectionGetAll() ([]models.Connection, error) {
rows, err := s.DB.Query(`SELECT id, connection_type_id, label_1, label_2, color, port_id_1, port_id_2, created_at, updated_at FROM connections`)
if err != nil {
return nil, err
}
defer rows.Close()
var conns []models.Connection
for rows.Next() {
var c models.Connection
if err := rows.Scan(&c.ID, &c.ConnectionTypeID, &c.Label1, &c.Label2, &c.Color, &c.PortID1, &c.PortID2, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
conns = append(conns, c)
}
return conns, rows.Err()
}
func (s *Store) ConnectionCreate(c *models.Connection) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if c.PortID1 != nil {
exists, err := s.portConnectedTx(tx, *c.PortID1)
if err != nil {
return err
}
if exists {
return fmt.Errorf("port %d is already connected", *c.PortID1)
}
}
if c.PortID2 != nil {
exists, err := s.portConnectedTx(tx, *c.PortID2)
if err != nil {
return err
}
if exists {
return fmt.Errorf("port %d is already connected", *c.PortID2)
}
}
res, err := tx.Exec(`INSERT INTO connections (connection_type_id, label_1, label_2, color, port_id_1, port_id_2) VALUES (?, ?, ?, ?, ?, ?)`,
c.ConnectionTypeID, c.Label1, c.Label2, c.Color, c.PortID1, c.PortID2)
if err != nil {
return err
}
c.ID, err = res.LastInsertId()
if err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ConnectionUpdate(c *models.Connection) error {
_, err := s.DB.Exec(`UPDATE connections SET connection_type_id=?, label_1=?, label_2=?, color=?, port_id_1=?, port_id_2=?, updated_at=datetime('now') WHERE id=?`,
c.ConnectionTypeID, c.Label1, c.Label2, c.Color, c.PortID1, c.PortID2, c.ID)
return err
}
func (s *Store) ConnectionDelete(id int64) error {
_, err := s.DB.Exec(`DELETE FROM connections WHERE id=?`, id)
return err
}
func (s *Store) portConnectedTx(tx *sql.Tx, portID int64) (bool, error) {
var count int
err := tx.QueryRow(`SELECT COUNT(*) FROM connections WHERE port_id_1 = ? OR port_id_2 = ?`, portID, portID).Scan(&count)
return count > 0, err
}
func (s *Store) populateConnection(c *models.Connection) {
if c.PortID1 != nil {
c.Port1, _ = s.PortGetByID(*c.PortID1)
if c.Port1 != nil {
c.Device1, _ = s.DeviceGetByID(c.Port1.DeviceID)
}
}
if c.PortID2 != nil {
c.Port2, _ = s.PortGetByID(*c.PortID2)
if c.Port2 != nil {
c.Device2, _ = s.DeviceGetByID(c.Port2.DeviceID)
}
}
ct, _ := s.ConnectionTypeGetByID(c.ConnectionTypeID)
c.ConnectionType = ct
}
func (s *Store) ConnectionTypeGetByID(id int64) (*models.ConnectionType, error) {
ct := &models.ConnectionType{}
err := s.DB.QueryRow(`SELECT id, name FROM connection_types WHERE id = ?`, id).Scan(&ct.ID, &ct.Name)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return ct, nil
}

41
internal/db/db.go Normal file
View File

@ -0,0 +1,41 @@
package db
import (
"database/sql"
_ "embed"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
//go:embed schema.sql
var schemaSQL string
type Store struct {
DB *sql.DB
}
func Init(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("run schema: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("wal mode: %w", err)
}
return &Store{DB: db}, nil
}
func (s *Store) Close() error {
return s.DB.Close()
}

View File

@ -0,0 +1,159 @@
package db
import (
"database/sql"
"lostcavewireplanner/internal/models"
)
func (s *Store) ModelGetAll() ([]models.DeviceModel, error) {
rows, err := s.DB.Query(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket, created_at, updated_at FROM device_models ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.DeviceModel
for rows.Next() {
var m models.DeviceModel
if err := rows.Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits,
&m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.CreatedAt, &m.UpdatedAt); err != nil {
return nil, err
}
list = append(list, m)
}
return list, rows.Err()
}
func (s *Store) ModelGetByID(id int64) (*models.DeviceModel, error) {
m := &models.DeviceModel{}
err := s.DB.QueryRow(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket, created_at, updated_at FROM device_models WHERE id = ?`, id).
Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits,
&m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.CreatedAt, &m.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
m.Ports, err = s.ModelPortGetByModelID(m.ID)
if err != nil {
return nil, err
}
return m, nil
}
func (s *Store) modelGetByIDTx(tx *sql.Tx, id int64) (*models.DeviceModel, error) {
m := &models.DeviceModel{}
err := tx.QueryRow(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket FROM device_models WHERE id = ?`, id).
Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits,
&m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket)
if err != nil {
return nil, err
}
portRows, err := tx.Query(`SELECT id, device_model_id, name, side, position FROM device_model_ports WHERE device_model_id = ? ORDER BY side, position`, id)
if err != nil {
return nil, err
}
defer portRows.Close()
for portRows.Next() {
var p models.DeviceModelPort
if err := portRows.Scan(&p.ID, &p.DeviceModelID, &p.Name, &p.Side, &p.Position); err != nil {
return nil, err
}
m.Ports = append(m.Ports, p)
}
return m, portRows.Err()
}
func (s *Store) ModelCreate(m *models.DeviceModel, imageDir string) (int64, error) {
tx, err := s.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO device_models (name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket)
if err != nil {
return 0, err
}
modelID, err := res.LastInsertId()
if err != nil {
return 0, err
}
for i := range m.Ports {
m.Ports[i].DeviceModelID = modelID
}
if err := s.modelPortCreateTx(tx, modelID, m.Ports); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return modelID, nil
}
func (s *Store) ModelUpdate(m *models.DeviceModel) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`UPDATE device_models SET name=?, manufacturer=?, is_rack_mountable=?, height_units=?, front_image=?, back_image=?, is_patch_panel=?, is_wall_socket=?, updated_at=datetime('now') WHERE id=?`,
m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket, m.ID)
if err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM device_model_ports WHERE device_model_id = ?`, m.ID); err != nil {
return err
}
for i := range m.Ports {
m.Ports[i].DeviceModelID = m.ID
}
if err := s.modelPortCreateTx(tx, m.ID, m.Ports); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) ModelDelete(id int64) error {
_, err := s.DB.Exec(`DELETE FROM device_models WHERE id=?`, id)
return err
}
func (s *Store) ModelPortGetByModelID(modelID int64) ([]models.DeviceModelPort, error) {
rows, err := s.DB.Query(`SELECT id, device_model_id, name, side, position FROM device_model_ports WHERE device_model_id = ? ORDER BY side, position`, modelID)
if err != nil {
return nil, err
}
defer rows.Close()
var ports []models.DeviceModelPort
for rows.Next() {
var p models.DeviceModelPort
if err := rows.Scan(&p.ID, &p.DeviceModelID, &p.Name, &p.Side, &p.Position); err != nil {
return nil, err
}
ports = append(ports, p)
}
return ports, rows.Err()
}
func (s *Store) modelPortCreateTx(tx *sql.Tx, modelID int64, ports []models.DeviceModelPort) error {
for _, p := range ports {
_, err := tx.Exec(`INSERT INTO device_model_ports (device_model_id, name, side, position) VALUES (?, ?, ?, ?)`,
modelID, p.Name, p.Side, p.Position)
if err != nil {
return err
}
}
return nil
}

170
internal/db/devices.go Normal file
View File

@ -0,0 +1,170 @@
package db
import (
"database/sql"
"lostcavewireplanner/internal/models"
)
func scanDevice(scanner interface{ Scan(dest ...any) error }, d *models.Device) error {
return scanner.Scan(&d.ID, &d.DeviceModelID, &d.Name, &d.UsageDescription, &d.Comment,
&d.Location, &d.RackID, &d.RackUnitStart, &d.RackSide, &d.CreatedAt, &d.UpdatedAt)
}
const deviceColumns = `d.id, d.device_model_id, d.name, d.usage_description, d.comment, d.location, d.rack_id, d.rack_unit_start, d.rack_side, d.created_at, d.updated_at`
func (s *Store) DeviceGetByID(id int64) (*models.Device, error) {
d := &models.Device{}
err := s.DB.QueryRow(`SELECT `+deviceColumns+` FROM devices d WHERE d.id = ?`, id).Scan(
&d.ID, &d.DeviceModelID, &d.Name, &d.UsageDescription, &d.Comment,
&d.Location, &d.RackID, &d.RackUnitStart, &d.RackSide, &d.CreatedAt, &d.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
d.Model, _ = s.ModelGetByID(d.DeviceModelID)
d.Ports, _ = s.PortGetByDeviceID(d.ID)
if d.RackID != nil {
d.Rack, _ = s.RackGetByID(*d.RackID)
}
return d, nil
}
func (s *Store) DeviceGetAllUnracked() ([]models.Device, error) {
rows, err := s.DB.Query(`SELECT ` + deviceColumns + ` FROM devices d WHERE d.rack_id IS NULL ORDER BY d.name`)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []models.Device
for rows.Next() {
var d models.Device
if err := scanDevice(rows, &d); err != nil {
return nil, err
}
d.Model, _ = s.ModelGetByID(d.DeviceModelID)
d.Ports, _ = s.PortGetByDeviceID(d.ID)
devices = append(devices, d)
}
return devices, rows.Err()
}
func (s *Store) DeviceGetByRackID(rackID int64) ([]models.Device, error) {
rows, err := s.DB.Query(`SELECT `+deviceColumns+` FROM devices d WHERE d.rack_id = ? AND d.rack_unit_start IS NOT NULL ORDER BY d.rack_unit_start`, rackID)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []models.Device
for rows.Next() {
var d models.Device
if err := scanDevice(rows, &d); err != nil {
return nil, err
}
d.Model, _ = s.ModelGetByID(d.DeviceModelID)
d.Ports, _ = s.PortGetByDeviceID(d.ID)
devices = append(devices, d)
}
return devices, rows.Err()
}
func (s *Store) DeviceGetUnrackedByRackID(rackID int64) ([]models.Device, error) {
rows, err := s.DB.Query(`SELECT `+deviceColumns+` FROM devices d WHERE d.rack_id = ? AND d.rack_unit_start IS NULL ORDER BY d.name`, rackID)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []models.Device
for rows.Next() {
var d models.Device
if err := scanDevice(rows, &d); err != nil {
return nil, err
}
d.Model, _ = s.ModelGetByID(d.DeviceModelID)
d.Ports, _ = s.PortGetByDeviceID(d.ID)
devices = append(devices, d)
}
return devices, rows.Err()
}
func (s *Store) DeviceCreate(d *models.Device) (int64, error) {
tx, err := s.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT INTO devices (device_model_id, name, usage_description, comment, location, rack_id, rack_unit_start, rack_side) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
d.DeviceModelID, d.Name, d.UsageDescription, d.Comment, d.Location, d.RackID, d.RackUnitStart, d.RackSide)
if err != nil {
return 0, err
}
deviceID, err := res.LastInsertId()
if err != nil {
return 0, err
}
model, err := s.modelGetByIDTx(tx, d.DeviceModelID)
if err != nil {
return 0, err
}
if err := s.portCreateFromModelTx(tx, deviceID, model); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return deviceID, nil
}
func (s *Store) DeviceUpdate(d *models.Device) error {
_, err := s.DB.Exec(`UPDATE devices SET name=?, usage_description=?, comment=?, location=?, rack_id=?, rack_unit_start=?, rack_side=?, updated_at=datetime('now') WHERE id=?`,
d.Name, d.UsageDescription, d.Comment, d.Location, d.RackID, d.RackUnitStart, d.RackSide, d.ID)
return err
}
func (s *Store) DeviceDelete(id int64) error {
_, err := s.DB.Exec(`DELETE FROM devices WHERE id=?`, id)
return err
}
func (s *Store) DeviceGetByName(name string) (*models.Device, error) {
d := &models.Device{}
err := s.DB.QueryRow(`SELECT `+deviceColumns+` FROM devices d WHERE d.name = ?`, name).Scan(
&d.ID, &d.DeviceModelID, &d.Name, &d.UsageDescription, &d.Comment,
&d.Location, &d.RackID, &d.RackUnitStart, &d.RackSide, &d.CreatedAt, &d.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return d, nil
}
func (s *Store) DeviceGetAllWallSockets() ([]models.Device, error) {
rows, err := s.DB.Query(`SELECT ` + deviceColumns + ` FROM devices d JOIN device_models dm ON dm.id = d.device_model_id WHERE dm.is_wall_socket = TRUE ORDER BY d.location, d.name`)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []models.Device
for rows.Next() {
var d models.Device
if err := scanDevice(rows, &d); err != nil {
return nil, err
}
d.Model, _ = s.ModelGetByID(d.DeviceModelID)
d.Ports, _ = s.PortGetByDeviceID(d.ID)
devices = append(devices, d)
}
return devices, rows.Err()
}

74
internal/db/ports.go Normal file
View File

@ -0,0 +1,74 @@
package db
import (
"database/sql"
"lostcavewireplanner/internal/models"
)
func (s *Store) PortGetByDeviceID(deviceID int64) ([]models.DevicePort, error) {
rows, err := s.DB.Query(`SELECT id, device_id, name, side, position FROM device_ports WHERE device_id = ? ORDER BY side, position`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
var ports []models.DevicePort
for rows.Next() {
var p models.DevicePort
if err := rows.Scan(&p.ID, &p.DeviceID, &p.Name, &p.Side, &p.Position); err != nil {
return nil, err
}
ports = append(ports, p)
}
return ports, rows.Err()
}
func (s *Store) PortGetByID(id int64) (*models.DevicePort, error) {
p := &models.DevicePort{}
err := s.DB.QueryRow(`SELECT id, device_id, name, side, position FROM device_ports WHERE id = ?`, id).
Scan(&p.ID, &p.DeviceID, &p.Name, &p.Side, &p.Position)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return p, nil
}
func (s *Store) PortGetPaired(deviceID int64, name string, side string) (*models.DevicePort, error) {
p := &models.DevicePort{}
err := s.DB.QueryRow(`SELECT id, device_id, name, side, position FROM device_ports WHERE device_id = ? AND name = ? AND side = ?`, deviceID, name, side).
Scan(&p.ID, &p.DeviceID, &p.Name, &p.Side, &p.Position)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return p, nil
}
func (s *Store) portCreateFromModelTx(tx *sql.Tx, deviceID int64, model *models.DeviceModel) error {
for _, mp := range model.Ports {
if model.IsPatchPanel || model.IsWallSocket {
_, err := tx.Exec(`INSERT INTO device_ports (device_id, name, side, position) VALUES (?, ?, 'front', ?)`,
deviceID, mp.Name, mp.Position)
if err != nil {
return err
}
_, err = tx.Exec(`INSERT INTO device_ports (device_id, name, side, position) VALUES (?, ?, 'back', ?)`,
deviceID, mp.Name, mp.Position)
if err != nil {
return err
}
} else {
_, err := tx.Exec(`INSERT INTO device_ports (device_id, name, side, position) VALUES (?, ?, ?, ?)`,
deviceID, mp.Name, mp.Side, mp.Position)
if err != nil {
return err
}
}
}
return nil
}

70
internal/db/racks.go Normal file
View File

@ -0,0 +1,70 @@
package db
import (
"database/sql"
"lostcavewireplanner/internal/models"
)
func (s *Store) RackGetAll() ([]models.Rack, error) {
rows, err := s.DB.Query(`SELECT id, name, rack_type, depth, height_units, comment, created_at, updated_at FROM racks ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var racks []models.Rack
for rows.Next() {
var r models.Rack
if err := rows.Scan(&r.ID, &r.Name, &r.RackType, &r.Depth, &r.HeightUnits, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
racks = append(racks, r)
}
return racks, rows.Err()
}
func (s *Store) RackGetByID(id int64) (*models.Rack, error) {
r := &models.Rack{}
err := s.DB.QueryRow(`SELECT id, name, rack_type, depth, height_units, comment, created_at, updated_at FROM racks WHERE id = ?`, id).
Scan(&r.ID, &r.Name, &r.RackType, &r.Depth, &r.HeightUnits, &r.Comment, &r.CreatedAt, &r.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return r, nil
}
func (s *Store) RackCreate(r *models.Rack) (int64, error) {
res, err := s.DB.Exec(`INSERT INTO racks (name, rack_type, depth, height_units, comment) VALUES (?, ?, ?, ?, ?)`,
r.Name, r.RackType, r.Depth, r.HeightUnits, r.Comment)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) RackUpdate(r *models.Rack) error {
_, err := s.DB.Exec(`UPDATE racks SET name=?, rack_type=?, depth=?, height_units=?, comment=?, updated_at=datetime('now') WHERE id=?`,
r.Name, r.RackType, r.Depth, r.HeightUnits, r.Comment, r.ID)
return err
}
func (s *Store) RackDelete(id int64) error {
_, err := s.DB.Exec(`DELETE FROM racks WHERE id=?`, id)
return err
}
func (s *Store) RackGetByName(name string) (*models.Rack, error) {
r := &models.Rack{}
err := s.DB.QueryRow(`SELECT id, name, rack_type, depth, height_units, comment, created_at, updated_at FROM racks WHERE name = ?`, name).
Scan(&r.ID, &r.Name, &r.RackType, &r.Depth, &r.HeightUnits, &r.Comment, &r.CreatedAt, &r.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return r, nil
}

94
internal/db/schema.sql Normal file
View File

@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS device_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
manufacturer TEXT NOT NULL DEFAULT '',
is_rack_mountable BOOLEAN NOT NULL DEFAULT TRUE,
height_units INTEGER,
front_image TEXT DEFAULT '',
back_image TEXT DEFAULT '',
is_patch_panel BOOLEAN NOT NULL DEFAULT FALSE,
is_wall_socket BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS device_model_ports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_model_id INTEGER NOT NULL REFERENCES device_models(id) ON DELETE CASCADE,
name TEXT NOT NULL,
side TEXT NOT NULL CHECK(side IN ('front', 'back')),
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS racks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
rack_type TEXT NOT NULL CHECK(rack_type IN ('network', 'server')),
depth TEXT NOT NULL CHECK(depth IN ('shallow', 'deep')),
height_units INTEGER NOT NULL,
comment TEXT DEFAULT '',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_model_id INTEGER NOT NULL REFERENCES device_models(id),
name TEXT NOT NULL UNIQUE,
usage_description TEXT DEFAULT '',
comment TEXT DEFAULT '',
location TEXT DEFAULT '',
rack_id INTEGER REFERENCES racks(id) ON DELETE SET NULL,
rack_unit_start INTEGER,
rack_side TEXT CHECK(rack_side IN ('front', 'back')),
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS device_ports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
name TEXT NOT NULL,
side TEXT NOT NULL CHECK(side IN ('front', 'back')),
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS connection_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_type_id INTEGER NOT NULL REFERENCES connection_types(id),
label_1 TEXT,
label_2 TEXT,
color TEXT NOT NULL DEFAULT '#808080',
port_id_1 INTEGER REFERENCES device_ports(id) ON DELETE SET NULL,
port_id_2 INTEGER REFERENCES device_ports(id) ON DELETE SET NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_device_ports_device_id ON device_ports(device_id);
CREATE INDEX IF NOT EXISTS idx_device_model_ports_model_id ON device_model_ports(device_model_id);
CREATE INDEX IF NOT EXISTS idx_devices_rack_id ON devices(rack_id);
CREATE INDEX IF NOT EXISTS idx_connections_port_1 ON connections(port_id_1);
CREATE INDEX IF NOT EXISTS idx_connections_port_2 ON connections(port_id_2);
CREATE INDEX IF NOT EXISTS idx_devices_model_id ON devices(device_model_id);
INSERT OR IGNORE INTO connection_types (name) VALUES
('Ethernet'),
('FibreChannel'),
('SAS'),
('power'),
('video'),
('audio'),
('serial'),
('USB');
INSERT OR IGNORE INTO schema_version (version) VALUES (1);

View File

@ -0,0 +1,221 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"lostcavewireplanner/internal/models"
"lostcavewireplanner/internal/services"
)
func (h *Handlers) ConnectionModal(w http.ResponseWriter, r *http.Request) {
portIDStr := r.PathValue("portId")
portID, err := strconv.ParseInt(portIDStr, 10, 64)
if err != nil {
http.Error(w, "invalid port id", http.StatusBadRequest)
return
}
trace, err := services.TraceConnection(h.Store, portID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
connTypes, _ := h.Store.ConnectionTypeGetAll()
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
type ConnectionModalData struct {
Trace *services.TraceResult
ConnectionTypes []models.ConnectionType
AllPorts []FlatPort
Error string
}
allDevices, _ := h.Store.DeviceGetAllUnracked()
rackedDevicesMap := map[int64]bool{}
var flatPorts []FlatPort
addDevicePorts := func(devices []models.Device) {
for _, d := range devices {
if rackedDevicesMap[d.ID] {
continue
}
rackedDevicesMap[d.ID] = true
for _, p := range d.Ports {
flatPorts = append(flatPorts, FlatPort{
ID: p.ID,
Name: p.Name,
Side: p.Side,
DeviceID: d.ID,
DeviceName: d.Name,
DeviceModel: "",
})
if d.Model != nil {
flatPorts[len(flatPorts)-1].DeviceModel = d.Model.Name
}
}
}
}
addDevicePorts(allDevices)
for _, rack := range h.mustGetAllRacks() {
if devs, err := h.Store.DeviceGetByRackID(rack.ID); err == nil {
addDevicePorts(devs)
}
if devs, err := h.Store.DeviceGetUnrackedByRackID(rack.ID); err == nil {
addDevicePorts(devs)
}
}
h.render(w, "connection_modal.html", ConnectionModalData{
Trace: trace,
ConnectionTypes: connTypes,
AllPorts: flatPorts,
})
}
type FlatPort struct {
ID int64
Name string
Side string
DeviceID int64
DeviceName string
DeviceModel string
}
func (h *Handlers) mustGetAllRacks() []models.Rack {
racks, _ := h.Store.RackGetAll()
if racks == nil {
return []models.Rack{}
}
return racks
}
func (h *Handlers) ConnectionCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
connTypeID, _ := strconv.ParseInt(r.FormValue("connection_type_id"), 10, 64)
portID1Str := r.FormValue("port_id_1")
portID2Str := r.FormValue("port_id_2")
label1 := r.FormValue("label_1")
label2 := r.FormValue("label_2")
color := r.FormValue("color")
returnPortID := r.FormValue("return_port_id")
if color == "" {
color = "#808080"
}
var p1, p2 *int64
if portID1Str != "" {
v, _ := strconv.ParseInt(portID1Str, 10, 64)
p1 = &v
}
if portID2Str != "" {
v, _ := strconv.ParseInt(portID2Str, 10, 64)
p2 = &v
}
var label1Ptr, label2Ptr *string
if label1 != "" {
label1Ptr = &label1
}
if label2 != "" {
label2Ptr = &label2
}
conn := &models.Connection{
ConnectionTypeID: connTypeID,
Label1: label1Ptr,
Label2: label2Ptr,
Color: color,
PortID1: p1,
PortID2: p2,
}
if err := h.Store.ConnectionCreate(conn); err != nil {
h.redirect(w, r, "/connections/"+returnPortID)
return
}
if returnPortID != "" {
h.redirect(w, r, "/connections/"+returnPortID)
return
}
h.redirect(w, r, "/")
}
func (h *Handlers) ConnectionEdit(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
r.ParseForm()
conn, err := h.Store.ConnectionGetByID(id)
if err != nil || conn == nil {
http.Error(w, "connection not found", http.StatusNotFound)
return
}
connTypeID, _ := strconv.ParseInt(r.FormValue("connection_type_id"), 10, 64)
label1 := r.FormValue("label_1")
label2 := r.FormValue("label_2")
color := r.FormValue("color")
returnPortID := r.FormValue("return_port_id")
if color == "" {
color = "#808080"
}
conn.ConnectionTypeID = connTypeID
if label1 != "" {
conn.Label1 = &label1
} else {
conn.Label1 = nil
}
if label2 != "" {
conn.Label2 = &label2
} else {
conn.Label2 = nil
}
conn.Color = color
if err := h.Store.ConnectionUpdate(conn); err != nil {
if returnPortID != "" {
h.redirect(w, r, "/connections/"+returnPortID)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if returnPortID != "" {
h.redirect(w, r, "/connections/"+returnPortID)
return
}
h.redirect(w, r, "/")
}
func (h *Handlers) ConnectionDelete(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
returnPortID := r.URL.Query().Get("return_port_id")
h.Store.ConnectionDelete(id)
if returnPortID != "" {
h.redirect(w, r, "/connections/"+returnPortID)
return
}
h.redirect(w, r, "/")
}
func (h *Handlers) ConnectionGetByID(id int64) (*models.Connection, error) {
conn, err := h.Store.ConnectionGetByID(id)
return conn, err
}
func lower(s string) string { return strings.ToLower(s) }

View File

@ -0,0 +1,141 @@
package handlers
import (
"net/http"
"strconv"
"lostcavewireplanner/internal/models"
)
type DeviceViewData struct {
Device models.Device
ConnectionTypes []models.ConnectionType
AllPorts []models.DevicePort
Error string
}
func (h *Handlers) DeviceView(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
device, err := h.Store.DeviceGetByID(id)
if err != nil || device == nil {
http.NotFound(w, r)
return
}
connTypes, _ := h.Store.ConnectionTypeGetAll()
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
h.render(w, "device.html", DeviceViewData{
Device: *device,
ConnectionTypes: connTypes,
})
}
func (h *Handlers) DeviceCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := r.FormValue("name")
modelIDStr := r.FormValue("device_model_id")
modelID, _ := strconv.ParseInt(modelIDStr, 10, 64)
usage := r.FormValue("usage_description")
comment := r.FormValue("comment")
location := r.FormValue("location")
if name == "" || modelID == 0 {
h.redirect(w, r, "/")
return
}
device := &models.Device{
DeviceModelID: modelID,
Name: name,
UsageDescription: usage,
Comment: comment,
Location: location,
}
id, err := h.Store.DeviceCreate(device)
if err != nil {
h.redirect(w, r, "/")
return
}
h.redirect(w, r, "/devices/"+strconv.FormatInt(id, 10))
}
func (h *Handlers) DeviceEdit(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
r.ParseForm()
device, err := h.Store.DeviceGetByID(id)
if err != nil || device == nil {
http.NotFound(w, r)
return
}
device.Name = r.FormValue("name")
device.UsageDescription = r.FormValue("usage_description")
device.Comment = r.FormValue("comment")
device.Location = r.FormValue("location")
if rackSide := r.FormValue("rack_side"); rackSide != "" {
device.RackSide = &rackSide
} else {
device.RackSide = nil
}
if unitStartStr := r.FormValue("rack_unit_start"); unitStartStr != "" {
us, _ := strconv.Atoi(unitStartStr)
device.RackUnitStart = &us
} else {
device.RackUnitStart = nil
}
if err := h.Store.DeviceUpdate(device); err != nil {
h.renderDeviceError(w, *device, err.Error())
return
}
redirectTo := "/devices/" + idStr
if device.RackID != nil {
redirectTo = "/racks/" + strconv.FormatInt(*device.RackID, 10)
}
h.redirect(w, r, redirectTo)
}
func (h *Handlers) DeviceDelete(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
device, _ := h.Store.DeviceGetByID(id)
rackID := device.RackID
h.Store.DeviceDelete(id)
if rackID != nil {
h.redirect(w, r, "/racks/"+strconv.FormatInt(*rackID, 10))
} else {
h.redirect(w, r, "/")
}
}
func (h *Handlers) renderDeviceError(w http.ResponseWriter, device models.Device, errMsg string) {
connTypes, _ := h.Store.ConnectionTypeGetAll()
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
h.render(w, "device.html", DeviceViewData{
Device: device,
ConnectionTypes: connTypes,
Error: errMsg,
})
}

View File

@ -0,0 +1,95 @@
package handlers
import (
"fmt"
"html/template"
"net/http"
"path/filepath"
"lostcavewireplanner/internal/db"
)
type Handlers struct {
Store *db.Store
tmpl map[string]*template.Template
}
func New(store *db.Store) *Handlers {
funcMap := template.FuncMap{
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict requires even arguments")
}
d := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
d[key] = values[i+1]
}
return d, nil
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"safe": func(s string) template.HTML { return template.HTML(s) },
}
pages := []string{
"overview.html",
"rack.html",
"device.html",
"model_list.html",
"model_form.html",
"wall_sockets.html",
}
fragments := []string{
"connection_modal.html",
}
tmpl := make(map[string]*template.Template)
baseFiles := []string{filepath.Join("templates", "base.html")}
for _, page := range pages {
files := append(baseFiles, filepath.Join("templates", page))
t, err := template.New("base.html").Funcs(funcMap).ParseFiles(files...)
if err != nil {
panic(fmt.Errorf("parse %s: %w", page, err))
}
tmpl[page] = t
}
for _, frag := range fragments {
t, err := template.New(frag).Funcs(funcMap).ParseFiles(filepath.Join("templates", frag))
if err != nil {
panic(fmt.Errorf("parse fragment %s: %w", frag, err))
}
tmpl[frag] = t
}
return &Handlers{Store: store, tmpl: tmpl}
}
func (h *Handlers) render(w http.ResponseWriter, name string, data interface{}) {
t, ok := h.tmpl[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
execName := name
if _, isPage := map[string]bool{
"overview.html": true, "rack.html": true, "device.html": true,
"model_list.html": true, "model_form.html": true, "wall_sockets.html": true,
}[name]; isPage {
execName = "base.html"
}
if err := t.ExecuteTemplate(w, execName, data); err != nil {
http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError)
}
}
func (h *Handlers) redirect(w http.ResponseWriter, r *http.Request, url string) {
w.Header().Set("HX-Redirect", url)
http.Redirect(w, r, url, http.StatusSeeOther)
}

199
internal/handlers/models.go Normal file
View File

@ -0,0 +1,199 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"lostcavewireplanner/internal/models"
)
type ModelListData struct {
Models []models.DeviceModel
Error string
}
type ModelFormData struct {
Model *models.DeviceModel
Error string
IsEdit bool
}
func (h *Handlers) ModelList(w http.ResponseWriter, r *http.Request) {
list, _ := h.Store.ModelGetAll()
if list == nil {
list = []models.DeviceModel{}
}
h.render(w, "model_list.html", ModelListData{Models: list})
}
func (h *Handlers) ModelCreateForm(w http.ResponseWriter, r *http.Request) {
h.render(w, "model_form.html", ModelFormData{IsEdit: false})
}
func (h *Handlers) ModelEditForm(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
model, err := h.Store.ModelGetByID(id)
if err != nil || model == nil {
http.NotFound(w, r)
return
}
h.render(w, "model_form.html", ModelFormData{Model: model, IsEdit: true})
}
func (h *Handlers) ModelCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m := &models.DeviceModel{
Name: r.FormValue("name"),
Manufacturer: r.FormValue("manufacturer"),
IsPatchPanel: r.FormValue("is_patch_panel") == "on",
IsWallSocket: r.FormValue("is_wall_socket") == "on",
}
isRack := r.FormValue("is_rack_mountable") == "on"
m.IsRackMountable = isRack
if isRack {
if huStr := r.FormValue("height_units"); huStr != "" {
hu, _ := strconv.Atoi(huStr)
m.HeightUnits = &hu
}
}
m.FrontImage = saveUploadedFile(r, "front_image", "uploads")
m.BackImage = saveUploadedFile(r, "back_image", "uploads")
parsePortsFromForm(r, m)
if m.Name == "" {
h.render(w, "model_form.html", ModelFormData{Model: m, IsEdit: false, Error: "Name is required"})
return
}
_, err := h.Store.ModelCreate(m, "uploads")
if err != nil {
h.render(w, "model_form.html", ModelFormData{Model: m, IsEdit: false, Error: err.Error()})
return
}
http.Redirect(w, r, "/models", http.StatusSeeOther)
}
func (h *Handlers) ModelUpdate(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m, err := h.Store.ModelGetByID(id)
if err != nil || m == nil {
http.NotFound(w, r)
return
}
m.Name = r.FormValue("name")
m.Manufacturer = r.FormValue("manufacturer")
m.IsPatchPanel = r.FormValue("is_patch_panel") == "on"
m.IsWallSocket = r.FormValue("is_wall_socket") == "on"
isRack := r.FormValue("is_rack_mountable") == "on"
m.IsRackMountable = isRack
if isRack {
if huStr := r.FormValue("height_units"); huStr != "" {
hu, _ := strconv.Atoi(huStr)
m.HeightUnits = &hu
}
} else {
m.HeightUnits = nil
}
if frontImg := saveUploadedFile(r, "front_image", "uploads"); frontImg != "" {
m.FrontImage = frontImg
}
if backImg := saveUploadedFile(r, "back_image", "uploads"); backImg != "" {
m.BackImage = backImg
}
parsePortsFromForm(r, m)
if m.Name == "" {
h.render(w, "model_form.html", ModelFormData{Model: m, IsEdit: true, Error: "Name is required"})
return
}
if err := h.Store.ModelUpdate(m); err != nil {
h.render(w, "model_form.html", ModelFormData{Model: m, IsEdit: true, Error: err.Error()})
return
}
http.Redirect(w, r, "/models", http.StatusSeeOther)
}
func (h *Handlers) ModelDelete(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
h.Store.ModelDelete(id)
http.Redirect(w, r, "/models", http.StatusSeeOther)
}
func saveUploadedFile(r *http.Request, field, dir string) string {
file, header, err := r.FormFile(field)
if err != nil {
return ""
}
defer file.Close()
ext := ""
if idx := strings.LastIndex(header.Filename, "."); idx >= 0 {
ext = header.Filename[idx:]
}
safeName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
fpath := filepath.Join(dir, safeName)
dst, err := os.Create(fpath)
if err != nil {
return ""
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return ""
}
return fpath
}
func parsePortsFromForm(r *http.Request, m *models.DeviceModel) {
r.ParseForm()
portNames := r.Form["port_name"]
portSides := r.Form["port_side"]
m.Ports = nil
for i := range portNames {
if i >= len(portSides) {
break
}
if portNames[i] == "" {
continue
}
m.Ports = append(m.Ports, models.DeviceModelPort{
Name: portNames[i],
Side: portSides[i],
Position: i,
})
}
}

View File

@ -0,0 +1,98 @@
package handlers
import (
"net/http"
"lostcavewireplanner/internal/models"
)
type OverviewData struct {
Racks []models.Rack
Devices []models.Device
Connections []models.Connection
AllModels []models.DeviceModel
Error string
}
func (h *Handlers) Overview(w http.ResponseWriter, r *http.Request) {
racks, _ := h.Store.RackGetAll()
devices, _ := h.Store.DeviceGetAllUnracked()
connections, _ := h.Store.ConnectionGetAll()
allModels, _ := h.Store.ModelGetAll()
if racks == nil {
racks = []models.Rack{}
}
if devices == nil {
devices = []models.Device{}
}
if connections == nil {
connections = []models.Connection{}
}
if allModels == nil {
allModels = []models.DeviceModel{}
}
h.render(w, "overview.html", OverviewData{
Racks: racks,
Devices: devices,
Connections: connections,
AllModels: allModels,
})
}
func (h *Handlers) RackCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := r.FormValue("name")
rackType := r.FormValue("rack_type")
depth := r.FormValue("depth")
heightUnits := 42
if name == "" {
h.renderError(w, "overview.html", "Name is required")
return
}
rack := &models.Rack{
Name: name,
RackType: rackType,
Depth: depth,
HeightUnits: heightUnits,
}
_, err := h.Store.RackCreate(rack)
if err != nil {
h.renderError(w, "overview.html", err.Error())
return
}
h.redirect(w, r, "/")
}
func renderOverviewError(h *Handlers, w http.ResponseWriter, errMsg string) {
racks, _ := h.Store.RackGetAll()
devices, _ := h.Store.DeviceGetAllUnracked()
connections, _ := h.Store.ConnectionGetAll()
allModels, _ := h.Store.ModelGetAll()
if racks == nil {
racks = []models.Rack{}
}
if devices == nil {
devices = []models.Device{}
}
if connections == nil {
connections = []models.Connection{}
}
if allModels == nil {
allModels = []models.DeviceModel{}
}
data := OverviewData{Racks: racks, Devices: devices, Connections: connections, AllModels: allModels, Error: errMsg}
h.render(w, "overview.html", data)
}
func (h *Handlers) renderError(w http.ResponseWriter, page string, errMsg string) {
switch page {
case "overview.html":
renderOverviewError(h, w, errMsg)
default:
http.Error(w, errMsg, http.StatusBadRequest)
}
}

268
internal/handlers/racks.go Normal file
View File

@ -0,0 +1,268 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"lostcavewireplanner/internal/models"
)
type RackViewData struct {
Rack models.Rack
FrontSlots []RackSlot
BackSlots []RackSlot
RackedDevices []models.Device
UnrackedDevices []models.Device
Connections []models.Connection
ConnectionTypes []models.ConnectionType
AllDevices []models.Device
WallSocketModels []models.DeviceModel
Error string
}
type RackSlot struct {
Unit int
Device *models.Device
Height int
IsStart bool
}
func (h *Handlers) RackView(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
rack, err := h.Store.RackGetByID(id)
if err != nil || rack == nil {
http.NotFound(w, r)
return
}
rackedDevices, _ := h.Store.DeviceGetByRackID(id)
unrackedDevices, _ := h.Store.DeviceGetUnrackedByRackID(id)
connections, _ := h.Store.ConnectionGetAllForRack(id)
connTypes, _ := h.Store.ConnectionTypeGetAll()
allDevices, _ := h.Store.DeviceGetAllUnracked()
if rackedDevices == nil {
rackedDevices = []models.Device{}
}
if unrackedDevices == nil {
unrackedDevices = []models.Device{}
}
if connections == nil {
connections = []models.Connection{}
}
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
if allDevices == nil {
allDevices = []models.Device{}
}
frontSlots, backSlots := buildRackSlots(rack.HeightUnits, rackedDevices)
h.render(w, "rack.html", RackViewData{
Rack: *rack,
FrontSlots: frontSlots,
BackSlots: backSlots,
RackedDevices: rackedDevices,
UnrackedDevices: unrackedDevices,
Connections: connections,
ConnectionTypes: connTypes,
AllDevices: allDevices,
})
}
func buildRackSlots(heightUnits int, devices []models.Device) (front, back []RackSlot) {
front = make([]RackSlot, heightUnits)
back = make([]RackSlot, heightUnits)
for i := range front {
front[i].Unit = i + 1
back[i].Unit = i + 1
}
for _, d := range devices {
if d.RackUnitStart == nil || d.RackSide == nil {
continue
}
height := 1
if d.Model != nil && d.Model.HeightUnits != nil {
height = *d.Model.HeightUnits
}
start := *d.RackUnitStart - 1
if start < 0 || start >= heightUnits {
continue
}
end := start + height
if end > heightUnits {
end = heightUnits
}
slots := &front
if *d.RackSide == "back" {
slots = &back
}
for u := start; u < end; u++ {
(*slots)[u].Device = &d
(*slots)[u].Height = height
(*slots)[u].IsStart = (u == start)
}
}
return
}
func (h *Handlers) RackDelete(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
h.Store.RackDelete(id)
h.redirect(w, r, "/")
}
func (h *Handlers) RackEdit(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
r.ParseForm()
rack, err := h.Store.RackGetByID(id)
if err != nil || rack == nil {
http.NotFound(w, r)
return
}
rack.Name = r.FormValue("name")
rack.RackType = r.FormValue("rack_type")
rack.Depth = r.FormValue("depth")
if hu := r.FormValue("height_units"); hu != "" {
if n, err := strconv.Atoi(hu); err == nil {
rack.HeightUnits = n
}
}
rack.Comment = r.FormValue("comment")
if err := h.Store.RackUpdate(rack); err != nil {
h.renderRackError(w, *rack, "Failed to update: "+err.Error())
return
}
h.redirect(w, r, "/racks/"+idStr)
}
func (h *Handlers) RackAddRackedDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
rackID, _ := strconv.ParseInt(idStr, 10, 64)
r.ParseForm()
deviceIDStr := r.FormValue("device_id")
deviceID, _ := strconv.ParseInt(deviceIDStr, 10, 64)
unitStartStr := r.FormValue("rack_unit_start")
unitStart, _ := strconv.Atoi(unitStartStr)
rackSide := r.FormValue("rack_side")
device, err := h.Store.DeviceGetByID(deviceID)
if err != nil || device == nil {
h.redirect(w, r, "/racks/"+idStr)
return
}
device.RackID = &rackID
device.RackUnitStart = &unitStart
device.RackSide = &rackSide
if err := h.Store.DeviceUpdate(device); err != nil {
h.redirect(w, r, "/racks/"+idStr)
return
}
h.redirect(w, r, "/racks/"+idStr)
}
func (h *Handlers) RackAddUnrackedDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
rackID, _ := strconv.ParseInt(idStr, 10, 64)
r.ParseForm()
deviceIDStr := r.FormValue("device_id")
deviceID, _ := strconv.ParseInt(deviceIDStr, 10, 64)
device, err := h.Store.DeviceGetByID(deviceID)
if err != nil || device == nil {
h.redirect(w, r, "/racks/"+idStr)
return
}
device.RackID = &rackID
device.RackUnitStart = nil
device.RackSide = nil
if err := h.Store.DeviceUpdate(device); err != nil {
h.redirect(w, r, "/racks/"+idStr)
return
}
h.redirect(w, r, "/racks/"+idStr)
}
func (h *Handlers) RackRemoveDevice(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
devIDStr := r.PathValue("devId")
rackID, _ := strconv.ParseInt(idStr, 10, 64)
devID, _ := strconv.ParseInt(devIDStr, 10, 64)
device, err := h.Store.DeviceGetByID(devID)
if err != nil || device == nil {
h.redirect(w, r, "/racks/"+strconv.FormatInt(rackID, 10))
return
}
device.RackID = nil
device.RackUnitStart = nil
device.RackSide = nil
h.Store.DeviceUpdate(device)
h.redirect(w, r, "/racks/"+idStr)
}
func (h *Handlers) renderRackError(w http.ResponseWriter, rack models.Rack, errMsg string) {
rackedDevices, _ := h.Store.DeviceGetByRackID(rack.ID)
unrackedDevices, _ := h.Store.DeviceGetUnrackedByRackID(rack.ID)
connections, _ := h.Store.ConnectionGetAllForRack(rack.ID)
connTypes, _ := h.Store.ConnectionTypeGetAll()
allDevices, _ := h.Store.DeviceGetAllUnracked()
if rackedDevices == nil {
rackedDevices = []models.Device{}
}
if unrackedDevices == nil {
unrackedDevices = []models.Device{}
}
if connections == nil {
connections = []models.Connection{}
}
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
frontSlots, backSlots := buildRackSlots(rack.HeightUnits, rackedDevices)
h.render(w, "rack.html", RackViewData{
Rack: rack,
FrontSlots: frontSlots,
BackSlots: backSlots,
RackedDevices: rackedDevices,
UnrackedDevices: unrackedDevices,
Connections: connections,
ConnectionTypes: connTypes,
AllDevices: allDevices,
Error: errMsg,
})
}
func formatPortName(name string) string {
parts := strings.Split(name, "-")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return name
}

View File

@ -0,0 +1,73 @@
package handlers
import (
"net/http"
"strconv"
"lostcavewireplanner/internal/models"
)
type WallSocketData struct {
Sockets []models.Device
Models []models.DeviceModel
Error string
}
func (h *Handlers) WallSockets(w http.ResponseWriter, r *http.Request) {
sockets, _ := h.Store.DeviceGetAllWallSockets()
if sockets == nil {
sockets = []models.Device{}
}
allModels, _ := h.Store.ModelGetAll()
wallModels := []models.DeviceModel{}
for _, m := range allModels {
if m.IsWallSocket {
wallModels = append(wallModels, m)
}
}
if wallModels == nil {
wallModels = []models.DeviceModel{}
}
h.render(w, "wall_sockets.html", WallSocketData{
Sockets: sockets,
Models: wallModels,
})
}
func (h *Handlers) WallSocketCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
name := r.FormValue("name")
modelIDStr := r.FormValue("device_model_id")
modelID, _ := strconv.ParseInt(modelIDStr, 10, 64)
location := r.FormValue("location")
comment := r.FormValue("comment")
if name == "" || modelID == 0 {
h.redirect(w, r, "/wall-sockets")
return
}
device := &models.Device{
DeviceModelID: modelID,
Name: name,
Location: location,
Comment: comment,
}
_, err := h.Store.DeviceCreate(device)
if err != nil {
h.redirect(w, r, "/wall-sockets")
return
}
h.redirect(w, r, "/wall-sockets")
}
func (h *Handlers) WallSocketDelete(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
h.Store.DeviceDelete(id)
h.redirect(w, r, "/wall-sockets")
}

84
internal/models/models.go Normal file
View File

@ -0,0 +1,84 @@
package models
import "time"
type DeviceModel struct {
ID int64
Name string
Manufacturer string
IsRackMountable bool
HeightUnits *int
FrontImage string
BackImage string
IsPatchPanel bool
IsWallSocket bool
CreatedAt time.Time
UpdatedAt time.Time
Ports []DeviceModelPort
}
type DeviceModelPort struct {
ID int64
DeviceModelID int64
Name string
Side string
Position int
}
type Rack struct {
ID int64
Name string
RackType string
Depth string
HeightUnits int
Comment string
CreatedAt time.Time
UpdatedAt time.Time
}
type Device struct {
ID int64
DeviceModelID int64
Name string
UsageDescription string
Comment string
Location string
RackID *int64
RackUnitStart *int
RackSide *string
CreatedAt time.Time
UpdatedAt time.Time
Model *DeviceModel
Ports []DevicePort
Rack *Rack
}
type DevicePort struct {
ID int64
DeviceID int64
Name string
Side string
Position int
}
type ConnectionType struct {
ID int64
Name string
}
type Connection struct {
ID int64
ConnectionTypeID int64
Label1 *string
Label2 *string
Color string
PortID1 *int64
PortID2 *int64
CreatedAt time.Time
UpdatedAt time.Time
ConnectionType *ConnectionType
Port1 *DevicePort
Port2 *DevicePort
Device1 *Device
Device2 *Device
}

View File

@ -0,0 +1,220 @@
package services
import (
"fmt"
"lostcavewireplanner/internal/db"
"lostcavewireplanner/internal/models"
)
type Segment struct {
DeviceID int64
DeviceName string
ModelName string
PortID int64
PortName string
Side string
}
type TraceResult struct {
FarSegments []Segment
ConnectionID int64
ConnectionType string
Color string
Label1 string
Label2 string
ClickedPortID int64
ClickedPortName string
ClickedDeviceID int64
ClickedDeviceName string
ClickedModelName string
ClickedSide string
NearSegments []Segment
HasConnection bool
}
func TraceConnection(store *db.Store, clickedPortID int64) (*TraceResult, error) {
clickedPort, err := store.PortGetByID(clickedPortID)
if err != nil {
return nil, err
}
if clickedPort == nil {
return nil, fmt.Errorf("port not found")
}
clickedDevice, err := store.DeviceGetByID(clickedPort.DeviceID)
if err != nil {
return nil, err
}
result := &TraceResult{
ClickedPortID: clickedPort.ID,
ClickedPortName: clickedPort.Name,
ClickedDeviceID: clickedDevice.ID,
ClickedDeviceName: clickedDevice.Name,
ClickedSide: clickedPort.Side,
}
if clickedDevice.Model != nil {
result.ClickedModelName = clickedDevice.Model.Name
}
conn, err := store.ConnectionGetByPortID(clickedPortID)
if err != nil {
return nil, err
}
if conn == nil {
return result, nil
}
result.HasConnection = true
result.ConnectionID = conn.ID
result.Color = conn.Color
if conn.Label1 != nil {
result.Label1 = *conn.Label1
}
if conn.Label2 != nil {
result.Label2 = *conn.Label2
}
if conn.ConnectionType != nil {
result.ConnectionType = conn.ConnectionType.Name
}
var remotePort *models.DevicePort
if conn.PortID1 != nil && *conn.PortID1 == clickedPortID {
if conn.PortID2 != nil {
remotePort, _ = store.PortGetByID(*conn.PortID2)
}
} else if conn.PortID2 != nil && *conn.PortID2 == clickedPortID {
if conn.PortID1 != nil {
remotePort, _ = store.PortGetByID(*conn.PortID1)
}
}
if remotePort != nil {
result.FarSegments = traceAway(store, remotePort.ID, conn.ID)
}
result.NearSegments = traceThrough(store, clickedPortID, conn.ID)
return result, nil
}
func traceAway(store *db.Store, portID int64, excludeConnID int64) []Segment {
var segments []Segment
visitedPorts := map[int64]bool{}
currentPortID := portID
for {
if visitedPorts[currentPortID] {
break
}
visitedPorts[currentPortID] = true
port, err := store.PortGetByID(currentPortID)
if err != nil || port == nil {
break
}
device, err := store.DeviceGetByID(port.DeviceID)
if err != nil || device == nil {
break
}
modelName := ""
if device.Model != nil {
modelName = device.Model.Name
}
segments = append(segments, Segment{
DeviceID: device.ID,
DeviceName: device.Name,
ModelName: modelName,
PortID: port.ID,
PortName: port.Name,
Side: port.Side,
})
isPatch := device.Model != nil && (device.Model.IsPatchPanel || device.Model.IsWallSocket)
if !isPatch {
break
}
pairedSide := "back"
if port.Side == "back" {
pairedSide = "front"
}
pairedPort, err := store.PortGetPaired(port.DeviceID, port.Name, pairedSide)
if err != nil || pairedPort == nil {
break
}
if visitedPorts[pairedPort.ID] {
break
}
nextConn, err := store.ConnectionGetByPortIDExcluding(pairedPort.ID, excludeConnID)
if err != nil || nextConn == nil {
break
}
var nextPortID int64
if nextConn.PortID1 != nil && *nextConn.PortID1 != pairedPort.ID {
nextPortID = *nextConn.PortID1
} else if nextConn.PortID2 != nil && *nextConn.PortID2 != pairedPort.ID {
nextPortID = *nextConn.PortID2
} else {
break
}
excludeConnID = nextConn.ID
currentPortID = nextPortID
}
return segments
}
func traceThrough(store *db.Store, portID int64, excludeConnID int64) []Segment {
var segments []Segment
port, err := store.PortGetByID(portID)
if err != nil || port == nil {
return segments
}
device, err := store.DeviceGetByID(port.DeviceID)
if err != nil || device == nil {
return segments
}
isPatch := device.Model != nil && (device.Model.IsPatchPanel || device.Model.IsWallSocket)
if !isPatch {
return segments
}
pairedSide := "back"
if port.Side == "back" {
pairedSide = "front"
}
pairedPort, err := store.PortGetPaired(port.DeviceID, port.Name, pairedSide)
if err != nil || pairedPort == nil {
return segments
}
nextConn, err := store.ConnectionGetByPortIDExcluding(pairedPort.ID, excludeConnID)
if err != nil || nextConn == nil {
return segments
}
var nextPortID int64
if nextConn.PortID1 != nil && *nextConn.PortID1 != pairedPort.ID {
nextPortID = *nextConn.PortID1
} else if nextConn.PortID2 != nil && *nextConn.PortID2 != pairedPort.ID {
nextPortID = *nextConn.PortID2
} else {
return segments
}
return traceAway(store, nextPortID, nextConn.ID)
}

61
main.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"log"
"net/http"
"os"
"lostcavewireplanner/internal/db"
"lostcavewireplanner/internal/handlers"
)
func main() {
database, err := db.Init("data.db")
if err != nil {
log.Fatalf("failed to init database: %v", err)
}
defer database.Close()
h := handlers.New(database)
mux := http.NewServeMux()
fsStatic := http.FileServer(http.Dir("static"))
mux.Handle("GET /static/", http.StripPrefix("/static/", fsStatic))
fsUploads := http.FileServer(http.Dir("uploads"))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", fsUploads))
mux.HandleFunc("GET /{$}", h.Overview)
mux.HandleFunc("POST /racks/add", h.RackCreate)
mux.HandleFunc("GET /racks/{id}", h.RackView)
mux.HandleFunc("POST /racks/{id}/delete", h.RackDelete)
mux.HandleFunc("POST /racks/{id}/edit", h.RackEdit)
mux.HandleFunc("POST /racks/{id}/devices/add/racked", h.RackAddRackedDevice)
mux.HandleFunc("POST /racks/{id}/devices/add/unracked", h.RackAddUnrackedDevice)
mux.HandleFunc("POST /racks/{id}/devices/{devId}/remove", h.RackRemoveDevice)
mux.HandleFunc("POST /devices/{id}/edit", h.DeviceEdit)
mux.HandleFunc("POST /devices/{id}/delete", h.DeviceDelete)
mux.HandleFunc("POST /devices/add", h.DeviceCreate)
mux.HandleFunc("GET /devices/{id}", h.DeviceView)
mux.HandleFunc("GET /models", h.ModelList)
mux.HandleFunc("GET /models/new", h.ModelCreateForm)
mux.HandleFunc("POST /models/create", h.ModelCreate)
mux.HandleFunc("GET /models/{id}/edit", h.ModelEditForm)
mux.HandleFunc("POST /models/{id}/update", h.ModelUpdate)
mux.HandleFunc("POST /models/{id}/delete", h.ModelDelete)
mux.HandleFunc("GET /connections/{portId}", h.ConnectionModal)
mux.HandleFunc("POST /connections/create", h.ConnectionCreate)
mux.HandleFunc("POST /connections/{id}/edit", h.ConnectionEdit)
mux.HandleFunc("POST /connections/{id}/delete", h.ConnectionDelete)
mux.HandleFunc("GET /wall-sockets", h.WallSockets)
mux.HandleFunc("POST /wall-sockets/add", h.WallSocketCreate)
mux.HandleFunc("POST /wall-sockets/{id}/delete", h.WallSocketDelete)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}

24
static/js/app.js Normal file
View File

@ -0,0 +1,24 @@
function openModal() {
document.getElementById('modal-overlay').style.display = 'flex';
}
function closeModal(event) {
if (event && event.target !== document.getElementById('modal-overlay')) {
if (!event.target.classList.contains('modal-overlay')) return;
}
document.getElementById('modal-overlay').style.display = 'none';
document.getElementById('modal-content').innerHTML = '';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.getElementById('modal-overlay').style.display = 'none';
document.getElementById('modal-content').innerHTML = '';
}
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'modal-content') {
openModal();
}
});

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

185
static/style.css Normal file
View File

@ -0,0 +1,185 @@
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--text: #e0e0e0;
--text-muted: #999;
--accent: #e94560;
--border: #333;
--green: #4ecca3;
--orange: #f0a500;
--red: #e94560;
--blue: #4a9ff5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
color: var(--text);
background: var(--bg);
font-size: 14px;
line-height: 1.5;
}
body { margin: 0; padding: 0; }
a { color: var(--blue); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4, h5 { margin: 1em 0 0.5em; font-weight: 600; }
h1 { font-size: 1.6em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.1em; }
small, .muted { color: var(--text-muted); font-size: 0.85em; }
.comment { color: var(--text-muted); font-style: italic; }
/* NAV */
.top-nav {
display: flex; align-items: center; gap: 1.5em;
padding: 0.6em 1.5em; background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.nav-brand { font-weight: 700; font-size: 1.1em; color: var(--accent) !important; }
.nav-links { display: flex; gap: 1em; }
.nav-links a { color: var(--text); }
main { max-width: 1200px; margin: 0 auto; padding: 1em 1.5em; }
/* CARDS & GRID */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.8em; }
.card {
display: block; padding: 0.8em; background: var(--surface);
border: 1px solid var(--border); border-radius: 4px;
transition: border-color 0.15s;
}
.card:hover { border-color: var(--accent); text-decoration: none; }
.card strong { display: block; }
.card span { display: block; color: var(--text-muted); font-size: 0.9em; }
.alert { padding: 0.6em 1em; border-radius: 4px; margin-bottom: 1em; }
.alert-error { background: #442222; border: 1px solid var(--red); color: #ff8888; }
/* BUTTONS */
.btn, button {
display: inline-block; padding: 0.4em 0.9em; font-size: 0.9em;
background: var(--surface2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
font-family: inherit;
}
.btn:hover, button:hover { border-color: var(--accent); }
.btn-danger, button.btn-danger { background: #442222; border-color: var(--red); color: #ff8888; }
.btn-danger:hover, button.btn-danger:hover { background: #552222; }
.btn-sm, button.btn-sm { padding: 0.2em 0.5em; font-size: 0.8em; }
/* FORMS */
label { display: block; margin-bottom: 0.3em; font-size: 0.9em; }
input, select, textarea {
display: block; width: 100%; max-width: 400px;
padding: 0.4em 0.6em; margin-bottom: 0.5em;
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 4px;
font-family: inherit; font-size: inherit;
}
fieldset { border: 1px solid var(--border); border-radius: 4px; padding: 0.8em; margin-bottom: 0.8em; }
details { margin: 0.8em 0; }
details summary { cursor: pointer; color: var(--blue); margin-bottom: 0.5em; }
.inline-form { margin-top: 0.5em; }
/* TABLE */
table { width: 100%; border-collapse: collapse; margin: 0.8em 0; }
th, td { padding: 0.4em 0.7em; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.9em; }
th { background: var(--surface); font-weight: 600; }
/* RACK LAYOUT */
.rack-layout { display: flex; gap: 2em; margin: 1em 0; }
.rack-panel { flex: 1; }
.rack-panel h3 { margin-top: 0; }
.rack-slot {
display: flex; align-items: center; gap: 0.5em;
padding: 1px 0.4em; border-bottom: 1px solid #222;
font-size: 0.85em;
}
.rack-slot.occupied { background: var(--surface); }
.unit-num { color: var(--text-muted); width: 2em; text-align: right; font-size: 0.75em; flex-shrink: 0; }
.rack-device { display: flex; flex-direction: column; }
.rack-device strong { font-size: 0.9em; }
.rack-device small { font-size: 0.75em; }
.rack-toolbar { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.5em; }
/* DEVICE SECTIONS */
.device-section { margin-bottom: 1.5em; padding: 0.8em; background: var(--surface); border-radius: 4px; }
.device-section h4 { margin-top: 0; }
.device-meta p { margin: 0.3em 0; }
/* PORTS */
.ports { display: flex; gap: 1.5em; }
.port-column { flex: 1; }
.port-column h5 { margin: 0 0 0.3em; }
.port {
display: inline-block; padding: 0.15em 0.5em; margin: 2px;
border: 1px solid var(--border); border-radius: 3px;
font-size: 0.8em; font-family: monospace;
background: var(--bg); cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.port:hover { border-color: var(--accent); }
.port.connected { border-color: var(--green); background: #2a4a3a; }
/* MODAL */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: 1.5em;
max-width: 650px; width: 95%; max-height: 85vh; overflow-y: auto;
}
.modal-inner { position: relative; }
.modal-close {
position: absolute; top: 0; right: 0;
background: none; border: none; color: var(--text);
font-size: 1.4em; cursor: pointer; padding: 0 0.3em;
}
/* CONNECTION TRACE */
.connection-trace { margin: 1em 0; padding: 0.8em; background: var(--bg); border-radius: 4px; }
.trace-chain { display: flex; align-items: center; gap: 0.3em; flex-wrap: wrap; }
.trace-segment {
padding: 0.3em 0.6em; background: var(--surface); border-radius: 3px;
border: 1px solid var(--border);
}
.trace-segment small { display: block; }
.trace-arrow { color: var(--text-muted); font-weight: bold; }
.trace-clicked {
padding: 0.3em 0.6em; background: var(--surface2); border-radius: 3px;
border: 2px solid var(--accent); display: inline-block;
}
.clicked-badge {
display: block; font-size: 0.65em; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.2em;
}
.port-ref { font-family: monospace; color: var(--green); font-size: 0.85em; }
.connection-details { margin: 0.8em 0; }
/* PORT EDITOR */
.port-entry { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.3em; }
.port-entry input { width: 18em; margin-bottom: 0; }
.port-entry select { width: 6em; margin-bottom: 0; }
/* DEVICE TOOLBAR */
.device-toolbar { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.5em; }
/* BADGES */
.badge { display: inline-block; padding: 0.1em 0.4em; font-size: 0.75em; border-radius: 3px; background: var(--surface2); }
.badge-network { background: #224444; color: var(--green); }
.badge-server { background: #332244; color: #b388ff; }
/* SECTION */
section { margin-bottom: 2em; }
/* TOOLBAR */
.toolbar { margin-bottom: 1em; }

27
templates/base.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lostcave Wireplanner</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/js/htmx.min.js"></script>
</head>
<body>
<nav class="top-nav">
<a href="/" class="nav-brand">Wireplanner</a>
<div class="nav-links">
<a href="/">Overview</a>
<a href="/models">Models</a>
<a href="/wall-sockets">Wall Sockets</a>
</div>
</nav>
<main>
{{block "content" .}}{{end}}
</main>
<div id="modal-overlay" class="modal-overlay" onclick="closeModal(event)">
<div id="modal-content" class="modal-content" onclick="event.stopPropagation()"></div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,94 @@
<div class="modal-inner">
<button class="modal-close" onclick="closeModal()">&times;</button>
{{if not .Trace.HasConnection}}
<h3>No connection on port {{.Trace.ClickedPortName}} ({{.Trace.ClickedDeviceName}})</h3>
<details>
<summary>Create Connection</summary>
<form method="POST" action="/connections/create" hx-post="/connections/create" hx-target="#modal-content" hx-swap="innerHTML">
<input type="hidden" name="return_port_id" value="{{.Trace.ClickedPortID}}">
<label>Connection type
<select name="connection_type_id">
{{range .ConnectionTypes}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
<input type="hidden" name="port_id_1" value="{{.Trace.ClickedPortID}}">
<label>Other port
<select name="port_id_2">
<option value="">-- select --</option>
{{range .AllPorts}}<option value="{{.ID}}">{{.DeviceName}} &mdash; {{.Name}} ({{.Side}})</option>{{end}}
</select>
</label>
<label>Label this end <input name="label_1"></label>
<label>Label other end <input name="label_2"></label>
<label>Color <input type="color" name="color" value="#808080"></label>
<button type="submit">Create</button>
</form>
</details>
{{else}}
<h3>Connection &mdash; {{.Trace.ConnectionType}}</h3>
<div class="connection-trace">
{{if .Trace.FarSegments}}
<div class="trace-chain">
{{range .Trace.FarSegments}}
<div class="trace-segment">
<a href="/devices/{{.DeviceID}}">{{.DeviceName}}</a>
<span class="port-ref">[{{.PortName}}]</span>
{{if .ModelName}}<small>{{.ModelName}}</small>{{end}}
</div>
<div class="trace-arrow">&mdash;</div>
{{end}}
</div>
<div class="trace-arrow">&mdash;</div>
{{end}}
<div class="trace-clicked">
<span class="clicked-badge">YOU ARE HERE</span>
<a href="/devices/{{.Trace.ClickedDeviceID}}">{{.Trace.ClickedDeviceName}}</a>
<span class="port-ref">[{{.Trace.ClickedPortName}}]</span>
</div>
{{if .Trace.NearSegments}}
<div class="trace-arrow">&mdash;</div>
<div class="trace-chain">
{{range .Trace.NearSegments}}
<div class="trace-arrow">&mdash;</div>
<div class="trace-segment">
<a href="/devices/{{.DeviceID}}">{{.DeviceName}}</a>
<span class="port-ref">[{{.PortName}}]</span>
{{if .ModelName}}<small>{{.ModelName}}</small>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="connection-details">
<p><strong>Type:</strong> {{.Trace.ConnectionType}}</p>
<p><strong>Color:</strong> <span style="display:inline-block;width:1em;height:1em;background:{{.Trace.Color}};border:1px solid #999;vertical-align:middle;"></span> {{.Trace.Color}}</p>
{{if .Trace.Label1}}<p><strong>Label end 1:</strong> {{.Trace.Label1}}</p>{{end}}
{{if .Trace.Label2}}<p><strong>Label end 2:</strong> {{.Trace.Label2}}</p>{{end}}
</div>
<div class="connection-actions">
<details>
<summary>Edit</summary>
<form method="POST" action="/connections/{{.Trace.ConnectionID}}/edit" hx-post="/connections/{{.Trace.ConnectionID}}/edit" hx-target="#modal-content" hx-swap="innerHTML">
<input type="hidden" name="return_port_id" value="{{.Trace.ClickedPortID}}">
<label>Type
<select name="connection_type_id">
{{range .ConnectionTypes}}<option value="{{.ID}}" {{if eq .Name $.Trace.ConnectionType}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label>Color <input type="color" name="color" value="{{.Trace.Color}}"></label>
<label>Label 1 <input name="label_1" value="{{.Trace.Label1}}"></label>
<label>Label 2 <input name="label_2" value="{{.Trace.Label2}}"></label>
<button type="submit">Update</button>
</form>
</details>
<form method="POST" action="/connections/{{.Trace.ConnectionID}}/delete" hx-post="/connections/{{.Trace.ConnectionID}}/delete?return_port_id={{.Trace.ClickedPortID}}" hx-target="#modal-content" hx-swap="innerHTML" style="display:inline">
<button class="btn-danger" onclick="return confirm('Delete this connection?')">Delete</button>
</form>
</div>
{{end}}
</div>

66
templates/device.html Normal file
View File

@ -0,0 +1,66 @@
{{define "content"}}
<h1>{{.Device.Name}}</h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="device-meta">
{{if .Device.Model}}<p><strong>Model:</strong> {{.Device.Model.Manufacturer}} {{.Device.Model.Name}}</p>{{end}}
{{if .Device.UsageDescription}}<p><strong>Usage:</strong> {{.Device.UsageDescription}}</p>{{end}}
{{if .Device.Location}}<p><strong>Location:</strong> {{.Device.Location}}</p>{{end}}
{{if .Device.Comment}}<p><strong>Comment:</strong> {{.Device.Comment}}</p>{{end}}
{{if .Device.Rack}}
<p><strong>Rack:</strong> <a href="/racks/{{.Device.Rack.ID}}">{{.Device.Rack.Name}}</a>
{{if .Device.RackUnitStart}} U{{.Device.RackUnitStart}} {{.Device.RackSide}}{{end}}
</p>
{{end}}
</div>
<div class="device-toolbar">
<form method="POST" action="/devices/{{.Device.ID}}/delete" onsubmit="return confirm('Delete this device?')" style="display:inline">
<button class="btn-danger">Delete Device</button>
</form>
<details style="display:inline">
<summary>Edit</summary>
<form method="POST" action="/devices/{{.Device.ID}}/edit" class="inline-form">
<label>Name <input name="name" value="{{.Device.Name}}" required></label>
<label>Usage <input name="usage_description" value="{{.Device.UsageDescription}}"></label>
<label>Comment <input name="comment" value="{{.Device.Comment}}"></label>
<label>Location <input name="location" value="{{.Device.Location}}"></label>
{{if .Device.Rack}}
<label>Unit Start <input type="number" name="rack_unit_start" value="{{.Device.RackUnitStart}}"></label>
<label>Side <select name="rack_side"><option value="front" {{if eq .Device.RackSide "front"}}selected{{end}}>Front</option><option value="back" {{if eq .Device.RackSide "back"}}selected{{end}}>Back</option></select></label>
{{end}}
<button type="submit">Update</button>
</form>
</details>
</div>
<h3>Ports</h3>
<div class="ports">
<div class="port-column">
<h4>Front</h4>
{{range .Device.Ports}}{{if eq .Side "front"}}
<span class="port"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="{{.Name}}">
{{.Name}}
</span>
{{end}}{{end}}
</div>
<div class="port-column">
<h4>Back</h4>
{{range .Device.Ports}}{{if eq .Side "back"}}
<span class="port"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="{{.Name}}">
{{.Name}}
</span>
{{end}}{{end}}
</div>
</div>
{{end}}

58
templates/model_form.html Normal file
View File

@ -0,0 +1,58 @@
{{define "content"}}
<h1>{{if .IsEdit}}Edit{{else}}Create{{end}} Device Model</h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<form method="POST" action="{{if .IsEdit}}/models/{{.Model.ID}}/update{{else}}/models/create{{end}}" enctype="multipart/form-data" id="model-form">
<fieldset>
<label>Name <input name="name" value="{{if .Model}}{{.Model.Name}}{{end}}" required></label>
<label>Manufacturer <input name="manufacturer" value="{{if .Model}}{{.Model.Manufacturer}}{{end}}"></label>
</fieldset>
<fieldset>
<label><input type="checkbox" name="is_rack_mountable" {{if not .Model}}checked{{else if .Model.IsRackMountable}}checked{{end}}> Rack-mountable</label>
<label>Height (U) <input type="number" name="height_units" value="{{if .Model}}{{if .Model.HeightUnits}}{{.Model.HeightUnits}}{{end}}{{end}}" min="1" style="width:5em"></label>
</fieldset>
<fieldset>
<label><input type="checkbox" name="is_patch_panel" {{if .Model}}{{if .Model.IsPatchPanel}}checked{{end}}{{end}}> Patch panel</label>
<label><input type="checkbox" name="is_wall_socket" {{if .Model}}{{if .Model.IsWallSocket}}checked{{end}}{{end}}> Wall socket</label>
</fieldset>
<fieldset>
<label>Front image <input type="file" name="front_image" accept="image/*">
{{if .Model}}{{if .Model.FrontImage}}<br><a href="/{{.Model.FrontImage}}" target="_blank">Current: {{.Model.FrontImage}}</a>{{end}}{{end}}
</label>
<label>Back image <input type="file" name="back_image" accept="image/*">
{{if .Model}}{{if .Model.BackImage}}<br><a href="/{{.Model.BackImage}}" target="_blank">Current: {{.Model.BackImage}}</a>{{end}}{{end}}
</label>
</fieldset>
<h3>Ports</h3>
<div id="ports-container">
{{if .Model}}
{{range $i, $p := .Model.Ports}}
<div class="port-entry">
<input name="port_name" value="{{.Name}}" placeholder="Port name (e.g. Gig1/0/1)">
<select name="port_side"><option value="front" {{if eq .Side "front"}}selected{{end}}>Front</option><option value="back" {{if eq .Side "back"}}selected{{end}}>Back</option></select>
<button type="button" onclick="this.parentElement.remove()">Remove</button>
</div>
{{end}}
{{end}}
</div>
<button type="button" onclick="addPortRow()">Add Port</button>
<div style="margin-top:1em">
<button type="submit" class="btn">{{if .IsEdit}}Update{{else}}Create{{end}} Model</button>
<a href="/models" class="btn">Cancel</a>
</div>
</form>
<script>
function addPortRow() {
var div = document.createElement('div');
div.className = 'port-entry';
div.innerHTML = '<input name="port_name" placeholder="Port name"> <select name="port_side"><option value="front">Front</option><option value="back">Back</option></select> <button type="button" onclick="this.parentElement.remove()">Remove</button>';
document.getElementById('ports-container').appendChild(div);
}
</script>
{{end}}

30
templates/model_list.html Normal file
View File

@ -0,0 +1,30 @@
{{define "content"}}
<h1>Device Models</h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="toolbar">
<a href="/models/new" class="btn">Create Model</a>
</div>
<table>
<thead><tr><th>Name</th><th>Manufacturer</th><th>Rack?</th><th>U</th><th>Patch</th><th>Wall</th><th></th></tr></thead>
<tbody>
{{range .Models}}
<tr>
<td><a href="/models/{{.ID}}/edit">{{.Name}}</a></td>
<td>{{.Manufacturer}}</td>
<td>{{if .IsRackMountable}}yes{{else}}no{{end}}</td>
<td>{{if .HeightUnits}}{{.HeightUnits}}{{else}}-{{end}}</td>
<td>{{if .IsPatchPanel}}yes{{end}}</td>
<td>{{if .IsWallSocket}}yes{{end}}</td>
<td>
<a href="/models/{{.ID}}/edit" class="btn-sm">Edit</a>
<form method="POST" action="/models/{{.ID}}/delete" style="display:inline" onsubmit="return confirm('Delete model?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

74
templates/overview.html Normal file
View File

@ -0,0 +1,74 @@
{{define "content"}}
<h1>Overview</h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<section>
<h2>Racks</h2>
<div class="grid">
{{range .Racks}}
<a href="/racks/{{.ID}}" class="card">
<strong>{{.Name}}</strong>
<span>{{.RackType}} / {{.Depth}} / {{.HeightUnits}}U</span>
{{if .Comment}}<small>{{.Comment}}</small>{{end}}
</a>
{{end}}
</div>
<details>
<summary>Add Rack</summary>
<form method="POST" action="/racks/add">
<label>Name <input name="name" required></label>
<label>Type <select name="rack_type"><option value="network">Network</option><option value="server">Server</option></select></label>
<label>Depth <select name="depth"><option value="shallow">Shallow</option><option value="deep">Deep</option></select></label>
<button type="submit">Create Rack</button>
</form>
</details>
</section>
<section>
<h2>Devices</h2>
<div class="grid">
{{range .Devices}}
<a href="/devices/{{.ID}}" class="card">
<strong>{{.Name}}</strong>
{{if .Model}}<span>{{.Model.Manufacturer}} {{.Model.Name}}</span>{{end}}
{{if .UsageDescription}}<small>{{.UsageDescription}}</small>{{end}}
</a>
{{end}}
</div>
<details>
<summary>Create Device</summary>
<form method="POST" action="/devices/add">
<label>Name <input name="name" required></label>
<label>Model
<select name="device_model_id" required>
<option value="">--</option>
{{range .AllModels}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
<label>Usage <input name="usage_description"></label>
<label>Comment <input name="comment"></label>
<label>Location <input name="location"></label>
<button type="submit">Create Device</button>
</form>
</details>
</section>
{{if .Connections}}
<section>
<h2>Connections</h2>
<table>
<thead><tr><th>Type</th><th>Endpoint A</th><th>Endpoint B</th><th>Labels</th></tr></thead>
<tbody>
{{range .Connections}}
<tr>
<td>{{if .ConnectionType}}{{.ConnectionType.Name}}{{end}}</td>
<td>{{if .Device1}}{{.Device1.Name}} [{{.Port1.Name}}]{{end}}</td>
<td>{{if .Device2}}{{.Device2.Name}} [{{.Port2.Name}}]{{end}}</td>
<td>{{.Label1}}{{if and .Label1 .Label2}} / {{end}}{{.Label2}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{end}}

148
templates/rack.html Normal file
View File

@ -0,0 +1,148 @@
{{define "content"}}
<h1>{{.Rack.Name}} <small>({{.Rack.RackType}} / {{.Rack.Depth}} / {{.Rack.HeightUnits}}U)</small></h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{if .Rack.Comment}}<p class="comment">{{.Rack.Comment}}</p>{{end}}
<div class="rack-toolbar">
<form method="POST" action="/racks/{{.Rack.ID}}/delete" onsubmit="return confirm('Delete this rack?')" style="display:inline">
<button class="btn-danger">Delete Rack</button>
</form>
<details style="display:inline">
<summary>Edit</summary>
<form method="POST" action="/racks/{{.Rack.ID}}/edit" class="inline-form">
<label>Name <input name="name" value="{{.Rack.Name}}" required></label>
<label>Type <select name="rack_type"><option value="network" {{if eq .Rack.RackType "network"}}selected{{end}}>Network</option><option value="server" {{if eq .Rack.RackType "server"}}selected{{end}}>Server</option></select></label>
<label>Depth <select name="depth"><option value="shallow" {{if eq .Rack.Depth "shallow"}}selected{{end}}>Shallow</option><option value="deep" {{if eq .Rack.Depth "deep"}}selected{{end}}>Deep</option></select></label>
<label>Height (U) <input type="number" name="height_units" value="{{.Rack.HeightUnits}}" min="1"></label>
<label>Comment <input name="comment" value="{{.Rack.Comment}}"></label>
<button type="submit">Update</button>
</form>
</details>
</div>
<div class="rack-layout">
<div class="rack-panel">
<h3>Front</h3>
{{range .FrontSlots}}
<div class="rack-slot {{if .Device}}occupied{{end}}" style="{{if .IsStart}}border-top:2px solid #444;{{end}} min-height:28px;">
<span class="unit-num">{{.Unit}}</span>
{{if .IsStart}}
<div class="rack-device">
<a href="/devices/{{.Device.ID}}"><strong>{{.Device.Name}}</strong></a>
{{if .Device.Model}}<small>{{.Device.Model.Manufacturer}} {{.Device.Model.Name}} ({{.Height}}U)</small>{{end}}
{{if .Device.UsageDescription}}<small>{{.Device.UsageDescription}}</small>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="rack-panel">
<h3>Back</h3>
{{range .BackSlots}}
<div class="rack-slot {{if .Device}}occupied{{end}}" style="{{if .IsStart}}border-top:2px solid #444;{{end}} min-height:28px;">
<span class="unit-num">{{.Unit}}</span>
{{if .IsStart}}
<div class="rack-device">
<a href="/devices/{{.Device.ID}}"><strong>{{.Device.Name}}</strong></a>
{{if .Device.Model}}<small>{{.Device.Model.Manufacturer}} {{.Device.Model.Name}} ({{.Height}}U)</small>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
</div>
<h3>Devices &amp; Ports</h3>
{{range .RackedDevices}}
<div class="device-section">
<h4>
<a href="/devices/{{.ID}}">{{.Name}}</a>
{{if .Model}}({{.Model.Manufacturer}} {{.Model.Name}}){{end}}
&mdash; U{{.RackUnitStart}} {{.RackSide}}
<form method="POST" action="/racks/{{$.Rack.ID}}/devices/{{.ID}}/remove" style="display:inline">
<button class="btn-sm btn-danger">Remove</button>
</form>
</h4>
{{template "port_list" dict "Device" . "Connections" $.Connections}}
</div>
{{end}}
{{if .UnrackedDevices}}
<h3>Unplaced Devices in Rack</h3>
{{range .UnrackedDevices}}
<div class="device-section">
<h4>
<a href="/devices/{{.ID}}">{{.Name}}</a>
{{if .Model}}({{.Model.Manufacturer}} {{.Model.Name}}){{end}}
<form method="POST" action="/racks/{{$.Rack.ID}}/devices/{{.ID}}/remove" style="display:inline">
<button class="btn-sm btn-danger">Remove</button>
</form>
</h4>
{{template "port_list" dict "Device" . "Connections" $.Connections}}
</div>
{{end}}
{{end}}
<details>
<summary>Add Device to Rack</summary>
<div style="margin-bottom:1em">
<form method="POST" action="/racks/{{.Rack.ID}}/devices/add/racked">
<label>Place at specific rack unit:</label>
<label>Device <select name="device_id" style="width:auto"><option value="">--</option>
{{range .AllDevices}}
{{if .Model}}{{if .Model.IsRackMountable}}
<option value="{{.ID}}">{{.Name}} ({{.Model.Name}})</option>
{{end}}{{end}}
{{end}}
</select></label>
<label>Unit <input type="number" name="rack_unit_start" value="1" min="1" max="{{.Rack.HeightUnits}}" style="width:5em"></label>
<label>Side <select name="rack_side" style="width:auto"><option value="front">Front</option><option value="back">Back</option></select></label>
<button type="submit">Place</button>
</form>
</div>
<div>
<form method="POST" action="/racks/{{.Rack.ID}}/devices/add/unracked">
<label>Add unplaced (in-rack list):</label>
<label>Device <select name="device_id" style="width:auto"><option value="">--</option>
{{range .AllDevices}}
<option value="{{.ID}}">{{.Name}} {{if .Model}}({{.Model.Name}}){{end}}</option>
{{end}}
</select></label>
<button type="submit">Add</button>
</form>
</div>
</details>
{{end}}
{{define "port_list"}}
<div class="ports">
<div class="port-column">
<h5>Front</h5>
{{range .Device.Ports}}{{if eq .Side "front"}}
{{$pid := .ID}}
<span class="port {{range $.Connections}}{{if and .Port1 .Port2}}{{if eq .Port1.ID $pid}}connected{{else if eq .Port2.ID $pid}}connected{{end}}{{end}}{{end}}"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="{{.Name}}">
{{.Name}}
</span>
{{end}}{{end}}
</div>
<div class="port-column">
<h5>Back</h5>
{{range .Device.Ports}}{{if eq .Side "back"}}
{{$pid := .ID}}
<span class="port {{range $.Connections}}{{if and .Port1 .Port2}}{{if eq .Port1.ID $pid}}connected{{else if eq .Port2.ID $pid}}connected{{end}}{{end}}{{end}}"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="{{.Name}}">
{{.Name}}
</span>
{{end}}{{end}}
</div>
</div>
{{end}}

View File

@ -0,0 +1,51 @@
{{define "content"}}
<h1>Wall Sockets</h1>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<table>
<thead><tr><th>Name</th><th>Location</th><th>Model</th><th>Comment</th><th>Ports</th><th></th></tr></thead>
<tbody>
{{range .Sockets}}
<tr>
<td><a href="/devices/{{.ID}}">{{.Name}}</a></td>
<td>{{.Location}}</td>
<td>{{if .Model}}{{.Model.Name}}{{end}}</td>
<td>{{.Comment}}</td>
<td>
{{range .Ports}}
<span class="port"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="{{.Name}}">
{{.Name}}
</span>
{{end}}
</td>
<td>
<form method="POST" action="/wall-sockets/{{.ID}}/delete" style="display:inline" onsubmit="return confirm('Delete?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<details>
<summary>Add Wall Socket</summary>
<form method="POST" action="/wall-sockets/add">
<label>Name <input name="name" required></label>
<label>Model
<select name="device_model_id" required>
<option value="">--</option>
{{range .Models}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
<label>Location <input name="location" placeholder="e.g. Room 4.2"></label>
<label>Comment <input name="comment"></label>
<button type="submit">Add</button>
</form>
</details>
{{end}}

0
uploads/.gitkeep Normal file
View File