Support unracked devices across API and UI
This commit is contained in:
234
main.go
234
main.go
@@ -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")
|
||||
}
|
||||
|
||||
196
static/app.js
196
static/app.js
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user