Support unracked devices across API and UI

This commit is contained in:
2026-02-11 15:33:21 -06:00
parent 3f1a2b04cd
commit 76a719abde
4 changed files with 349 additions and 89 deletions

234
main.go
View File

@@ -57,6 +57,7 @@ type Datacenter struct {
Name string `json:"name"`
PDUMode string `json:"pduMode"`
RackIDs []string `json:"rackIds"`
DeviceIDs []string `json:"deviceIds,omitempty"`
Notes string `json:"notes"`
PowerFeed string `json:"powerFeed"`
}
@@ -78,6 +79,7 @@ type PortProfile struct {
type Device struct {
ID string `json:"id"`
RackID string `json:"rackId"`
DatacenterID string `json:"datacenterId,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
UnitSize int `json:"unitSize"`
@@ -457,6 +459,7 @@ type createRackRequest struct {
type createDeviceRequest struct {
RackID string `json:"rackId"`
DatacenterID string `json:"datacenterId"`
Name string `json:"name"`
Kind string `json:"kind"`
UnitSize int `json:"unitSize"`
@@ -471,6 +474,7 @@ type updateDeviceRequest struct {
Name *string `json:"name"`
Kind *string `json:"kind"`
RackID *string `json:"rackId"`
DatacenterID *string `json:"datacenterId"`
UnitSize *int `json:"unitSize"`
StartU *int `json:"startU"`
PortCount *int `json:"portCount"`
@@ -573,6 +577,7 @@ func (s *Store) createDatacenter(req createDatacenterRequest) (*Datacenter, erro
Name: name,
PDUMode: pdu,
RackIDs: []string{},
DeviceIDs: []string{},
Notes: strings.TrimSpace(req.Notes),
PowerFeed: strings.TrimSpace(req.PowerFeed),
}
@@ -633,8 +638,9 @@ func (s *Store) createRack(req createRackRequest) (*Rack, error) {
func (s *Store) createDevice(req createDeviceRequest) (*Device, error) {
rackID := strings.TrimSpace(req.RackID)
if rackID == "" {
return nil, errors.New("rackId is required")
datacenterID := strings.TrimSpace(req.DatacenterID)
if rackID == "" && datacenterID == "" {
return nil, errors.New("either rackId or datacenterId is required")
}
name := strings.TrimSpace(req.Name)
if name == "" {
@@ -672,6 +678,7 @@ func (s *Store) createDevice(req createDeviceRequest) (*Device, error) {
d := &Device{
ID: makeID("dev"),
RackID: rackID,
DatacenterID: datacenterID,
Name: name,
Kind: kind,
UnitSize: unitSize,
@@ -685,30 +692,41 @@ func (s *Store) createDevice(req createDeviceRequest) (*Device, error) {
s.mu.Lock()
defer s.mu.Unlock()
rack := s.state.Racks[rackID]
if rack == nil {
return nil, errors.New("rack not found")
}
if d.UnitSize > rack.Units {
return nil, fmt.Errorf("device size %dU exceeds rack capacity %dU", d.UnitSize, rack.Units)
}
if d.StartU+d.UnitSize-1 > rack.Units {
return nil, fmt.Errorf("device exceeds rack height (%dU)", rack.Units)
}
for _, existingID := range rack.DeviceIDs {
ex := s.state.Devices[existingID]
if ex == nil {
continue
if rackID != "" {
rack := s.state.Racks[rackID]
if rack == nil {
return nil, errors.New("rack not found")
}
if overlaps(d.StartU, d.UnitSize, ex.StartU, ex.UnitSize) {
return nil, fmt.Errorf("device overlaps with existing device: %s", ex.Name)
if d.UnitSize > rack.Units {
return nil, fmt.Errorf("device size %dU exceeds rack capacity %dU", d.UnitSize, rack.Units)
}
if d.StartU+d.UnitSize-1 > rack.Units {
return nil, fmt.Errorf("device exceeds rack height (%dU)", rack.Units)
}
for _, existingID := range rack.DeviceIDs {
ex := s.state.Devices[existingID]
if ex == nil {
continue
}
if overlaps(d.StartU, d.UnitSize, ex.StartU, ex.UnitSize) {
return nil, fmt.Errorf("device overlaps with existing device: %s", ex.Name)
}
}
d.DatacenterID = rack.DatacenterID
s.state.Devices[d.ID] = d
rack.DeviceIDs = append(rack.DeviceIDs, d.ID)
} else {
dc := s.state.Datacenters[datacenterID]
if dc == nil {
return nil, errors.New("datacenter not found")
}
d.RackID = ""
d.StartU = 1
s.state.Devices[d.ID] = d
dc.DeviceIDs = append(dc.DeviceIDs, d.ID)
}
s.state.Devices[d.ID] = d
rack.DeviceIDs = append(rack.DeviceIDs, d.ID)
if err := s.saveLocked(); err != nil {
return nil, err
}
@@ -731,6 +749,29 @@ func hasString(values []string, target string) bool {
return false
}
func (s *Store) deviceDatacenterIDLocked(device *Device) (string, error) {
if device == nil {
return "", errors.New("device not found")
}
if strings.TrimSpace(device.RackID) != "" {
rack := s.state.Racks[device.RackID]
if rack == nil {
return "", errors.New("rack for device not found")
}
return rack.DatacenterID, nil
}
dcID := strings.TrimSpace(device.DatacenterID)
if dcID == "" {
return "", errors.New("datacenter for unracked device not found")
}
dc := s.state.Datacenters[dcID]
if dc == nil {
return "", errors.New("datacenter for device not found")
}
return dcID, nil
}
func (s *Store) updateDevice(id string, req updateDeviceRequest) (*Device, error) {
id = strings.TrimSpace(id)
if id == "" {
@@ -744,11 +785,19 @@ func (s *Store) updateDevice(id string, req updateDeviceRequest) (*Device, error
if current == nil {
return nil, errors.New("device not found")
}
sourceRack := s.state.Racks[current.RackID]
if sourceRack == nil {
return nil, errors.New("rack not found for device")
sourceRack := (*Rack)(nil)
if strings.TrimSpace(current.RackID) != "" {
sourceRack = s.state.Racks[current.RackID]
if sourceRack == nil {
return nil, errors.New("rack not found for device")
}
}
sourceDCID, err := s.deviceDatacenterIDLocked(current)
if err != nil {
return nil, err
}
targetRack := sourceRack
targetDCID := sourceDCID
updated := *current
@@ -769,14 +818,33 @@ func (s *Store) updateDevice(id string, req updateDeviceRequest) (*Device, error
if req.RackID != nil {
nextRackID := strings.TrimSpace(*req.RackID)
if nextRackID == "" {
return nil, errors.New("rackId is required")
updated.RackID = ""
targetRack = nil
} else {
nextRack := s.state.Racks[nextRackID]
if nextRack == nil {
return nil, errors.New("rack not found")
}
updated.RackID = nextRackID
updated.DatacenterID = nextRack.DatacenterID
targetRack = nextRack
targetDCID = nextRack.DatacenterID
}
nextRack := s.state.Racks[nextRackID]
if nextRack == nil {
return nil, errors.New("rack not found")
}
if req.DatacenterID != nil {
nextDatacenterID := strings.TrimSpace(*req.DatacenterID)
if nextDatacenterID == "" {
return nil, errors.New("datacenterId is required")
}
nextDC := s.state.Datacenters[nextDatacenterID]
if nextDC == nil {
return nil, errors.New("datacenter not found")
}
targetDCID = nextDatacenterID
updated.DatacenterID = nextDatacenterID
if targetRack != nil && targetRack.DatacenterID != nextDatacenterID {
return nil, errors.New("rack and datacenter must match")
}
updated.RackID = nextRackID
targetRack = nextRack
}
if req.UnitSize != nil {
if *req.UnitSize <= 0 {
@@ -818,38 +886,78 @@ func (s *Store) updateDevice(id string, req updateDeviceRequest) (*Device, error
updated.Notes = strings.TrimSpace(*req.Notes)
}
if updated.StartU+updated.UnitSize-1 > targetRack.Units {
return nil, fmt.Errorf("device exceeds rack height (%dU)", targetRack.Units)
}
if updated.UnitSize > targetRack.Units {
return nil, fmt.Errorf("device size %dU exceeds rack capacity %dU", updated.UnitSize, targetRack.Units)
if targetRack != nil {
if updated.StartU+updated.UnitSize-1 > targetRack.Units {
return nil, fmt.Errorf("device exceeds rack height (%dU)", targetRack.Units)
}
if updated.UnitSize > targetRack.Units {
return nil, fmt.Errorf("device size %dU exceeds rack capacity %dU", updated.UnitSize, targetRack.Units)
}
for _, existingID := range targetRack.DeviceIDs {
if existingID == id {
continue
}
existing := s.state.Devices[existingID]
if existing == nil {
continue
}
if overlaps(updated.StartU, updated.UnitSize, existing.StartU, existing.UnitSize) {
return nil, fmt.Errorf("device overlaps with existing device: %s", existing.Name)
}
}
updated.DatacenterID = targetRack.DatacenterID
targetDCID = targetRack.DatacenterID
} else {
if targetDCID == "" {
targetDCID = strings.TrimSpace(updated.DatacenterID)
}
if targetDCID == "" {
return nil, errors.New("datacenterId is required for unracked device")
}
if s.state.Datacenters[targetDCID] == nil {
return nil, errors.New("datacenter not found")
}
updated.RackID = ""
updated.DatacenterID = targetDCID
updated.StartU = 1
}
for _, existingID := range targetRack.DeviceIDs {
if existingID == id {
continue
}
existing := s.state.Devices[existingID]
if existing == nil {
continue
}
if overlaps(updated.StartU, updated.UnitSize, existing.StartU, existing.UnitSize) {
return nil, fmt.Errorf("device overlaps with existing device: %s", existing.Name)
}
}
if sourceRack.ID != targetRack.ID {
if sourceRack != nil && (targetRack == nil || sourceRack.ID != targetRack.ID) {
for i, existingID := range sourceRack.DeviceIDs {
if existingID == id {
sourceRack.DeviceIDs = removeStringAt(sourceRack.DeviceIDs, i)
break
}
}
}
if targetRack != nil && (sourceRack == nil || sourceRack.ID != targetRack.ID) {
if !hasString(targetRack.DeviceIDs, id) {
targetRack.DeviceIDs = append(targetRack.DeviceIDs, id)
}
}
sourceDC := s.state.Datacenters[sourceDCID]
targetDC := s.state.Datacenters[targetDCID]
wasUnracked := strings.TrimSpace(current.RackID) == ""
nowUnracked := strings.TrimSpace(updated.RackID) == ""
if wasUnracked && sourceDC != nil {
for i, existingID := range sourceDC.DeviceIDs {
if existingID == id {
sourceDC.DeviceIDs = removeStringAt(sourceDC.DeviceIDs, i)
break
}
}
}
if nowUnracked {
if targetDC == nil {
return nil, errors.New("target datacenter not found")
}
if !hasString(targetDC.DeviceIDs, id) {
targetDC.DeviceIDs = append(targetDC.DeviceIDs, id)
}
}
s.state.Devices[id] = &updated
if err := s.saveLocked(); err != nil {
return nil, err
@@ -879,6 +987,16 @@ func (s *Store) deleteDevice(id string) error {
break
}
}
} else if strings.TrimSpace(dev.DatacenterID) != "" {
dc := s.state.Datacenters[dev.DatacenterID]
if dc != nil {
for i, deviceID := range dc.DeviceIDs {
if deviceID == id {
dc.DeviceIDs = removeStringAt(dc.DeviceIDs, i)
break
}
}
}
}
for connID, conn := range s.state.Connections {
@@ -998,11 +1116,19 @@ func (s *Store) endpointMapIDLocked(kind, id string) (string, error) {
if dev == nil {
return "", errors.New("device not found")
}
rack := s.state.Racks[dev.RackID]
if rack == nil {
return "", errors.New("rack for device not found")
if strings.TrimSpace(dev.RackID) != "" {
rack := s.state.Racks[dev.RackID]
if rack == nil {
return "", errors.New("rack for device not found")
}
dc := s.state.Datacenters[rack.DatacenterID]
if dc == nil {
return "", errors.New("datacenter for device not found")
}
return dc.MapID, nil
}
dc := s.state.Datacenters[rack.DatacenterID]
dc := s.state.Datacenters[strings.TrimSpace(dev.DatacenterID)]
if dc == nil {
return "", errors.New("datacenter for device not found")
}

View File

@@ -68,6 +68,7 @@ const refs = {
topoResetBtn: document.getElementById('topoResetBtn'),
rackSelect: document.getElementById('rackSelect'),
rackServerOnlyToggle: document.getElementById('rackServerOnlyToggle'),
deviceDatacenterSelect: document.getElementById('deviceDatacenterSelect'),
rackContainer: document.getElementById('rackContainer'),
summary: document.getElementById('connectionSummary'),
connectionFilter: document.getElementById('connectionFilter'),
@@ -527,6 +528,18 @@ function getDeviceById(id) {
return appState.data?.devices?.[id] || null;
}
function getDeviceDatacenterId(device) {
if (!device) return '';
const rack = getRackById(device.rackId);
if (rack?.datacenterId) return rack.datacenterId;
return String(device.datacenterId || '').trim();
}
function getDeviceDatacenter(device) {
const dcId = getDeviceDatacenterId(device);
return dcId ? getDatacenterById(dcId) : null;
}
function endpointMapId(kind, id) {
const data = appState.data;
if (!data) return '';
@@ -539,8 +552,8 @@ function endpointMapId(kind, id) {
}
if (kind === 'device') {
const dev = data.devices[id];
const rack = dev ? data.racks[dev.rackId] : null;
const dc = rack ? data.datacenters[rack.datacenterId] : null;
const dcId = getDeviceDatacenterId(dev);
const dc = dcId ? data.datacenters[dcId] : null;
return dc?.mapId || '';
}
return '';
@@ -568,7 +581,7 @@ function endpointLabel(kind, id) {
const dev = data.devices[id];
if (!dev) return `Device:${id}`;
const rack = data.racks[dev.rackId];
const dc = rack ? data.datacenters[rack.datacenterId] : null;
const dc = getDeviceDatacenter(dev);
return `[${dev.kind}] ${dev.name}${dc ? ` (${dc.name})` : ''}`;
}
@@ -577,10 +590,11 @@ function endpointLabel(kind, id) {
function deviceLocationLabel(device) {
const rack = getRackById(device?.rackId);
const dc = getDatacenterById(rack?.datacenterId);
const dc = getDeviceDatacenter(device);
if (rack && dc) return `${dc.name} / ${rack.name}`;
if (dc) return `${dc.name} / Unracked`;
if (rack) return rack.name;
return 'Unassigned rack';
return 'Unassigned';
}
function populateSelect(selectId, options, selected = '', includeEmpty = false, emptyLabel = 'Select...') {
@@ -630,12 +644,13 @@ function refreshSelectors() {
return { value: dc.id, label: `${dc.name}${map ? ` (${map.name})` : ''}` };
});
populateSelect('rackDatacenterSelect', dcOptions);
populateSelect('deviceDatacenterSelect', dcOptions);
const rackOptions = racks.map((rack) => {
const dc = getDatacenterById(rack.datacenterId);
return { value: rack.id, label: `${rack.name}${dc ? ` (${dc.name})` : ''}` };
});
populateSelect('deviceRackSelect', rackOptions);
populateSelect('deviceRackSelect', rackOptions, '', true, 'No Rack (Datacenter only)');
populateSelect('rackSelect', [{ value: '', label: 'All Racks' }, ...rackOptions], appState.selectedRackId);
if (appState.selectedRackId && !rackOptions.find((r) => r.value === appState.selectedRackId)) {
@@ -937,6 +952,36 @@ function buildTopologyModel() {
edges.push({ from: rackNode.id, to: deviceNode.id, type: 'containment' });
}
}
const looseIds = new Set([
...listField(dc, 'deviceIds', 'deviceIDs'),
...values(data.devices)
.filter((dev) => !dev.rackId && getDeviceDatacenterId(dev) === dc.id)
.map((dev) => dev.id)
]);
for (const deviceId of [...looseIds]) {
const dev = data.devices[deviceId];
if (!dev) continue;
const normalizedKind = deviceKind(dev.kind);
const serverNode = isServerKind(normalizedKind);
const category = categorizeDeviceKind(normalizedKind);
const deviceNode = {
id: `device:${dev.id}`,
kind: 'device',
label: dev.name,
mapId: mapObj.id,
level: serverNode ? 5 : 4,
deviceKind: normalizedKind,
deviceCategory: category,
isServer: serverNode,
unitSize: dev.unitSize,
portCount: dev.portCount,
portSummary: summarizePortProfiles(getDevicePortProfiles(dev))
};
nodes.push(deviceNode);
nodeIndex.set(deviceNode.id, deviceNode);
edges.push({ from: dcNode.id, to: deviceNode.id, type: 'containment' });
}
}
}
@@ -1070,6 +1115,35 @@ function renderTopology() {
rackLayouts.push({
rack,
devices,
areaId: rack.id,
x: 0,
y: 0,
w: rackW,
h: rackHeight
});
}
const looseDevices = [
...new Set([
...listField(dc, 'deviceIds', 'deviceIDs'),
...values(data.devices)
.filter((dev) => !dev.rackId && getDeviceDatacenterId(dev) === dc.id)
.map((dev) => dev.id)
])
]
.map((id) => data.devices[id])
.filter(Boolean)
.filter(deviceMatchesFilter)
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
if (looseDevices.length) {
const rowCount = Math.max(1, looseDevices.length);
const contentHeight = rowCount * deviceH + Math.max(0, rowCount - 1) * deviceGap;
const rackHeight = rackHeaderH + rackPad + contentHeight + rackPad;
rackLayouts.push({
rack: null,
devices: looseDevices,
areaId: `unracked:${dc.id}`,
x: 0,
y: 0,
w: rackW,
@@ -1279,6 +1353,9 @@ function renderTopology() {
for (const rackLayout of dcLayout.racks) {
const rackX = dcX + dcPad + rackLayout.x;
const rackY = dcY + dcHeaderH + dcPad + rackLayout.y;
const rackLabel = rackLayout.rack
? `${rackLayout.rack.name} (${rackLayout.rack.units}U)`
: 'Unracked Devices';
const rackRect = createSvg('rect', {
x: rackX,
@@ -1295,24 +1372,26 @@ function renderTopology() {
y: rackY + 15,
class: 'topo-rack-title'
});
rackTitle.textContent = `${rackLayout.rack.name} (${rackLayout.rack.units}U)`;
rackTitle.textContent = rackLabel;
layerNodes.appendChild(rackTitle);
endpointPositions.set(`rack:${rackLayout.rack.id}`, {
x: rackX + rackLayout.w / 2,
y: rackY + 1,
kind: 'rack',
mapId: mapObj.id,
dcId: dcLayout.dc.id,
left: rackX,
right: rackX + rackLayout.w,
top: rackY,
bottom: rackY + rackLayout.h,
rackTop: rackY,
rackBottom: rackY + rackLayout.h,
dcTop: dcY,
mapTop: mapY
});
if (rackLayout.rack) {
endpointPositions.set(`rack:${rackLayout.rack.id}`, {
x: rackX + rackLayout.w / 2,
y: rackY + 1,
kind: 'rack',
mapId: mapObj.id,
dcId: dcLayout.dc.id,
left: rackX,
right: rackX + rackLayout.w,
top: rackY,
bottom: rackY + rackLayout.h,
rackTop: rackY,
rackBottom: rackY + rackLayout.h,
dcTop: dcY,
mapTop: mapY
});
}
const deviceStartY = rackY + rackHeaderH + rackPad;
if (!rackLayout.devices.length) {
@@ -1368,7 +1447,7 @@ function renderTopology() {
kind: 'device',
mapId: mapObj.id,
dcId: dcLayout.dc.id,
rackId: rackLayout.rack.id,
rackId: rackLayout.areaId,
dcTop: dcY,
mapTop: mapY,
rackTop: rackY,
@@ -2380,7 +2459,6 @@ function renderBuildStudio() {
const deviceAnchorByID = new Map();
const deviceIDByDatacenter = new Map();
const renderedRackIDs = new Set();
for (const dc of datacenters) {
const dcCard = document.createElement('section');
@@ -2399,36 +2477,58 @@ function renderBuildStudio() {
const racks = sortByName(
listField(dc, 'rackIds', 'rackIDs').map((id) => getRackById(id)).filter(Boolean)
);
const looseDevices = [
...new Set([
...listField(dc, 'deviceIds', 'deviceIDs'),
...values(appState.data.devices)
.filter((dev) => !dev.rackId && getDeviceDatacenterId(dev) === dc.id)
.map((dev) => dev.id)
])
]
.map((id) => getDeviceById(id))
.filter(Boolean)
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
if (looseDevices.length) {
racks.push({
id: '',
name: 'Unracked Devices',
units: 0,
__loose: true,
__devices: looseDevices
});
}
if (!racks.length) {
const empty = document.createElement('div');
empty.className = 'build-empty-note';
empty.textContent = 'No racks in this datacenter.';
empty.textContent = 'No racks or unracked devices in this datacenter.';
rackGrid.appendChild(empty);
}
for (const rack of racks) {
renderedRackIDs.add(rack.id);
const rackCard = document.createElement('div');
rackCard.className = 'build-rack-dropzone';
rackCard.dataset.rackId = rack.id;
if (rack.__loose) rackCard.classList.add('build-rack-unracked');
const rackHead = document.createElement('div');
rackHead.className = 'build-rack-head';
rackHead.innerHTML = `<strong>${rack.name}</strong><span>${rack.units}U</span>`;
rackHead.innerHTML = `<strong>${rack.name}</strong><span>${rack.__loose ? 'Datacenter' : `${rack.units}U`}</span>`;
const deviceList = document.createElement('div');
deviceList.className = 'build-device-list';
const devices = listField(rack, 'deviceIds', 'deviceIDs')
.map((id) => getDeviceById(id))
.filter(Boolean)
.sort((a, b) => b.startU - a.startU);
const devices = rack.__loose
? rack.__devices
: listField(rack, 'deviceIds', 'deviceIDs')
.map((id) => getDeviceById(id))
.filter(Boolean)
.sort((a, b) => b.startU - a.startU);
if (!devices.length) {
const empty = document.createElement('div');
empty.className = 'build-empty-note';
empty.textContent = 'Drop hardware here';
empty.textContent = rack.__loose ? 'No unracked devices' : 'Drop hardware here';
deviceList.appendChild(empty);
}
@@ -2510,7 +2610,9 @@ function renderBuildStudio() {
const kindText = document.createElement('div');
kindText.className = 'build-device-kind';
kindText.textContent = `${kind} ${device.unitSize}U @ U${device.startU}`;
kindText.textContent = rack.__loose
? `${kind} | Unracked`
: `${kind} ${device.unitSize}U @ U${device.startU}`;
const portText = document.createElement('div');
portText.className = 'build-device-meta';
@@ -2546,6 +2648,7 @@ function renderBuildStudio() {
rackCard.appendChild(deviceList);
rackCard.addEventListener('dragover', (event) => {
if (rack.__loose) return;
event.preventDefault();
rackCard.classList.add('drag-over');
});
@@ -2553,6 +2656,7 @@ function renderBuildStudio() {
rackCard.classList.remove('drag-over');
});
rackCard.addEventListener('drop', async (event) => {
if (rack.__loose) return;
event.preventDefault();
rackCard.classList.remove('drag-over');
@@ -2606,7 +2710,7 @@ function renderBuildStudio() {
const fromDevice = getDeviceById(fromRaw);
const toDevice = getDeviceById(toRaw);
if (!fromDevice || !toDevice) continue;
if (!renderedRackIDs.has(fromDevice.rackId) || !renderedRackIDs.has(toDevice.rackId)) continue;
if (!deviceAnchorByID.has(fromRaw) || !deviceAnchorByID.has(toRaw)) continue;
const from = anchorPoint(fromRaw);
const to = anchorPoint(toRaw);
@@ -3057,12 +3161,25 @@ function attachForms() {
const dcForm = document.getElementById('dcForm');
const rackForm = document.getElementById('rackForm');
const deviceForm = document.getElementById('deviceForm');
const deviceRackSelect = document.getElementById('deviceRackSelect');
const deviceDatacenterSelect = document.getElementById('deviceDatacenterSelect');
const connectionForm = document.getElementById('connectionForm');
const fromEndpointSelect = document.getElementById('fromEndpointSelect');
const toEndpointSelect = document.getElementById('toEndpointSelect');
fromEndpointSelect?.addEventListener('change', refreshConnectionPortSuggestions);
toEndpointSelect?.addEventListener('change', refreshConnectionPortSuggestions);
deviceRackSelect?.addEventListener('change', () => {
const rack = getRackById(deviceRackSelect.value);
if (rack && deviceDatacenterSelect) deviceDatacenterSelect.value = rack.datacenterId;
});
deviceDatacenterSelect?.addEventListener('change', () => {
const rack = getRackById(deviceRackSelect?.value || '');
if (!rack) return;
if (rack.datacenterId !== deviceDatacenterSelect.value && deviceRackSelect) {
deviceRackSelect.value = '';
}
});
mapForm?.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -3119,7 +3236,14 @@ function attachForms() {
e.preventDefault();
try {
const payload = formDataToPayload(deviceForm);
payload.rackId = String(payload.rackId || '').trim();
payload.datacenterId = String(payload.datacenterId || '').trim();
const selectedRack = payload.rackId;
const selectedDatacenter = payload.datacenterId;
if (!payload.rackId && !payload.datacenterId) {
showMessage('Select a datacenter, or choose a rack.', 'error');
return;
}
const normalizedProfiles = normalizePortProfiles(devicePortProfilesDraft);
payload.portProfiles = normalizedProfiles;
payload.unitSize = toInt(payload.unitSize, 1);
@@ -3133,6 +3257,10 @@ function attachForms() {
const rackField = deviceForm.elements.namedItem('rackId');
if (rackField) rackField.value = selectedRack;
}
if (selectedDatacenter) {
const dcField = deviceForm.elements.namedItem('datacenterId');
if (dcField) dcField.value = selectedDatacenter;
}
const kindField = deviceForm.elements.namedItem('kind');
if (kindField) kindField.value = 'server';
await loadState();

View File

@@ -591,6 +591,11 @@ code {
min-height: 180px;
}
.build-rack-dropzone.build-rack-unracked {
border-style: dashed;
background: #f8fbf5;
}
.build-rack-dropzone.drag-over {
border-color: #2498a1;
background: #e8f9f8;

View File

@@ -99,7 +99,8 @@
<details class="editor-group" open>
<summary>Add Device</summary>
<form id="deviceForm" class="stack-form in-details">
<label>Rack<select name="rackId" id="deviceRackSelect" required></select></label>
<label>Datacenter<select name="datacenterId" id="deviceDatacenterSelect" required></select></label>
<label>Rack (optional)<select name="rackId" id="deviceRackSelect"></select></label>
<label>Name<input name="name" required placeholder="Core Switch 1"></label>
<label>Type
<select name="kind" id="deviceKindSelect">