23
README.md
23
README.md
@@ -1,3 +1,24 @@
|
||||
# InfraMap
|
||||
|
||||
Infrastructure Mapping and Planning Written in Go
|
||||
Infrastructure mapping and planning app written in Go.
|
||||
|
||||
## Features
|
||||
- Create maps with WAN/Internet inputs (speed, media, public IPs, provider, tag)
|
||||
- Add datacenters per map with PDU mode and power notes
|
||||
- Add racks per datacenter with configurable rack units (U)
|
||||
- Add devices (servers, switches, routers, PDU, battery) with U size, start U, and ports
|
||||
- Create cable connections with cable type/color, ports, speed, redundancy, and tags
|
||||
- Topology view with layered non-overlapping layout
|
||||
- Rack view showing physical U placement
|
||||
|
||||
## Run
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`.
|
||||
|
||||
## Data
|
||||
- Default state file: `data/state.json`
|
||||
- Override with: `INFRAMAP_DATA=/path/to/state.json`
|
||||
- Change port with: `PORT=9090`
|
||||
|
||||
671
data/state.json
Normal file
671
data/state.json
Normal file
@@ -0,0 +1,671 @@
|
||||
{
|
||||
"maps": {
|
||||
"map_prod_na": {
|
||||
"id": "map_prod_na",
|
||||
"name": "North America Production",
|
||||
"description": "Dual-datacenter map with redundant 10G WAN and inter-DC links.",
|
||||
"wanInputIds": [
|
||||
"wan_edge_a",
|
||||
"wan_edge_b"
|
||||
],
|
||||
"datacenterIds": [
|
||||
"dc_phx1",
|
||||
"dc_ash1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"wanInputs": {
|
||||
"wan_edge_a": {
|
||||
"id": "wan_edge_a",
|
||||
"mapId": "map_prod_na",
|
||||
"name": "WAN Feed A",
|
||||
"speed": "10G",
|
||||
"mediaType": "Fiber SFP+",
|
||||
"publicIps": "198.51.100.8/30",
|
||||
"provider": "CarrierOne",
|
||||
"identifier": "WAN-A"
|
||||
},
|
||||
"wan_edge_b": {
|
||||
"id": "wan_edge_b",
|
||||
"mapId": "map_prod_na",
|
||||
"name": "WAN Feed B",
|
||||
"speed": "10G",
|
||||
"mediaType": "Fiber SFP+",
|
||||
"publicIps": "203.0.113.8/30",
|
||||
"provider": "CarrierTwo",
|
||||
"identifier": "WAN-B"
|
||||
}
|
||||
},
|
||||
"datacenters": {
|
||||
"dc_phx1": {
|
||||
"id": "dc_phx1",
|
||||
"mapId": "map_prod_na",
|
||||
"name": "Phoenix DC1",
|
||||
"pduMode": "PDU + Battery",
|
||||
"rackIds": [
|
||||
"rack_phx_a1",
|
||||
"rack_phx_a2"
|
||||
],
|
||||
"notes": "Primary compute site",
|
||||
"powerFeed": "Dual A/B utility feed"
|
||||
},
|
||||
"dc_ash1": {
|
||||
"id": "dc_ash1",
|
||||
"mapId": "map_prod_na",
|
||||
"name": "Ashburn DC2",
|
||||
"pduMode": "PDU + Battery",
|
||||
"rackIds": [
|
||||
"rack_ash_b1",
|
||||
"rack_ash_b2"
|
||||
],
|
||||
"notes": "Secondary site and DR",
|
||||
"powerFeed": "Dual A/B utility feed"
|
||||
}
|
||||
},
|
||||
"racks": {
|
||||
"rack_phx_a1": {
|
||||
"id": "rack_phx_a1",
|
||||
"datacenterId": "dc_phx1",
|
||||
"name": "PHX Rack A1",
|
||||
"units": 42,
|
||||
"deviceIds": [
|
||||
"dev_phx_a1_pdu1",
|
||||
"dev_phx_a1_tor1",
|
||||
"dev_phx_a1_router1",
|
||||
"dev_phx_a1_srv1",
|
||||
"dev_phx_a1_srv2",
|
||||
"dev_phx_a1_batt1"
|
||||
]
|
||||
},
|
||||
"rack_phx_a2": {
|
||||
"id": "rack_phx_a2",
|
||||
"datacenterId": "dc_phx1",
|
||||
"name": "PHX Rack A2",
|
||||
"units": 42,
|
||||
"deviceIds": [
|
||||
"dev_phx_a2_pdu1",
|
||||
"dev_phx_a2_tor1",
|
||||
"dev_phx_a2_srv3",
|
||||
"dev_phx_a2_srv4",
|
||||
"dev_phx_a2_storage1",
|
||||
"dev_phx_a2_batt1"
|
||||
]
|
||||
},
|
||||
"rack_ash_b1": {
|
||||
"id": "rack_ash_b1",
|
||||
"datacenterId": "dc_ash1",
|
||||
"name": "ASH Rack B1",
|
||||
"units": 42,
|
||||
"deviceIds": [
|
||||
"dev_ash_b1_pdu1",
|
||||
"dev_ash_b1_tor1",
|
||||
"dev_ash_b1_router1",
|
||||
"dev_ash_b1_srv1",
|
||||
"dev_ash_b1_srv2",
|
||||
"dev_ash_b1_batt1"
|
||||
]
|
||||
},
|
||||
"rack_ash_b2": {
|
||||
"id": "rack_ash_b2",
|
||||
"datacenterId": "dc_ash1",
|
||||
"name": "ASH Rack B2",
|
||||
"units": 42,
|
||||
"deviceIds": [
|
||||
"dev_ash_b2_pdu1",
|
||||
"dev_ash_b2_tor1",
|
||||
"dev_ash_b2_srv3",
|
||||
"dev_ash_b2_srv4",
|
||||
"dev_ash_b2_storage1",
|
||||
"dev_ash_b2_batt1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"devices": {
|
||||
"dev_phx_a1_pdu1": {
|
||||
"id": "dev_phx_a1_pdu1",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX-A1 PDU-A",
|
||||
"kind": "pdu",
|
||||
"unitSize": 1,
|
||||
"startU": 42,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Monitored PDU"
|
||||
},
|
||||
"dev_phx_a1_tor1": {
|
||||
"id": "dev_phx_a1_tor1",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX-A1 ToR-01",
|
||||
"kind": "switch",
|
||||
"unitSize": 2,
|
||||
"startU": 40,
|
||||
"portCount": 52,
|
||||
"portType": "SFP+",
|
||||
"notes": "48x10G + 4x40G uplink"
|
||||
},
|
||||
"dev_phx_a1_router1": {
|
||||
"id": "dev_phx_a1_router1",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX Edge Router 1",
|
||||
"kind": "router",
|
||||
"unitSize": 1,
|
||||
"startU": 38,
|
||||
"portCount": 8,
|
||||
"portType": "SFP+",
|
||||
"notes": "WAN termination"
|
||||
},
|
||||
"dev_phx_a1_srv1": {
|
||||
"id": "dev_phx_a1_srv1",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX APP-01",
|
||||
"kind": "server",
|
||||
"unitSize": 2,
|
||||
"startU": 10,
|
||||
"portCount": 4,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Application node"
|
||||
},
|
||||
"dev_phx_a1_srv2": {
|
||||
"id": "dev_phx_a1_srv2",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX APP-02",
|
||||
"kind": "server",
|
||||
"unitSize": 2,
|
||||
"startU": 13,
|
||||
"portCount": 4,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Application node"
|
||||
},
|
||||
"dev_phx_a1_batt1": {
|
||||
"id": "dev_phx_a1_batt1",
|
||||
"rackId": "rack_phx_a1",
|
||||
"name": "PHX-A1 UPS Battery",
|
||||
"kind": "battery",
|
||||
"unitSize": 2,
|
||||
"startU": 1,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Rack battery module"
|
||||
},
|
||||
"dev_phx_a2_pdu1": {
|
||||
"id": "dev_phx_a2_pdu1",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX-A2 PDU-A",
|
||||
"kind": "pdu",
|
||||
"unitSize": 1,
|
||||
"startU": 42,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Monitored PDU"
|
||||
},
|
||||
"dev_phx_a2_tor1": {
|
||||
"id": "dev_phx_a2_tor1",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX-A2 ToR-01",
|
||||
"kind": "switch",
|
||||
"unitSize": 2,
|
||||
"startU": 40,
|
||||
"portCount": 52,
|
||||
"portType": "SFP+",
|
||||
"notes": "Aggregation/compute access"
|
||||
},
|
||||
"dev_phx_a2_srv3": {
|
||||
"id": "dev_phx_a2_srv3",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX WEB-01",
|
||||
"kind": "server",
|
||||
"unitSize": 1,
|
||||
"startU": 20,
|
||||
"portCount": 2,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Web tier"
|
||||
},
|
||||
"dev_phx_a2_srv4": {
|
||||
"id": "dev_phx_a2_srv4",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX WEB-02",
|
||||
"kind": "server",
|
||||
"unitSize": 1,
|
||||
"startU": 22,
|
||||
"portCount": 2,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Web tier"
|
||||
},
|
||||
"dev_phx_a2_storage1": {
|
||||
"id": "dev_phx_a2_storage1",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX Storage-01",
|
||||
"kind": "server",
|
||||
"unitSize": 4,
|
||||
"startU": 4,
|
||||
"portCount": 4,
|
||||
"portType": "25G SFP28",
|
||||
"notes": "Primary storage"
|
||||
},
|
||||
"dev_phx_a2_batt1": {
|
||||
"id": "dev_phx_a2_batt1",
|
||||
"rackId": "rack_phx_a2",
|
||||
"name": "PHX-A2 UPS Battery",
|
||||
"kind": "battery",
|
||||
"unitSize": 2,
|
||||
"startU": 1,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Rack battery module"
|
||||
},
|
||||
"dev_ash_b1_pdu1": {
|
||||
"id": "dev_ash_b1_pdu1",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH-B1 PDU-A",
|
||||
"kind": "pdu",
|
||||
"unitSize": 1,
|
||||
"startU": 42,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Monitored PDU"
|
||||
},
|
||||
"dev_ash_b1_tor1": {
|
||||
"id": "dev_ash_b1_tor1",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH-B1 ToR-01",
|
||||
"kind": "switch",
|
||||
"unitSize": 2,
|
||||
"startU": 40,
|
||||
"portCount": 52,
|
||||
"portType": "SFP+",
|
||||
"notes": "48x10G + 4x40G uplink"
|
||||
},
|
||||
"dev_ash_b1_router1": {
|
||||
"id": "dev_ash_b1_router1",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH Edge Router 1",
|
||||
"kind": "router",
|
||||
"unitSize": 1,
|
||||
"startU": 38,
|
||||
"portCount": 8,
|
||||
"portType": "SFP+",
|
||||
"notes": "WAN termination"
|
||||
},
|
||||
"dev_ash_b1_srv1": {
|
||||
"id": "dev_ash_b1_srv1",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH APP-01",
|
||||
"kind": "server",
|
||||
"unitSize": 2,
|
||||
"startU": 12,
|
||||
"portCount": 4,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "DR application node"
|
||||
},
|
||||
"dev_ash_b1_srv2": {
|
||||
"id": "dev_ash_b1_srv2",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH APP-02",
|
||||
"kind": "server",
|
||||
"unitSize": 2,
|
||||
"startU": 15,
|
||||
"portCount": 4,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "DR application node"
|
||||
},
|
||||
"dev_ash_b1_batt1": {
|
||||
"id": "dev_ash_b1_batt1",
|
||||
"rackId": "rack_ash_b1",
|
||||
"name": "ASH-B1 UPS Battery",
|
||||
"kind": "battery",
|
||||
"unitSize": 2,
|
||||
"startU": 1,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Rack battery module"
|
||||
},
|
||||
"dev_ash_b2_pdu1": {
|
||||
"id": "dev_ash_b2_pdu1",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH-B2 PDU-A",
|
||||
"kind": "pdu",
|
||||
"unitSize": 1,
|
||||
"startU": 42,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Monitored PDU"
|
||||
},
|
||||
"dev_ash_b2_tor1": {
|
||||
"id": "dev_ash_b2_tor1",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH-B2 ToR-01",
|
||||
"kind": "switch",
|
||||
"unitSize": 2,
|
||||
"startU": 40,
|
||||
"portCount": 52,
|
||||
"portType": "SFP+",
|
||||
"notes": "Aggregation/compute access"
|
||||
},
|
||||
"dev_ash_b2_srv3": {
|
||||
"id": "dev_ash_b2_srv3",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH WEB-01",
|
||||
"kind": "server",
|
||||
"unitSize": 1,
|
||||
"startU": 20,
|
||||
"portCount": 2,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Web tier"
|
||||
},
|
||||
"dev_ash_b2_srv4": {
|
||||
"id": "dev_ash_b2_srv4",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH WEB-02",
|
||||
"kind": "server",
|
||||
"unitSize": 1,
|
||||
"startU": 22,
|
||||
"portCount": 2,
|
||||
"portType": "10G SFP+",
|
||||
"notes": "Web tier"
|
||||
},
|
||||
"dev_ash_b2_storage1": {
|
||||
"id": "dev_ash_b2_storage1",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH Storage-01",
|
||||
"kind": "server",
|
||||
"unitSize": 4,
|
||||
"startU": 4,
|
||||
"portCount": 4,
|
||||
"portType": "25G SFP28",
|
||||
"notes": "Replica storage"
|
||||
},
|
||||
"dev_ash_b2_batt1": {
|
||||
"id": "dev_ash_b2_batt1",
|
||||
"rackId": "rack_ash_b2",
|
||||
"name": "ASH-B2 UPS Battery",
|
||||
"kind": "battery",
|
||||
"unitSize": 2,
|
||||
"startU": 1,
|
||||
"portCount": 0,
|
||||
"portType": "",
|
||||
"notes": "Rack battery module"
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"conn_wan_a_to_phx": {
|
||||
"id": "conn_wan_a_to_phx",
|
||||
"fromKind": "wan_input",
|
||||
"fromId": "wan_edge_a",
|
||||
"fromPort": "uplink-a",
|
||||
"toKind": "datacenter",
|
||||
"toId": "dc_phx1",
|
||||
"toPort": "wan-a",
|
||||
"cableType": "Singlemode Fiber LC",
|
||||
"cableColor": "#f59e0b",
|
||||
"tag": "WAN-A-PHX",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_wan_b_to_ash": {
|
||||
"id": "conn_wan_b_to_ash",
|
||||
"fromKind": "wan_input",
|
||||
"fromId": "wan_edge_b",
|
||||
"fromPort": "uplink-b",
|
||||
"toKind": "datacenter",
|
||||
"toId": "dc_ash1",
|
||||
"toPort": "wan-b",
|
||||
"cableType": "Singlemode Fiber LC",
|
||||
"cableColor": "#f59e0b",
|
||||
"tag": "WAN-B-ASH",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_dci_a": {
|
||||
"id": "conn_dci_a",
|
||||
"fromKind": "datacenter",
|
||||
"fromId": "dc_phx1",
|
||||
"fromPort": "ix-a",
|
||||
"toKind": "datacenter",
|
||||
"toId": "dc_ash1",
|
||||
"toPort": "ix-a",
|
||||
"cableType": "Singlemode Fiber Pair",
|
||||
"cableColor": "#2563eb",
|
||||
"tag": "DCI-A",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_dci_b": {
|
||||
"id": "conn_dci_b",
|
||||
"fromKind": "datacenter",
|
||||
"fromId": "dc_phx1",
|
||||
"fromPort": "ix-b",
|
||||
"toKind": "datacenter",
|
||||
"toId": "dc_ash1",
|
||||
"toPort": "ix-b",
|
||||
"cableType": "Singlemode Fiber Pair",
|
||||
"cableColor": "#1d4ed8",
|
||||
"tag": "DCI-B",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_dc_phx_to_router": {
|
||||
"id": "conn_dc_phx_to_router",
|
||||
"fromKind": "datacenter",
|
||||
"fromId": "dc_phx1",
|
||||
"fromPort": "edge-core-a",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a1_router1",
|
||||
"toPort": "1",
|
||||
"cableType": "Singlemode Fiber",
|
||||
"cableColor": "#0ea5e9",
|
||||
"tag": "PHX-EDGE-RTR",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_dc_ash_to_router": {
|
||||
"id": "conn_dc_ash_to_router",
|
||||
"fromKind": "datacenter",
|
||||
"fromId": "dc_ash1",
|
||||
"fromPort": "edge-core-a",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b1_router1",
|
||||
"toPort": "1",
|
||||
"cableType": "Singlemode Fiber",
|
||||
"cableColor": "#0ea5e9",
|
||||
"tag": "ASH-EDGE-RTR",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_router_to_tor": {
|
||||
"id": "conn_phx_router_to_tor",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_router1",
|
||||
"fromPort": "2",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a1_tor1",
|
||||
"toPort": "50",
|
||||
"cableType": "DAC SFP+",
|
||||
"cableColor": "#14b8a6",
|
||||
"tag": "PHX-RTR-TOR",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_ash_router_to_tor": {
|
||||
"id": "conn_ash_router_to_tor",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_ash_b1_router1",
|
||||
"fromPort": "2",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b1_tor1",
|
||||
"toPort": "50",
|
||||
"cableType": "DAC SFP+",
|
||||
"cableColor": "#14b8a6",
|
||||
"tag": "ASH-RTR-TOR",
|
||||
"speed": "10G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_srv1_nic1": {
|
||||
"id": "conn_phx_srv1_nic1",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_srv1",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a1_tor1",
|
||||
"toPort": "1",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "PHX-A1-SRV1-NIC1",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_srv1_nic2": {
|
||||
"id": "conn_phx_srv1_nic2",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_srv1",
|
||||
"fromPort": "2",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a1_tor1",
|
||||
"toPort": "2",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#16a34a",
|
||||
"tag": "PHX-A1-SRV1-NIC2",
|
||||
"speed": "1G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_phx_srv2_nic1": {
|
||||
"id": "conn_phx_srv2_nic1",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_srv2",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a1_tor1",
|
||||
"toPort": "3",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "PHX-A1-SRV2-NIC1",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_web1": {
|
||||
"id": "conn_phx_web1",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a2_srv3",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a2_tor1",
|
||||
"toPort": "5",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "PHX-A2-WEB1",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_web2": {
|
||||
"id": "conn_phx_web2",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a2_srv4",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a2_tor1",
|
||||
"toPort": "6",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "PHX-A2-WEB2",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_phx_a1_a2_uplink_a": {
|
||||
"id": "conn_phx_a1_a2_uplink_a",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_tor1",
|
||||
"fromPort": "47",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a2_tor1",
|
||||
"toPort": "47",
|
||||
"cableType": "Multimode Fiber",
|
||||
"cableColor": "#06b6d4",
|
||||
"tag": "PHX-MLAG-A",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_phx_a1_a2_uplink_b": {
|
||||
"id": "conn_phx_a1_a2_uplink_b",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a1_tor1",
|
||||
"fromPort": "48",
|
||||
"toKind": "device",
|
||||
"toId": "dev_phx_a2_tor1",
|
||||
"toPort": "48",
|
||||
"cableType": "Multimode Fiber",
|
||||
"cableColor": "#0891b2",
|
||||
"tag": "PHX-MLAG-B",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_ash_srv1_nic1": {
|
||||
"id": "conn_ash_srv1_nic1",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_ash_b1_srv1",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b1_tor1",
|
||||
"toPort": "1",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "ASH-B1-SRV1-NIC1",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_ash_srv2_nic1": {
|
||||
"id": "conn_ash_srv2_nic1",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_ash_b1_srv2",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b1_tor1",
|
||||
"toPort": "2",
|
||||
"cableType": "Cat6A",
|
||||
"cableColor": "#22c55e",
|
||||
"tag": "ASH-B1-SRV2-NIC1",
|
||||
"speed": "1G",
|
||||
"redundant": false
|
||||
},
|
||||
"conn_ash_b1_b2_uplink_a": {
|
||||
"id": "conn_ash_b1_b2_uplink_a",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_ash_b1_tor1",
|
||||
"fromPort": "47",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b2_tor1",
|
||||
"toPort": "47",
|
||||
"cableType": "Multimode Fiber",
|
||||
"cableColor": "#06b6d4",
|
||||
"tag": "ASH-MLAG-A",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_ash_b1_b2_uplink_b": {
|
||||
"id": "conn_ash_b1_b2_uplink_b",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_ash_b1_tor1",
|
||||
"fromPort": "48",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b2_tor1",
|
||||
"toPort": "48",
|
||||
"cableType": "Multimode Fiber",
|
||||
"cableColor": "#0891b2",
|
||||
"tag": "ASH-MLAG-B",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
},
|
||||
"conn_storage_replication": {
|
||||
"id": "conn_storage_replication",
|
||||
"fromKind": "device",
|
||||
"fromId": "dev_phx_a2_storage1",
|
||||
"fromPort": "1",
|
||||
"toKind": "device",
|
||||
"toId": "dev_ash_b2_storage1",
|
||||
"toPort": "1",
|
||||
"cableType": "Singlemode Fiber",
|
||||
"cableColor": "#a855f7",
|
||||
"tag": "STORAGE-REPL-01",
|
||||
"speed": "10G",
|
||||
"redundant": true
|
||||
}
|
||||
}
|
||||
}
|
||||
756
main.go
Normal file
756
main.go
Normal file
@@ -0,0 +1,756 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/index.html static/*
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
type InfraState struct {
|
||||
Maps map[string]*Map `json:"maps"`
|
||||
WANInputs map[string]*WANInput `json:"wanInputs"`
|
||||
Datacenters map[string]*Datacenter `json:"datacenters"`
|
||||
Racks map[string]*Rack `json:"racks"`
|
||||
Devices map[string]*Device `json:"devices"`
|
||||
Connections map[string]*Connection `json:"connections"`
|
||||
}
|
||||
|
||||
type Map struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
WANInputIDs []string `json:"wanInputIds"`
|
||||
DatacenterIDs []string `json:"datacenterIds"`
|
||||
}
|
||||
|
||||
type WANInput struct {
|
||||
ID string `json:"id"`
|
||||
MapID string `json:"mapId"`
|
||||
Name string `json:"name"`
|
||||
Speed string `json:"speed"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PublicIPs string `json:"publicIps"`
|
||||
Provider string `json:"provider"`
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
type Datacenter struct {
|
||||
ID string `json:"id"`
|
||||
MapID string `json:"mapId"`
|
||||
Name string `json:"name"`
|
||||
PDUMode string `json:"pduMode"`
|
||||
RackIDs []string `json:"rackIds"`
|
||||
Notes string `json:"notes"`
|
||||
PowerFeed string `json:"powerFeed"`
|
||||
}
|
||||
|
||||
type Rack struct {
|
||||
ID string `json:"id"`
|
||||
DatacenterID string `json:"datacenterId"`
|
||||
Name string `json:"name"`
|
||||
Units int `json:"units"`
|
||||
DeviceIDs []string `json:"deviceIds"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
RackID string `json:"rackId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
UnitSize int `json:"unitSize"`
|
||||
StartU int `json:"startU"`
|
||||
PortCount int `json:"portCount"`
|
||||
PortType string `json:"portType"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
FromKind string `json:"fromKind"`
|
||||
FromID string `json:"fromId"`
|
||||
FromPort string `json:"fromPort"`
|
||||
ToKind string `json:"toKind"`
|
||||
ToID string `json:"toId"`
|
||||
ToPort string `json:"toPort"`
|
||||
CableType string `json:"cableType"`
|
||||
CableColor string `json:"cableColor"`
|
||||
Tag string `json:"tag"`
|
||||
Speed string `json:"speed"`
|
||||
Redundant bool `json:"redundant"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
state InfraState
|
||||
}
|
||||
|
||||
func newStore(path string) *Store {
|
||||
return &Store{
|
||||
path: path,
|
||||
state: InfraState{
|
||||
Maps: map[string]*Map{},
|
||||
WANInputs: map[string]*WANInput{},
|
||||
Datacenters: map[string]*Datacenter{},
|
||||
Racks: map[string]*Rack{},
|
||||
Devices: map[string]*Device{},
|
||||
Connections: map[string]*Connection{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
payload, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsed InfraState
|
||||
if err := json.Unmarshal(payload, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Maps == nil {
|
||||
parsed.Maps = map[string]*Map{}
|
||||
}
|
||||
if parsed.WANInputs == nil {
|
||||
parsed.WANInputs = map[string]*WANInput{}
|
||||
}
|
||||
if parsed.Datacenters == nil {
|
||||
parsed.Datacenters = map[string]*Datacenter{}
|
||||
}
|
||||
if parsed.Racks == nil {
|
||||
parsed.Racks = map[string]*Rack{}
|
||||
}
|
||||
if parsed.Devices == nil {
|
||||
parsed.Devices = map[string]*Device{}
|
||||
}
|
||||
if parsed.Connections == nil {
|
||||
parsed.Connections = map[string]*Connection{}
|
||||
}
|
||||
s.state = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) snapshot() (InfraState, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
payload, err := json.Marshal(s.state)
|
||||
if err != nil {
|
||||
return InfraState{}, err
|
||||
}
|
||||
|
||||
var copyState InfraState
|
||||
if err := json.Unmarshal(payload, ©State); err != nil {
|
||||
return InfraState{}, err
|
||||
}
|
||||
return copyState, nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(s.state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, payload, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, s.path)
|
||||
}
|
||||
|
||||
func makeID(prefix string) string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixNano(), hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
func normalizeKind(v string) string {
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func overlaps(startA, sizeA, startB, sizeB int) bool {
|
||||
endA := startA + sizeA - 1
|
||||
endB := startB + sizeB - 1
|
||||
return startA <= endB && startB <= endA
|
||||
}
|
||||
|
||||
type createMapRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type createWANInputRequest struct {
|
||||
MapID string `json:"mapId"`
|
||||
Name string `json:"name"`
|
||||
Speed string `json:"speed"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PublicIPs string `json:"publicIps"`
|
||||
Provider string `json:"provider"`
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
type createDatacenterRequest struct {
|
||||
MapID string `json:"mapId"`
|
||||
Name string `json:"name"`
|
||||
PDUMode string `json:"pduMode"`
|
||||
Notes string `json:"notes"`
|
||||
PowerFeed string `json:"powerFeed"`
|
||||
}
|
||||
|
||||
type createRackRequest struct {
|
||||
DatacenterID string `json:"datacenterId"`
|
||||
Name string `json:"name"`
|
||||
Units int `json:"units"`
|
||||
}
|
||||
|
||||
type createDeviceRequest struct {
|
||||
RackID string `json:"rackId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
UnitSize int `json:"unitSize"`
|
||||
StartU int `json:"startU"`
|
||||
PortCount int `json:"portCount"`
|
||||
PortType string `json:"portType"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type createConnectionRequest struct {
|
||||
FromKind string `json:"fromKind"`
|
||||
FromID string `json:"fromId"`
|
||||
FromPort string `json:"fromPort"`
|
||||
ToKind string `json:"toKind"`
|
||||
ToID string `json:"toId"`
|
||||
ToPort string `json:"toPort"`
|
||||
CableType string `json:"cableType"`
|
||||
CableColor string `json:"cableColor"`
|
||||
Tag string `json:"tag"`
|
||||
Speed string `json:"speed"`
|
||||
Redundant bool `json:"redundant"`
|
||||
}
|
||||
|
||||
func (s *Store) createMap(req createMapRequest) (*Map, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("map name is required")
|
||||
}
|
||||
|
||||
m := &Map{
|
||||
ID: makeID("map"),
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
WANInputIDs: []string{},
|
||||
DatacenterIDs: []string{},
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state.Maps[m.ID] = m
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Store) createWANInput(req createWANInputRequest) (*WANInput, error) {
|
||||
mapID := strings.TrimSpace(req.MapID)
|
||||
if mapID == "" {
|
||||
return nil, errors.New("mapId is required")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("wan input name is required")
|
||||
}
|
||||
|
||||
w := &WANInput{
|
||||
ID: makeID("wan"),
|
||||
MapID: mapID,
|
||||
Name: name,
|
||||
Speed: strings.TrimSpace(req.Speed),
|
||||
MediaType: strings.TrimSpace(req.MediaType),
|
||||
PublicIPs: strings.TrimSpace(req.PublicIPs),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
Identifier: strings.TrimSpace(req.Identifier),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
m := s.state.Maps[mapID]
|
||||
if m == nil {
|
||||
return nil, errors.New("map not found")
|
||||
}
|
||||
|
||||
s.state.WANInputs[w.ID] = w
|
||||
m.WANInputIDs = append(m.WANInputIDs, w.ID)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *Store) createDatacenter(req createDatacenterRequest) (*Datacenter, error) {
|
||||
mapID := strings.TrimSpace(req.MapID)
|
||||
if mapID == "" {
|
||||
return nil, errors.New("mapId is required")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("datacenter name is required")
|
||||
}
|
||||
pdu := strings.TrimSpace(req.PDUMode)
|
||||
if pdu == "" {
|
||||
pdu = "PDU"
|
||||
}
|
||||
|
||||
dc := &Datacenter{
|
||||
ID: makeID("dc"),
|
||||
MapID: mapID,
|
||||
Name: name,
|
||||
PDUMode: pdu,
|
||||
RackIDs: []string{},
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
PowerFeed: strings.TrimSpace(req.PowerFeed),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
m := s.state.Maps[mapID]
|
||||
if m == nil {
|
||||
return nil, errors.New("map not found")
|
||||
}
|
||||
|
||||
s.state.Datacenters[dc.ID] = dc
|
||||
m.DatacenterIDs = append(m.DatacenterIDs, dc.ID)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func (s *Store) createRack(req createRackRequest) (*Rack, error) {
|
||||
dcID := strings.TrimSpace(req.DatacenterID)
|
||||
if dcID == "" {
|
||||
return nil, errors.New("datacenterId is required")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("rack name is required")
|
||||
}
|
||||
units := req.Units
|
||||
if units <= 0 {
|
||||
units = 42
|
||||
}
|
||||
|
||||
r := &Rack{
|
||||
ID: makeID("rack"),
|
||||
DatacenterID: dcID,
|
||||
Name: name,
|
||||
Units: units,
|
||||
DeviceIDs: []string{},
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
dc := s.state.Datacenters[dcID]
|
||||
if dc == nil {
|
||||
return nil, errors.New("datacenter not found")
|
||||
}
|
||||
|
||||
s.state.Racks[r.ID] = r
|
||||
dc.RackIDs = append(dc.RackIDs, r.ID)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) createDevice(req createDeviceRequest) (*Device, error) {
|
||||
rackID := strings.TrimSpace(req.RackID)
|
||||
if rackID == "" {
|
||||
return nil, errors.New("rackId is required")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("device name is required")
|
||||
}
|
||||
kind := normalizeKind(req.Kind)
|
||||
if kind == "" {
|
||||
kind = "server"
|
||||
}
|
||||
unitSize := req.UnitSize
|
||||
if unitSize <= 0 {
|
||||
unitSize = 1
|
||||
}
|
||||
startU := req.StartU
|
||||
if startU <= 0 {
|
||||
startU = 1
|
||||
}
|
||||
if req.PortCount < 0 {
|
||||
return nil, errors.New("portCount cannot be negative")
|
||||
}
|
||||
|
||||
d := &Device{
|
||||
ID: makeID("dev"),
|
||||
RackID: rackID,
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
UnitSize: unitSize,
|
||||
StartU: startU,
|
||||
PortCount: req.PortCount,
|
||||
PortType: strings.TrimSpace(req.PortType),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
rack := s.state.Racks[rackID]
|
||||
if rack == nil {
|
||||
return nil, errors.New("rack not found")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
s.state.Devices[d.ID] = d
|
||||
rack.DeviceIDs = append(rack.DeviceIDs, d.ID)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func validatePort(device *Device, value string) error {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if device.PortCount <= 0 {
|
||||
return fmt.Errorf("device %q has no ports configured", device.Name)
|
||||
}
|
||||
if n < 1 || n > device.PortCount {
|
||||
return fmt.Errorf("port %d is outside configured range 1..%d for device %q", n, device.PortCount, device.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) endpointMapIDLocked(kind, id string) (string, error) {
|
||||
switch normalizeKind(kind) {
|
||||
case "wan_input":
|
||||
wan := s.state.WANInputs[id]
|
||||
if wan == nil {
|
||||
return "", errors.New("wan input not found")
|
||||
}
|
||||
return wan.MapID, nil
|
||||
case "datacenter":
|
||||
dc := s.state.Datacenters[id]
|
||||
if dc == nil {
|
||||
return "", errors.New("datacenter not found")
|
||||
}
|
||||
return dc.MapID, nil
|
||||
case "device":
|
||||
dev := s.state.Devices[id]
|
||||
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")
|
||||
}
|
||||
dc := s.state.Datacenters[rack.DatacenterID]
|
||||
if dc == nil {
|
||||
return "", errors.New("datacenter for device not found")
|
||||
}
|
||||
return dc.MapID, nil
|
||||
default:
|
||||
return "", errors.New("unsupported endpoint kind (use wan_input, datacenter, or device)")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) createConnection(req createConnectionRequest) (*Connection, error) {
|
||||
fromKind := normalizeKind(req.FromKind)
|
||||
toKind := normalizeKind(req.ToKind)
|
||||
fromID := strings.TrimSpace(req.FromID)
|
||||
toID := strings.TrimSpace(req.ToID)
|
||||
if fromKind == "" || fromID == "" || toKind == "" || toID == "" {
|
||||
return nil, errors.New("from and to endpoints are required")
|
||||
}
|
||||
if fromKind == toKind && fromID == toID && strings.TrimSpace(req.FromPort) == strings.TrimSpace(req.ToPort) {
|
||||
return nil, errors.New("from and to endpoints cannot be identical")
|
||||
}
|
||||
|
||||
c := &Connection{
|
||||
ID: makeID("conn"),
|
||||
FromKind: fromKind,
|
||||
FromID: fromID,
|
||||
FromPort: strings.TrimSpace(req.FromPort),
|
||||
ToKind: toKind,
|
||||
ToID: toID,
|
||||
ToPort: strings.TrimSpace(req.ToPort),
|
||||
CableType: strings.TrimSpace(req.CableType),
|
||||
CableColor: strings.TrimSpace(req.CableColor),
|
||||
Tag: strings.TrimSpace(req.Tag),
|
||||
Speed: strings.TrimSpace(req.Speed),
|
||||
Redundant: req.Redundant,
|
||||
}
|
||||
if c.CableColor == "" {
|
||||
c.CableColor = "#3b82f6"
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
fromMapID, err := s.endpointMapIDLocked(c.FromKind, c.FromID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("from endpoint invalid: %w", err)
|
||||
}
|
||||
toMapID, err := s.endpointMapIDLocked(c.ToKind, c.ToID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("to endpoint invalid: %w", err)
|
||||
}
|
||||
if fromMapID != toMapID {
|
||||
return nil, errors.New("connections across different maps are not supported")
|
||||
}
|
||||
|
||||
if c.FromKind == "device" {
|
||||
if err := validatePort(s.state.Devices[c.FromID], c.FromPort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if c.ToKind == "device" {
|
||||
if err := validatePort(s.state.Devices[c.ToID], c.ToPort); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.state.Connections[c.ID] = c
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type App struct {
|
||||
store *Store
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
func decodeJSON(body io.Reader, target any) error {
|
||||
dec := json.NewDecoder(body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(target)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Printf("write json error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (a *App) index(w http.ResponseWriter, _ *http.Request) {
|
||||
if err := a.tmpl.ExecuteTemplate(w, "index.html", nil); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) state(w http.ResponseWriter, _ *http.Request) {
|
||||
s, err := a.store.snapshot()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (a *App) createMap(w http.ResponseWriter, r *http.Request) {
|
||||
var req createMapRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createMap(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (a *App) createWANInput(w http.ResponseWriter, r *http.Request) {
|
||||
var req createWANInputRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createWANInput(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (a *App) createDatacenter(w http.ResponseWriter, r *http.Request) {
|
||||
var req createDatacenterRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createDatacenter(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (a *App) createRack(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRackRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createRack(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (a *App) createDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var req createDeviceRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createDevice(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (a *App) createConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var req createConnectionRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
created, err := a.store.createConnection(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataPath := strings.TrimSpace(os.Getenv("INFRAMAP_DATA"))
|
||||
if dataPath == "" {
|
||||
dataPath = filepath.Join("data", "state.json")
|
||||
}
|
||||
|
||||
store := newStore(dataPath)
|
||||
if err := store.load(); err != nil {
|
||||
log.Fatalf("failed to load state: %v", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFS(embeddedFiles, "templates/index.html")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse template: %v", err)
|
||||
}
|
||||
staticFS, err := fs.Sub(embeddedFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to mount static files: %v", err)
|
||||
}
|
||||
|
||||
app := &App{store: store, tmpl: tmpl}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
mux.HandleFunc("GET /", app.index)
|
||||
mux.HandleFunc("GET /api/state", app.state)
|
||||
mux.HandleFunc("POST /api/map", app.createMap)
|
||||
mux.HandleFunc("POST /api/wan-input", app.createWANInput)
|
||||
mux.HandleFunc("POST /api/datacenter", app.createDatacenter)
|
||||
mux.HandleFunc("POST /api/rack", app.createRack)
|
||||
mux.HandleFunc("POST /api/device", app.createDevice)
|
||||
mux.HandleFunc("POST /api/connection", app.createConnection)
|
||||
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
if !strings.Contains(port, ":") {
|
||||
port = ":" + port
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: port,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("InfraMap running on http://localhost%s", port)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
807
static/app.js
Normal file
807
static/app.js
Normal file
@@ -0,0 +1,807 @@
|
||||
const appState = {
|
||||
data: null,
|
||||
activeView: 'topology',
|
||||
selectedMapId: '',
|
||||
selectedRackId: ''
|
||||
};
|
||||
|
||||
const refs = {
|
||||
messageBox: document.getElementById('messages'),
|
||||
topologySvg: document.getElementById('topologySvg'),
|
||||
topologyMapSelect: document.getElementById('topologyMapSelect'),
|
||||
rackSelect: document.getElementById('rackSelect'),
|
||||
rackContainer: document.getElementById('rackContainer'),
|
||||
summary: document.getElementById('connectionSummary'),
|
||||
topologyControls: document.getElementById('topologyControls'),
|
||||
rackControls: document.getElementById('rackControls'),
|
||||
topologyView: document.getElementById('topologyView'),
|
||||
rackView: document.getElementById('rackView')
|
||||
};
|
||||
|
||||
function sortByName(arr) {
|
||||
return [...arr].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function values(obj) {
|
||||
return Object.values(obj || {});
|
||||
}
|
||||
|
||||
function listField(obj, ...keys) {
|
||||
for (const key of keys) {
|
||||
const value = obj?.[key];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function deviceKind(value) {
|
||||
return String(value || '').toLowerCase();
|
||||
}
|
||||
|
||||
function isServerKind(value) {
|
||||
return deviceKind(value) === 'server';
|
||||
}
|
||||
|
||||
function showMessage(text, kind = 'ok') {
|
||||
refs.messageBox.textContent = text;
|
||||
refs.messageBox.className = `messages ${kind === 'error' ? 'message-error' : 'message-ok'}`;
|
||||
if (text) {
|
||||
window.setTimeout(() => {
|
||||
if (refs.messageBox.textContent === text) {
|
||||
refs.messageBox.textContent = '';
|
||||
refs.messageBox.className = 'messages';
|
||||
}
|
||||
}, 4200);
|
||||
}
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body.error) msg = body.error;
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, payload) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function getMapById(id) {
|
||||
return appState.data?.maps?.[id] || null;
|
||||
}
|
||||
|
||||
function getDatacenterById(id) {
|
||||
return appState.data?.datacenters?.[id] || null;
|
||||
}
|
||||
|
||||
function endpointLabel(kind, id) {
|
||||
const data = appState.data;
|
||||
if (!data) return `${kind}:${id}`;
|
||||
|
||||
if (kind === 'wan_input') {
|
||||
const wan = data.wanInputs[id];
|
||||
if (!wan) return `WAN:${id}`;
|
||||
const map = data.maps[wan.mapId];
|
||||
return `[WAN] ${wan.name}${map ? ` (${map.name})` : ''}`;
|
||||
}
|
||||
if (kind === 'datacenter') {
|
||||
const dc = data.datacenters[id];
|
||||
if (!dc) return `Datacenter:${id}`;
|
||||
const map = data.maps[dc.mapId];
|
||||
return `[DC] ${dc.name}${map ? ` (${map.name})` : ''}`;
|
||||
}
|
||||
if (kind === 'device') {
|
||||
const dev = data.devices[id];
|
||||
if (!dev) return `Device:${id}`;
|
||||
const rack = data.racks[dev.rackId];
|
||||
const dc = rack ? data.datacenters[rack.datacenterId] : null;
|
||||
return `[${dev.kind}] ${dev.name}${dc ? ` (${dc.name})` : ''}`;
|
||||
}
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
function populateSelect(selectId, options, selected = '', includeEmpty = false, emptyLabel = 'Select...') {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
|
||||
const existing = selected || select.value;
|
||||
select.innerHTML = '';
|
||||
|
||||
if (includeEmpty) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = emptyLabel;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
for (const option of options) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
if (existing && [...select.options].some((o) => o.value === existing)) {
|
||||
select.value = existing;
|
||||
} else if (!includeEmpty && select.options.length > 0) {
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSelectors() {
|
||||
const data = appState.data;
|
||||
if (!data) return;
|
||||
|
||||
const maps = sortByName(values(data.maps));
|
||||
const dcs = sortByName(values(data.datacenters));
|
||||
const racks = sortByName(values(data.racks));
|
||||
|
||||
const mapOptions = maps.map((m) => ({ value: m.id, label: m.name }));
|
||||
populateSelect('wanMapSelect', mapOptions);
|
||||
populateSelect('dcMapSelect', mapOptions);
|
||||
|
||||
populateSelect(
|
||||
'topologyMapSelect',
|
||||
[{ value: '', label: 'All Maps' }, ...mapOptions],
|
||||
appState.selectedMapId
|
||||
);
|
||||
|
||||
const dcOptions = dcs.map((dc) => {
|
||||
const map = getMapById(dc.mapId);
|
||||
return { value: dc.id, label: `${dc.name}${map ? ` (${map.name})` : ''}` };
|
||||
});
|
||||
populateSelect('rackDatacenterSelect', 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(
|
||||
'rackSelect',
|
||||
[{ value: '', label: 'All Racks' }, ...rackOptions],
|
||||
appState.selectedRackId
|
||||
);
|
||||
|
||||
if (appState.selectedRackId && !rackOptions.find((r) => r.value === appState.selectedRackId)) {
|
||||
appState.selectedRackId = '';
|
||||
refs.rackSelect.value = appState.selectedRackId;
|
||||
}
|
||||
|
||||
const endpointOptions = [];
|
||||
for (const wan of sortByName(values(data.wanInputs))) {
|
||||
endpointOptions.push({ value: `wan_input|${wan.id}`, label: endpointLabel('wan_input', wan.id) });
|
||||
}
|
||||
for (const dc of sortByName(values(data.datacenters))) {
|
||||
endpointOptions.push({ value: `datacenter|${dc.id}`, label: endpointLabel('datacenter', dc.id) });
|
||||
}
|
||||
for (const dev of sortByName(values(data.devices))) {
|
||||
endpointOptions.push({ value: `device|${dev.id}`, label: endpointLabel('device', dev.id) });
|
||||
}
|
||||
|
||||
populateSelect('fromEndpointSelect', endpointOptions);
|
||||
populateSelect('toEndpointSelect', endpointOptions);
|
||||
}
|
||||
|
||||
function parseEndpointValue(raw) {
|
||||
const [kind, id] = String(raw || '').split('|');
|
||||
return { kind: kind || '', id: id || '' };
|
||||
}
|
||||
|
||||
function createSvg(tagName, attrs = {}) {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tagName);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function nodeKindToClass(kind) {
|
||||
if (kind === 'map') return 'map';
|
||||
if (kind === 'wan_input') return 'wan';
|
||||
if (kind === 'datacenter') return 'datacenter';
|
||||
if (kind === 'rack') return 'rack';
|
||||
return 'device';
|
||||
}
|
||||
|
||||
function nodeMeta(node) {
|
||||
if (node.kind === 'map') return node.description || 'Map';
|
||||
if (node.kind === 'wan_input') return `${node.speed || 'speed?'} ${node.mediaType || 'media?'}`;
|
||||
if (node.kind === 'datacenter') return node.pduMode || 'Datacenter';
|
||||
if (node.kind === 'rack') return `${node.units}U rack`;
|
||||
if (node.kind === 'device') {
|
||||
const ports = node.portCount > 0 ? `${node.portCount} ports` : 'no ports';
|
||||
return `${node.deviceKind} ${node.unitSize}U, ${ports}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildTopologyModel() {
|
||||
const data = appState.data;
|
||||
const selectedMapId = appState.selectedMapId;
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
const nodeIndex = new Map();
|
||||
const mapOrder = [];
|
||||
|
||||
if (!data) return { nodes, edges, nodeIndex, mapOrder };
|
||||
|
||||
const maps = sortByName(values(data.maps)).filter((m) => !selectedMapId || m.id === selectedMapId);
|
||||
|
||||
for (const mapObj of maps) {
|
||||
mapOrder.push(mapObj.id);
|
||||
const mapNode = {
|
||||
id: `map:${mapObj.id}`,
|
||||
kind: 'map',
|
||||
label: mapObj.name,
|
||||
description: mapObj.description,
|
||||
mapId: mapObj.id,
|
||||
level: 0
|
||||
};
|
||||
nodes.push(mapNode);
|
||||
nodeIndex.set(mapNode.id, mapNode);
|
||||
|
||||
for (const wanId of listField(mapObj, 'wanInputIds', 'wanInputIDs')) {
|
||||
const wan = data.wanInputs[wanId];
|
||||
if (!wan) continue;
|
||||
const wanNode = {
|
||||
id: `wan_input:${wan.id}`,
|
||||
kind: 'wan_input',
|
||||
label: wan.name,
|
||||
mapId: mapObj.id,
|
||||
level: 1,
|
||||
speed: wan.speed,
|
||||
mediaType: wan.mediaType
|
||||
};
|
||||
nodes.push(wanNode);
|
||||
nodeIndex.set(wanNode.id, wanNode);
|
||||
edges.push({ from: mapNode.id, to: wanNode.id, type: 'containment' });
|
||||
}
|
||||
|
||||
for (const dcId of listField(mapObj, 'datacenterIds', 'datacenterIDs')) {
|
||||
const dc = data.datacenters[dcId];
|
||||
if (!dc) continue;
|
||||
const dcNode = {
|
||||
id: `datacenter:${dc.id}`,
|
||||
kind: 'datacenter',
|
||||
label: dc.name,
|
||||
mapId: mapObj.id,
|
||||
level: 2,
|
||||
pduMode: dc.pduMode
|
||||
};
|
||||
nodes.push(dcNode);
|
||||
nodeIndex.set(dcNode.id, dcNode);
|
||||
edges.push({ from: mapNode.id, to: dcNode.id, type: 'containment' });
|
||||
|
||||
for (const rackId of listField(dc, 'rackIds', 'rackIDs')) {
|
||||
const rack = data.racks[rackId];
|
||||
if (!rack) continue;
|
||||
const rackNode = {
|
||||
id: `rack:${rack.id}`,
|
||||
kind: 'rack',
|
||||
label: rack.name,
|
||||
mapId: mapObj.id,
|
||||
level: 3,
|
||||
units: rack.units
|
||||
};
|
||||
nodes.push(rackNode);
|
||||
nodeIndex.set(rackNode.id, rackNode);
|
||||
edges.push({ from: dcNode.id, to: rackNode.id, type: 'containment' });
|
||||
|
||||
for (const deviceId of listField(rack, 'deviceIds', 'deviceIDs')) {
|
||||
const dev = data.devices[deviceId];
|
||||
if (!dev) continue;
|
||||
const normalizedKind = deviceKind(dev.kind);
|
||||
const serverNode = isServerKind(normalizedKind);
|
||||
const deviceNode = {
|
||||
id: `device:${dev.id}`,
|
||||
kind: 'device',
|
||||
label: dev.name,
|
||||
mapId: mapObj.id,
|
||||
level: serverNode ? 5 : 4,
|
||||
deviceKind: normalizedKind,
|
||||
isServer: serverNode,
|
||||
unitSize: dev.unitSize,
|
||||
portCount: dev.portCount
|
||||
};
|
||||
nodes.push(deviceNode);
|
||||
nodeIndex.set(deviceNode.id, deviceNode);
|
||||
edges.push({ from: rackNode.id, to: deviceNode.id, type: 'containment' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const conn of values(data.connections)) {
|
||||
const fromId = `${conn.fromKind}:${conn.fromId}`;
|
||||
const toId = `${conn.toKind}:${conn.toId}`;
|
||||
if (!nodeIndex.has(fromId) || !nodeIndex.has(toId)) continue;
|
||||
edges.push({
|
||||
from: fromId,
|
||||
to: toId,
|
||||
type: 'connection',
|
||||
cableType: conn.cableType,
|
||||
cableColor: conn.cableColor || '#3b82f6',
|
||||
tag: conn.tag,
|
||||
speed: conn.speed,
|
||||
fromPort: conn.fromPort,
|
||||
toPort: conn.toPort,
|
||||
redundant: conn.redundant
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges, mapOrder };
|
||||
}
|
||||
|
||||
function renderTopology() {
|
||||
const svg = refs.topologySvg;
|
||||
svg.innerHTML = '';
|
||||
|
||||
const model = buildTopologyModel();
|
||||
const { nodes, edges, mapOrder } = model;
|
||||
|
||||
if (!nodes.length) {
|
||||
const text = createSvg('text', { x: 24, y: 30, fill: '#51626a', 'font-size': 14 });
|
||||
text.textContent = 'Create a map to begin modeling infrastructure.';
|
||||
svg.appendChild(text);
|
||||
svg.setAttribute('viewBox', '0 0 900 680');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeW = 176;
|
||||
const nodeH = 58;
|
||||
const hGap = 22;
|
||||
const levelGap = 118;
|
||||
const marginX = 36;
|
||||
const marginY = 54;
|
||||
const maxLevel = Math.max(...nodes.map((n) => n.level));
|
||||
|
||||
const nodesByMap = new Map();
|
||||
for (const mapId of mapOrder) {
|
||||
nodesByMap.set(mapId, new Map());
|
||||
for (let lvl = 0; lvl <= maxLevel; lvl += 1) nodesByMap.get(mapId).set(lvl, []);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!nodesByMap.has(node.mapId)) continue;
|
||||
nodesByMap.get(node.mapId).get(node.level).push(node);
|
||||
}
|
||||
|
||||
const mapBands = new Map();
|
||||
let cursor = marginX;
|
||||
for (const mapId of mapOrder) {
|
||||
const levels = nodesByMap.get(mapId);
|
||||
let maxCount = 1;
|
||||
for (let lvl = 0; lvl <= maxLevel; lvl += 1) {
|
||||
maxCount = Math.max(maxCount, levels.get(lvl).length);
|
||||
}
|
||||
const width = Math.max(320, maxCount * (nodeW + hGap) + 24);
|
||||
mapBands.set(mapId, { x: cursor, width });
|
||||
cursor += width + 72;
|
||||
}
|
||||
|
||||
const totalWidth = Math.max(980, cursor + marginX);
|
||||
const totalHeight = marginY * 2 + (maxLevel + 1) * levelGap;
|
||||
|
||||
svg.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
|
||||
svg.setAttribute('width', `${totalWidth}`);
|
||||
svg.setAttribute('height', `${Math.max(680, totalHeight)}`);
|
||||
|
||||
const layerBands = createSvg('g');
|
||||
const layerEdges = createSvg('g');
|
||||
const layerNodes = createSvg('g');
|
||||
|
||||
for (const mapId of mapOrder) {
|
||||
const band = mapBands.get(mapId);
|
||||
const mapObj = appState.data.maps[mapId];
|
||||
|
||||
const rect = createSvg('rect', {
|
||||
x: band.x,
|
||||
y: 14,
|
||||
width: band.width,
|
||||
height: totalHeight - 24,
|
||||
class: 'topo-band'
|
||||
});
|
||||
|
||||
const label = createSvg('text', {
|
||||
x: band.x + 12,
|
||||
y: 38,
|
||||
fill: '#38525f',
|
||||
'font-size': 13,
|
||||
'font-weight': 600
|
||||
});
|
||||
label.textContent = mapObj?.name || mapId;
|
||||
layerBands.appendChild(rect);
|
||||
layerBands.appendChild(label);
|
||||
}
|
||||
|
||||
const positions = new Map();
|
||||
for (const mapId of mapOrder) {
|
||||
const levels = nodesByMap.get(mapId);
|
||||
const band = mapBands.get(mapId);
|
||||
|
||||
for (let lvl = 0; lvl <= maxLevel; lvl += 1) {
|
||||
const arr = levels.get(lvl).sort((a, b) => {
|
||||
if (a.kind === 'device' && b.kind === 'device' && a.isServer !== b.isServer) {
|
||||
return a.isServer ? -1 : 1;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
const rowWidth = arr.length * nodeW + Math.max(0, arr.length - 1) * hGap;
|
||||
let startX = band.x + (band.width - rowWidth) / 2;
|
||||
if (!arr.length) continue;
|
||||
const y = marginY + lvl * levelGap;
|
||||
|
||||
for (const node of arr) {
|
||||
positions.set(node.id, { x: startX, y });
|
||||
startX += nodeW + hGap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routedPath(fromPos, toPos, offset = 0, lane = 0) {
|
||||
const goingDown = toPos.y >= fromPos.y;
|
||||
const x1 = fromPos.x + nodeW / 2 + offset;
|
||||
const y1 = goingDown ? fromPos.y + nodeH : fromPos.y;
|
||||
const x2 = toPos.x + nodeW / 2 + offset;
|
||||
const y2 = goingDown ? toPos.y : toPos.y + nodeH;
|
||||
const laneOffset = lane * 10;
|
||||
|
||||
if (Math.abs(y2 - y1) < 8) {
|
||||
const bendY = Math.min(y1, y2) - 26 - laneOffset;
|
||||
return `M ${x1} ${y1} L ${x1} ${bendY} L ${x2} ${bendY} L ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
const midY = y1 + (y2 - y1) * 0.5 + laneOffset;
|
||||
return `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
let connIndex = 0;
|
||||
for (const edge of edges) {
|
||||
const fromPos = positions.get(edge.from);
|
||||
const toPos = positions.get(edge.to);
|
||||
if (!fromPos || !toPos) continue;
|
||||
|
||||
const path = createSvg('path', {
|
||||
d: routedPath(
|
||||
fromPos,
|
||||
toPos,
|
||||
edge.type === 'connection' ? (connIndex % 7) - 3 : 0,
|
||||
edge.type === 'connection' ? (connIndex % 5) - 2 : 0
|
||||
),
|
||||
class: edge.type === 'connection' ? 'topo-edge-link' : 'topo-edge-base'
|
||||
});
|
||||
if (edge.type === 'connection') {
|
||||
path.setAttribute('stroke', edge.cableColor || '#3b82f6');
|
||||
const title = createSvg('title');
|
||||
const fromParts = edge.from.split(':');
|
||||
const toParts = edge.to.split(':');
|
||||
const left = `${endpointLabel(fromParts[0], fromParts[1])}${edge.fromPort ? `:${edge.fromPort}` : ''}`;
|
||||
const right = `${endpointLabel(toParts[0], toParts[1])}${edge.toPort ? `:${edge.toPort}` : ''}`;
|
||||
title.textContent = `${left} -> ${right} | ${edge.cableType || 'Cable'} ${edge.speed || ''}${edge.tag ? ` | ${edge.tag}` : ''}${edge.redundant ? ' | redundant' : ''}`;
|
||||
path.appendChild(title);
|
||||
connIndex += 1;
|
||||
}
|
||||
layerEdges.appendChild(path);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const group = createSvg('g');
|
||||
const rect = createSvg('rect', {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: nodeW,
|
||||
height: nodeH,
|
||||
rx: 8,
|
||||
class: `topo-node ${nodeKindToClass(node.kind)}${node.kind === 'device' && node.isServer ? ' device-server' : ''}`
|
||||
});
|
||||
|
||||
const nameText = createSvg('text', {
|
||||
x: pos.x + 10,
|
||||
y: pos.y + 23,
|
||||
class: 'topo-node-label'
|
||||
});
|
||||
nameText.textContent = node.label;
|
||||
|
||||
const metaText = createSvg('text', {
|
||||
x: pos.x + 10,
|
||||
y: pos.y + 42,
|
||||
class: 'topo-node-meta'
|
||||
});
|
||||
metaText.textContent = nodeMeta(node);
|
||||
|
||||
group.appendChild(rect);
|
||||
group.appendChild(nameText);
|
||||
group.appendChild(metaText);
|
||||
layerNodes.appendChild(group);
|
||||
}
|
||||
|
||||
svg.appendChild(layerBands);
|
||||
svg.appendChild(layerEdges);
|
||||
svg.appendChild(layerNodes);
|
||||
}
|
||||
|
||||
function renderRack() {
|
||||
const data = appState.data;
|
||||
const host = refs.rackContainer;
|
||||
host.innerHTML = '';
|
||||
|
||||
if (!data) {
|
||||
host.textContent = 'No rack data found.';
|
||||
return;
|
||||
}
|
||||
|
||||
const racks = appState.selectedRackId
|
||||
? [data.racks[appState.selectedRackId]].filter(Boolean)
|
||||
: sortByName(values(data.racks));
|
||||
|
||||
if (!racks.length) {
|
||||
host.textContent = 'No racks available.';
|
||||
return;
|
||||
}
|
||||
|
||||
const rackGrid = document.createElement('div');
|
||||
rackGrid.className = 'rack-grid-list';
|
||||
|
||||
for (const rack of racks) {
|
||||
const dc = data.datacenters[rack.datacenterId];
|
||||
const unitHeight = 24;
|
||||
const gridHeight = rack.units * unitHeight;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'rack-shell';
|
||||
|
||||
const devices = listField(rack, 'deviceIds', 'deviceIDs')
|
||||
.map((id) => data.devices[id])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.startU - a.startU);
|
||||
const serverCount = devices.filter((d) => isServerKind(d.kind)).length;
|
||||
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'rack-legend';
|
||||
legend.textContent = `${rack.name} (${rack.units}U)${dc ? ` - ${dc.name}` : ''} | ${serverCount} server${serverCount === 1 ? '' : 's'}`;
|
||||
wrapper.appendChild(legend);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'rack-grid';
|
||||
grid.style.height = `${gridHeight}px`;
|
||||
|
||||
for (let u = rack.units; u >= 1; u -= 1) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'rack-unit';
|
||||
row.textContent = `U${u}`;
|
||||
grid.appendChild(row);
|
||||
}
|
||||
|
||||
for (const dev of devices) {
|
||||
const block = document.createElement('div');
|
||||
const normalizedKind = deviceKind(dev.kind);
|
||||
const knownKinds = ['server', 'switch', 'pdu', 'battery', 'router'];
|
||||
const kindClass = knownKinds.includes(normalizedKind) ? normalizedKind : 'other';
|
||||
block.className = `rack-device ${kindClass}`;
|
||||
block.style.bottom = `${(dev.startU - 1) * unitHeight + 1}px`;
|
||||
block.style.height = `${Math.max(20, dev.unitSize * unitHeight - 2)}px`;
|
||||
const portsText = dev.portCount > 0 ? `, ${dev.portCount} x ${dev.portType || 'ports'}` : '';
|
||||
block.innerHTML = `<strong>${dev.name}</strong><br>${normalizedKind} ${dev.unitSize}U${portsText}`;
|
||||
grid.appendChild(block);
|
||||
}
|
||||
|
||||
wrapper.appendChild(grid);
|
||||
rackGrid.appendChild(wrapper);
|
||||
}
|
||||
|
||||
host.appendChild(rackGrid);
|
||||
}
|
||||
|
||||
function renderConnectionSummary() {
|
||||
const summary = refs.summary;
|
||||
summary.innerHTML = '';
|
||||
if (!appState.data) return;
|
||||
|
||||
const rows = values(appState.data.connections).sort((a, b) => (a.tag || '').localeCompare(b.tag || ''));
|
||||
if (!rows.length) {
|
||||
summary.textContent = 'No links yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const conn of rows) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'connection-row';
|
||||
const left = `${endpointLabel(conn.fromKind, conn.fromId)}${conn.fromPort ? `:${conn.fromPort}` : ''}`;
|
||||
const right = `${endpointLabel(conn.toKind, conn.toId)}${conn.toPort ? `:${conn.toPort}` : ''}`;
|
||||
row.textContent = `${left} -> ${right} | ${conn.cableType || 'Cable'}${conn.speed ? ` ${conn.speed}` : ''}${conn.tag ? ` | ${conn.tag}` : ''}${conn.redundant ? ' | redundant' : ''}`;
|
||||
summary.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
refreshSelectors();
|
||||
renderTopology();
|
||||
renderRack();
|
||||
renderConnectionSummary();
|
||||
}
|
||||
|
||||
function attachTabs() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
for (const tab of tabs) {
|
||||
tab.addEventListener('click', () => {
|
||||
for (const t of tabs) t.classList.remove('active');
|
||||
tab.classList.add('active');
|
||||
|
||||
const view = tab.dataset.view;
|
||||
appState.activeView = view;
|
||||
if (view === 'topology') {
|
||||
refs.topologyView.classList.remove('hidden');
|
||||
refs.rackView.classList.add('hidden');
|
||||
refs.topologyControls.classList.remove('hidden');
|
||||
refs.rackControls.classList.add('hidden');
|
||||
} else {
|
||||
refs.topologyView.classList.add('hidden');
|
||||
refs.rackView.classList.remove('hidden');
|
||||
refs.topologyControls.classList.add('hidden');
|
||||
refs.rackControls.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formDataToPayload(form) {
|
||||
return Object.fromEntries(new FormData(form).entries());
|
||||
}
|
||||
|
||||
function toInt(value, fallback = 0) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function attachForms() {
|
||||
const mapForm = document.getElementById('mapForm');
|
||||
const wanForm = document.getElementById('wanForm');
|
||||
const dcForm = document.getElementById('dcForm');
|
||||
const rackForm = document.getElementById('rackForm');
|
||||
const deviceForm = document.getElementById('deviceForm');
|
||||
const connectionForm = document.getElementById('connectionForm');
|
||||
|
||||
mapForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiPost('/api/map', formDataToPayload(mapForm));
|
||||
mapForm.reset();
|
||||
await loadState();
|
||||
showMessage('Map created.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
wanForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiPost('/api/wan-input', formDataToPayload(wanForm));
|
||||
wanForm.reset();
|
||||
await loadState();
|
||||
showMessage('WAN input added.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
dcForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiPost('/api/datacenter', formDataToPayload(dcForm));
|
||||
dcForm.reset();
|
||||
await loadState();
|
||||
showMessage('Datacenter added.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
rackForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = formDataToPayload(rackForm);
|
||||
payload.units = toInt(payload.units, 42);
|
||||
await apiPost('/api/rack', payload);
|
||||
rackForm.reset();
|
||||
await loadState();
|
||||
showMessage('Rack added.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
deviceForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = formDataToPayload(deviceForm);
|
||||
payload.unitSize = toInt(payload.unitSize, 1);
|
||||
payload.startU = toInt(payload.startU, 1);
|
||||
payload.portCount = toInt(payload.portCount, 0);
|
||||
await apiPost('/api/device', payload);
|
||||
deviceForm.reset();
|
||||
await loadState();
|
||||
showMessage('Device added.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
connectionForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = formDataToPayload(connectionForm);
|
||||
const from = parseEndpointValue(payload.fromEndpoint);
|
||||
const to = parseEndpointValue(payload.toEndpoint);
|
||||
await apiPost('/api/connection', {
|
||||
fromKind: from.kind,
|
||||
fromId: from.id,
|
||||
fromPort: payload.fromPort,
|
||||
toKind: to.kind,
|
||||
toId: to.id,
|
||||
toPort: payload.toPort,
|
||||
cableType: payload.cableType,
|
||||
cableColor: payload.cableColor,
|
||||
tag: payload.tag,
|
||||
speed: payload.speed,
|
||||
redundant: payload.redundant === 'on'
|
||||
});
|
||||
connectionForm.reset();
|
||||
await loadState();
|
||||
showMessage('Connection added.');
|
||||
} catch (err) {
|
||||
showMessage(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function attachViewSelectors() {
|
||||
refs.topologyMapSelect.addEventListener('change', () => {
|
||||
appState.selectedMapId = refs.topologyMapSelect.value;
|
||||
renderTopology();
|
||||
});
|
||||
|
||||
refs.rackSelect.addEventListener('change', () => {
|
||||
appState.selectedRackId = refs.rackSelect.value;
|
||||
renderRack();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
appState.data = await apiGet('/api/state');
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
attachTabs();
|
||||
attachForms();
|
||||
attachViewSelectors();
|
||||
try {
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
showMessage(`Failed to load state: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', init);
|
||||
579
static/styles.css
Normal file
579
static/styles.css
Normal file
@@ -0,0 +1,579 @@
|
||||
:root {
|
||||
--bg: #edf2f4;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f5f8fa;
|
||||
--ink: #14252e;
|
||||
--ink-soft: #5a6d77;
|
||||
--line: #d2dde3;
|
||||
--line-strong: #b4c4cd;
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #0b5f59;
|
||||
--accent-soft: #dff3f2;
|
||||
--warn: #b74126;
|
||||
--good: #1d7f3f;
|
||||
--shadow: 0 14px 30px rgba(9, 30, 41, 0.09);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1150px 460px at 8% -8%, #d8ebe8 0, transparent 60%),
|
||||
radial-gradient(1000px 400px at 92% 4%, #e7efe0 0, transparent 62%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.top-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.top-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.55rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.top-header p {
|
||||
margin: 0.36rem 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.header-note {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
color: #184c57;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: #dbedf3;
|
||||
border: 1px solid #bfd9e2;
|
||||
border-radius: 999px;
|
||||
padding: 0.33rem 0.65rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 410px 1fr;
|
||||
gap: 1rem;
|
||||
min-height: calc(100vh - 90px);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
overflow: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 0.95rem;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.82rem;
|
||||
margin: 0.42rem 0 0.72rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.46rem;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 0.52rem;
|
||||
}
|
||||
|
||||
.in-details {
|
||||
margin-top: 0.58rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.26rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--ink);
|
||||
padding: 0.48rem 0.55rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
button:focus {
|
||||
outline: 2px solid #7bd0c8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.56rem 0.72rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #dbecee;
|
||||
color: #1d4d57;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #c7e2e5;
|
||||
}
|
||||
|
||||
.editor-group {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.58rem 0.65rem;
|
||||
margin-top: 0.56rem;
|
||||
background: #fcfeff;
|
||||
}
|
||||
|
||||
.editor-group summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
color: #204652;
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: #e7f1f4;
|
||||
color: #20414a;
|
||||
border: 1px solid #c5dae1;
|
||||
font-size: 0.74rem;
|
||||
padding: 0.35rem 0.56rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: #d8eaee;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(148deg, #ffffff 0, #f4f9fa 100%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.62rem 0.72rem;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 1.35rem;
|
||||
color: #173745;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.76rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: #e2eceb;
|
||||
color: #1c433d;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #0d5f55;
|
||||
color: #f6fffb;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.62rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.compact-toggle {
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.58rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f4faf8;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.messages {
|
||||
min-height: 1.3rem;
|
||||
margin: 0.72rem 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.message-ok {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.view {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
width: 100%;
|
||||
height: 700px;
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#topologySvg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 700px;
|
||||
}
|
||||
|
||||
.topo-band {
|
||||
fill: #f8fcfd;
|
||||
stroke: #cedce1;
|
||||
stroke-width: 1;
|
||||
rx: 14;
|
||||
ry: 14;
|
||||
}
|
||||
|
||||
.topo-edge-base {
|
||||
fill: none;
|
||||
stroke: #94a8b2;
|
||||
stroke-width: 1.05;
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
|
||||
.topo-edge-link {
|
||||
fill: none;
|
||||
stroke-width: 2.2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: square;
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.topo-edge-link.muted {
|
||||
opacity: 0.28;
|
||||
stroke: #9caeb7 !important;
|
||||
}
|
||||
|
||||
.topo-node {
|
||||
stroke: #8ea2ac;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.topo-node.map {
|
||||
fill: #dff2f0;
|
||||
}
|
||||
|
||||
.topo-node.wan {
|
||||
fill: #ecf8ea;
|
||||
}
|
||||
|
||||
.topo-node.datacenter {
|
||||
fill: #f9f0df;
|
||||
}
|
||||
|
||||
.topo-node.rack {
|
||||
fill: #edf4fb;
|
||||
}
|
||||
|
||||
.topo-node.device {
|
||||
fill: #f3f2f8;
|
||||
}
|
||||
|
||||
.topo-node.device-server {
|
||||
fill: #e3f8e6;
|
||||
stroke: #2d8f56;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.topo-node.device-network {
|
||||
fill: #e5f0fd;
|
||||
}
|
||||
|
||||
.topo-node.device-power {
|
||||
fill: #f6edd9;
|
||||
}
|
||||
|
||||
.topo-node.device-storage {
|
||||
fill: #eee5fb;
|
||||
}
|
||||
|
||||
.topo-node.device-security {
|
||||
fill: #ffe9e5;
|
||||
}
|
||||
|
||||
.topo-node.dimmed {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.topo-node-label {
|
||||
font-size: 12px;
|
||||
fill: #16252c;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.topo-node-meta {
|
||||
font-size: 11px;
|
||||
fill: #4a5c65;
|
||||
}
|
||||
|
||||
.rack-container {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rack-grid-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(410px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.rack-shell {
|
||||
width: min(100%, 430px);
|
||||
border: 2px solid #283942;
|
||||
border-radius: 11px;
|
||||
background: #e8eef0;
|
||||
box-shadow: inset 0 0 0 8px #121b20;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.rack-legend {
|
||||
margin-bottom: 0.68rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.rack-grid {
|
||||
position: relative;
|
||||
border: 1px solid #384c57;
|
||||
background: #1a262d;
|
||||
}
|
||||
|
||||
.rack-unit {
|
||||
height: 24px;
|
||||
border-bottom: 1px solid rgba(230, 237, 240, 0.18);
|
||||
color: rgba(239, 246, 248, 0.8);
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.rack-device {
|
||||
position: absolute;
|
||||
left: 58px;
|
||||
right: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||
border-radius: 4px;
|
||||
color: #fbfdff;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rack-device.server,
|
||||
.rack-device.gpu_server,
|
||||
.rack-device.hypervisor {
|
||||
background: #236f64;
|
||||
}
|
||||
|
||||
.rack-device.switch,
|
||||
.rack-device.router,
|
||||
.rack-device.modem,
|
||||
.rack-device.access_point,
|
||||
.rack-device.patch_panel {
|
||||
background: #28688f;
|
||||
}
|
||||
|
||||
.rack-device.firewall,
|
||||
.rack-device.load_balancer {
|
||||
background: #9a4730;
|
||||
}
|
||||
|
||||
.rack-device.storage_array,
|
||||
.rack-device.nas,
|
||||
.rack-device.san {
|
||||
background: #57458e;
|
||||
}
|
||||
|
||||
.rack-device.pdu,
|
||||
.rack-device.battery,
|
||||
.rack-device.ups {
|
||||
background: #756023;
|
||||
}
|
||||
|
||||
.rack-device.kvm {
|
||||
background: #636a73;
|
||||
}
|
||||
|
||||
.rack-device.other {
|
||||
background: #596b74;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.summary-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-head input {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.connection-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 1fr;
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.connection-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.connection-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1220px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.rack-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
206
templates/index.html
Normal file
206
templates/index.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>InfraMap</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-header">
|
||||
<div>
|
||||
<h1>InfraMap</h1>
|
||||
<p>Map WAN, datacenters, racks, hardware, and cable-level links in one workspace.</p>
|
||||
</div>
|
||||
<div class="header-note">Interactive Topology + Rack Planner</div>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<section class="panel">
|
||||
<h2>Quick Actions</h2>
|
||||
<p class="muted">Bootstrap common layouts faster.</p>
|
||||
<div class="quick-actions">
|
||||
<button type="button" id="starterRackBtn" class="btn-secondary">Create Starter Rack</button>
|
||||
<button type="button" id="quickDciBtn" class="btn-secondary">Add Redundant DCI Links</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Hardware Library</h2>
|
||||
<p class="muted">Click a profile to prefill the device form.</p>
|
||||
<div id="hardwareCatalog" class="chip-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Infrastructure Editor</h2>
|
||||
|
||||
<details class="editor-group" open>
|
||||
<summary>Create Map</summary>
|
||||
<form id="mapForm" class="stack-form in-details">
|
||||
<label>Name<input name="name" required placeholder="Primary WAN Map"></label>
|
||||
<label>Description<input name="description" placeholder="US East + US West"></label>
|
||||
<button type="submit">Add Map</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="editor-group">
|
||||
<summary>Add WAN Input</summary>
|
||||
<form id="wanForm" class="stack-form in-details">
|
||||
<label>Map<select name="mapId" id="wanMapSelect" required></select></label>
|
||||
<label>Name<input name="name" required placeholder="WAN Feed A"></label>
|
||||
<label>Speed<input name="speed" placeholder="10G"></label>
|
||||
<label>Media Type<input name="mediaType" placeholder="Fiber SFP+"></label>
|
||||
<label>Public IPs<input name="publicIps" placeholder="203.0.113.10/31"></label>
|
||||
<label>Provider<input name="provider" placeholder="Carrier Name"></label>
|
||||
<label>Identifier / Tag<input name="identifier" placeholder="WAN-A"></label>
|
||||
<button type="submit">Add WAN Input</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="editor-group">
|
||||
<summary>Add Datacenter</summary>
|
||||
<form id="dcForm" class="stack-form in-details">
|
||||
<label>Map<select name="mapId" id="dcMapSelect" required></select></label>
|
||||
<label>Name<input name="name" required placeholder="Datacenter 1"></label>
|
||||
<label>PDU Mode
|
||||
<select name="pduMode">
|
||||
<option value="PDU">PDU only</option>
|
||||
<option value="PDU + Battery">PDU + Battery</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Power Feed<input name="powerFeed" placeholder="Dual A/B feed"></label>
|
||||
<label>Notes<input name="notes" placeholder="Optional notes"></label>
|
||||
<button type="submit">Add Datacenter</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="editor-group">
|
||||
<summary>Add Rack</summary>
|
||||
<form id="rackForm" class="stack-form in-details">
|
||||
<label>Datacenter<select name="datacenterId" id="rackDatacenterSelect" required></select></label>
|
||||
<label>Name<input name="name" required placeholder="Rack A1"></label>
|
||||
<label>Total Units<input name="units" type="number" min="1" value="42"></label>
|
||||
<button type="submit">Add Rack</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<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>Name<input name="name" required placeholder="Core Switch 1"></label>
|
||||
<label>Type
|
||||
<select name="kind" id="deviceKindSelect">
|
||||
<option value="server">Server</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="router">Router</option>
|
||||
<option value="firewall">Firewall</option>
|
||||
<option value="load_balancer">Load Balancer</option>
|
||||
<option value="storage_array">Storage Array</option>
|
||||
<option value="nas">NAS</option>
|
||||
<option value="san">SAN</option>
|
||||
<option value="patch_panel">Patch Panel</option>
|
||||
<option value="pdu">PDU</option>
|
||||
<option value="battery">Battery</option>
|
||||
<option value="ups">UPS</option>
|
||||
<option value="kvm">KVM</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="access_point">Access Point</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Height (U)<input name="unitSize" type="number" min="1" value="1"></label>
|
||||
<label>Start U (from bottom)<input name="startU" type="number" min="1" value="1"></label>
|
||||
<label>Port Count<input name="portCount" type="number" min="0" value="0"></label>
|
||||
<label>Port Type<input name="portType" placeholder="SFP+, RJ45"></label>
|
||||
<label>Notes<input name="notes" placeholder="Optional notes"></label>
|
||||
<button type="submit">Add Device</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="editor-group" open>
|
||||
<summary>Add Connection</summary>
|
||||
<form id="connectionForm" class="stack-form in-details">
|
||||
<label>From<select name="fromEndpoint" id="fromEndpointSelect" required></select></label>
|
||||
<label>From Port<input name="fromPort" placeholder="1 or uplink-a"></label>
|
||||
<label>To<select name="toEndpoint" id="toEndpointSelect" required></select></label>
|
||||
<label>To Port<input name="toPort" placeholder="48 or uplink-b"></label>
|
||||
<label>Cable Type<input name="cableType" placeholder="Fiber / Cat6A / DAC"></label>
|
||||
<label>Cable Color<input name="cableColor" type="color" value="#3b82f6"></label>
|
||||
<label>Tag / ID<input name="tag" placeholder="CAB-101"></label>
|
||||
<label>Link Speed<input name="speed" placeholder="10G"></label>
|
||||
<label class="inline-row"><input name="redundant" type="checkbox"> Redundant link</label>
|
||||
<button type="submit">Add Connection</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<section class="stats-grid">
|
||||
<article class="stat-card"><span>Maps</span><strong id="statMaps">0</strong></article>
|
||||
<article class="stat-card"><span>Datacenters</span><strong id="statDatacenters">0</strong></article>
|
||||
<article class="stat-card"><span>Racks</span><strong id="statRacks">0</strong></article>
|
||||
<article class="stat-card"><span>Servers</span><strong id="statServers">0</strong></article>
|
||||
<article class="stat-card"><span>Total Devices</span><strong id="statDevices">0</strong></article>
|
||||
<article class="stat-card"><span>Connections</span><strong id="statConnections">0</strong></article>
|
||||
</section>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-view="topology">Topology View</button>
|
||||
<button class="tab" data-view="rack">Rack View</button>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="topologyControls">
|
||||
<label>Map
|
||||
<select id="topologyMapSelect"></select>
|
||||
</label>
|
||||
<label>Device Filter
|
||||
<select id="topologyDeviceFilter">
|
||||
<option value="all">All devices</option>
|
||||
<option value="servers">Servers</option>
|
||||
<option value="network">Network gear</option>
|
||||
<option value="storage">Storage</option>
|
||||
<option value="power">Power</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inline-row compact-toggle"><input type="checkbox" id="topoContainmentToggle" checked> Show structure links</label>
|
||||
<label class="inline-row compact-toggle"><input type="checkbox" id="topoServerFocusToggle"> Focus servers</label>
|
||||
</div>
|
||||
|
||||
<div class="controls hidden" id="rackControls">
|
||||
<label>Rack
|
||||
<select id="rackSelect"></select>
|
||||
</label>
|
||||
<label class="inline-row compact-toggle"><input type="checkbox" id="rackServerOnlyToggle"> Servers only</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messages" class="messages"></div>
|
||||
|
||||
<article id="topologyView" class="view">
|
||||
<div class="canvas-wrap" id="topologyCanvasWrap">
|
||||
<svg id="topologySvg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Infrastructure topology map"></svg>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="rackView" class="view hidden">
|
||||
<div id="rackContainer" class="rack-container"></div>
|
||||
</article>
|
||||
|
||||
<section class="panel summary-panel">
|
||||
<div class="summary-head">
|
||||
<h2>Connection Summary</h2>
|
||||
<input id="connectionFilter" type="search" placeholder="Filter by endpoint, tag, cable, speed...">
|
||||
</div>
|
||||
<p class="muted">Visible connections: <span id="connectionCount">0</span></p>
|
||||
<div id="connectionSummary"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script defer src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user