Merge pull request 'Rack: front and rear mounting, rack settings on the page' (#25) from rack-rear-and-settings into main
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 52s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m44s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 52s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m44s
This commit was merged in pull request #25.
This commit is contained in:
@@ -135,10 +135,15 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
|
||||
item and its device as the same thing.
|
||||
- **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.
|
||||
- **More than one rack**: each item has a rack (empty means the first). The page shows a tab per rack.
|
||||
Configure them with `racks: [{"name": "Main", "units": 42}, ...]`; without it there is one rack,
|
||||
`rack.name` (default "Rack 1") with `rack.units` (default 42) units. A rack name used by an item but not
|
||||
configured gets the default height.
|
||||
- **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`).
|
||||
- **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:
|
||||
`racks: [{"name": "Main", "units": 42}, ...]`, or one rack `rack.name` (default "Rack 1") with
|
||||
`rack.units` (default 42); saving the first one carries its items over.
|
||||
- Every documented port shows its cutsheet state (green matches, red mismatch, grey unconfirmed).
|
||||
Hovering a port lights up every port on the same cable run, through patch panels, and names the
|
||||
cables; clicking opens the switch port or the cable.
|
||||
@@ -712,7 +717,8 @@ 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` and `device` columns; they are added automatically on first use
|
||||
- `cutsheet_items` gained `rack`, `shelf`, `device` and `mount` 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
|
||||
`recovery_notified` once the recovery note is on its ticket (column added automatically at monitor startup)
|
||||
|
||||
@@ -1073,11 +1073,11 @@ def rack_page():
|
||||
|
||||
|
||||
def _racks(items: list) -> list:
|
||||
"""Every rack, in order: the configured ones, then any other name an item uses.
|
||||
"""Every rack, in order: those saved on the rack page, then any other name an item uses.
|
||||
|
||||
Config: "racks": [{"name": "Main", "units": 42}, ...]. Without it there is one rack,
|
||||
named rack.name (default "Rack 1") with rack.units (default 42). An item with no rack
|
||||
belongs to the first.
|
||||
With none saved, racks come from config: "racks": [{"name": "Main", "units": 42}, ...], or
|
||||
one rack named rack.name (default "Rack 1") with rack.units (default 42). Those have no id
|
||||
until saved. An item with no rack belongs to the first.
|
||||
"""
|
||||
def units(value, default=42):
|
||||
try:
|
||||
@@ -1088,15 +1088,19 @@ def _racks(items: list) -> list:
|
||||
cfg = _config()
|
||||
single = cfg.get('rack') or {}
|
||||
default_units = units(single.get('units', 42))
|
||||
racks = [{'name': str(r.get('name') or '').strip(), 'units': units(r.get('units', default_units), default_units)}
|
||||
for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()]
|
||||
racks = [{'id': r['id'], 'name': r['name'], 'units': r['units'], 'notes': r.get('notes') or ''}
|
||||
for r in db.cutsheet_racks()]
|
||||
if not racks:
|
||||
racks = [{'name': str(single.get('name') or 'Rack 1'), 'units': default_units}]
|
||||
racks = [{'id': None, 'name': str(r.get('name') or '').strip(), 'notes': '',
|
||||
'units': units(r.get('units', default_units), default_units)}
|
||||
for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()]
|
||||
if not racks:
|
||||
racks = [{'id': None, 'name': str(single.get('name') or 'Rack 1'), 'units': default_units, 'notes': ''}]
|
||||
named = {r['name'] for r in racks}
|
||||
for item in items:
|
||||
if item.get('rack') and item['rack'] not in named:
|
||||
named.add(item['rack'])
|
||||
racks.append({'name': item['rack'], 'units': default_units})
|
||||
racks.append({'id': None, 'name': item['rack'], 'units': default_units, 'notes': ''})
|
||||
return racks
|
||||
|
||||
|
||||
@@ -1113,10 +1117,13 @@ def _device_status(name: str, snapshot: dict) -> str:
|
||||
@app.route('/api/rack')
|
||||
@require_auth
|
||||
def api_rack():
|
||||
"""One rack's front: placed cutsheet items, live switch ports, and each port's cutsheet state.
|
||||
"""One rack face: placed cutsheet items, live switch ports, and each port's cutsheet state.
|
||||
|
||||
Query: rack (name; default the first 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
|
||||
view = _cutsheet_view()
|
||||
link_stats = _state_json('link_stats')
|
||||
switches = link_stats.get('unifi_switches') or {}
|
||||
@@ -1135,7 +1142,9 @@ def api_rack():
|
||||
return item.get('rack') or default
|
||||
|
||||
for r in racks:
|
||||
r['count'] = sum(1 for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name'])
|
||||
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)
|
||||
r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full'))
|
||||
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'])
|
||||
@@ -1156,7 +1165,7 @@ def api_rack():
|
||||
on_shelf.setdefault(item['shelf'], []).append(item)
|
||||
|
||||
here = [i for i in view['items'] if not i.get('shelf') and rack_of(i) == current['name']]
|
||||
layout = cutsheet.rack_layout(here, current['units'])
|
||||
layout = cutsheet.rack_layout(here, current['units'], face)
|
||||
out = []
|
||||
for item in layout['placed']:
|
||||
name, device = item['name'], dev(item)
|
||||
@@ -1164,6 +1173,8 @@ def api_rack():
|
||||
'rack_top', 'location', 'notes')}
|
||||
entry['device'] = device
|
||||
entry['linked'] = device in known
|
||||
entry['mount'] = item.get('mount') or 'front'
|
||||
entry['through'] = item.get('through', False)
|
||||
entry['url'] = url(item)
|
||||
entry['status'] = _device_status(device, snapshot) if device in known else ''
|
||||
# Cables may name either the item or the device it is linked to
|
||||
@@ -1185,13 +1196,47 @@ def api_rack():
|
||||
]
|
||||
out.append(entry)
|
||||
return jsonify({
|
||||
'racks': racks, 'rack': current['name'], 'units': current['units'], 'items': out,
|
||||
'racks': racks, 'rack': current['name'], 'rack_id': current['id'], 'units': current['units'],
|
||||
'notes': current['notes'], 'face': face, 'items': out,
|
||||
'overlaps': layout['overlaps'], 'outside': layout['outside'],
|
||||
'unplaced': sorted(i['name'] for i in here if not i.get('rack_u')),
|
||||
'updated': link_stats.get('updated'),
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/racks', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
@_cutsheet_write
|
||||
def api_cutsheet_create_rack():
|
||||
"""Add a rack. `previous` is the name of the default rack this replaces, when the first is saved."""
|
||||
body = _json_body()
|
||||
rack = cutsheet.validate_rack(body)
|
||||
previous = str(body.get('previous') or '')[:100] if not db.cutsheet_racks() else ''
|
||||
rack_id = db.cutsheet_save_rack(None, rack, _get_user()['username'], previous=previous)
|
||||
return jsonify({'success': True, 'id': rack_id})
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/racks/<int:rack_id>', methods=['PUT'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
@_cutsheet_write
|
||||
def api_cutsheet_update_rack(rack_id: int):
|
||||
rack = cutsheet.validate_rack(_json_body())
|
||||
db.cutsheet_save_rack(rack_id, rack, _get_user()['username'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/racks/<int:rack_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
@_cutsheet_write
|
||||
def api_cutsheet_delete_rack(rack_id: int):
|
||||
if not db.cutsheet_delete_rack(rack_id, _get_user()['username']):
|
||||
return jsonify({'error': 'No such rack'}), 404
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/cables', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
|
||||
+42
-11
@@ -22,6 +22,10 @@ import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
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')
|
||||
RACK_UNITS_MAX = 60
|
||||
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
|
||||
SIDES = ('front', 'rear')
|
||||
STATUSES = ('planned', 'installed', 'dark', 'retired')
|
||||
@@ -94,9 +98,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
|
||||
'rack_height': _int(data, 'rack_height', 1, 20, 'Height'),
|
||||
'rack': _text(data, 'rack', RACK_NAME_MAX),
|
||||
'device': _text(data, 'device'),
|
||||
'mount': _text(data, 'mount', 10) or 'front',
|
||||
'shelf': _text(data, 'shelf', NAME_MAX),
|
||||
'notes': _text(data, 'notes', NOTES_MAX),
|
||||
}
|
||||
if item['mount'] not in MOUNTS:
|
||||
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
|
||||
if item['shelf']:
|
||||
shelf = (items or {}).get(item['shelf'])
|
||||
if not shelf or shelf['kind'] != 'shelf':
|
||||
@@ -105,7 +112,8 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
|
||||
raise ValidationError('A shelf cannot sit on a shelf')
|
||||
if item['rack_u'] or item['rack_height']:
|
||||
raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit')
|
||||
item['rack'] = '' # the shelf's rack
|
||||
item['rack'] = '' # the shelf's rack and side
|
||||
item['mount'] = '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']:
|
||||
@@ -575,14 +583,22 @@ def problems(cables: List[dict], items: Dict[str, dict], switches: dict) -> List
|
||||
|
||||
# ── Rack elevation ──────────────────────────────────────────────────────────
|
||||
|
||||
def rack_layout(items: List[dict], units: int) -> dict:
|
||||
"""Place items that have a rack unit, top of the rack first.
|
||||
def _sides(item: dict) -> Tuple[str, ...]:
|
||||
mount = item.get('mount') or 'front'
|
||||
return ('front', 'rear') if mount == 'full' else (mount,)
|
||||
|
||||
An item that would share a unit with one already placed, or runs past the
|
||||
top of the rack, is reported rather than drawn over another.
|
||||
Returns {'placed': [item + 'rack_top'], 'overlaps': [{'name', 'units', 'with'}], 'outside': [name]}.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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).
|
||||
"""
|
||||
taken: Dict[int, str] = {}
|
||||
taken: Dict[tuple, str] = {}
|
||||
placed, overlaps, outside = [], [], []
|
||||
racked = [i for i in items if i.get('rack_u')]
|
||||
# Top first; where two claim the same top unit, the one recorded first is drawn
|
||||
@@ -593,13 +609,16 @@ def rack_layout(items: List[dict], units: int) -> dict:
|
||||
if high > units:
|
||||
outside.append({'name': item['name'], 'units': span})
|
||||
continue
|
||||
clash = sorted({taken[u] for u in range(low, high + 1) if u in taken})
|
||||
sides = _sides(item)
|
||||
cells = [(u, s) for u in range(low, high + 1) for s in sides]
|
||||
clash = sorted({taken[c] for c in cells if c in taken})
|
||||
if clash:
|
||||
overlaps.append({'name': item['name'], 'units': span, 'with': clash})
|
||||
continue
|
||||
for u in range(low, high + 1):
|
||||
taken[u] = item['name']
|
||||
placed.append(dict(item, rack_top=high))
|
||||
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'))
|
||||
return {'placed': placed, 'overlaps': overlaps, 'outside': outside}
|
||||
|
||||
|
||||
@@ -633,3 +652,15 @@ def port_marks(cables: List[dict]) -> Dict[tuple, dict]:
|
||||
entry['text'] = '; '.join(f'cable{"s" if len(labels) > 1 else ""} {", ".join(labels)}: {detail}'
|
||||
for detail, labels in entry.pop('by_detail').items())
|
||||
return out
|
||||
|
||||
|
||||
def validate_rack(data: dict) -> dict:
|
||||
"""A rack from the rack settings form."""
|
||||
rack = {
|
||||
'name': _text(data, 'name', RACK_NAME_MAX, required=True, label='Rack name'),
|
||||
'units': _int(data, 'units', 1, RACK_UNITS_MAX, 'Height'),
|
||||
'notes': _text(data, 'notes', NOTES_MAX),
|
||||
}
|
||||
if not rack['units']:
|
||||
raise ValidationError('Set the rack height in units')
|
||||
return rack
|
||||
|
||||
@@ -677,6 +677,7 @@ _CUTSHEET_SCHEMA = (
|
||||
rack VARCHAR(100) NOT NULL DEFAULT '',
|
||||
shelf VARCHAR(100) NOT NULL DEFAULT '',
|
||||
device VARCHAR(255) NOT NULL DEFAULT '',
|
||||
mount VARCHAR(10) NOT NULL DEFAULT 'front',
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -725,6 +726,15 @@ _CUTSHEET_SCHEMA = (
|
||||
INDEX idx_hist_time (changed_at),
|
||||
INDEX idx_hist_entity (entity, entity_id)
|
||||
) ENGINE=InnoDB""",
|
||||
"""CREATE TABLE IF NOT EXISTS cutsheet_racks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
units SMALLINT NOT NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rack_name (name)
|
||||
) ENGINE=InnoDB""",
|
||||
# Where a device lives in the asset inventory (HomeBox), set by hand on its Gandalf page
|
||||
"""CREATE TABLE IF NOT EXISTS device_links (
|
||||
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
@@ -758,13 +768,14 @@ 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',
|
||||
'notes')
|
||||
'mount', 'notes')
|
||||
# Columns added to cutsheet_items after its first release: (name, definition)
|
||||
_ITEM_COLUMNS_ADDED = (
|
||||
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
('shelf', "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
# The Gandalf device (UniFi or host name) an item is, when its own name differs
|
||||
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
|
||||
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
|
||||
)
|
||||
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
|
||||
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
|
||||
@@ -1027,3 +1038,64 @@ def cutsheet_import_cables(cables: list, user: str) -> dict:
|
||||
None, cable)
|
||||
added += 1
|
||||
return {'added': added, 'updated': updated}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Racks (cutsheet_racks): name and height, edited on the rack page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cutsheet_racks() -> list:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT id, name, units, notes FROM cutsheet_racks ORDER BY id')
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def cutsheet_save_rack(rack_id: Optional[int], rack: dict, user: str, previous: str = '') -> int:
|
||||
"""Create (rack_id None) or update a rack. A rename carries over to the items in it.
|
||||
|
||||
`previous` names the rack being replaced when the first rack is saved for the first
|
||||
time (it existed only as the default until then), so its items follow the new name.
|
||||
"""
|
||||
with _cs_transaction() as cur:
|
||||
before = None
|
||||
if rack_id is not None:
|
||||
cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,))
|
||||
before = cur.fetchone()
|
||||
if before is None:
|
||||
raise CutsheetConflict('No such rack')
|
||||
cur.execute('SELECT name FROM cutsheet_racks WHERE LOWER(name)=LOWER(%s) AND id<>%s',
|
||||
(rack['name'], rack_id or 0))
|
||||
clash = cur.fetchone()
|
||||
if clash:
|
||||
raise CutsheetConflict(f'A rack named {clash["name"]} already exists')
|
||||
if before is None:
|
||||
cur.execute('INSERT INTO cutsheet_racks (name, units, notes) VALUES (%s, %s, %s)',
|
||||
(rack['name'], rack['units'], rack['notes']))
|
||||
rack_id = cur.lastrowid
|
||||
old_name = previous
|
||||
else:
|
||||
cur.execute('UPDATE cutsheet_racks SET name=%s, units=%s, notes=%s WHERE id=%s',
|
||||
(rack['name'], rack['units'], rack['notes'], rack_id))
|
||||
old_name = before['name']
|
||||
if old_name and old_name != rack['name']:
|
||||
cur.execute('UPDATE cutsheet_items SET rack=%s WHERE rack=%s', (rack['name'], old_name))
|
||||
renamed = f' (was {old_name})' if old_name and old_name != rack['name'] else ''
|
||||
_cs_log(cur, user, 'rack', rack_id, 'update' if before else 'create',
|
||||
f'Rack {rack["name"]}: {rack["units"]}U{renamed}', _cs_jsonable(before), rack)
|
||||
return rack_id
|
||||
|
||||
|
||||
def cutsheet_delete_rack(rack_id: int, user: str) -> bool:
|
||||
with _cs_transaction() as cur:
|
||||
cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,))
|
||||
before = cur.fetchone()
|
||||
if before is None:
|
||||
return False
|
||||
cur.execute('SELECT COUNT(*) AS n FROM cutsheet_items WHERE rack=%s', (before['name'],))
|
||||
n = cur.fetchone()['n']
|
||||
if n:
|
||||
raise CutsheetConflict(f'{before["name"]} still holds {n} item(s); move them to another rack first')
|
||||
cur.execute('DELETE FROM cutsheet_racks WHERE id=%s', (rack_id,))
|
||||
_cs_log(cur, user, 'rack', rack_id, 'delete', f'Removed rack {before["name"]}', _cs_jsonable(before), None)
|
||||
return True
|
||||
|
||||
+7
-5
@@ -260,7 +260,8 @@ function renderRack() {
|
||||
function rackText(i) {
|
||||
if (i.shelf) return `on ${esc(i.shelf)}`;
|
||||
if (!i.rack_u) return '–';
|
||||
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}`;
|
||||
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -388,13 +389,14 @@ function openCable(cable, prefill) {
|
||||
function openItem(item) {
|
||||
const form = document.getElementById('cs-item-form');
|
||||
form.reset();
|
||||
const data = Object.assign({ kind: 'patch_panel' }, item || {});
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: '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('');
|
||||
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', 'shelf', 'location', 'notes']) {
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'location', 'notes']) {
|
||||
form[key].value = data[key] == null ? '' : data[key];
|
||||
}
|
||||
if (!item) form.port_count.value = 24;
|
||||
@@ -447,8 +449,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', 'shelf', 'location',
|
||||
'notes']);
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'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');
|
||||
|
||||
+108
-13
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* global FACEPLATES, renderFaceplate */
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */
|
||||
// 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.
|
||||
|
||||
@@ -114,9 +114,18 @@ function plateHtml(item) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function backHtml(item) {
|
||||
// A full-depth item seen from the rear: its back, not its faceplate
|
||||
return `<div class="rack-plate rack-back">
|
||||
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
|
||||
<span class="rack-plate-sub">back of a full-depth ${esc(KIND_TEXT[item.kind] || item.kind).toLowerCase()}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function itemHtml(item) {
|
||||
let body;
|
||||
if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
|
||||
if (item.through) body = backHtml(item);
|
||||
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
|
||||
else if (item.kind === 'patch_panel') body = panelSvg(item);
|
||||
else if (item.kind === 'shelf') body = shelfHtml(item);
|
||||
else body = plateHtml(item);
|
||||
@@ -161,7 +170,14 @@ 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.racks.length > 1 ? `${data.rack} · front` : 'Front';
|
||||
document.getElementById('rack-title').textContent = `${data.rack} · ${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';
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
@@ -183,8 +199,17 @@ function render(data) {
|
||||
u = low - 1;
|
||||
}
|
||||
document.getElementById('rack').innerHTML = rows.join('');
|
||||
// Every rack device is 19" wide: fill the slot rather than keep each drawing's own proportions
|
||||
document.querySelectorAll('#rack .fp-svg').forEach(s => s.setAttribute('preserveAspectRatio', 'none'));
|
||||
// Every rack device is 19" wide: stretch a faceplate to fill its slot when its drawing is close to
|
||||
// the slot's proportions. A drawing far from them (the PDU's is twice as tall as a real 1U) keeps its
|
||||
// shape rather than being squashed.
|
||||
document.querySelectorAll('#rack .rack-slot').forEach(slot => {
|
||||
const svg = slot.querySelector('.fp-svg');
|
||||
if (!svg) return;
|
||||
const [, , w, h] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
const n = parseInt(slot.closest('.rack-row').style.getPropertyValue('--n'), 10);
|
||||
const off = (w / h) / (RATIO / n);
|
||||
svg.setAttribute('preserveAspectRatio', off > 0.85 && off < 1.15 ? 'none' : 'xMidYMid meet');
|
||||
});
|
||||
document.getElementById('rack-show-empty').checked = showEmpty();
|
||||
document.getElementById('rack-count').textContent = data.items.length;
|
||||
|
||||
@@ -283,19 +308,37 @@ function currentRack() {
|
||||
return new URLSearchParams(location.search).get('rack') || '';
|
||||
}
|
||||
|
||||
function currentFace() {
|
||||
return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front';
|
||||
}
|
||||
|
||||
function setView(changes) {
|
||||
const q = new URLSearchParams(location.search);
|
||||
Object.entries(changes).forEach(([k, v]) => (v ? q.set(k, v) : q.delete(k)));
|
||||
const qs = q.toString();
|
||||
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
|
||||
_rack = null;
|
||||
load();
|
||||
}
|
||||
|
||||
document.getElementById('rack-tabs').addEventListener('click', e => {
|
||||
const tab = e.target.closest('[data-rack]');
|
||||
if (!tab) return;
|
||||
const first = _rack && _rack.racks[0] && _rack.racks[0].name;
|
||||
history.replaceState(null, '', tab.dataset.rack === first ? location.pathname : `?rack=${encodeURIComponent(tab.dataset.rack)}`);
|
||||
_rack = null;
|
||||
load();
|
||||
setView({ rack: tab.dataset.rack === first ? '' : tab.dataset.rack });
|
||||
});
|
||||
|
||||
document.querySelector('.rack-face').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-face]');
|
||||
if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' });
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const rack = currentRack();
|
||||
render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`));
|
||||
const q = new URLSearchParams();
|
||||
if (currentRack()) q.set('rack', currentRack());
|
||||
if (currentFace() === 'rear') q.set('face', 'rear');
|
||||
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
|
||||
} catch (err) {
|
||||
if (_rack) return; // keep the last drawing on a failed refresh
|
||||
document.getElementById('rack').innerHTML = `<div class="lt-empty-state lt-empty-state--sm">
|
||||
@@ -342,7 +385,7 @@ function buildExport() {
|
||||
root.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: '#ffffff' }));
|
||||
const stamp = new Date().toLocaleString();
|
||||
root.appendChild(svgEl('text', { x: pad, y: pad + 14, 'font-size': 18, 'font-weight': 700, fill: '#111' },
|
||||
`${_rack.rack} · front`));
|
||||
`${_rack.rack} · ${_rack.face}`));
|
||||
root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' },
|
||||
`Gandalf · ${stamp}`));
|
||||
|
||||
@@ -370,7 +413,7 @@ function buildExport() {
|
||||
const clone = art.cloneNode(true);
|
||||
inlineStyles(art, clone);
|
||||
['x', 'y', 'width', 'height'].forEach((k, i) => clone.setAttribute(k, [x, y, W, h][i]));
|
||||
clone.setAttribute('preserveAspectRatio', art.classList.contains('fp-svg') ? 'none' : 'xMidYMid meet');
|
||||
clone.setAttribute('preserveAspectRatio', art.getAttribute('preserveAspectRatio') || 'xMidYMid meet');
|
||||
clone.removeAttribute('style');
|
||||
root.appendChild(clone);
|
||||
} else if (plate) {
|
||||
@@ -406,7 +449,7 @@ async function exportRack(kind) {
|
||||
return;
|
||||
}
|
||||
const svg = buildExport();
|
||||
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`;
|
||||
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-${_rack.face}`;
|
||||
if (kind === 'svg') {
|
||||
download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`);
|
||||
return;
|
||||
@@ -436,3 +479,55 @@ document.getElementById('rack-show-empty').addEventListener('change', e => {
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export)));
|
||||
|
||||
// ── Rack settings (admins) ──────────────────────────────────────────────
|
||||
function openRackForm(adding) {
|
||||
const form = document.getElementById('rack-form');
|
||||
form.reset();
|
||||
const r = adding ? null : (_rack.racks.find(x => x.name === _rack.rack) || {});
|
||||
form.id.value = r && r.id ? r.id : '';
|
||||
// The default rack exists only in config until saved; its items follow the new name
|
||||
form.previous.value = r && !r.id ? r.name : '';
|
||||
form.name.value = r ? r.name : '';
|
||||
form.units.value = r ? r.units : 42;
|
||||
form.notes.value = r ? (r.notes || '') : '';
|
||||
form.querySelector('[data-action="delete-rack"]').hidden = !(r && r.id);
|
||||
document.getElementById('rack-modal-title').textContent = adding ? 'Add a rack' : 'Rack settings';
|
||||
lt.modal.open('rack-modal');
|
||||
form.name.focus();
|
||||
}
|
||||
|
||||
async function saveRack(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = { name: form.name.value.trim(), units: form.units.value, notes: form.notes.value, previous: form.previous.value };
|
||||
try {
|
||||
if (form.id.value) await lt.api.put(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`, body);
|
||||
else await lt.api.post('/api/cutsheet/racks', body);
|
||||
lt.modal.close('rack-modal');
|
||||
lt.toast.success('Rack saved');
|
||||
setView({ rack: body.name });
|
||||
} catch (err) {
|
||||
lt.toast.error(err.message || 'Save failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRack() {
|
||||
const form = document.getElementById('rack-form');
|
||||
if (!form.id.value || !window.confirm(`Delete the rack ${form.name.value}?`)) return;
|
||||
try {
|
||||
await lt.api.delete(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`);
|
||||
lt.modal.close('rack-modal');
|
||||
lt.toast.success('Rack deleted');
|
||||
setView({ rack: '' });
|
||||
} catch (err) {
|
||||
lt.toast.error(err.message || 'Delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (RACK_CAN_EDIT) {
|
||||
document.querySelector('[data-action="rack-settings"]').addEventListener('click', () => _rack && openRackForm(false));
|
||||
document.querySelector('[data-action="add-rack"]').addEventListener('click', () => _rack && openRackForm(true));
|
||||
document.getElementById('rack-form').addEventListener('submit', saveRack);
|
||||
document.querySelector('[data-action="delete-rack"]').addEventListener('click', deleteRack);
|
||||
}
|
||||
|
||||
@@ -1515,3 +1515,11 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.rack-shelf-thing:hover, .rack-shelf-thing:focus-visible { outline: 2px solid var(--accent-cyan); }
|
||||
.rack-shelf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@container (max-height: 50px) { .rack-shelf-label { display: none; } .rack-shelf-thing { font-size: .6rem; gap: 3px; } }
|
||||
.rack-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between;
|
||||
max-width: 1000px; margin: 0 auto 14px; }
|
||||
.rack-bar .rack-tabs { margin: 0 !important; }
|
||||
.rack-face { display: inline-flex; border: 1px solid var(--border-color); border-radius: 3px; overflow: hidden; margin-left: auto; }
|
||||
.rack-face-btn { background: none; border: 0; color: inherit; font: inherit; font-size: .82rem; padding: 6px 14px; cursor: pointer; }
|
||||
.rack-face-btn + .rack-face-btn { border-left: 1px solid var(--border-color); }
|
||||
.rack-face-btn.active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||
.rack-back { opacity: .75; background: repeating-linear-gradient(135deg, var(--rk-plate-b) 0 8px, var(--rk-frame) 8px 16px); }
|
||||
|
||||
+11
-1
@@ -269,12 +269,22 @@
|
||||
<input class="lt-input" id="cs-item-rack" name="rack" maxlength="100" list="cs-rack-list" placeholder="first rack">
|
||||
<datalist id="cs-rack-list"></datalist>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="cs-item-mount">Mounted</label>
|
||||
<select class="lt-select" id="cs-item-mount" name="mount">
|
||||
<option value="front">Front</option>
|
||||
<option value="rear">Rear</option>
|
||||
<option value="full">Full depth (both sides)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="lt-form-group">
|
||||
<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>
|
||||
<div class="lt-field-hint cs-shelf-hint">Gear on a shelf (mini PCs, desk switches) takes its place from the shelf: leave its rack unit empty.</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">
|
||||
<label class="lt-label" for="cs-item-location">Location</label>
|
||||
<input class="lt-input" id="cs-item-location" name="location" maxlength="255" placeholder="e.g. rack, office wall">
|
||||
|
||||
+46
-1
@@ -11,12 +11,20 @@
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-export="svg">Download SVG</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-export="png">Download PNG</button>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="rack-settings">Rack settings</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="add-rack">+ Rack</button>
|
||||
<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/cutsheet#cs-items-section">Edit positions</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rack-tabs" id="rack-tabs" role="tablist" aria-label="Racks" hidden></div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rack-warnings" id="rack-warnings" role="alert" hidden></div>
|
||||
|
||||
@@ -35,9 +43,46 @@
|
||||
<div class="rack" id="rack"><div class="link-loading">Loading rack</div></div>
|
||||
<p class="cs-hint rack-unplaced" id="rack-unplaced"></p>
|
||||
</section>
|
||||
{% if can_edit %}
|
||||
<div id="rack-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="rack-modal-title" aria-hidden="true">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<h3 class="lt-modal-title" id="rack-modal-title">Rack settings</h3>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="rack-form" autocomplete="off">
|
||||
<div class="lt-modal-body">
|
||||
<input type="hidden" name="id">
|
||||
<input type="hidden" name="previous">
|
||||
<div class="form-row">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="rack-name">Name <span class="required">*</span></label>
|
||||
<input class="lt-input" id="rack-name" name="name" required maxlength="100" placeholder="e.g. StarTech 25U">
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="rack-units">Height (U) <span class="required">*</span></label>
|
||||
<input class="lt-input" id="rack-units" name="units" type="number" min="1" max="60" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="rack-notes">Notes</label>
|
||||
<textarea class="lt-input" id="rack-notes" name="notes" rows="2" maxlength="2000" placeholder="model, location, depth"></textarea>
|
||||
</div>
|
||||
<p class="cs-hint">Renaming a rack moves its items with it. A rack can be deleted once it is empty.</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="button" class="lt-btn lt-btn-danger" data-action="delete-rack">Delete</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
|
||||
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>const RACK_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
|
||||
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='rack.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+53
-2
@@ -36,6 +36,7 @@ def client():
|
||||
db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0}
|
||||
db.check_suppressed.return_value = False
|
||||
db.get_device_link.return_value = ''
|
||||
db.cutsheet_racks.return_value = []
|
||||
with patch.object(gandalf, '_cfg', CONFIG):
|
||||
yield gandalf.app.test_client()
|
||||
|
||||
@@ -503,9 +504,59 @@ class TestRackRacksShelvesLinks:
|
||||
cfg = dict(CONFIG, racks=[{'name': 'Main', 'units': 24}])
|
||||
with patch.object(gandalf, '_cfg', cfg):
|
||||
data = client.get('/api/rack', headers=AUTH).get_json()
|
||||
assert data['racks'] == [{'name': 'Main', 'units': 24, 'count': 1},
|
||||
{'name': 'Garage', 'units': 42, 'count': 1}]
|
||||
assert [(r['id'], r['name'], r['units'], r['count']) for r in data['racks']] == [
|
||||
(None, 'Main', 24, 1), (None, 'Garage', 42, 1)]
|
||||
assert data['rack'] == 'Main' and [i['name'] for i in data['items']] == ['A']
|
||||
garage = client.get('/api/rack?rack=Garage', headers=AUTH).get_json()
|
||||
assert [i['name'] for i in garage['items']] == ['B'] and garage['units'] == 42
|
||||
assert client.get('/api/rack?rack=Nope', headers=AUTH).status_code == 404
|
||||
|
||||
|
||||
class TestRackFacesAndSettings:
|
||||
def _items(self, *items):
|
||||
base = {'port_count': None, 'rack_u': None, 'rack_height': None, 'rack': '', 'shelf': '', 'device': '',
|
||||
'mount': 'front', 'location': '', 'notes': ''}
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
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):
|
||||
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': '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
|
||||
assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400
|
||||
|
||||
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'}]
|
||||
data = client.get('/api/rack', headers=AUTH).get_json()
|
||||
assert (data['rack'], data['rack_id'], data['units'], data['notes']) == ('StarTech 25U', 4, 25, 'open frame')
|
||||
|
||||
def test_first_save_carries_the_default_racks_items(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
gandalf.db.cutsheet_save_rack.return_value = 7
|
||||
resp = client.post('/api/cutsheet/racks', json={'name': 'StarTech 25U', 'units': 25, 'previous': 'Rack 1'},
|
||||
headers=AUTH)
|
||||
assert resp.get_json() == {'success': True, 'id': 7}
|
||||
args, kwargs = gandalf.db.cutsheet_save_rack.call_args
|
||||
assert args[0] is None and args[1]['units'] == 25 and kwargs == {'previous': 'Rack 1'}
|
||||
|
||||
def test_later_racks_ignore_previous(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'A', 'units': 25, 'notes': ''}]
|
||||
client.post('/api/cutsheet/racks', json={'name': 'B', 'units': 42, 'previous': 'A'}, headers=AUTH)
|
||||
assert gandalf.db.cutsheet_save_rack.call_args.kwargs == {'previous': ''}
|
||||
|
||||
def test_rack_writes_need_admin_and_valid_data(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
assert client.post('/api/cutsheet/racks', json={'name': 'x', 'units': 99}, headers=AUTH).status_code == 400
|
||||
user = dict(AUTH, **{'Remote-Groups': 'staff'})
|
||||
with patch.object(gandalf, '_cfg', dict(CONFIG, auth=dict(CONFIG['auth'], allowed_groups=['admin', 'staff']))):
|
||||
assert client.put('/api/cutsheet/racks/1', json={'name': 'x', 'units': 2}, headers=user).status_code == 403
|
||||
gandalf.db.cutsheet_delete_rack.return_value = False
|
||||
assert client.delete('/api/cutsheet/racks/9', headers=AUTH).status_code == 404
|
||||
|
||||
@@ -321,3 +321,39 @@ class TestDeviceAliases:
|
||||
def test_runs_for_the_device_include_aliased_cables(self):
|
||||
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'), label='C1')
|
||||
assert [r['label'] for r in cs.runs_for('udm', [c], self.ITEMS, {})] == ['C1']
|
||||
|
||||
|
||||
class TestRackFaces:
|
||||
def _item(self, name, u, h=1, mount='front', id_=1):
|
||||
return {'id': id_, 'name': name, 'kind': 'device', 'rack_u': u, 'rack_height': h, 'mount': mount}
|
||||
|
||||
def test_front_and_rear_share_a_unit(self):
|
||||
items = [self._item('switch', 10), self._item('pdu', 10, mount='rear', id_=2)]
|
||||
front = cs.rack_layout(items, 25, 'front')
|
||||
rear = cs.rack_layout(items, 25, 'rear')
|
||||
assert [i['name'] for i in front['placed']] == ['switch'] and front['overlaps'] == []
|
||||
assert [i['name'] for i in rear['placed']] == ['pdu'] and rear['overlaps'] == []
|
||||
|
||||
def test_full_depth_takes_both_sides(self):
|
||||
items = [self._item('server', 5, 4, mount='full'), self._item('pdu', 6, mount='rear', id_=2)]
|
||||
rear = cs.rack_layout(items, 25, 'rear')
|
||||
assert [(i['name'], i['through']) for i in rear['placed']] == [('server', True)]
|
||||
assert rear['overlaps'] == [{'name': 'pdu', 'units': 'U6', 'with': ['server']}]
|
||||
front = cs.rack_layout(items, 25, 'front')
|
||||
assert [(i['name'], i['through']) for i in front['placed']] == [('server', False)]
|
||||
assert front['overlaps'] == rear['overlaps'] # reported on both faces
|
||||
|
||||
def test_mount_is_validated_and_shelf_items_take_the_shelf_side(self):
|
||||
assert cs.validate_item({'name': 'p', 'mount': 'rear', 'rack_u': 3})['mount'] == 'rear'
|
||||
assert cs.validate_item({'name': 'p'})['mount'] == 'front'
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_item({'name': 'p', 'mount': 'side'})
|
||||
shelf = {'Shelf': {'name': 'Shelf', 'kind': 'shelf'}}
|
||||
assert cs.validate_item({'name': 'm', 'shelf': 'Shelf', 'mount': 'rear'}, shelf)['mount'] == 'front'
|
||||
|
||||
def test_validate_rack(self):
|
||||
rack = cs.validate_rack({'name': ' StarTech 25U ', 'units': '25'})
|
||||
assert rack == {'name': 'StarTech 25U', 'units': 25, 'notes': ''}
|
||||
for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}):
|
||||
with pytest.raises(cs.ValidationError):
|
||||
cs.validate_rack(bad)
|
||||
|
||||
Reference in New Issue
Block a user