Power strip visual in rack view with outlet grid

Added is_power_strip flag to device_models (schema migration, model,
form checkbox, DB CRUD). Power strip devices in rack view now render
a grid of plug-icon outlets instead of the regular port list, with
custom cable colors showing which outlets are in use.
master
Joca 2026-06-09 20:10:42 -03:00
parent a845b242a2
commit 56c525cb26
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
10 changed files with 66 additions and 10 deletions

View File

@ -28,6 +28,8 @@ func Init(path string) (*Store, error) {
return nil, fmt.Errorf("run schema: %w", err) return nil, fmt.Errorf("run schema: %w", err)
} }
db.Exec("ALTER TABLE device_models ADD COLUMN is_power_strip BOOLEAN NOT NULL DEFAULT FALSE")
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("wal mode: %w", err) return nil, fmt.Errorf("wal mode: %w", err)

View File

@ -6,7 +6,7 @@ import (
) )
func (s *Store) ModelGetAll() ([]models.DeviceModel, error) { 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`) rows, err := s.DB.Query(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket, is_power_strip, created_at, updated_at FROM device_models ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -16,7 +16,7 @@ func (s *Store) ModelGetAll() ([]models.DeviceModel, error) {
for rows.Next() { for rows.Next() {
var m models.DeviceModel var m models.DeviceModel
if err := rows.Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits, 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 { &m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.IsPowerStrip, &m.CreatedAt, &m.UpdatedAt); err != nil {
return nil, err return nil, err
} }
list = append(list, m) list = append(list, m)
@ -26,9 +26,9 @@ func (s *Store) ModelGetAll() ([]models.DeviceModel, error) {
func (s *Store) ModelGetByID(id int64) (*models.DeviceModel, error) { func (s *Store) ModelGetByID(id int64) (*models.DeviceModel, error) {
m := &models.DeviceModel{} 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). err := s.DB.QueryRow(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket, is_power_strip, created_at, updated_at FROM device_models WHERE id = ?`, id).
Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits, Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits,
&m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.CreatedAt, &m.UpdatedAt) &m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.IsPowerStrip, &m.CreatedAt, &m.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -45,9 +45,9 @@ func (s *Store) ModelGetByID(id int64) (*models.DeviceModel, error) {
func (s *Store) modelGetByIDTx(tx *sql.Tx, id int64) (*models.DeviceModel, error) { func (s *Store) modelGetByIDTx(tx *sql.Tx, id int64) (*models.DeviceModel, error) {
m := &models.DeviceModel{} 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). err := tx.QueryRow(`SELECT id, name, manufacturer, is_rack_mountable, height_units, front_image, back_image, is_patch_panel, is_wall_socket, is_power_strip FROM device_models WHERE id = ?`, id).
Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits, Scan(&m.ID, &m.Name, &m.Manufacturer, &m.IsRackMountable, &m.HeightUnits,
&m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket) &m.FrontImage, &m.BackImage, &m.IsPatchPanel, &m.IsWallSocket, &m.IsPowerStrip)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -74,8 +74,8 @@ func (s *Store) ModelCreate(m *models.DeviceModel, imageDir string) (int64, erro
} }
defer tx.Rollback() 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 (?, ?, ?, ?, ?, ?, ?, ?)`, 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, is_power_strip) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket) m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket, m.IsPowerStrip)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@ -104,8 +104,8 @@ func (s *Store) ModelUpdate(m *models.DeviceModel) error {
} }
defer tx.Rollback() 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=?`, _, err = tx.Exec(`UPDATE device_models SET name=?, manufacturer=?, is_rack_mountable=?, height_units=?, front_image=?, back_image=?, is_patch_panel=?, is_wall_socket=?, is_power_strip=?, updated_at=datetime('now') WHERE id=?`,
m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket, m.ID) m.Name, m.Manufacturer, m.IsRackMountable, m.HeightUnits, m.FrontImage, m.BackImage, m.IsPatchPanel, m.IsWallSocket, m.IsPowerStrip, m.ID)
if err != nil { if err != nil {
return err return err
} }

View File

@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS device_models (
back_image TEXT DEFAULT '', back_image TEXT DEFAULT '',
is_patch_panel BOOLEAN NOT NULL DEFAULT FALSE, is_patch_panel BOOLEAN NOT NULL DEFAULT FALSE,
is_wall_socket BOOLEAN NOT NULL DEFAULT FALSE, is_wall_socket BOOLEAN NOT NULL DEFAULT FALSE,
is_power_strip BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT (datetime('now')), created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now')) updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
); );

View File

