Merge pull request 'Rack: posts vs facing, three views, ZimaBoard 832 shelves to scale' (#30) from zimaboard-and-facing into main
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m31s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m47s
Lint / Deploy (push) Successful in 8s
Test / Python Tests (pytest) (push) Successful in 49s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m31s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m47s
Lint / Deploy (push) Successful in 8s
Test / Python Tests (pytest) (push) Successful in 49s
This commit was merged in pull request #30.
This commit is contained in:
@@ -141,10 +141,20 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
|
||||
drawings live in `static/fronts.js`.
|
||||
- **Shelves**: an item can sit on a shelf item instead of having a rack unit. The shelf draws what is on
|
||||
it side by side (mini PCs, desk switches), each with its status and a link to its page.
|
||||
- **Front and rear**: each item is mounted from the front, the rear, or full depth (both sides). On a
|
||||
4-post rack a front and a rear item can share a unit; a full-depth item (a server) takes it on both
|
||||
sides, and the rear view shows its back. Items on a shelf take the shelf's side. The Front / Rear switch
|
||||
flips the drawing (`?face=rear`).
|
||||
- **Posts, facing and views**: each item hangs on the front posts, the rear posts, or both (full
|
||||
depth), and its ports face the front or the rear. On a 4-post rack a front-post and a rear-post item
|
||||
can share a unit; a full-depth item takes it on both. The page has three views: **Front** (the front
|
||||
posts), **Rear posts** (gear on the rear posts seen from the front, i.e. what sits behind the front
|
||||
gear, such as a PDU or a shelf mounted from the rear but facing forward) and **Rear** (from behind).
|
||||
An item seen from the side it doesn't face shows its back. Items on a shelf take the shelf's posts
|
||||
and facing.
|
||||
- **Shelves to scale**: a shelf draws its contents left to right by **Position on shelf**, at true scale
|
||||
(a 19" panel is 482.6 mm). Gear with a drawing, such as the IceWhale ZimaBoard 832 (traced from
|
||||
IceWhale's labelled photo of its port end), shows its real ports: the onboard RJ45 link lights follow
|
||||
enp2s0 (left) and enp3s0 (right), and the status glow follows the host. The "ZimaBoard 832 +
|
||||
ConnectX-3" drawing adds the single-port SFP+ card lying flat in the side PCIe slot, as in the rack,
|
||||
with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an
|
||||
LED on the card. Anything else is a labelled box.
|
||||
- **Racks**: admins name a rack and set its height with **Rack settings** on the page, and add more with
|
||||
**+ Rack** (a tab per rack). Renaming a rack moves its items; an empty rack can be deleted. Each item
|
||||
has a rack (empty means the first). Until a rack is saved, racks come from config:
|
||||
@@ -734,7 +744,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
|
||||
### Database Schema Notes
|
||||
|
||||
- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` columns; they are added automatically on first use
|
||||
- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount`, `chassis`, `facing` and `slot` columns; they are added automatically on first use
|
||||
- `cutsheet_racks`: racks saved on the rack page (name, height, notes)
|
||||
- `device_links`: each device's inventory URL, by device name; created with the cutsheet tables
|
||||
- `network_events`: one row per active event; `resolved_at` is set when recovered, and
|
||||
|
||||
@@ -1105,6 +1105,19 @@ def _racks(items: list) -> list:
|
||||
return racks
|
||||
|
||||
|
||||
_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
|
||||
|
||||
|
||||
def _physical_interfaces(name: str, snapshot: dict) -> list:
|
||||
"""A host's physical NICs in name order, with their state: [{'name', 'up'}].
|
||||
|
||||
Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
|
||||
"""
|
||||
ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {}
|
||||
return [{'name': n, 'up': str(state).lower() == 'up'} for n, state in sorted(ifaces.items())
|
||||
if not n.startswith(_VIRTUAL_IFACE)]
|
||||
|
||||
|
||||
def _device_status(name: str, snapshot: dict) -> str:
|
||||
host = (snapshot.get('hosts') or {}).get(name)
|
||||
if host:
|
||||
@@ -1123,8 +1136,8 @@ def api_rack():
|
||||
Query: rack (name; default the first rack), face (front|rear).
|
||||
"""
|
||||
face = request.args.get('face', 'front')
|
||||
if face not in ('front', 'rear'):
|
||||
return jsonify({'error': 'face must be front or rear'}), 400
|
||||
if face not in cutsheet.RACK_VIEWS:
|
||||
return jsonify({'error': f'face must be one of {", ".join(cutsheet.RACK_VIEWS)}'}), 400
|
||||
view = _cutsheet_view()
|
||||
link_stats = _state_json('link_stats')
|
||||
switches = link_stats.get('unifi_switches') or {}
|
||||
@@ -1145,7 +1158,9 @@ def api_rack():
|
||||
for r in racks:
|
||||
racked = [i for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name']]
|
||||
r['count'] = len(racked)
|
||||
# How many items each view shows: rear-post gear is what "behind" and "rear" add
|
||||
r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full'))
|
||||
r['behind'] = sum(1 for i in racked if i.get('mount') == 'rear')
|
||||
for cable in view['cables']:
|
||||
cable['run'] = [h['cable_id'] for h in cable['path'] if 'cable_id' in h]
|
||||
marks = cutsheet.port_marks(view['cables'])
|
||||
@@ -1189,12 +1204,14 @@ def api_rack():
|
||||
'outlets': (unifi_info.get(device) or {}).get('outlets') or [],
|
||||
'online': online.get(device, True)}
|
||||
if item['kind'] == 'shelf':
|
||||
# Left to right: by position on the shelf, then by name
|
||||
entry['contents'] = [
|
||||
{'id': c['id'], 'name': c['name'], 'kind': c['kind'], 'url': url(c),
|
||||
'status': _device_status(dev(c), snapshot) if dev(c) in known else '',
|
||||
'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '')
|
||||
or '', 'notes': c.get('notes') or ''}
|
||||
for c in sorted(on_shelf.get(name, []), key=lambda c: c['name'].lower())
|
||||
or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '',
|
||||
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)}
|
||||
for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower()))
|
||||
]
|
||||
out.append(entry)
|
||||
return jsonify({
|
||||
|
||||
+27
-8
@@ -25,6 +25,11 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
|
||||
# Which side of a 4-post rack an item is mounted from. A front and a rear item can share
|
||||
# a unit; a full-depth one (a server) takes the unit on both sides.
|
||||
MOUNTS = ('front', 'rear', 'full')
|
||||
# Which way an item's ports face. Gear on the rear posts usually still faces the front.
|
||||
FACINGS = ('front', 'rear')
|
||||
# Rack views: the front posts seen from the front, the rear posts seen from the front
|
||||
# (what is behind the front gear), and the rack seen from behind
|
||||
RACK_VIEWS = ('front', 'behind', 'rear')
|
||||
RACK_UNITS_MAX = 60
|
||||
# Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js)
|
||||
CHASSIS = {
|
||||
@@ -34,6 +39,8 @@ CHASSIS = {
|
||||
'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)',
|
||||
'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
|
||||
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
|
||||
'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)',
|
||||
'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)',
|
||||
'server-1u': 'Generic 1U server',
|
||||
'server-2u': 'Generic 2U server',
|
||||
'server-4u': 'Generic 4U server',
|
||||
@@ -112,11 +119,17 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
|
||||
'device': _text(data, 'device'),
|
||||
'mount': _text(data, 'mount', 10) or 'front',
|
||||
'chassis': _text(data, 'chassis', 40),
|
||||
'facing': _text(data, 'facing', 10) or 'front',
|
||||
'slot': _int(data, 'slot', 1, 99, 'Position on the shelf'),
|
||||
'shelf': _text(data, 'shelf', NAME_MAX),
|
||||
'notes': _text(data, 'notes', NOTES_MAX),
|
||||
}
|
||||
if item['chassis'] and item['chassis'] not in CHASSIS:
|
||||
raise ValidationError(f'Unknown front drawing {item["chassis"]}')
|
||||
if item['facing'] not in FACINGS:
|
||||
raise ValidationError(f'facing must be one of {", ".join(FACINGS)}')
|
||||
if item['slot'] and not item['shelf']:
|
||||
raise ValidationError('A position applies only to an item on a shelf')
|
||||
if item['mount'] not in MOUNTS:
|
||||
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
|
||||
if item['shelf']:
|
||||
@@ -129,6 +142,7 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
|
||||
raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit')
|
||||
item['rack'] = '' # the shelf's rack and side
|
||||
item['mount'] = 'front'
|
||||
item['facing'] = 'front'
|
||||
if item['rack_height'] and not item['rack_u']:
|
||||
raise ValidationError('Set a rack unit to go with the height')
|
||||
if item['rack_u'] and not item['rack_height']:
|
||||
@@ -604,15 +618,20 @@ def _sides(item: dict) -> Tuple[str, ...]:
|
||||
|
||||
|
||||
def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
|
||||
"""Place items that have a rack unit, top of the rack first, as seen from one face.
|
||||
"""Place items that have a rack unit, top of the rack first, for one view.
|
||||
|
||||
Front and rear items may share a unit; full-depth items take it on both sides.
|
||||
An item that would share a unit and side with one already placed, or runs past the
|
||||
top of the rack, is reported (whichever face is asked for) rather than drawn over another.
|
||||
Front-post and rear-post items may share a unit; full-depth items take it on both.
|
||||
Views (RACK_VIEWS): 'front' shows the front posts from the front; 'behind' shows the
|
||||
rear posts from the front (what sits behind the front gear); 'rear' shows the rear posts
|
||||
from behind. Full-depth items appear in all three.
|
||||
An item that would share a unit and post with one already placed, or runs past the
|
||||
top of the rack, is reported (whatever the view) rather than drawn over another.
|
||||
Returns {'placed': [item + 'rack_top' + 'through'], 'overlaps': [{'name', 'units', 'with'}],
|
||||
'outside': [name]}, where 'through' marks a full-depth item seen from the side it isn't faced to
|
||||
(its back, for the rear view).
|
||||
'outside': [name]}, where 'through' marks an item seen from the side it doesn't face
|
||||
(its back, rather than its ports).
|
||||
"""
|
||||
side = 'front' if face == 'front' else 'rear'
|
||||
looking = 'rear' if face == 'rear' else 'front'
|
||||
taken: Dict[tuple, str] = {}
|
||||
placed, overlaps, outside = [], [], []
|
||||
racked = [i for i in items if i.get('rack_u')]
|
||||
@@ -632,8 +651,8 @@ def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
|
||||
continue
|
||||
for c in cells:
|
||||
taken[c] = item['name']
|
||||
if face in sides:
|
||||
placed.append(dict(item, rack_top=high, through=item.get('mount') == 'full' and face == 'rear'))
|
||||
if side in sides:
|
||||
placed.append(dict(item, rack_top=high, through=(item.get('facing') or 'front') != looking))
|
||||
return {'placed': placed, 'overlaps': overlaps, 'outside': outside}
|
||||
|
||||
|
||||
|
||||
@@ -679,6 +679,8 @@ _CUTSHEET_SCHEMA = (
|
||||
device VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mount VARCHAR(10) NOT NULL DEFAULT 'front',
|
||||
chassis VARCHAR(40) NOT NULL DEFAULT '',
|
||||
facing VARCHAR(10) NOT NULL DEFAULT 'front',
|
||||
slot SMALLINT NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -769,7 +771,7 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None:
|
||||
)
|
||||
|
||||
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device',
|
||||
'mount', 'chassis', 'notes')
|
||||
'mount', 'chassis', 'facing', 'slot', 'notes')
|
||||
# Columns added to cutsheet_items after its first release: (name, definition)
|
||||
_ITEM_COLUMNS_ADDED = (
|
||||
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
@@ -778,6 +780,10 @@ _ITEM_COLUMNS_ADDED = (
|
||||
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
|
||||
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
|
||||
('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"),
|
||||
# Which way the ports face, independent of which posts it hangs on
|
||||
('facing', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
|
||||
# Left-to-right position on a shelf
|
||||
('slot', 'SMALLINT NULL'),
|
||||
)
|
||||
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
|
||||
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
|
||||
|
||||
+7
-7
@@ -258,9 +258,9 @@ function renderRack() {
|
||||
}
|
||||
|
||||
function rackText(i) {
|
||||
if (i.shelf) return `on ${esc(i.shelf)}`;
|
||||
if (i.shelf) return `on ${esc(i.shelf)}${i.slot ? `, position ${esc(i.slot)}` : ''}`;
|
||||
if (!i.rack_u) return '-';
|
||||
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
|
||||
const side = ({ rear: ' rear posts', full: ' full depth' }[i.mount] || '') + (i.facing === 'rear' ? ', faces rear' : '');
|
||||
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`;
|
||||
return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
|
||||
}
|
||||
@@ -389,7 +389,7 @@ function openCable(cable, prefill) {
|
||||
function openItem(item) {
|
||||
const form = document.getElementById('cs-item-form');
|
||||
form.reset();
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {});
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: 'front', facing: 'front' }, item || {});
|
||||
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `<option value="${esc(r)}">`).join('');
|
||||
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => `<option value="${esc(d)}">`).join('');
|
||||
form.chassis.innerHTML = '<option value="">Automatic (faceplate or plain plate)</option>' +
|
||||
@@ -397,8 +397,8 @@ function openItem(item) {
|
||||
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
|
||||
form.shelf.innerHTML = '<option value="">Not on a shelf</option>' +
|
||||
shelves.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).join('');
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'chassis', 'location', 'notes']) {
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||||
'shelf', 'slot', 'chassis', 'location', 'notes']) {
|
||||
form[key].value = data[key] == null ? '' : data[key];
|
||||
}
|
||||
if (!item) form.port_count.value = 24;
|
||||
@@ -451,8 +451,8 @@ async function saveCable(e) {
|
||||
async function saveItem(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'chassis', 'location', 'notes']);
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||||
'shelf', 'slot', 'chassis', 'location', 'notes']);
|
||||
const id = form.id.value;
|
||||
await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body)
|
||||
: lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal');
|
||||
|
||||
+145
-1
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */
|
||||
// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
|
||||
// shows the device's live status on its power light when the item is linked to a device.
|
||||
//
|
||||
@@ -151,3 +151,147 @@ function rackFrontSvg(key, ctx) {
|
||||
${spec.draw(ctx)}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ── IceWhale ZimaBoard 832 (shelf gear) ─────────────────────────────────
|
||||
// The port end, traced from IceWhale's labelled photo in ZimaDocs
|
||||
// (source/images/Hardware-Interface-Introduction-/hardware-interface-introduction.webp),
|
||||
// in that photo's pixel coordinates (the port end spans 862 x 415 px; 81.4 mm wide).
|
||||
// Left to right: Mini-DisplayPort 1.2, two stacks of GbE RJ45 over USB 3.0, DC 12V 5.5x2.5mm,
|
||||
// on a gunmetal case over a thin gold PCB edge and the black polycarbonate base.
|
||||
const ZIMA = { width: 862, height: 415, mm: 81.4 };
|
||||
|
||||
function zimaRj45(x, y, link) {
|
||||
const contacts = Array.from({ length: 8 }, (_, i) => `<rect class="zb-contact" x="${x + 52 + i * 14}" y="${y + 34}" width="5" height="22"/>`).join('');
|
||||
return `<rect class="zb-shield" x="${x}" y="${y}" width="207" height="143" rx="5"/>
|
||||
<rect class="zb-hole" x="${x + 22}" y="${y + 30}" width="163" height="102" rx="2"/>
|
||||
<rect class="zb-hole-key" x="${x + 70}" y="${y + 120}" width="67" height="16" rx="1"/>
|
||||
${contacts}
|
||||
<rect class="zb-led" x="${x + 16}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#3ad16b' : '#8f8f8a'}"/>
|
||||
<rect class="zb-led" x="${x + 161}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#f2d13a' : '#77714a'}"/>`;
|
||||
}
|
||||
|
||||
function zimaUsb(x, y) {
|
||||
return `<rect class="zb-shield" x="${x}" y="${y}" width="204" height="100" rx="5"/>
|
||||
<rect class="zb-hole" x="${x + 26}" y="${y + 20}" width="152" height="62" rx="2"/>
|
||||
<rect class="zb-usb-tongue" x="${x + 38}" y="${y + 28}" width="128" height="22" rx="2"/>
|
||||
${Array.from({ length: 5 }, (_, i) => `<rect class="zb-usb-pin" x="${x + 50 + i * 24}" y="${y + 46}" width="10" height="4"/>`).join('')}`;
|
||||
}
|
||||
|
||||
// Which NIC is which on a ZimaBoard: the onboard Realtek GbE ports enumerate on PCI buses 2 and 3
|
||||
// (enp2s0 left jack, enp3s0 right), and a card in the PCIe slot on bus 1 (enp1s0, enp1s0d1).
|
||||
const ZIMA_NICS = { rj45: ['enp2s0', 'enp3s0'], slot: /^enp1s0/ };
|
||||
|
||||
function zimaNic(ctx, name) {
|
||||
const n = (ctx.interfaces || []).find(i => i.name === name);
|
||||
return n ? n.up : ctx.status === 'up';
|
||||
}
|
||||
|
||||
function zimaFront(ctx) {
|
||||
// ctx: { status, interfaces: [{name, up}] }
|
||||
const link = i => zimaNic(ctx, ZIMA_NICS.rj45[i]);
|
||||
const on = ctx.status === 'up' || ctx.status === 'degraded';
|
||||
const pads = [60, 105, 420, 470, 640, 700, 790, 835]
|
||||
.map(x => `<rect class="zb-pad" x="${x}" y="309" width="12" height="7"/>`).join('');
|
||||
return `
|
||||
<rect class="zb-base" x="0" y="300" width="862" height="115" rx="10"/>
|
||||
<rect class="zb-base-sheen" x="6" y="330" width="850" height="6" rx="3"/>
|
||||
${on ? '<ellipse class="zb-glow" cx="34" cy="404" rx="34" ry="9"/>' : ''}
|
||||
<rect class="zb-pcb" x="2" y="306" width="858" height="12"/>${pads}
|
||||
<path class="zb-case" d="M0 14 Q0 0 14 0 H848 Q862 0 862 14 V308 H0 Z"/>
|
||||
<rect class="zb-case-edge" x="0" y="6" width="18" height="300"/>
|
||||
<rect class="zb-case-top" x="14" y="0" width="834" height="5"/>
|
||||
<rect class="zb-shield" x="63" y="225" width="127" height="80" rx="6"/>
|
||||
<path class="zb-hole" d="M75 243 H178 V280 L163 293 H90 L75 280 Z"/>
|
||||
<rect class="zb-dp-pins" x="92" y="262" width="69" height="9" rx="1"/>
|
||||
${zimaRj45(213, 47, link(0))}${zimaRj45(443, 47, link(1))}
|
||||
${zimaUsb(216, 205)}${zimaUsb(443, 205)}
|
||||
<rect class="zb-dc" x="678" y="177" width="132" height="130" rx="8"/>
|
||||
<rect class="zb-dc-face" x="690" y="189" width="108" height="106" rx="6"/>
|
||||
<circle class="zb-dc-ring" cx="744" cy="242" r="36"/>
|
||||
<circle class="zb-hole" cx="744" cy="242" r="27"/>
|
||||
<circle class="zb-dc-pin" cx="744" cy="242" r="8"/>`;
|
||||
}
|
||||
|
||||
// A Mellanox ConnectX-3 single-port SFP+ card (MCX311A) in the ZimaBoard's side PCIe slot, lying flat
|
||||
// beside the board as in the rack photo (wiki.lotusguild.org/zimaboards.jpg): its front edge is about
|
||||
// 54 mm (572 px at the ZimaBoard photo's 10.59 px/mm), PCB at the board's PCB height, heatsink on the
|
||||
// chip, and the SFP+ cage toward the board with a DAC plugged in and its pull tab hanging down.
|
||||
// The round dot on the cage is Gandalf's link mark (like the cutsheet marks), not an LED on the card.
|
||||
const CX3_PX = 572;
|
||||
|
||||
function cx3Card(up) {
|
||||
return `
|
||||
<rect class="cx-part" x="40" y="314" width="70" height="22" rx="2"/>
|
||||
<rect class="cx-part" x="150" y="314" width="40" height="16" rx="2"/>
|
||||
<rect class="cx-part" x="250" y="314" width="90" height="26" rx="2"/>
|
||||
<rect class="cx-pcb" x="0" y="296" width="${CX3_PX}" height="18" rx="2"/>
|
||||
<rect class="cx-pcb-edge" x="0" y="296" width="${CX3_PX}" height="4"/>
|
||||
<rect class="cx-heatsink" x="110" y="232" width="210" height="64" rx="3"/>
|
||||
${Array.from({ length: 9 }, (_, i) => `<rect class="cx-fin" x="${120 + i * 22}" y="232" width="9" height="64"/>`).join('')}
|
||||
<rect class="cx-cage" x="380" y="190" width="150" height="106" rx="4"/>
|
||||
<rect class="cx-cage-lip" x="380" y="190" width="150" height="10" rx="3"/>
|
||||
<rect class="cx-plug" x="392" y="206" width="126" height="80" rx="5"/>
|
||||
<rect class="cx-boot" x="410" y="220" width="90" height="52" rx="10"/>
|
||||
<circle class="cx-cable" cx="455" cy="246" r="20"/>
|
||||
<path class="cx-tab" d="M441 286 V392 Q441 404 455 404 Q469 404 469 392 V286"/>
|
||||
<circle class="cx-link" cx="520" cy="200" r="13" style="fill:${up ? '#3ad16b' : '#e0312b'}"><title>Gandalf: SFP+ link ${up ? 'up' : 'down'}</title></circle>`;
|
||||
}
|
||||
|
||||
function zimaCx3Front(ctx) {
|
||||
return `${cx3Card(zimaNic(ctx, (ctx.interfaces || []).map(i => i.name).find(n => ZIMA_NICS.slot.test(n)) || 'enp1s0'))}
|
||||
<g transform="translate(${CX3_PX} 0)">${zimaFront(ctx)}</g>`;
|
||||
}
|
||||
|
||||
const ZIMA_SPECS = 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W';
|
||||
|
||||
const SHELF_GEAR = {
|
||||
'zimaboard-832': {
|
||||
label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront,
|
||||
specs: ZIMA_SPECS,
|
||||
},
|
||||
'zimaboard-832-cx3': {
|
||||
label: 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+', mm: ZIMA.mm * (ZIMA.width + CX3_PX) / ZIMA.width,
|
||||
width: ZIMA.width + CX3_PX, height: ZIMA.height, draw: zimaCx3Front,
|
||||
specs: `${ZIMA_SPECS}; Mellanox ConnectX-3 10G SFP+ (enp1s0) in the PCIe slot, DAC attached`,
|
||||
},
|
||||
};
|
||||
|
||||
// A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right.
|
||||
// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box.
|
||||
function shelfSvg(item, esc) {
|
||||
const units = item.rack_height || 1;
|
||||
const W = 1100, H = 105 * units, perMm = W / 482.6;
|
||||
const floor = H - 20;
|
||||
let x = 64;
|
||||
const things = (item.contents || []).map(c => {
|
||||
const gear = SHELF_GEAR[c.chassis];
|
||||
const w = gear ? gear.mm * perMm : 150;
|
||||
const k = gear ? w / gear.width : 1;
|
||||
const h = gear ? gear.height * k : 58;
|
||||
const top = floor - h;
|
||||
const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', ');
|
||||
const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
|
||||
const body = gear
|
||||
? `<g transform="translate(${x} ${top}) scale(${k})">${gear.draw(c)}</g>`
|
||||
: `<rect class="sh-box" x="${x}" y="${top}" width="${w}" height="${h}" rx="4"/>
|
||||
<text class="sh-box-name" x="${x + w / 2}" y="${top + h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
|
||||
const out = `<a class="sh-thing" href="${esc(c.url)}"><title>${esc(tip)}</title>
|
||||
${body}
|
||||
<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>
|
||||
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${floor + 5}" width="92" height="12" rx="2"/>
|
||||
<text class="sh-tag-text" x="${x + w / 2}" y="${floor + 14}" text-anchor="middle">${esc(c.name)}</text>
|
||||
</a>`;
|
||||
x += w + 26;
|
||||
return out;
|
||||
}).join('');
|
||||
return `<svg class="rack-svg rack-shelf-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
|
||||
aria-label="${esc(item.name)}">
|
||||
<title>${esc(item.name)}</title>
|
||||
${ears(W, H, 'rf-ear-dark')}
|
||||
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
|
||||
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
|
||||
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>
|
||||
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">empty shelf</text>`}
|
||||
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
+11
-20
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
|
||||
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
|
||||
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
|
||||
|
||||
@@ -93,18 +93,6 @@ function statusDot(status) {
|
||||
return status ? `<span class="${STATUS_CLASS[status] || 'dot-unknown'}" title="${esc(status)}"></span>` : '';
|
||||
}
|
||||
|
||||
function shelfHtml(item) {
|
||||
const contents = item.contents || [];
|
||||
const things = contents.map(c => `
|
||||
<a class="rack-shelf-thing" href="${esc(c.url)}" title="${esc([c.name, c.model, c.status].filter(Boolean).join(' · '))}">
|
||||
${statusDot(c.status)}<span class="rack-shelf-name">${esc(c.name)}</span>
|
||||
</a>`).join('');
|
||||
return `<div class="rack-plate rack-shelf">
|
||||
<span class="rack-shelf-label">${esc(item.name)}</span>
|
||||
<div class="rack-shelf-things">${things || '<span class="rack-plate-sub">empty shelf</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
|
||||
// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
|
||||
// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
|
||||
@@ -173,7 +161,7 @@ function itemHtml(item) {
|
||||
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
|
||||
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
|
||||
else if (item.kind === 'patch_panel') body = panelSvg(item);
|
||||
else if (item.kind === 'shelf') body = shelfHtml(item);
|
||||
else if (item.kind === 'shelf') body = shelfSvg(item, esc);
|
||||
else body = plateHtml(item);
|
||||
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
|
||||
}
|
||||
@@ -216,14 +204,16 @@ function renderTabs(data) {
|
||||
return `<button type="button" class="rack-tab${on ? ' active' : ''}" role="tab" aria-selected="${on}"
|
||||
data-rack="${esc(r.name)}">${esc(r.name)} <span class="rack-tab-count">${r.count}</span></button>`;
|
||||
}).join('');
|
||||
document.getElementById('rack-title').textContent = `${data.rack} · ${data.face}`;
|
||||
const VIEW_TITLE = { front: 'front', behind: 'rear posts, from the front', rear: 'rear' };
|
||||
document.getElementById('rack-title').textContent = `${data.rack} · ${VIEW_TITLE[data.face] || data.face}`;
|
||||
document.querySelectorAll('.rack-face-btn').forEach(b => {
|
||||
const on = b.dataset.face === data.face;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on);
|
||||
});
|
||||
const rear = (data.racks.find(r => r.name === data.rack) || {}).rear || 0;
|
||||
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = rear ? `Rear (${rear})` : 'Rear';
|
||||
const r = data.racks.find(x => x.name === data.rack) || {};
|
||||
document.querySelector('.rack-face-btn[data-face="behind"]').textContent = r.behind ? `Rear posts (${r.behind})` : 'Rear posts';
|
||||
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = r.rear ? `Rear (${r.rear})` : 'Rear';
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
@@ -362,7 +352,8 @@ function currentRack() {
|
||||
}
|
||||
|
||||
function currentFace() {
|
||||
return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front';
|
||||
const face = new URLSearchParams(location.search).get('face');
|
||||
return ['behind', 'rear'].includes(face) ? face : 'front';
|
||||
}
|
||||
|
||||
function setView(changes) {
|
||||
@@ -383,14 +374,14 @@ document.getElementById('rack-tabs').addEventListener('click', e => {
|
||||
|
||||
document.querySelector('.rack-face').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-face]');
|
||||
if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' });
|
||||
if (btn) setView({ face: btn.dataset.face === 'front' ? '' : btn.dataset.face });
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
if (currentRack()) q.set('rack', currentRack());
|
||||
if (currentFace() === 'rear') q.set('face', 'rear');
|
||||
if (currentFace() !== 'front') q.set('face', currentFace());
|
||||
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
|
||||
} catch (err) {
|
||||
if (_rack) return; // keep the last drawing on a failed refresh
|
||||
|
||||
@@ -1585,4 +1585,48 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.rack-pp-doc .ua-mark { stroke: #4c5662; stroke-width: 2; }
|
||||
.rack-pp-port.rack-hl .ua-mark { fill: rgba(0,212,255,.25); stroke: #00b8e6; stroke-width: 3.5; }
|
||||
|
||||
/* ── Shelves drawn to scale, and the ZimaBoard 832 (static/fronts.js) ─ */
|
||||
.sh-back { fill: #0f1215; }
|
||||
.sh-floor { fill: #3a414b; }
|
||||
.sh-lip { fill: #59636f; }
|
||||
.sh-name { fill: var(--rk-muted, #8b97a5); font-size: 11px; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-empty { fill: var(--rk-muted, #8b97a5); font-size: 13px; }
|
||||
.sh-box { fill: #d4d7dc; stroke: #8a929c; }
|
||||
.sh-box-name { fill: #15191e; font-size: 13px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-tag { fill: #111; stroke: #3a3f45; }
|
||||
.sh-tag-text { fill: #e9edf1; font-size: 9px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-hit { fill: transparent; stroke: transparent; stroke-width: 2.5; }
|
||||
.sh-thing:hover .sh-hit, .sh-thing:focus-visible .sh-hit { stroke: #00b8e6; }
|
||||
.zb-case { fill: #66625b; }
|
||||
.zb-case-edge { fill: #807c74; }
|
||||
.zb-case-top { fill: #8b877f; }
|
||||
.zb-pcb { fill: #a48f55; }
|
||||
.zb-pad { fill: #d3bd73; }
|
||||
.zb-base { fill: #121212; }
|
||||
.zb-base-sheen { fill: #2b2b2b; }
|
||||
.zb-glow { fill: #ff9a2e; opacity: .85; filter: blur(4px); }
|
||||
.zb-shield { fill: #cfcfca; stroke: #8f8f8a; stroke-width: 3; }
|
||||
.zb-hole { fill: #0b0b0b; }
|
||||
.zb-hole-key { fill: #0b0b0b; }
|
||||
.zb-contact { fill: #b89545; }
|
||||
.zb-led { stroke: #5c5c58; stroke-width: 1.5; }
|
||||
.zb-usb-tongue { fill: #2f7fe0; }
|
||||
.zb-usb-pin { fill: #c7a64b; }
|
||||
.zb-dp-pins { fill: #b89545; }
|
||||
.zb-dc { fill: #3a3a3a; stroke: #222; stroke-width: 2; }
|
||||
.zb-dc-face { fill: #2b2b2b; }
|
||||
.zb-dc-ring { fill: #4b4b4b; }
|
||||
.zb-dc-pin { fill: #c9a43a; }
|
||||
.cx-pcb { fill: #1d6b43; }
|
||||
.cx-pcb-edge { fill: #2f8a5a; }
|
||||
.cx-part { fill: #26292d; }
|
||||
.cx-heatsink { fill: #18191b; }
|
||||
.cx-fin { fill: #2c2e31; }
|
||||
.cx-cage { fill: #c8cbcf; stroke: #85898f; stroke-width: 3; }
|
||||
.cx-cage-lip { fill: #dde0e3; }
|
||||
.cx-plug { fill: #2b2e33; }
|
||||
.cx-boot { fill: #1b1d20; }
|
||||
.cx-cable { fill: #0e0f10; }
|
||||
.cx-tab { fill: none; stroke: #c9ccd0; stroke-width: 9; stroke-linejoin: round; }
|
||||
.cx-link { stroke: #101418; stroke-width: 3; }
|
||||
.fp-speedbar { fill: #b3b5ba; }
|
||||
|
||||
+13
-2
@@ -275,11 +275,18 @@
|
||||
<datalist id="cs-rack-list"></datalist>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-mount">Mounted</label>
|
||||
<label class="lt-label" for="cs-item-mount">Mounted on</label>
|
||||
<select class="lt-select" id="cs-item-mount" name="mount">
|
||||
<option value="front">Front posts</option>
|
||||
<option value="rear">Rear posts</option>
|
||||
<option value="full">Both (full depth)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-facing">Ports face</label>
|
||||
<select class="lt-select" id="cs-item-facing" name="facing">
|
||||
<option value="front">Front</option>
|
||||
<option value="rear">Rear</option>
|
||||
<option value="full">Full depth (both sides)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,6 +295,10 @@
|
||||
<label class="lt-label" for="cs-item-shelf">On shelf</label>
|
||||
<select class="lt-select" id="cs-item-shelf" name="shelf"><option value="">Not on a shelf</option></select>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-slot">Position on shelf</label>
|
||||
<input class="lt-input" id="cs-item-slot" name="slot" type="number" min="1" max="99" placeholder="1 = leftmost">
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-field-hint cs-shelf-hint">Gear on a shelf (mini PCs, desk switches) takes its place and side from the shelf: leave its rack unit empty. On a 4-post rack a front and a rear item can share a unit; a full-depth one (a server) takes it on both sides.</div>
|
||||
<div class="lt-form-group">
|
||||
|
||||
+6
-2
@@ -21,8 +21,12 @@
|
||||
<div class="rack-bar">
|
||||
<div class="rack-tabs" id="rack-tabs" role="tablist" aria-label="Racks" hidden></div>
|
||||
<div class="rack-face" role="group" aria-label="Rack side">
|
||||
<button type="button" class="rack-face-btn active" data-face="front" aria-pressed="true">Front</button>
|
||||
<button type="button" class="rack-face-btn" data-face="rear" aria-pressed="false">Rear</button>
|
||||
<button type="button" class="rack-face-btn active" data-face="front" aria-pressed="true"
|
||||
title="The front posts, seen from the front">Front</button>
|
||||
<button type="button" class="rack-face-btn" data-face="behind" aria-pressed="false"
|
||||
title="Gear on the rear posts, seen from the front (what is behind the front gear)">Rear posts</button>
|
||||
<button type="button" class="rack-face-btn" data-face="rear" aria-pressed="false"
|
||||
title="The rack seen from behind">Rear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+24
-6
@@ -524,17 +524,35 @@ class TestRackFacesAndSettings:
|
||||
gandalf.db.cutsheet_load.return_value = {
|
||||
'items': [dict(base, id=n, **i) for n, i in enumerate(items, 1)], 'cables': [], 'port_labels': []}
|
||||
|
||||
def test_rear_face(self, client):
|
||||
def test_three_views(self, client):
|
||||
# A server through both posts, a PDU on the rear posts facing the front (as in the StarTech),
|
||||
# a fan tray on the rear posts facing the rear, and a switch on the front posts
|
||||
self._items({'name': 'Server', 'kind': 'device', 'rack_u': 5, 'rack_height': 4, 'mount': 'full'},
|
||||
{'name': 'PDU', 'kind': 'device', 'rack_u': 12, 'rack_height': 1, 'mount': 'rear'},
|
||||
{'name': 'Fans', 'kind': 'device', 'rack_u': 14, 'rack_height': 1, 'mount': 'rear',
|
||||
'facing': 'rear'},
|
||||
{'name': 'Switch', 'kind': 'device', 'rack_u': 12, 'rack_height': 1})
|
||||
front = client.get('/api/rack', headers=AUTH).get_json()
|
||||
rear = client.get('/api/rack?face=rear', headers=AUTH).get_json()
|
||||
assert [(i['name'], i['through']) for i in front['items']] == [('Switch', False), ('Server', False)]
|
||||
assert [(i['name'], i['through']) for i in rear['items']] == [('PDU', False), ('Server', True)]
|
||||
assert rear['face'] == 'rear' and rear['racks'][0]['rear'] == 2 and rear['racks'][0]['count'] == 3
|
||||
|
||||
def view(face):
|
||||
return [(i['name'], i['through']) for i in
|
||||
client.get(f'/api/rack?face={face}', headers=AUTH).get_json()['items']]
|
||||
assert view('front') == [('Switch', False), ('Server', False)]
|
||||
assert view('behind') == [('Fans', True), ('PDU', False), ('Server', False)]
|
||||
assert view('rear') == [('Fans', False), ('PDU', True), ('Server', True)]
|
||||
racks = client.get('/api/rack', headers=AUTH).get_json()['racks'][0]
|
||||
assert (racks['count'], racks['rear'], racks['behind']) == (4, 3, 2)
|
||||
assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400
|
||||
|
||||
def test_shelf_contents_ordered_with_nics(self, client):
|
||||
self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 2, 'mount': 'rear'},
|
||||
{'name': 'b-box', 'kind': 'device', 'shelf': 'Shelf', 'slot': 1, 'chassis': 'zimaboard-832'},
|
||||
{'name': 'large1', 'kind': 'device', 'shelf': 'Shelf', 'slot': 2})
|
||||
data = client.get('/api/rack?face=behind', headers=AUTH).get_json()
|
||||
things = data['items'][0]['contents']
|
||||
assert [(c['name'], c['slot'], c['chassis']) for c in things] == [('b-box', 1, 'zimaboard-832'),
|
||||
('large1', 2, '')]
|
||||
assert things[1]['interfaces'] == [{'name': 'eth0', 'up': True}]
|
||||
|
||||
def test_saved_racks_replace_config(self, client):
|
||||
self._items()
|
||||
gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'StarTech 25U', 'units': 25, 'notes': 'open frame'}]
|
||||
|
||||
+21
-1
@@ -369,4 +369,24 @@ class TestChassis:
|
||||
def test_every_drawing_has_a_label(self):
|
||||
assert all(cs.CHASSIS.values())
|
||||
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24',
|
||||
'usw-pro-max-24-poe', 'usw-pro-max-48-poe'} <= set(cs.CHASSIS)
|
||||
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= set(cs.CHASSIS)
|
||||
|
||||
|
||||
class TestFacingAndSlots:
|
||||
def test_facing_and_slot_validation(self):
|
||||
shelf = {'S': {'name': 'S', 'kind': 'shelf'}}
|
||||
assert cs.validate_item({'name': 'p', 'facing': 'rear'})['facing'] == 'rear'
|
||||
assert cs.validate_item({'name': 'p'})['facing'] == 'front'
|
||||
assert cs.validate_item({'name': 'z', 'shelf': 'S', 'slot': '2'}, shelf)['slot'] == 2
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_item({'name': 'p', 'facing': 'up'})
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_item({'name': 'p', 'slot': 1}) # a position needs a shelf
|
||||
|
||||
def test_rear_posts_facing_front_are_seen_from_the_front(self):
|
||||
items = [{'id': 1, 'name': 'pdu', 'kind': 'device', 'rack_u': 24, 'rack_height': 2, 'mount': 'rear'},
|
||||
{'id': 2, 'name': 'udm', 'kind': 'device', 'rack_u': 24, 'rack_height': 1}]
|
||||
assert [i['name'] for i in cs.rack_layout(items, 25, 'front')['placed']] == ['udm']
|
||||
behind = cs.rack_layout(items, 25, 'behind')['placed']
|
||||
assert [(i['name'], i['through']) for i in behind] == [('pdu', False)]
|
||||
assert [(i['name'], i['through']) for i in cs.rack_layout(items, 25, 'rear')['placed']] == [('pdu', True)]
|
||||
|
||||
Reference in New Issue
Block a user