fix(rack): draw the desktop NAS plates like the boxes they are

The first cut put the drive bays in a squat block across the top half and left
the name across the middle, which looked nothing like a UGREEN DXP or a
Synology DS: their front is two to five tall tray doors filling the height, over
a thin strip carrying the badge, the LED and the sockets.

The name band was the blocker — it was nailed to mid-height for every plate, so
anything tall wore its name across its own artwork. `labelBox.y` (default 0.5,
so rack gear is untouched) now places the band, and the status LED rides it.
The NAS plates put it at 0.86 with the trays above and the ports on the same
strip.

Bay corners also scaled badly: a flat 1.5px radius reads as a square hole once
the cells are as large as a 3U tray, so the radius now follows the cell size.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-08-09 20:35:23 +02:00
committed by Pouzor - Rémy Jardient
parent 7067bd76d4
commit 9bf90cbbed
6 changed files with 95 additions and 22 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Create one from **New Canvas → Kind → Rack**.
| Faceplates | Declarative templates in `faceplates.ts`, drawn as SVG in unit coordinates so a plate scales with U height and rack width. Applying a template seeds ports; the user edits them afterwards. `suggestFaceplate()` picks one from the device's discovery type. Chosen from `FaceplatePicker` — a visual catalog, not a `<select>`: every template is drawn with the canvas renderer at its real relative width and U height, grouped, searchable, and filterable by `kind` (accessories are offered only to accessories). |
| Ports | RJ45 and SFP/SFP+ only, drawn as real jack artwork at a fixed pixel size so plates of different U heights line up. Manual list per device. Power outlets are artwork, never a cable endpoint. |
| Port visibility | Patch-facing gear (switches, patch panels) shows its ports permanently. Everything else reveals them on hover, on selection, or when cables are on — a cable never ends on an invisible port. |
| Plate zones | Each template reserves three non-overlapping bands: status LED (fixed left), `labelBox` (name, clipped), then artwork and ports. A test asserts no port lands on the name band. |
| Plate zones | Each template reserves three non-overlapping bands: status LED (fixed left), `labelBox` (name, clipped), then artwork and ports. A test asserts no port lands on the name band. `labelBox.y` (default 0.5) moves the name band *and* the LED off mid-height — desktop NAS boxes are drive doors over a badge strip at the bottom. |
| Cables | Port-to-port, a relation of their own — not React Flow edges. One cable per port. Drawn in a `ViewportPortal` so they pan/zoom with the canvas and can cross racks. |
| Cable visibility | Hidden by default; shown on hover/selection, or all-on via patch mode / the header. Plates fade to 40 % when cables are on. Copper vs fibre follows the port the patch starts from. |
| Colours | `rackTheme.ts` derives the whole rack palette from the active app theme rather than declaring one per theme, so a new theme works here for free. Per-rack chrome (frame, rails, interior) stays user-editable; only its default comes from the theme. |
@@ -0,0 +1,45 @@
/**
* The renderer places the name band and the status LED; a template can move
* both off mid-height, which is what makes a desktop NAS look like one.
*/
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Faceplate } from '../components/Faceplate'
import { getFaceplate } from '../faceplates'
const HEIGHT = 100
function draw(faceplateId: string) {
const plate = getFaceplate(faceplateId)
const { container } = render(
<Faceplate
faceplateId={faceplateId}
label="nas-01"
status="online"
ports={plate.ports.map((p, i) => ({ ...p, id: `p${i}` }))}
width={200}
height={HEIGHT}
revealed
/>,
)
return {
led: container.querySelector('circle'),
text: container.querySelector('text'),
}
}
describe('Faceplate — name band', () => {
it('keeps rack gear labelled across the middle', () => {
const { led, text } = draw('server-1u')
expect(led).toHaveAttribute('cy', String(HEIGHT / 2))
expect(text).toHaveAttribute('y', String(HEIGHT / 2))
})
it('drops the badge and the LED to the bottom strip of a desktop NAS', () => {
const band = getFaceplate('nas-desktop-2').labelBox.y!
const { led, text } = draw('nas-desktop-2')
// Drive trays own the height; the badge belongs under them, not across them.
expect(led).toHaveAttribute('cy', String(band * HEIGHT))
expect(text).toHaveAttribute('y', String(band * HEIGHT))
})
})
+12 -2
View File
@@ -95,8 +95,18 @@ describe('faceplate catalog', () => {
const tray = plate.elements.find((e) => e.kind === 'bays')
expect(tray).toMatchObject({ cols: bays, rows: 1 })
expect(plate.ports.length).toBeGreaterThan(0)
// Trays own the upper half; the name band and the ports share the lower.
expect(tray!.kind === 'bays' && tray.y + tray.h).toBeLessThanOrEqual(0.5)
// The front is tray doors, portrait, over a thin bottom strip carrying the
// badge, the LED and the sockets — not a mid-height band like rack gear.
const band = plate.labelBox.y!
expect(band).toBeGreaterThan(0.7)
const trayBottom = tray!.kind === 'bays' ? tray.y + tray.h : 1
expect(trayBottom).toBeLessThan(band)
expect(plate.ports.every((p) => p.y === band)).toBe(true)
// Doors taller than they are wide, at the plate's real aspect (3U on a
// third of the rack is roughly as tall as it is wide).
const doorW = (tray!.kind === 'bays' ? tray.w : 0) / bays
expect(doorW).toBeLessThan(tray!.kind === 'bays' ? tray.h : 0)
}
})
+7 -3
View File
@@ -75,6 +75,9 @@ function drawElement(el: FaceplateElement, i: number, w: number, h: number) {
case 'bays': {
const cellW = (el.w * w) / el.cols
const cellH = (el.h * h) / el.rows
// Tray doors are rounded; a flat 1.5px radius reads as a square hole once
// the cells get big, as they do on a tall desktop NAS.
const rx = Math.max(1.5, Math.min(cellW, cellH) * 0.14)
const cells = []
for (let r = 0; r < el.rows; r++) {
for (let c = 0; c < el.cols; c++) {
@@ -85,7 +88,7 @@ function drawElement(el: FaceplateElement, i: number, w: number, h: number) {
y={el.y * h + r * cellH + 1}
width={Math.max(2, cellW - 2)}
height={Math.max(2, cellH - 2)}
rx={1.5}
rx={rx}
fill={el.fill ?? '#1b1f26'}
stroke="#0d1117"
strokeWidth={0.5}
@@ -191,6 +194,7 @@ export const Faceplate = memo(function Faceplate({
const labelX = template.labelBox.x * width
const labelW = template.labelBox.w * width
const bandY = (template.labelBox.y ?? 0.5) * height
const fontSize = Math.max(8, Math.min(11, height * 0.42))
const showLabel = labelW > 24 && height >= 14
@@ -225,13 +229,13 @@ export const Faceplate = memo(function Faceplate({
{template.elements.map((el, i) => drawElement(el, i, width, height))}
{template.statusLed && (
<circle cx={LED_X} cy={height / 2} r={LED_R} fill={palette.status[status]} />
<circle cx={LED_X} cy={bandY} r={LED_R} fill={palette.status[status]} />
)}
{showLabel && (
<text
x={labelX}
y={height / 2}
y={bandY}
clipPath={`url(#${clipId})`}
fontSize={fontSize}
fill={palette.text}
+22 -16
View File
@@ -64,6 +64,13 @@ const LABEL_LEFT = { x: 0.055, w: 0.24 }
const LABEL_TIGHT = { x: 0.05, w: 0.15 }
/** Half/third-width plates: the LED is proportionally wider, so shift right. */
const LABEL_SMALL = { x: 0.1, w: 0.4 }
/** Bottom strip of a desktop NAS: badge, LED and sockets all sit on it. */
const NAS_BAND = 0.86
/** Drop a bank onto the NAS bottom strip — `bank` centres rows on the plate. */
function nasPorts(ports: Omit<Port, 'id'>[]): Omit<Port, 'id'>[] {
return ports.map((p) => ({ ...p, y: NAS_BAND }))
}
export const FACEPLATES: FaceplateTemplate[] = [
// --- Servers ------------------------------------------------------------
@@ -301,10 +308,9 @@ export const FACEPLATES: FaceplateTemplate[] = [
],
},
// Desktop NAS boxes (UGREEN DXP, Synology DS…) sat on a rack shelf: not rack
// gear, so they take a third of the width and stand ~3U tall, with their
// drive trays side by side. Bays fill the upper half; the name band and the
// ports share the lower one, side by side, since the name is always drawn at
// mid-height whatever the U count.
// gear, so they take a third of the width and stand ~3U tall. Their front is
// drive-tray doors, tall and portrait, filling everything above a thin bottom
// strip that carries the badge, the LED and the sockets — hence `labelBox.y`.
{
id: 'nas-desktop-2',
label: 'Desktop NAS — 2 bays',
@@ -313,13 +319,13 @@ export const FACEPLATES: FaceplateTemplate[] = [
uHeight: 3,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.12, w: 0.5 },
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
{ kind: 'bays', x: 0.14, y: 0.06, w: 0.72, h: 0.42, cols: 2, rows: 1, fill: BLACK_BOX },
{ kind: 'bays', x: 0.09, y: 0.07, w: 0.82, h: 0.68, cols: 2, rows: 1, fill: BLACK_BOX },
],
ports: bank({ type: 'rj45', count: 1, x: 0.68, w: 0.24, prefix: 'eth' }),
ports: nasPorts(bank({ type: 'rj45', count: 1, x: 0.62, w: 0.3, prefix: 'eth' })),
},
{
id: 'nas-desktop-4',
@@ -329,13 +335,13 @@ export const FACEPLATES: FaceplateTemplate[] = [
uHeight: 3,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.12, w: 0.5 },
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
{ kind: 'bays', x: 0.1, y: 0.06, w: 0.8, h: 0.42, cols: 4, rows: 1, fill: BLACK_BOX },
{ kind: 'bays', x: 0.06, y: 0.07, w: 0.88, h: 0.68, cols: 4, rows: 1, fill: BLACK_BOX },
],
ports: bank({ type: 'rj45', count: 2, x: 0.64, w: 0.3, prefix: 'eth' }),
ports: nasPorts(bank({ type: 'rj45', count: 2, x: 0.6, w: 0.34, prefix: 'eth' })),
},
{
id: 'nas-desktop-5',
@@ -345,16 +351,16 @@ export const FACEPLATES: FaceplateTemplate[] = [
uHeight: 3,
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.12, w: 0.5 },
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
{ kind: 'bays', x: 0.08, y: 0.06, w: 0.84, h: 0.42, cols: 5, rows: 1, fill: BLACK_BOX },
],
ports: [
...bank({ type: 'rj45', count: 2, x: 0.64, w: 0.22, prefix: 'eth' }),
...bank({ type: 'sfp+', count: 1, x: 0.88, w: 0.1, prefix: 'sfp' }),
{ kind: 'bays', x: 0.05, y: 0.07, w: 0.9, h: 0.68, cols: 5, rows: 1, fill: BLACK_BOX },
],
ports: nasPorts([
...bank({ type: 'rj45', count: 2, x: 0.58, w: 0.24, prefix: 'eth' }),
...bank({ type: 'sfp+', count: 1, x: 0.84, w: 0.12, prefix: 'sfp' }),
]),
},
{
id: 'ups-2u',
+8
View File
@@ -81,6 +81,14 @@ export type FaceplateKind = 'device' | 'accessory'
export interface LabelBox {
x: number
w: number
/**
* Vertical centre of the name band, 0..1, default 0.5.
*
* Rack gear wears its name across the middle. Tall desktop boxes do not —
* their front is drive trays, and the badge sits on a strip at the bottom.
* The status LED rides the same band.
*/
y?: number
}
export interface FaceplateTemplate {