docs(spec): drive bay Option B restructure design (deferred)
Captures the full design for splitting LFF/SFF/NVMe drive bay
groups from flat radios into Drive Selection + Drive Quantity
composite controls. Includes the SAS variants user requested,
per-drive pricing table, schema decisions (no migrations needed,
existing schema already supports multi-option groups), Pinia
store changes, new DriveBayGroupSelector component sketch, URL
param contract changes, and migration steps.
Implementation deferred to a focused next session — realistic
4-5 hour build (backend seeder + frontend component + store
rework + test rewrite). Phase A (PCIe NVMe Add-in) shipped
ahead of this in c74ca7f.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
# Drive Bay Configurator — Option B Restructure (deferred)
|
||||||
|
|
||||||
|
**Date:** 2026-04-26
|
||||||
|
**Status:** Approved (design only) — implementation deferred to dedicated session
|
||||||
|
**Owner:** Andrew
|
||||||
|
**Repo:** `EZSCALE/website`
|
||||||
|
**Related:** `2026-04-26-dedicated-server-lineup-design.md` (parent spec, already shipped)
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The dedicated 14th-gen drive bay configurator (LFF / SFF / NVMe) currently uses a flat radio per chassis-bay-type group. After the recent expansion (high-cap HDDs, LFF SSDs, PCIe NVMe add-in), the LFF group alone is 35 entries on a single radio. Adding SAS variants on top would push it past 50 — UX is breaking under its own weight.
|
||||||
|
|
||||||
|
Option B splits each drive bay group into composite controls so customers pick **media + capacity + quantity** independently, and the frontend computes total = `unit_cost × quantity`. Net: ~10 drive selections + a stepper instead of 35-50 flat combo radios.
|
||||||
|
|
||||||
|
## Scope (in)
|
||||||
|
|
||||||
|
- Restructure the three existing drive bay groups (LFF, SFF, NVMe) from "one option, many flat values" to "two options per group: Drive Selection + Drive Quantity"
|
||||||
|
- Add SAS HDD and SAS SSD variants to LFF and SFF Drive Selection lists (currently only SATA)
|
||||||
|
- Update Pinia store to track `{drive, quantity}` per drive-bay group
|
||||||
|
- New `DriveBayGroupSelector` component (or extend `OptionGroupSelector`) to render the two-option pattern
|
||||||
|
- Update route filter to clamp `Drive Quantity` max to chassis bay_count
|
||||||
|
- Update `BuildSummary` to render drive bay line items as `N× <drive type> @ $X/drive = $Y total`
|
||||||
|
- Migrate URL params: `lff_drive=`, `lff_qty=`, `sff_drive=`, `sff_qty=`, `nvme_drive=`, `nvme_qty=`
|
||||||
|
- Test rewrite: existing drive-bay tests need updating, add coverage for the new structure
|
||||||
|
|
||||||
|
## Scope (out / deferred)
|
||||||
|
|
||||||
|
- PCIe NVMe Add-in group keeps flat-radio pattern (combos bundle adapter+drive cost, hard to split cleanly into media+capacity+quantity). Already shipped as `c74ca7f`.
|
||||||
|
- Mixed-bay heterogeneous configs (e.g., 4× HDD + 4× SSD on R540) — still ticket-only post-order. Schema doesn't natively support multi-drive-type per group.
|
||||||
|
- Frontend animation polish on the new component
|
||||||
|
- Per-bay-position drive picker (was descoped in original spec)
|
||||||
|
|
||||||
|
## Schema decisions
|
||||||
|
|
||||||
|
**No migrations needed.** The existing `plan_config_groups` / `plan_config_options` / `plan_config_values` schema already supports multiple options per group; the seeder just wasn't using that capability for drive bays.
|
||||||
|
|
||||||
|
Each drive bay group becomes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
PlanConfigGroup::updateOrCreate(['name' => 'Dedicated 14th Gen — LFF Drive Bays'], [...]);
|
||||||
|
|
||||||
|
// Option 1: Drive Selection (radio) — values store PER-DRIVE monthly cost
|
||||||
|
$driveSelection = $this->seedRadioOption($group, 'Drive Selection', false, 1);
|
||||||
|
$this->seedValues($driveSelection, [
|
||||||
|
['label' => 'No drives — configure via ticket', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
|
||||||
|
['label' => '4 TB SATA HDD', 'value' => 'sata-hdd-4tb', 'monthly' => 12.00],
|
||||||
|
['label' => '8 TB SATA HDD', 'value' => 'sata-hdd-8tb', 'monthly' => 20.00],
|
||||||
|
['label' => '12 TB Enterprise SATA HDD', 'value' => 'sata-hdd-12tb', 'monthly' => 45.00],
|
||||||
|
['label' => '20 TB Enterprise SATA HDD', 'value' => 'sata-hdd-20tb', 'monthly' => 55.00],
|
||||||
|
['label' => '24 TB Enterprise SATA HDD', 'value' => 'sata-hdd-24tb', 'monthly' => 75.00],
|
||||||
|
['label' => '12 TB Enterprise SAS HDD', 'value' => 'sas-hdd-12tb', 'monthly' => 50.00], // NEW SAS
|
||||||
|
['label' => '16 TB Enterprise SAS HDD', 'value' => 'sas-hdd-16tb', 'monthly' => 55.00], // NEW SAS
|
||||||
|
['label' => '480 GB SATA SSD (LFF carrier)','value' => 'sata-ssd-480gb-lff', 'monthly' => 10.00],
|
||||||
|
['label' => '1.92 TB SATA SSD (LFF carrier)','value' => 'sata-ssd-1920gb-lff', 'monthly' => 18.00],
|
||||||
|
['label' => '3.84 TB SATA SSD (LFF carrier)','value' => 'sata-ssd-3840gb-lff', 'monthly' => 45.00],
|
||||||
|
['label' => '7.68 TB SAS SSD (LFF carrier)','value' => 'sas-ssd-7680gb-lff', 'monthly' => 200.00], // NEW SAS
|
||||||
|
['label' => '1.92 TB SAS SSD (LFF carrier)','value' => 'sas-ssd-1920gb-lff', 'monthly' => 40.00], // NEW SAS
|
||||||
|
['label' => '3.84 TB SAS SSD (LFF carrier)','value' => 'sas-ssd-3840gb-lff', 'monthly' => 80.00], // NEW SAS
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Option 2: Drive Quantity (quantity stepper)
|
||||||
|
$driveQuantity = $this->seedQuantityOption(
|
||||||
|
$group, 'Drive Quantity', 0, 12, 'drives', 0, 2
|
||||||
|
);
|
||||||
|
// monthly_price=0 on the option itself — actual cost computed
|
||||||
|
// in the frontend as drive_selection.monthly_price × quantity.
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `seedQuantityOption` already exists and supports `min_qty / max_qty / step`. Setting `monthly_price=0` on it lets us reuse the helper without it interfering with our cost math.
|
||||||
|
|
||||||
|
Same pattern for SFF (Drive Selection list with SATA + SAS SSD options + the high-cap HDDs that fit, max_qty=24) and NVMe (U.2 NVMe sizes only, max_qty=24).
|
||||||
|
|
||||||
|
## Per-drive pricing table (in monthly cost terms)
|
||||||
|
|
||||||
|
| Drive | Per-drive $/mo | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 4 TB SATA HDD | $12 | Existing |
|
||||||
|
| 8 TB SATA HDD | $20 | Existing |
|
||||||
|
| 12 TB SATA HDD | $45 | Existing |
|
||||||
|
| 20 TB SATA HDD | $55 | Existing |
|
||||||
|
| 24 TB SATA HDD | $75 | Existing |
|
||||||
|
| 12 TB SAS HDD | $50 | NEW — ~10% premium over SATA for 12 Gb/s + dual-port |
|
||||||
|
| 16 TB SAS HDD | $55 | NEW |
|
||||||
|
| 480 GB SATA SSD | $10 | Existing |
|
||||||
|
| 1.92 TB SATA SSD | $18 | Existing |
|
||||||
|
| 3.84 TB SATA SSD | $45 | Existing |
|
||||||
|
| 7.68 TB SATA SSD | $100 | Existing — covers most "premium" cases without going SAS |
|
||||||
|
| 1.92 TB SAS SSD | $40 | NEW — Nytro 3350 / 2532 class, 12 Gb/s dual-port |
|
||||||
|
| 3.84 TB SAS SSD | $80 | NEW |
|
||||||
|
| 7.68 TB SAS SSD | $200 | NEW |
|
||||||
|
| 1.92 TB U.2 NVMe (SFF chassis only) | $30 | NEW SFF entry — fits in 2.5" SFF NVMe-capable bays where present |
|
||||||
|
| 3.84 TB U.2 NVMe | $70 | |
|
||||||
|
| 7.68 TB U.2 NVMe | $150 | |
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
**Pinia store** (`stores/dedicatedConfigurator.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Selections shape becomes:
|
||||||
|
// non-drive groups: { groupName: 'value-slug' }
|
||||||
|
// drive bay groups: { groupName: { drive: 'value-slug', quantity: 0 } }
|
||||||
|
|
||||||
|
const selections = ref<Record<string, string | { drive: string; quantity: number }>>({})
|
||||||
|
|
||||||
|
function isDriveBayGroup(name: string): boolean {
|
||||||
|
return name.endsWith('Drive Bays')
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveBayCost = computed<Record<string, number>>(() => {
|
||||||
|
const costs: Record<string, number> = {}
|
||||||
|
for (const [groupName, sel] of Object.entries(selections.value)) {
|
||||||
|
if (typeof sel !== 'object') continue
|
||||||
|
const group = findGroup(groupName)
|
||||||
|
const driveOption = group?.options.find(o => o.name === 'Drive Selection')
|
||||||
|
const drive = driveOption?.values.find(v => v.value === sel.drive)
|
||||||
|
if (!drive) continue
|
||||||
|
const perDriveCost = pickCyclePrice(drive, cycle.value)
|
||||||
|
costs[groupName] = perDriveCost * sel.quantity
|
||||||
|
}
|
||||||
|
return costs
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**New component** `Components/Marketing/Dedicated/DedicatedConfigurator/DriveBayGroupSelector.vue`:
|
||||||
|
|
||||||
|
Renders both options of a drive bay group:
|
||||||
|
- Top: radio list of Drive Selection values (uses existing `OptionGroupSelector`-style cards)
|
||||||
|
- Bottom: quantity stepper (1 → max_qty), labeled `Drives in chassis`
|
||||||
|
- Shows live computed cost: `2× 1.92 TB SATA SSD = +$36.00/mo`
|
||||||
|
|
||||||
|
`DedicatedConfigurator/index.vue` switches between `OptionGroupSelector` (single-option groups) and `DriveBayGroupSelector` based on group name.
|
||||||
|
|
||||||
|
**BuildSummary** updates to render drive bay rows as:
|
||||||
|
```
|
||||||
|
LFF Drive Bays
|
||||||
|
4× 1.92 TB SATA SSD +$72.00
|
||||||
|
```
|
||||||
|
|
||||||
|
(Currently shows the flat-combo label which works the same way visually, but the data flow changes.)
|
||||||
|
|
||||||
|
## URL state contract
|
||||||
|
|
||||||
|
Drive bay groups now serialize as two params each:
|
||||||
|
|
||||||
|
| Old (flat radio) | New (split) |
|
||||||
|
|---|---|
|
||||||
|
| `?lff=4x1920gb-ssd` | `?lff_drive=sata-ssd-1920gb-lff&lff_qty=4` |
|
||||||
|
| `?sff=8x3840gb-ssd` | `?sff_drive=sata-ssd-3840gb&sff_qty=8` |
|
||||||
|
| `?nvme=2x2tb-nvme` | `?nvme_drive=u2-nvme-2tb&nvme_qty=2` |
|
||||||
|
|
||||||
|
`hydrateFromUrl` reads both params per group. Old URLs with single-key drive params are silently dropped (acceptable since drive bay configs were rarely shared as URLs and the structure changed entirely).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Replace existing drive-bay flat-combo tests with new tests for the multi-option structure
|
||||||
|
- Cover: drive selection persists, quantity persists, total = drive × qty, quantity max respects chassis bay_count
|
||||||
|
- New test: SAS variants are visible only on LFF/SFF (not NVMe-native chassis)
|
||||||
|
|
||||||
|
## Migration path
|
||||||
|
|
||||||
|
The seeder uses `updateOrCreate` keyed on `[option_id, label]`. Restructuring requires a one-time DB cleanup before re-seeding:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Before re-running ConfigOptionSeeder for the new structure
|
||||||
|
DELETE pco, pcv
|
||||||
|
FROM plan_config_options pco
|
||||||
|
LEFT JOIN plan_config_values pcv ON pcv.option_id = pco.id
|
||||||
|
JOIN plan_config_groups pcg ON pcg.id = pco.group_id
|
||||||
|
WHERE pcg.name IN (
|
||||||
|
'Dedicated 14th Gen — LFF Drive Bays',
|
||||||
|
'Dedicated 14th Gen — SFF Drive Bays',
|
||||||
|
'Dedicated 14th Gen — NVMe Drive Bays'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `php artisan db:seed --class=ConfigOptionSeeder` recreates with new shape.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] LFF / SFF / NVMe groups each have exactly 2 options (Drive Selection radio + Drive Quantity stepper)
|
||||||
|
- [ ] LFF Drive Selection includes ≥3 SAS variants (12/16 TB SAS HDD; 1.92/3.84/7.68 TB SAS SSD)
|
||||||
|
- [ ] SFF Drive Selection includes ≥3 SAS SSD variants
|
||||||
|
- [ ] Per-chassis filter clamps `Drive Quantity.max_qty` to chassis `bay_count`
|
||||||
|
- [ ] BuildSummary shows drive bay line items as `N× <drive> = $Y` not `<combo label>`
|
||||||
|
- [ ] URL params use new `_drive=&_qty=` format
|
||||||
|
- [ ] Existing 14 dedicated tests rewrite cleanly — count stays in 14-16 range
|
||||||
|
- [ ] `npm run build` passes
|
||||||
|
- [ ] Mobile (< 768 px) renders the stepper without overflow
|
||||||
|
|
||||||
|
## Estimated effort
|
||||||
|
|
||||||
|
- Backend (seeder rewrite + DB cleanup migration): ~1 hour
|
||||||
|
- Frontend (new component + store rework): ~2 hours
|
||||||
|
- Tests rewrite: ~1 hour
|
||||||
|
- Verify + commit: ~30 min
|
||||||
|
|
||||||
|
**Total: ~4-5 hour focused session.**
|
||||||
Reference in New Issue
Block a user