Click-to-connect mode for rapid cabling

Added Connect Mode toggle button on rack and device views. When active,
clicking a port selects it (glowing accent border), clicking a second
port opens a mini form to choose connection type/color/labels and
create the cable in one flow. Escape or re-clicking deselects. Port
elements now use onclick JS instead of HTMX triggers to support both
normal inspection mode and connect mode.
master
Joca 2026-06-09 20:13:47 -03:00
parent 75255d5551
commit 0e97029c59
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
11 changed files with 165 additions and 17 deletions

View File

@ -14,6 +14,59 @@ func (h *Handlers) ConnectionModal(w http.ResponseWriter, r *http.Request) {
h.renderConnectionModal(w, portIDStr)
}
func (h *Handlers) ConnectionConnectForm(w http.ResponseWriter, r *http.Request) {
p1Str := r.URL.Query().Get("p1")
p2Str := r.URL.Query().Get("p2")
p1ID, _ := strconv.ParseInt(p1Str, 10, 64)
p2ID, _ := strconv.ParseInt(p2Str, 10, 64)
port1, _ := h.Store.PortGetByID(p1ID)
port2, _ := h.Store.PortGetByID(p2ID)
var dev1, dev2 *models.Device
if port1 != nil {
dev1, _ = h.Store.DeviceGetByID(port1.DeviceID)
}
if port2 != nil {
dev2, _ = h.Store.DeviceGetByID(port2.DeviceID)
}
connTypes, _ := h.Store.ConnectionTypeGetAll()
if connTypes == nil {
connTypes = []models.ConnectionType{}
}
type ConnectFormData struct {
PortID1 int64
PortID2 int64
Port1Name string
Port2Name string
Device1Name string
Device2Name string
ConnectionTypes []models.ConnectionType
}
data := ConnectFormData{
PortID1: p1ID,
PortID2: p2ID,
ConnectionTypes: connTypes,
}
if port1 != nil {
data.Port1Name = port1.Name
}
if port2 != nil {
data.Port2Name = port2.Name
}
if dev1 != nil {
data.Device1Name = dev1.Name
}
if dev2 != nil {
data.Device2Name = dev2.Name
}
h.render(w, "connect_form.html", data)
}
type FlatPort struct {
ID int64
Name string

View File

@ -46,6 +46,7 @@ func New(store *db.Store) *Handlers {
fragments := []string{
"connection_modal.html",
"connect_form.html",
}
tmpl := make(map[string]*template.Template)

View File

@ -46,6 +46,7 @@ func main() {
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("GET /connections/connect-form", h.ConnectionConnectForm)
mux.HandleFunc("POST /connections/create", h.ConnectionCreate)
mux.HandleFunc("POST /connections/{id}/edit", h.ConnectionEdit)
mux.HandleFunc("POST /connections/{id}/delete", h.ConnectionDelete)

View File

@ -8,7 +8,13 @@ function closeModal() {
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
if (e.key === 'Escape') {
if (document.getElementById('modal-overlay').classList.contains('open')) {
closeModal();
} else {
exitConnectMode();
}
}
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
@ -16,3 +22,59 @@ document.body.addEventListener('htmx:afterSwap', function(evt) {
openModal();
}
});
var connectMode = false;
var selectedPort = null;
var selectedEl = null;
function toggleConnectMode() {
connectMode = !connectMode;
if (connectMode) {
document.body.classList.add('connect-mode');
} else {
exitConnectMode();
}
updateConnectButtons();
}
function exitConnectMode() {
connectMode = false;
document.body.classList.remove('connect-mode');
if (selectedEl) selectedEl.classList.remove('port-selected');
selectedPort = null;
selectedEl = null;
updateConnectButtons();
document.getElementById('connect-status').textContent = '';
}
function updateConnectButtons() {
document.querySelectorAll('.btn-connect-mode').forEach(function(btn) {
btn.textContent = connectMode ? 'Cancel Connect' : 'Connect Mode';
btn.classList.toggle('active', connectMode);
});
}
function portClick(el, portId, evt) {
if (!connectMode) return true;
evt.preventDefault();
evt.stopPropagation();
if (selectedPort === portId) {
exitConnectMode();
return false;
}
if (selectedPort === null) {
selectedPort = portId;
selectedEl = el;
el.classList.add('port-selected');
document.getElementById('connect-status').textContent = 'Selected ' + el.textContent.trim() + '. Click another port.';
} else {
var p1 = selectedPort;
var p2 = portId;
exitConnectMode();
openModal();
htmx.ajax('GET', '/connections/connect-form?p1=' + p1 + '&p2=' + p2, {target: '#modal-content', swap: 'innerHTML'});
}
return false;
}

View File

@ -180,6 +180,13 @@ th { background: var(--surface); font-weight: 600; }
.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%; }
/* CONNECT MODE */
body.connect-mode .port, body.connect-mode .outlet { cursor: crosshair; }
body.connect-mode .port:hover, body.connect-mode .outlet:hover { border-color: var(--accent); box-shadow: 0 0 4px var(--accent); }
.port-selected, body.connect-mode .port-selected { border-color: var(--accent) !important; background: var(--surface2) !important; box-shadow: 0 0 8px var(--accent); }
.btn-connect-mode.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.connect-status { margin-left: 0.5em; color: var(--accent); font-size: 0.85em; font-style: italic; }
/* PORT EDITOR */
.port-entry { display: flex; gap: 0.5em; align-items: center; margin-bottom: 0.3em; }
.port-entry input { width: 18em; margin-bottom: 0; }

View File

@ -8,10 +8,8 @@
{{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="port {{if ne $bg ""}}connected{{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()"
data-port-id="{{.ID}}"
onclick="if(!connectMode){openModal();htmx.ajax('GET','/connections/{{.ID}}',{target:'#modal-content',swap:'innerHTML'});}else{portClick(this,{{.ID}},event);}"
title="{{.Name}}">
{{.Name}}
</span>
@ -25,10 +23,8 @@
{{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="port {{if ne $bg ""}}connected{{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()"
data-port-id="{{.ID}}"
onclick="if(!connectMode){openModal();htmx.ajax('GET','/connections/{{.ID}}',{target:'#modal-content',swap:'innerHTML'});}else{portClick(this,{{.ID}},event);}"
title="{{.Name}}">
{{.Name}}
</span>

View File

@ -6,10 +6,8 @@
{{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()"
data-port-id="{{.ID}}"
onclick="if(!connectMode){openModal();htmx.ajax('GET','/connections/{{.ID}}',{target:'#modal-content',swap:'innerHTML'});}else{portClick(this,{{.ID}},event);}"
title="Outlet: {{.Name}}">
<span class="outlet-icon">&#x1F50C;</span>
<span class="outlet-label">{{.Name}}</span>

View File

@ -0,0 +1,23 @@
<div class="modal-inner">
<button class="modal-close" onclick="closeModal()">&times;</button>
<h3>Create Connection</h3>
<p style="margin:0.5em 0">
<a href="/devices/{{.Device1Name}}">{{.Device1Name}}</a> <span class="port-ref">[{{.Port1Name}}]</span>
&mdash;
<a href="/devices/{{.Device2Name}}">{{.Device2Name}}</a> <span class="port-ref">[{{.Port2Name}}]</span>
</p>
<form method="POST" action="/connections/create" hx-post="/connections/create" hx-target="#modal-content" hx-swap="innerHTML">
<input type="hidden" name="port_id_1" value="{{.PortID1}}">
<input type="hidden" name="port_id_2" value="{{.PortID2}}">
<input type="hidden" name="return_port_id" value="{{.PortID1}}">
<label>Type
<select name="connection_type_id">
{{range .ConnectionTypes}}<option value="{{.ID}}" {{if eq .Name "Ethernet"}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label>Label end 1 <input name="label_1"></label>
<label>Label end 2 <input name="label_2"></label>
<label>Color <input type="color" name="color" value="#808080"></label>
<button type="submit">Create Connection</button>
</form>
</div>

View File

@ -39,6 +39,10 @@
</div>
<h3>Ports</h3>
<div class="toolbar">
<button class="btn btn-connect-mode" onclick="toggleConnectMode()">Connect Mode</button>
<span id="connect-status" class="connect-status"></span>
</div>
{{if .Device.Model}}{{if or .Device.Model.FrontImage .Device.Model.BackImage}}
<div class="device-images">
{{if .Device.Model.FrontImage}}<div><strong>Front</strong><br><img src="/{{.Device.Model.FrontImage}}" alt="Front" class="device-img"></div>{{end}}

View File

@ -50,6 +50,10 @@
</table>
<h3>Devices &amp; Ports</h3>
<div class="toolbar">
<button class="btn btn-connect-mode" onclick="toggleConnectMode()">Connect Mode</button>
<span id="connect-status" class="connect-status"></span>
</div>
{{range .RackedDevices}}
<div class="device-section">
<h4>

View File

@ -17,10 +17,9 @@
{{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="port {{if ne $bg ""}}connected{{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()"
data-port-id="{{.ID}}"
onclick="if(!connectMode){openModal();htmx.ajax('GET','/connections/{{.ID}}',{target:'#modal-content',swap:'innerHTML'});}else{portClick(this,{{.ID}},event);}"
onclick="if(!connectMode){openModal();htmx.ajax('GET','/connections/{{.ID}}',{target:'#modal-content',swap:'innerHTML'});}else{portClick(this,{{.ID}},event);}"
title="{{.Name}}">
{{.Name}}
</span>