@ -55,6 +55,7 @@ func New(store *db.Store) *Handlers {
files := append(baseFiles, files := append(baseFiles,
filepath.Join("templates", page), filepath.Join("templates", page),
filepath.Join("templates", "_port_list.html"), filepath.Join("templates", "_port_list.html"),
filepath.Join("templates", "_power_strip.html"),
) )
t, err := template.New("base.html").Funcs(funcMap).ParseFiles(files...) t, err := template.New("base.html").Funcs(funcMap).ParseFiles(files...)
if err != nil { if err != nil {

View File

@ -60,6 +60,7 @@ func (h *Handlers) ModelCreate(w http.ResponseWriter, r *http.Request) {
Manufacturer: r.FormValue("manufacturer"), Manufacturer: r.FormValue("manufacturer"),
IsPatchPanel: r.FormValue("is_patch_panel") == "on", IsPatchPanel: r.FormValue("is_patch_panel") == "on",
IsWallSocket: r.FormValue("is_wall_socket") == "on", IsWallSocket: r.FormValue("is_wall_socket") == "on",
IsPowerStrip: r.FormValue("is_power_strip") == "on",
} }
isRack := r.FormValue("is_rack_mountable") == "on" isRack := r.FormValue("is_rack_mountable") == "on"
@ -109,6 +110,7 @@ func (h *Handlers) ModelUpdate(w http.ResponseWriter, r *http.Request) {
m.Manufacturer = r.FormValue("manufacturer") m.Manufacturer = r.FormValue("manufacturer")
m.IsPatchPanel = r.FormValue("is_patch_panel") == "on" m.IsPatchPanel = r.FormValue("is_patch_panel") == "on"
m.IsWallSocket = r.FormValue("is_wall_socket") == "on" m.IsWallSocket = r.FormValue("is_wall_socket") == "on"
m.IsPowerStrip = r.FormValue("is_power_strip") == "on"
isRack := r.FormValue("is_rack_mountable") == "on" isRack := r.FormValue("is_rack_mountable") == "on"
m.IsRackMountable = isRack m.IsRackMountable = isRack

View File

@ -12,6 +12,7 @@ type DeviceModel struct {
BackImage string BackImage string
IsPatchPanel bool IsPatchPanel bool
IsWallSocket bool IsWallSocket bool
IsPowerStrip bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Ports []DeviceModelPort Ports []DeviceModelPort

View File

@ -167,6 +167,19 @@ th { background: var(--surface); font-weight: 600; }
.port-ref { font-family: monospace; color: var(--green); font-size: 0.85em; } .port-ref { font-family: monospace; color: var(--green); font-size: 0.85em; }
.connection-details { margin: 0.8em 0; } .connection-details { margin: 0.8em 0; }
/* POWER STRIP OUTLETS */
.power-strip-outlets { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 0.3em; margin-top: 0.3em; }
.outlet {
display: flex; flex-direction: column; align-items: center; gap: 0.15em;
padding: 0.3em; border: 1px solid var(--border); border-radius: 4px;
background: var(--bg); cursor: pointer; font-size: 0.8em;
transition: border-color 0.15s, background 0.15s;
}
.outlet:hover { border-color: var(--accent); }
.outlet-used { border-width: 2px; }
.outlet-icon { font-size: 1.2em; }
.outlet-label { font-family: monospace; font-size: 0.8em; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
/* PORT EDITOR */ /* PORT EDITOR */
.port-entry { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.3em; } .port-entry { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.3em; }
.port-entry input { width: 18em; margin-bottom: 0; } .port-entry input { width: 18em; margin-bottom: 0; }

View File

@ -0,0 +1,19 @@
{{define "power_strip"}}
<div class="power-strip-outlets">
{{range .Device.Ports}}
{{$pid := .ID}}
{{$color := "#333"}}{{$bg := ""}}
{{range $.Connections}}{{if and .Port1 .Port2}}{{if eq .Port1.ID $pid}}{{$color = .Color}}{{$bg = .Color}}{{else if eq .Port2.ID $pid}}{{$color = .Color}}{{$bg = .Color}}{{end}}{{end}}{{end}}
<span class="outlet {{if ne $bg ""}}outlet-used{{end}}"
style="{{if ne $bg ""}}border-color:{{$color}};background:{{$color}}22{{end}}"
hx-get="/connections/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
onclick="openModal()"
title="Outlet: {{.Name}}">
<span class="outlet-icon">&#x1F50C;</span>
<span class="outlet-label">{{.Name}}</span>
</span>
{{end}}
</div>
{{end}}

View File

@ -28,6 +28,11 @@
{{if .Model}}{{if .Model.IsWallSocket}}checked{{end}}{{end}}> {{if .Model}}{{if .Model.IsWallSocket}}checked{{end}}{{end}}>
Wall socket <small>(behaves like patch panel)</small> Wall socket <small>(behaves like patch panel)</small>
</label> </label>
<label>
<input type="checkbox" name="is_power_strip" id="chk-power"
{{if .Model}}{{if .Model.IsPowerStrip}}checked{{end}}{{end}}>
Power strip <small>(outlets shown as socket grid in rack view)</small>
</label>
<div id="patch-note" style="display:none;color:var(--text-muted);font-size:0.85em;margin-top:0.3em"> <div id="patch-note" style="display:none;color:var(--text-muted);font-size:0.85em;margin-top:0.3em">
Port side selectors disabled &mdash; patch panels create front+back pairs from every port regardless of side. Port side selectors disabled &mdash; patch panels create front+back pairs from every port regardless of side.
</div> </div>

View File

@ -60,7 +60,13 @@
<button class="btn-sm btn-danger">Remove</button> <button class="btn-sm btn-danger">Remove</button>
</form> </form>
</h4> </h4>
{{if .Model}}{{if .Model.IsPowerStrip}}
{{template "power_strip" dict "Device" . "Connections" $.Connections}}
{{else}}
{{template "port_list" dict "Device" . "Connections" $.Connections}} {{template "port_list" dict "Device" . "Connections" $.Connections}}
{{end}}{{else}}
{{template "port_list" dict "Device" . "Connections" $.Connections}}
{{end}}
</div> </div>
{{end}} {{end}}
@ -75,7 +81,13 @@
<button class="btn-sm btn-danger">Remove</button> <button class="btn-sm btn-danger">Remove</button>
</form> </form>
</h4> </h4>
{{if .Model}}{{if .Model.IsPowerStrip}}
{{template "power_strip" dict "Device" . "Connections" $.Connections}}
{{else}}
{{template "port_list" dict "Device" . "Connections" $.Connections}} {{template "port_list" dict "Device" . "Connections" $.Connections}}
{{end}}{{else}}
{{template "port_list" dict "Device" . "Connections" $.Connections}}
{{end}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}