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 managementmaster
commit
a0f05791f8
|
|
@ -0,0 +1,4 @@
|
|||
/wireplanner
|
||||
/data.db
|
||||
/uploads/*
|
||||
!.gitkeep
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,5 @@
|
|||
module lostcavewireplanner
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.44
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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; }
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<div class="modal-inner">
|
||||
<button class="modal-close" onclick="closeModal()">×</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}} — {{.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 — {{.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">—</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="trace-arrow">—</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">—</div>
|
||||
<div class="trace-chain">
|
||||
{{range .Trace.NearSegments}}
|
||||
<div class="trace-arrow">—</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>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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 & Ports</h3>
|
||||
{{range .RackedDevices}}
|
||||
<div class="device-section">
|
||||
<h4>
|
||||
<a href="/devices/{{.ID}}">{{.Name}}</a>
|
||||
{{if .Model}}({{.Model.Manufacturer}} {{.Model.Name}}){{end}}
|
||||
— 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}}
|
||||
|
|
@ -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}}
|
||||
Loading…
Reference in New Issue