v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,41 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11)
|
||||||
|
|
||||||
|
The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's
|
||||||
|
authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own
|
||||||
|
system storage and the backup safety-net are visibly protected with NO destructive controls; the
|
||||||
|
customer manages their own data drives with informed consent instead of a support ticket.
|
||||||
|
|
||||||
|
- **`agentapi` client** — `DiskInfo` gains `role` + capacity (`total_bytes`/`used_bytes`/
|
||||||
|
`used_fraction`); `FormatResult` gains `role`/`needs_confirmation`/`durable_id`. `FormatDisk` now
|
||||||
|
takes `confirmed` + `durableID` and returns a new `ErrNeedsConfirmation` (user-data, awaiting the
|
||||||
|
customer's confirmation) distinct from `ErrFormatRefused` (system/backup, operator signature).
|
||||||
|
- **Role-aware overview** (`settings.html`, "Meghajtók (ügynök nézet)") — restyled from a raw
|
||||||
|
`<table>` to **cards** in the house style: prominent storage name, mono device/mount detail, badges
|
||||||
|
for class (gyors/lassú), data ("Adatot tartalmaz"), **role** (🔒 Rendszer / 🔒 Biztonsági mentés —
|
||||||
|
védett / Felhasználói adat) and registered state, plus a **capacity bar** (the monitoring page's
|
||||||
|
green→amber→red `system-bar`). Destructive controls (Leválasztás / Törlés) render **only** for
|
||||||
|
user-data drives mounted under `/mnt`. System/backup get the lock badge and no controls.
|
||||||
|
- **Type-to-confirm + name-the-apps** — a modal that (1) lists, **by name**, the deployed apps whose
|
||||||
|
data lives on the drive (`GET /api/storage/impact` → `appsUsingPath`), and (2) requires the customer
|
||||||
|
to **type the mount name** before the destructive button enables. No reflex-clickable destructive
|
||||||
|
action. Applies to both eject and wipe.
|
||||||
|
- **Customer wipe** (`POST /api/storage/wipe`) — eject (unmount + deregister) then a server-side
|
||||||
|
two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true`
|
||||||
|
bound to it). The mount name is re-checked server-side. A system/backup device is refused by the
|
||||||
|
agent regardless of what the controller sends.
|
||||||
|
- **Init wizard** (`storage_init.html`) — the data-bearing path now uses the **customer-confirmation**
|
||||||
|
flow (type-to-confirm → re-submit confirmed) instead of the `felhom-opsign` instruction; the disk
|
||||||
|
selector is restyled to cards and lists only user-data targets. `storage_attach.html` likewise
|
||||||
|
restyled (cards, user-data only). No raw `<table>` remains in the storage UI.
|
||||||
|
- **Tests** — `agentapi`: blank → ok, system/backup → ErrFormatRefused (+pending op), user-data →
|
||||||
|
ErrNeedsConfirmation (+durable id), confirmed → formatted. `web`: init surfaces NeedsConfirmation and
|
||||||
|
does NO mount/register; confirmed init forwards the confirmation+durable id and proceeds; the
|
||||||
|
dependency-impact (`appsUsingPathIn`) names the right deployed apps.
|
||||||
|
|
||||||
|
Pairs with **felhom-agent v0.23.0** (the authoritative role classifier + the tiered wipe gate).
|
||||||
|
|
||||||
### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11)
|
### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11)
|
||||||
|
|
||||||
After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`,
|
After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`,
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
# REPORT — v0.43.0: rebuilt storage management (guided init/attach/eject on the agent disk model)
|
# REPORT — felhom-controller v0.44.0: role-aware drive management + customer type-to-confirm wipe
|
||||||
|
|
||||||
**Repo:** `felhom-controller` · **Version:** 0.43.0 · **Date:** 2026-06-11
|
**Repo:** `felhom-controller` · **Version:** 0.44.0 · **Date:** 2026-06-11 · pairs with **felhom-agent v0.23.0**
|
||||||
**Pushed commit:** `29a9dcd` · paired with `felhom-agent` v0.22.0 (`4734d4a`, exposes `durable_id`) + golden rebake.
|
|
||||||
|
|
||||||
## What shipped
|
## What this implements
|
||||||
|
|
||||||
After the 8C de-privileging the storage UI's buttons pointed at deleted routes (all 404); only manual
|
The controller half of the storage-authorization redesign (CC SPEC, Part B). The drive UI is driven by
|
||||||
"add already-mounted path" survived. The agent already owns disk execution + the data-bearing signature
|
the agent's authoritative **role** (`system` | `backup` | `user-data`): system/backup are visibly
|
||||||
gate, and the controller already had the `agentapi` client + `/api/disks/*` proxies + the `StoragePath`
|
protected with no destructive controls; the customer manages their own data drives with informed
|
||||||
registry. This is a **controller-only UI/orchestration layer** over those — the controller holds **no
|
consent (type-to-confirm + named app impact).
|
||||||
destructive authority**.
|
|
||||||
|
|
||||||
- **Storage overview** (`settings.html`, `GET /api/disks`): the agent's live disk view — name/type/state/
|
### B0 — `agentapi` client
|
||||||
device/mount/class + the **`data_bearing` badge** + a "registered?" cross-reference.
|
- `DiskInfo` += `role`, `total_bytes`, `used_bytes`, `used_fraction`.
|
||||||
- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): format → resolve the new fs UUID
|
- `FormatResult` += `role`, `needs_confirmation`, `durable_id`.
|
||||||
from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing device is
|
- `FormatDisk(ctx, device, fstype, confirmed, durableID)` — new `ErrNeedsConfirmation` (user-data,
|
||||||
REFUSED** by the agent; the UI surfaces the exact `felhom-opsign …` command and **stops** — no force-format.
|
awaiting the customer's confirmation) vs `ErrFormatRefused` (system/backup, operator signature).
|
||||||
- **Guided attach** (`/settings/storage/attach` + `POST /api/storage/attach`): non-destructive — resolve
|
|
||||||
the existing fs UUID → assign → register.
|
|
||||||
- **Eject** (`POST /api/storage/eject`): benign unmount + deregister, surfacing the agent's dependent-guest warning.
|
|
||||||
- **`agentapi`**: `DiskInfo.DurableID` + `FSUUID()` (the assign key — strip `uuid:`); `FormatResult.PendingOp`
|
|
||||||
+ `OpsignCommand()`, now parsed from the agent's 403 body (the old client discarded it).
|
|
||||||
- **Honest buttons**: init/attach wired; migrate (drive + per-stack, both places) disabled "Hamarosan" — **no 404s**.
|
|
||||||
- **Phase 3 (de-priv template debt)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2.
|
|
||||||
mentés" form + 3 JS fns) and `backups.html` (run buttons + 2 JS fns) — they referenced fields the
|
|
||||||
de-privileged handlers no longer provide.
|
|
||||||
|
|
||||||
## Security invariant — held, proven live
|
### B1 — Role-aware overview (no destructive controls on protected drives)
|
||||||
The UI **never** bypasses the agent's data-bearing gate; there is **no force-format**. A refusal surfaces
|
- `settings.html` "Meghajtók (ügynök nézet)" restyled from a raw `<table>` to **cards** (house style):
|
||||||
the `felhom-opsign` command only. Unit-tested (`runStorageInit` on a data-bearing refusal performs **zero**
|
name prominent, mono device/mount sub-detail, badges for class / data / **role** (🔒 lock for
|
||||||
assign/register) **and** proven live on 9201's real `sdb`:
|
system & backup) / registered, and a **capacity bar** reusing the monitoring `system-bar`
|
||||||
`POST /api/storage/init {device:/dev/sdb1}` → **HTTP 409**, `refused:true`, `registered:false`,
|
(green→amber→red). Eject/Wipe controls render **only** for user-data drives mounted under `/mnt`.
|
||||||
`opsign: felhom-opsign -op storage_wipe -host demo-felhom-01 -durable-id byid:wwn-0x5000039ddb108568-part1`.
|
|
||||||
No format, no mount, no registration.
|
|
||||||
|
|
||||||
## Live validation (guest 9201, real 1TB USB `sdb` = `felhom-usb`)
|
### B2 — Customer wipe/eject flow
|
||||||
- `/api/disks` now carries `durable_id`; `felhom-usb` → `/dev/sdb1`, `data_bearing:true` ("device is
|
- **Name the apps**: `GET /api/storage/impact?where=` → `appsUsingPath` → the deployed apps (by display
|
||||||
mounted"), `durable_id:uuid:277a2179-…`. Overview badge maps correctly.
|
name) whose `HDD_PATH` is that mount. Shown in the modal before any destructive action.
|
||||||
- **Init on sdb (data-bearing) → 409 + opsign, gate held** (the spec's passing gate test — sdb holds data).
|
- **Type-to-confirm**: a modal with a text field; the destructive button stays disabled until the typed
|
||||||
- Pages render (no 404/500): `/settings`, `/settings/storage/init`, `/settings/storage/attach`,
|
value equals the mount name exactly (enforced client-side AND server-side in `/api/storage/wipe`).
|
||||||
`/stacks/<app>/deploy` (deploy.html — CrossDrive removed), `/stacks`, `/monitoring`. No dead storage links.
|
- **Wipe** (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side two-step
|
||||||
- Tests: refusal-surfaces-opsign-and-does-NOT-mount/register; success assigns with the resolved UUID +
|
customer-confirmed format (learn the agent's durable id via the NeedsConfirmation response, then
|
||||||
registers the expected `StoragePath`; UUID resolution; a **template-parse test** guards every page.
|
re-submit `confirmed:true` bound to it). Deregisters the StoragePath.
|
||||||
|
|
||||||
## Deferred / flagged (NOT in this slice)
|
### B3 — Init/attach role-gated + restyled
|
||||||
- **Phase 2 — migration (controller-side rsync):** intentionally its own slice (the migrate buttons are
|
- `storage_init.html`: data-bearing path now uses the **customer-confirmation** flow (type-to-confirm →
|
||||||
disabled "Hamarosan", not dead). The controller still has `/mnt:/mnt:rw`, so it can rsync app-data
|
re-submit confirmed) instead of the `felhom-opsign` instruction; selector restyled to cards, lists
|
||||||
between mounts + update `app.yaml`'s `HDD_PATH` (stop→rsync→verify→start) — no agent endpoint needed.
|
**only user-data** targets (system/backup are not offered). The opsign surface remains as a fallback
|
||||||
- **`/backups` still 500s on PRE-EXISTING restic debt (NOT this change, NOT CrossDrive).** The page
|
if a protected device somehow reaches it.
|
||||||
references ~30 dead restic-tier fields (`.Backup.RepoStats`, `.SnapshotHistory`, `.ResticSchedule`,
|
- `storage_attach.html`: restyled to cards, user-data only.
|
||||||
`.Retention`, `.LastBackup`, `.NextBackup`, `.LastCheckOK`, …) that 8C removed from the backend — the
|
|
||||||
whole restic snapshot tier + repo stats + snapshot history + restic-password UI is dead. That's a
|
|
||||||
**backups-page de-priv rebuild** (a design slice: what the page shows in the app-data-only model), well
|
|
||||||
beyond the CrossDrive cleanup this spec scoped (the spec listed "backups.html (5)" = the CrossDrive refs,
|
|
||||||
which I removed). `/backups` was already 500'ing before this task. **Recommend it as the next slice.**
|
|
||||||
|
|
||||||
## Notes
|
### B4 — UI polish
|
||||||
- No agent disk-subsystem or gate changes; the only agent change is the read-only `durable_id` exposure
|
- New CSS appended to `style.css` (reusing existing tokens): `.drive-card` / `.drive-badges` /
|
||||||
(v0.22.0) the user approved (without it the de-privileged controller can't learn the fs UUID `assign`
|
`.drive-cap`, the missing `.badge-ok` / `.badge-lock` / `.badge-muted` / standalone `.mono`, and the
|
||||||
needs). Golden rebaked with controller 0.43.0 so fresh provisions get the rebuilt UI.
|
type-to-confirm `.confirm-overlay` / `.confirm-box`. No new design system; no raw table left.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- `internal/agentapi/disks_test.go` — blank → ok; system/backup → `ErrFormatRefused` (+pending op);
|
||||||
|
user-data → `ErrNeedsConfirmation` (+durable id + role); confirmed → formatted.
|
||||||
|
- `internal/web/storage_handlers_test.go` — init on a user-data data-bearing device returns
|
||||||
|
NeedsConfirmation and does NO mount/register; confirmed init forwards the confirmation+durable id and
|
||||||
|
proceeds to register; system/backup init still surfaces the opsign; `appsUsingPathIn` names the right
|
||||||
|
deployed apps (dependency-impact). Plus `TestTemplatesParse` (all templates parse).
|
||||||
|
|
||||||
|
`go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local).
|
||||||
|
|
||||||
|
## Version
|
||||||
|
`v0.43.0 → v0.44.0` (build-time ldflags `Version`).
|
||||||
|
|
||||||
|
## Live validation / deploy
|
||||||
|
Pending in this session: build+push the image, deploy to guest 9201, rebake the golden, and run the
|
||||||
|
live checks (overview tiers, the hand-issued `confirmed:true` refusal on a system/backup device, a
|
||||||
|
user-data confirmed wipe binding to the durable id, and the audit-log entry).
|
||||||
|
|||||||
+26
-13
@@ -514,23 +514,36 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
|
|||||||
|
|
||||||
### 4. Storage Management
|
### 4. Storage Management
|
||||||
|
|
||||||
> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0).** After the 8C de-privileging, the controller
|
> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0.** After the 8C
|
||||||
> holds **no Proxmox/disk credentials and no destructive authority** — disk execution + the data-bearing
|
> de-privileging, the controller holds **no Proxmox/disk credentials and no destructive authority** — disk
|
||||||
> signature gate live entirely in the **host agent**. The controller is now a thin presenter/orchestrator:
|
> execution + the gate live entirely in the **host agent**. The drive UI is driven by the agent's
|
||||||
> - **Overview** (`settings.html` ← `GET /api/disks`): the agent's live disk view (name/type/state/device/
|
> authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own
|
||||||
> mount/class) + the **`data_bearing`** badge + "registered?" cross-reference.
|
> system storage and the backup safety-net are visibly **protected** (lock badge, NO destructive controls);
|
||||||
|
> the customer manages their own **user-data** drives with informed consent. The agent re-enforces role at
|
||||||
|
> wipe time — the UI lockout is defense-in-depth, not the gate.
|
||||||
|
> - **Overview** (`settings.html` ← `GET /api/disks`): styled **cards** (not a table) — name, mono
|
||||||
|
> device/mount, badges for class (gyors/lassú), data (`Adatot tartalmaz`), **role** (🔒 Rendszer / 🔒
|
||||||
|
> Biztonsági mentés — védett / Felhasználói adat) and registered state, plus a **capacity bar** (the
|
||||||
|
> monitoring `system-bar`, from the agent's `total_bytes`/`used_bytes`). Eject/Wipe render **only** for
|
||||||
|
> user-data drives mounted under `/mnt`.
|
||||||
|
> - **Customer wipe/eject** — a **type-to-confirm** modal that names the deployed apps that break
|
||||||
|
> (`GET /api/storage/impact` → `appsUsingPath`) and disables the destructive button until the **mount
|
||||||
|
> name is typed exactly**. Wipe (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side
|
||||||
|
> two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true` bound
|
||||||
|
> to it). The agent refuses a protected device regardless of what the controller sends.
|
||||||
> - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format
|
> - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format
|
||||||
> → resolve the new fs UUID from the re-listed disks (`durable_id`, `uuid:`-stripped) → `assign` (mount)
|
> → resolve the new fs UUID → `assign` → register. The selector lists **only user-data** targets. A
|
||||||
> → register a `StoragePath`. **A data-bearing device is REFUSED by the agent** (`pending_op`); the UI
|
> data-bearing user-data device now uses the **customer-confirmation** flow (type-to-confirm → re-submit
|
||||||
> surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …` command and stops — **there
|
> `confirmed:true` + durable id), NOT the `felhom-opsign` command. The opsign surface remains a fallback
|
||||||
> is no force-format**. The agent's `data_bearing` verdict (it inspects the device) is ground truth.
|
> only if a protected device somehow reaches init.
|
||||||
> - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve
|
> - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve
|
||||||
> the existing fs UUID → `assign` → register.
|
> the existing fs UUID → `assign` → register. Selector restyled to cards (user-data only).
|
||||||
> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning.
|
> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning.
|
||||||
> - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/
|
> - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/
|
||||||
> `EjectDisk`/`FormatDisk`; `DiskInfo.FSUUID()` + `FormatResult.PendingOp.OpsignCommand()`.
|
> `EjectDisk`/`FormatDisk(…, confirmed, durableID)`; `DiskInfo.role`+capacity;
|
||||||
> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged —
|
> `FormatResult.{role,needs_confirmation,durable_id}`; `ErrNeedsConfirmation` (user-data) vs
|
||||||
> init/attach register into it; the existing per-path management handlers stay.
|
> `ErrFormatRefused` (system/backup). `FormatResult.PendingOp.OpsignCommand()` for the operator path.
|
||||||
|
> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged.
|
||||||
> - **Migration** (drive + per-stack) is **deferred** to its own slice (buttons disabled "Hamarosan").
|
> - **Migration** (drive + per-stack) is **deferred** to its own slice (buttons disabled "Hamarosan").
|
||||||
>
|
>
|
||||||
> The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/
|
> The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/
|
||||||
|
|||||||
@@ -184,8 +184,15 @@ type DiskInfo struct {
|
|||||||
BackingDevice string `json:"backing_device"`
|
BackingDevice string `json:"backing_device"`
|
||||||
MountPath string `json:"mount_path"`
|
MountPath string `json:"mount_path"`
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
DataBearing bool `json:"data_bearing"`
|
// Role is the agent's AUTHORITATIVE protection tier: "system" | "backup" | "user-data". The UI
|
||||||
DataReason string `json:"data_reason"`
|
// is driven from it — system/backup get a lock badge and NO destructive controls; user-data is
|
||||||
|
// customer-manageable (eject/wipe with type-to-confirm).
|
||||||
|
Role string `json:"role"`
|
||||||
|
DataBearing bool `json:"data_bearing"`
|
||||||
|
DataReason string `json:"data_reason"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
|
UsedFraction float64 `json:"used_fraction"`
|
||||||
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
|
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
|
||||||
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the
|
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the
|
||||||
// only way the de-privileged controller learns a mount key it cannot read off the device itself.
|
// only way the de-privileged controller learns a mount key it cannot read off the device itself.
|
||||||
@@ -209,12 +216,19 @@ type DisksResponse struct {
|
|||||||
|
|
||||||
// FormatResult mirrors POST /disks/format (the success/refusal payload).
|
// FormatResult mirrors POST /disks/format (the success/refusal payload).
|
||||||
type FormatResult struct {
|
type FormatResult struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
Formatted bool `json:"formatted"`
|
Formatted bool `json:"formatted"`
|
||||||
DataBearing bool `json:"data_bearing"`
|
DataBearing bool `json:"data_bearing"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
// Role is the agent's tier for this device (system | backup | user-data).
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: re-submit with confirmed=true +
|
||||||
|
// DurableID after the type-to-confirm UI (NOT an operator signature).
|
||||||
|
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
||||||
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
|
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
|
||||||
|
PendingOp *PendingOp `json:"pending_op,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
|
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
|
||||||
@@ -231,9 +245,14 @@ func (p PendingOp) OpsignCommand() string {
|
|||||||
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID)
|
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format
|
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format on a
|
||||||
// (pending operator authorization — the 8C invariant). The UI surfaces this distinctly.
|
// SYSTEM/BACKUP device (operator signature required). The UI surfaces the pending opsign command.
|
||||||
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — device is data-bearing (operator authorization required)")
|
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup device (operator authorization required)")
|
||||||
|
|
||||||
|
// ErrNeedsConfirmation is returned by FormatDisk when the agent refuses a data-bearing format on a
|
||||||
|
// USER-DATA device pending the CUSTOMER's informed-confirmation (bound to FormatResult.DurableID).
|
||||||
|
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
|
||||||
|
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
|
||||||
|
|
||||||
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
|
||||||
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
|
||||||
@@ -276,14 +295,22 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatDisk asks the agent to format a device. The AGENT inspects the device and decides
|
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
|
||||||
// data-bearing-ness — a data-bearing device is refused (ErrFormatRefused), the controller's claim
|
// (its own classification, never the controller's claim):
|
||||||
// is irrelevant. Only a device the agent reads as blank is formatted.
|
// - blank device → formatted.
|
||||||
func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) {
|
// - user-data, data-bearing, NOT confirmed → ErrNeedsConfirmation (out.DurableID = the id to confirm).
|
||||||
|
// - user-data, data-bearing, confirmed + matching durable id → formatted.
|
||||||
|
// - system/backup, data-bearing → ErrFormatRefused (out.PendingOp = the operator opsign command).
|
||||||
|
//
|
||||||
|
// confirmed + durableID authorize a user-data wipe (the durable id the agent gave on the prior
|
||||||
|
// ErrNeedsConfirmation); they are inert for system/backup.
|
||||||
|
func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (FormatResult, error) {
|
||||||
var out FormatResult
|
var out FormatResult
|
||||||
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the
|
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op / durable_id)
|
||||||
// 403 refusal, so we must read the body on non-2xx rather than discarding it.
|
// even on the 403 refusal, so we must read the body on non-2xx rather than discarding it.
|
||||||
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype})
|
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]any{
|
||||||
|
"device": device, "fstype": fstype, "confirmed": confirmed, "durable_id": durableID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -291,10 +318,16 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR
|
|||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
|
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
|
||||||
}
|
}
|
||||||
|
if out.Formatted {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if out.NeedsConfirmation {
|
||||||
|
out.DataBearing = true
|
||||||
|
return out, ErrNeedsConfirmation // user-data: surface the type-to-confirm flow
|
||||||
|
}
|
||||||
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
|
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
|
||||||
out.DataBearing = true
|
out.DataBearing = true
|
||||||
out.Formatted = false
|
return out, ErrFormatRefused // system/backup: surface the opsign command
|
||||||
return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,21 @@ func diskStub(t *testing.T) (*httptest.Server, string) {
|
|||||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`))
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct{ Device, FSType string }
|
var body struct {
|
||||||
_ = decodeJSON(r, &body)
|
Device, FSType, DurableID string
|
||||||
if strings.Contains(body.Device, "data") {
|
Confirmed bool
|
||||||
w.WriteHeader(http.StatusForbidden)
|
}
|
||||||
_, _ = w.Write([]byte(`{"ok":false,"error":"device is data-bearing — operator authorization (pending_signature)"}`))
|
_ = decodeJSON(r, &body)
|
||||||
return
|
switch {
|
||||||
|
case strings.Contains(body.Device, "protected"): // system/backup → operator signature
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"system","pending_op":{"op":"storage_wipe","host_scope":"h","durable_id":"byid:x","fstype":"ext4"}}}`))
|
||||||
|
case strings.Contains(body.Device, "data") && !body.Confirmed: // user-data, not yet confirmed
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"user-data","needs_confirmation":true,"durable_id":"byid:wwn-1"}}`))
|
||||||
|
default: // blank, or user-data confirmed
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true,"role":"user-data"}}`))
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true}}`))
|
|
||||||
})
|
})
|
||||||
s := httptest.NewTLSServer(mux)
|
s := httptest.NewTLSServer(mux)
|
||||||
return s, strings.TrimPrefix(s.URL, "https://")
|
return s, strings.TrimPrefix(s.URL, "https://")
|
||||||
@@ -65,20 +72,49 @@ func TestFormat_BlankOK(t *testing.T) {
|
|||||||
s, ep := diskStub(t)
|
s, ep := diskStub(t)
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
c := clientFor(t, s, ep)
|
c := clientFor(t, s, ep)
|
||||||
res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4")
|
res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4", false, "")
|
||||||
if err != nil || !res.Formatted {
|
if err != nil || !res.Formatted {
|
||||||
t.Fatalf("blank format: %v %+v", err, res)
|
t.Fatalf("blank format: %v %+v", err, res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormat_DataBearingRefused(t *testing.T) {
|
// SYSTEM/BACKUP data-bearing → ErrFormatRefused with the operator pending op.
|
||||||
|
func TestFormat_ProtectedRefused(t *testing.T) {
|
||||||
s, ep := diskStub(t)
|
s, ep := diskStub(t)
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
c := clientFor(t, s, ep)
|
c := clientFor(t, s, ep)
|
||||||
_, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4")
|
res, err := c.FormatDisk(context.Background(), "/dev/protected-disk", "ext4", false, "")
|
||||||
if !errors.Is(err, ErrFormatRefused) {
|
if !errors.Is(err, ErrFormatRefused) {
|
||||||
t.Fatalf("expected ErrFormatRefused, got %v", err)
|
t.Fatalf("expected ErrFormatRefused, got %v", err)
|
||||||
}
|
}
|
||||||
|
if res.PendingOp == nil {
|
||||||
|
t.Fatal("protected refusal should carry the operator pending op")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER-DATA data-bearing, not confirmed → ErrNeedsConfirmation with the durable id to confirm against.
|
||||||
|
func TestFormat_UserDataNeedsConfirmation(t *testing.T) {
|
||||||
|
s, ep := diskStub(t)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, ep)
|
||||||
|
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", false, "")
|
||||||
|
if !errors.Is(err, ErrNeedsConfirmation) {
|
||||||
|
t.Fatalf("expected ErrNeedsConfirmation, got %v", err)
|
||||||
|
}
|
||||||
|
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-1" || res.Role != "user-data" {
|
||||||
|
t.Fatalf("needs-confirmation payload not surfaced: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// USER-DATA data-bearing, confirmed + durable id → formatted.
|
||||||
|
func TestFormat_UserDataConfirmed(t *testing.T) {
|
||||||
|
s, ep := diskStub(t)
|
||||||
|
defer s.Close()
|
||||||
|
c := clientFor(t, s, ep)
|
||||||
|
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", true, "byid:wwn-1")
|
||||||
|
if err != nil || !res.Formatted {
|
||||||
|
t.Fatalf("confirmed user-data format: %v %+v", err, res)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEject_Dependents(t *testing.T) {
|
func TestEject_Dependents(t *testing.T) {
|
||||||
|
|||||||
@@ -139,8 +139,10 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// can show "operator authorization required".
|
// can show "operator authorization required".
|
||||||
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
FSType string `json:"fstype"`
|
FSType string `json:"fstype"`
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
DurableID string `json:"durable_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
|
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
|
||||||
@@ -155,9 +157,14 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType)
|
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID)
|
||||||
|
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
|
||||||
|
s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device)
|
||||||
|
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||||
s.logger.Printf("[WARN] [web] disk format refused by agent (data-bearing): %s", req.Device)
|
s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
|
||||||
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1022,12 +1022,19 @@ func (s *Server) countAppsUsingPath(storagePath string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) appsUsingPath(storagePath string) []string {
|
func (s *Server) appsUsingPath(storagePath string) []string {
|
||||||
|
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
||||||
|
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
||||||
|
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
||||||
|
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
|
||||||
var names []string
|
var names []string
|
||||||
for _, stack := range s.stackMgr.GetStacks() {
|
for _, stack := range allStacks {
|
||||||
if !stack.Deployed {
|
if !stack.Deployed {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
if appCfg := loadCfg(stack.Name); appCfg != nil {
|
||||||
if appCfg.Env["HDD_PATH"] == storagePath {
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
||||||
names = append(names, stack.Meta.DisplayName)
|
names = append(names, stack.Meta.DisplayName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// without a live agent). *agentapi.Client satisfies it.
|
// without a live agent). *agentapi.Client satisfies it.
|
||||||
type diskAgent interface {
|
type diskAgent interface {
|
||||||
Disks(ctx context.Context) (agentapi.DisksResponse, error)
|
Disks(ctx context.Context) (agentapi.DisksResponse, error)
|
||||||
FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error)
|
FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
|
||||||
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
|
||||||
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
|
||||||
}
|
}
|
||||||
@@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
|
|||||||
type storageInitResult struct {
|
type storageInitResult struct {
|
||||||
Registered bool `json:"registered"`
|
Registered bool `json:"registered"`
|
||||||
Where string `json:"where,omitempty"`
|
Where string `json:"where,omitempty"`
|
||||||
// Refusal (data-bearing): the operator must sign offline. No bypass.
|
// NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
|
||||||
|
// then the wizard re-submits with confirmed=true. NOT an operator signature.
|
||||||
|
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
|
// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
|
||||||
Refused bool `json:"refused,omitempty"`
|
Refused bool `json:"refused,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Opsign string `json:"opsign,omitempty"`
|
Opsign string `json:"opsign,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID →
|
// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
|
||||||
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
|
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
|
||||||
// NO further (destructive or mount) action.
|
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
|
||||||
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
|
// either refusal it performs NO further (destructive or mount) action.
|
||||||
|
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
|
||||||
if !validFSTypes[fstype] {
|
if !validFSTypes[fstype] {
|
||||||
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
||||||
}
|
}
|
||||||
// 1. Format — the AGENT inspects the device and decides. A data-bearing device is refused.
|
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
|
||||||
fr, err := agent.FormatDisk(ctx, device, fstype)
|
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
|
||||||
|
// an operator signature.
|
||||||
|
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
|
||||||
|
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
|
||||||
|
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
|
||||||
|
return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
|
||||||
|
}
|
||||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||||
res := storageInitResult{Refused: true, Reason: fr.Reason}
|
res := storageInitResult{Refused: true, Reason: fr.Reason}
|
||||||
if fr.PendingOp != nil {
|
if fr.PendingOp != nil {
|
||||||
@@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleStorageAttach(w, r)
|
s.handleStorageAttach(w, r)
|
||||||
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
|
||||||
s.handleStorageEject(w, r)
|
s.handleStorageEject(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
|
||||||
|
s.handleStorageWipe(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
|
||||||
|
s.handleStorageImpact(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -180,6 +196,10 @@ type storageProvReq struct {
|
|||||||
MountName string `json:"mount_name"`
|
MountName string `json:"mount_name"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
SetDefault bool `json:"set_default"`
|
SetDefault bool `json:"set_default"`
|
||||||
|
// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
|
||||||
|
// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
|
||||||
|
Confirmed bool `json:"confirmed"`
|
||||||
|
DurableID string `json:"durable_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
|
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if res.NeedsConfirmation {
|
||||||
|
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
|
||||||
|
return
|
||||||
|
}
|
||||||
if res.Refused {
|
if res.Refused {
|
||||||
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
|
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
|
||||||
return
|
return
|
||||||
@@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
|
||||||
|
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
|
||||||
|
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
|
||||||
|
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apps := s.appsUsingPath(where)
|
||||||
|
if apps == nil {
|
||||||
|
apps = []string{}
|
||||||
|
}
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
|
||||||
|
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
|
||||||
|
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
|
||||||
|
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
|
||||||
|
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
|
||||||
|
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
Where string `json:"where"`
|
||||||
|
MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
|
||||||
|
FSType string `json:"fstype"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Device == "" {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Where = path.Clean(strings.TrimSpace(req.Where))
|
||||||
|
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
|
||||||
|
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fstype := req.FSType
|
||||||
|
if !validFSTypes[fstype] {
|
||||||
|
fstype = "ext4"
|
||||||
|
}
|
||||||
|
agent, err := s.agentClient()
|
||||||
|
if err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath.
|
||||||
|
if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
|
||||||
|
} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
|
||||||
|
} else {
|
||||||
|
go s.SyncFileBrowserMounts()
|
||||||
|
}
|
||||||
|
// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
|
||||||
|
// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
|
||||||
|
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
|
||||||
|
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
|
||||||
|
if errors.Is(perr, agentapi.ErrFormatRefused) {
|
||||||
|
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
|
||||||
|
if perr != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
|
||||||
|
if ferr != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
||||||
var req storageProvReq
|
var req storageProvReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
|
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
|
||||||
@@ -24,21 +25,27 @@ func TestTemplatesParse(t *testing.T) {
|
|||||||
|
|
||||||
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
|
||||||
type mockAgent struct {
|
type mockAgent struct {
|
||||||
disks agentapi.DisksResponse
|
disks agentapi.DisksResponse
|
||||||
formatRes agentapi.FormatResult
|
formatRes agentapi.FormatResult
|
||||||
formatErr error
|
formatErr error
|
||||||
assignErr error
|
assignErr error
|
||||||
assignCalls []assignCall
|
assignCalls []assignCall
|
||||||
disksCalls int
|
disksCalls int
|
||||||
|
formatCalls []formatCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type assignCall struct{ uuid, where, fstype string }
|
type assignCall struct{ uuid, where, fstype string }
|
||||||
|
type formatCall struct {
|
||||||
|
device, fstype, durableID string
|
||||||
|
confirmed bool
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
|
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
|
||||||
m.disksCalls++
|
m.disksCalls++
|
||||||
return m.disks, nil
|
return m.disks, nil
|
||||||
}
|
}
|
||||||
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) {
|
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) {
|
||||||
|
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
|
||||||
return m.formatRes, m.formatErr
|
return m.formatRes, m.formatErr
|
||||||
}
|
}
|
||||||
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
|
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
|
||||||
@@ -59,22 +66,23 @@ func testServer(t *testing.T) *Server {
|
|||||||
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
|
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register.
|
// SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO
|
||||||
func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
|
// assign/register (operator signature required — confirmation cannot help).
|
||||||
|
func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
agent := &mockAgent{
|
agent := &mockAgent{
|
||||||
formatErr: agentapi.ErrFormatRefused,
|
formatErr: agentapi.ErrFormatRefused,
|
||||||
formatRes: agentapi.FormatResult{
|
formatRes: agentapi.FormatResult{
|
||||||
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
|
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Role: "system",
|
||||||
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"},
|
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true)
|
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !res.Refused {
|
if !res.Refused {
|
||||||
t.Fatal("expected Refused=true on a data-bearing device")
|
t.Fatal("expected Refused=true on a protected data-bearing device")
|
||||||
}
|
}
|
||||||
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
|
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
|
||||||
t.Errorf("opsign command not surfaced: %q", res.Opsign)
|
t.Errorf("opsign command not surfaced: %q", res.Opsign)
|
||||||
@@ -87,6 +95,54 @@ func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A USER-DATA data-bearing device returns NeedsConfirmation (+ the durable id to confirm against) and
|
||||||
|
// performs NO assign/register — the customer must confirm the wipe first (NOT an operator signature).
|
||||||
|
func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &mockAgent{
|
||||||
|
formatErr: agentapi.ErrNeedsConfirmation,
|
||||||
|
formatRes: agentapi.FormatResult{
|
||||||
|
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
|
||||||
|
Role: "user-data", DurableID: "byid:wwn-abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-abc" || res.Role != "user-data" {
|
||||||
|
t.Fatalf("expected NeedsConfirmation with the durable id + role: %+v", res)
|
||||||
|
}
|
||||||
|
if res.Refused || res.Opsign != "" {
|
||||||
|
t.Fatal("a user-data device must NOT surface an operator-signature path")
|
||||||
|
}
|
||||||
|
if len(agent.assignCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 {
|
||||||
|
t.Fatal("NeedsConfirmation MUST NOT mount or register")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the customer confirms, the wizard re-submits with confirmed=true + the durable id; the format
|
||||||
|
// then succeeds and the flow proceeds to assign + register. Assert the confirmation is forwarded.
|
||||||
|
func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) {
|
||||||
|
s := testServer(t)
|
||||||
|
agent := &mockAgent{
|
||||||
|
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: true, Role: "user-data"},
|
||||||
|
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
|
||||||
|
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !res.Registered {
|
||||||
|
t.Fatalf("confirmed wipe should proceed to register: %+v", res)
|
||||||
|
}
|
||||||
|
if len(agent.formatCalls) != 1 || !agent.formatCalls[0].confirmed || agent.formatCalls[0].durableID != "byid:wwn-abc" {
|
||||||
|
t.Fatalf("the customer confirmation + durable id were not forwarded to the agent: %+v", agent.formatCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
|
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
|
||||||
func TestRunStorageInit_Success(t *testing.T) {
|
func TestRunStorageInit_Success(t *testing.T) {
|
||||||
s := testServer(t)
|
s := testServer(t)
|
||||||
@@ -96,7 +152,7 @@ func TestRunStorageInit_Success(t *testing.T) {
|
|||||||
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"},
|
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true)
|
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -148,6 +204,39 @@ func TestFSUUIDForDevice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dependency-impact: name the deployed apps whose data lives on a given mount (the type-to-confirm
|
||||||
|
// "which apps break" list). Pure helper so no live stacks.Manager is needed.
|
||||||
|
func TestAppsUsingPathIn(t *testing.T) {
|
||||||
|
all := []stacks.Stack{
|
||||||
|
{Name: "immich", Deployed: true, Meta: stacks.Metadata{DisplayName: "Immich"}},
|
||||||
|
{Name: "nextcloud", Deployed: true, Meta: stacks.Metadata{DisplayName: "Nextcloud"}},
|
||||||
|
{Name: "paperless", Deployed: true, Meta: stacks.Metadata{DisplayName: "Paperless"}},
|
||||||
|
{Name: "notdeployed", Deployed: false, Meta: stacks.Metadata{DisplayName: "Nem telepített"}},
|
||||||
|
}
|
||||||
|
env := map[string]map[string]string{
|
||||||
|
"immich": {"HDD_PATH": "/mnt/hdd_1"},
|
||||||
|
"nextcloud": {"HDD_PATH": "/mnt/hdd_1"},
|
||||||
|
"paperless": {"HDD_PATH": "/mnt/hdd_2"}, // different drive
|
||||||
|
"notdeployed": {"HDD_PATH": "/mnt/hdd_1"}, // on the drive but not deployed → excluded
|
||||||
|
}
|
||||||
|
load := func(name string) *stacks.AppConfig {
|
||||||
|
if e, ok := env[name]; ok {
|
||||||
|
return &stacks.AppConfig{Env: e}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
got := appsUsingPathIn(all, load, "/mnt/hdd_1")
|
||||||
|
if len(got) != 2 || got[0] != "Immich" || got[1] != "Nextcloud" {
|
||||||
|
t.Fatalf("apps on /mnt/hdd_1: got %v, want [Immich Nextcloud]", got)
|
||||||
|
}
|
||||||
|
if other := appsUsingPathIn(all, load, "/mnt/hdd_2"); len(other) != 1 || other[0] != "Paperless" {
|
||||||
|
t.Fatalf("apps on /mnt/hdd_2: got %v, want [Paperless]", other)
|
||||||
|
}
|
||||||
|
if none := appsUsingPathIn(all, load, "/mnt/empty"); len(none) != 0 {
|
||||||
|
t.Fatalf("apps on an unused mount: got %v, want []", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMountWhere(t *testing.T) {
|
func TestMountWhere(t *testing.T) {
|
||||||
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
|
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
|
||||||
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
|
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
|
||||||
|
|||||||
@@ -354,46 +354,122 @@ function pollUntilBack() {
|
|||||||
|
|
||||||
<div style="margin-top:1.5rem">
|
<div style="margin-top:1.5rem">
|
||||||
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
|
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
|
||||||
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete (a tárolás végrehajtása az ügynöké).</p>
|
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> az ügynök saját vizsgálattal állapítja meg: a rendszer- és biztonsági-mentés meghajtók védettek (csak operátori aláírással módosíthatók), a felhasználói adatmeghajtókat Ön kezeli.</p>
|
||||||
<div id="agent-disks">Betöltés…</div>
|
<div id="agent-disks">Betöltés…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="confirm-root"></div>
|
||||||
<script>
|
<script>
|
||||||
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
|
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
|
||||||
(function(){
|
(function(){
|
||||||
function badge(d){
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
if(d.backing_device===""){ return ''; }
|
function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):v.toFixed(1))+' '+u[i]; }
|
||||||
return d.data_bearing
|
function usageColorClass(p){ if(p>=85) return 'system-bar-red'; if(p>=70) return 'system-bar-yellow'; return 'system-bar-green'; }
|
||||||
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>'
|
function classBadge(d){
|
||||||
: '<span class="badge badge-ok">Üres</span>';
|
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
|
||||||
|
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function roleBadge(role){
|
||||||
|
if(role==='system') return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Rendszer</span>';
|
||||||
|
if(role==='backup') return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Biztonsági mentés — védett</span>';
|
||||||
|
if(role==='user-data') return '<span class="badge badge-ok">Felhasználói adat</span>';
|
||||||
|
return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Védett</span>';
|
||||||
|
}
|
||||||
|
function dataBadge(d){ return d.data_bearing ? '<span class="badge badge-error" title="'+esc(d.data_reason)+'">Adatot tartalmaz</span>' : ''; }
|
||||||
|
function regBadge(d, registered){
|
||||||
|
if(!d.mount_path) return '';
|
||||||
|
return registered[d.mount_path] ? '<span class="badge badge-ok">Regisztrálva</span>' : '<span class="badge badge-muted">Nem regisztrált</span>';
|
||||||
|
}
|
||||||
|
function capBar(d){
|
||||||
|
if(!d.total_bytes || d.total_bytes<=0) return '';
|
||||||
|
var pct = d.used_fraction ? d.used_fraction*100 : (d.used_bytes/d.total_bytes*100);
|
||||||
|
pct = Math.max(0, Math.min(100, pct));
|
||||||
|
return '<div class="drive-cap"><div class="system-bar"><div class="system-bar-fill '+usageColorClass(pct)+'" style="width:'+pct.toFixed(1)+'%"></div></div>'
|
||||||
|
+'<div class="drive-cap-label">'+hum(d.used_bytes)+' / '+hum(d.total_bytes)+' ('+pct.toFixed(0)+'%)</div></div>';
|
||||||
|
}
|
||||||
|
function actions(d){
|
||||||
|
// Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none.
|
||||||
|
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
|
||||||
|
var dev = esc(d.backing_device||''), mp = esc(d.mount_path);
|
||||||
|
var btns = '<button class="btn btn-xs btn-danger-outline" onclick="confirmEject(\''+mp+'\')">Leválasztás</button>';
|
||||||
|
if(d.backing_device){ btns += ' <button class="btn btn-xs btn-danger-outline" onclick="confirmWipe(\''+dev+'\',\''+mp+'\')">Törlés…</button>'; }
|
||||||
|
return '<div class="drive-actions">'+btns+'</div>';
|
||||||
}
|
}
|
||||||
function reg(d, registered){ return registered[d.mount_path] ? '<span class="badge badge-ok">Regisztrálva</span>' : (d.mount_path?'<span class="badge">Nem regisztrált</span>':''); }
|
|
||||||
async function load(){
|
async function load(){
|
||||||
var box=document.getElementById('agent-disks'); if(!box) return;
|
var box=document.getElementById('agent-disks'); if(!box) return;
|
||||||
try{
|
try{
|
||||||
var r=await fetch('/api/disks'); var j=await r.json();
|
var r=await fetch('/api/disks'); var j=await r.json();
|
||||||
if(!j.ok){ box.innerHTML='<p class="form-hint">'+(j.error||'Nem elérhető')+'</p>'; return; }
|
if(!j.ok){ box.innerHTML='<p class="form-hint">'+esc(j.error||'Nem elérhető')+'</p>'; return; }
|
||||||
var disks=(j.data&&j.data.disks)||[];
|
var disks=(j.data&&j.data.disks)||[];
|
||||||
if(disks.length===0){ box.innerHTML='<p class="form-hint">Nincs észlelt meghajtó.</p>'; return; }
|
if(disks.length===0){ box.innerHTML='<p class="form-hint">Nincs észlelt meghajtó.</p>'; return; }
|
||||||
var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;});
|
var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;});
|
||||||
var html='<table class="data-table"><thead><tr><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Csatolás</th><th>Osztály</th><th>Adat</th><th>Reg.</th><th></th></tr></thead><tbody>';
|
var html='<div class="drive-list">';
|
||||||
disks.forEach(function(d){
|
disks.forEach(function(d){
|
||||||
var ej = (d.mount_path && d.mount_path.indexOf('/mnt/')===0) ? '<button class="btn btn-xs btn-danger-outline" onclick="ejectDisk(\''+d.mount_path+'\')">Leválasztás</button>' : '';
|
var sub = esc(d.type)+' · '+esc(d.backing_device||'—')+(d.mount_path?' · '+esc(d.mount_path):'');
|
||||||
html+='<tr><td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+(d.backing_device||'—')+'</td><td class="mono">'+(d.mount_path||'—')+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td><td>'+reg(d,registered)+'</td><td>'+ej+'</td></tr>';
|
var badges = roleBadge(d.role)+classBadge(d)+dataBadge(d)+regBadge(d,registered);
|
||||||
|
html+='<div class="drive-card role-'+esc(d.role||'system')+'">'
|
||||||
|
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div>'
|
||||||
|
+'<div class="drive-badges">'+badges+'</div></div>'
|
||||||
|
+capBar(d)
|
||||||
|
+actions(d)
|
||||||
|
+'</div>';
|
||||||
});
|
});
|
||||||
html+='</tbody></table>';
|
html+='</div>';
|
||||||
box.innerHTML=html;
|
box.innerHTML=html;
|
||||||
}catch(e){ box.innerHTML='<p class="form-hint">Hiba: '+e.message+'</p>'; }
|
}catch(e){ box.innerHTML='<p class="form-hint">Hiba: '+esc(e.message)+'</p>'; }
|
||||||
}
|
}
|
||||||
window.ejectDisk=async function(where){
|
|
||||||
if(!confirm('Leválasztja a(z) '+where+' meghajtót? Az adatok megmaradnak, de az ott lévő alkalmazások elveszítik a tárhelyet.')) return;
|
// ---- type-to-confirm modal (destructive user-data actions) ----
|
||||||
|
function closeModal(){ document.getElementById('confirm-root').innerHTML=''; }
|
||||||
|
window.__closeConfirm=closeModal;
|
||||||
|
async function openConfirm(opts){
|
||||||
|
// opts: {title, mount, mountName, danger, onConfirm}
|
||||||
|
var apps=[];
|
||||||
try{
|
try{
|
||||||
var r=await fetch('/api/storage/eject',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})});
|
var r=await fetch('/api/storage/impact?where='+encodeURIComponent(opts.mount));
|
||||||
var j=await r.json();
|
var j=await r.json(); if(j.ok && j.data && j.data.apps) apps=j.data.apps;
|
||||||
if(!j.ok){ alert('Hiba: '+(j.error||'')); return; }
|
}catch(e){}
|
||||||
var dep=(j.data&&j.data.dependent_guests)||[];
|
var appsHtml = apps.length
|
||||||
if(dep.length>0){ alert('Leválasztva. Figyelem: '+dep.length+' vendég (VMID: '+dep.join(', ')+') függött ettől a tárhelytől.'); }
|
? '<p>A művelet után a következő alkalmazások <strong>nem fognak működni</strong>:</p><ul class="confirm-apps">'+apps.map(function(a){return '<li>'+esc(a)+'</li>';}).join('')+'</ul>'
|
||||||
location.reload();
|
: '<p class="form-hint">Ehhez a meghajtóhoz jelenleg nincs telepített alkalmazás rendelve.</p>';
|
||||||
}catch(e){ alert('Hiba: '+e.message); }
|
var root=document.getElementById('confirm-root');
|
||||||
|
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)__closeConfirm()"><div class="confirm-box">'
|
||||||
|
+'<h3>'+esc(opts.title)+'</h3>'
|
||||||
|
+'<div class="alert alert-warning">'+esc(opts.danger)+'</div>'
|
||||||
|
+appsHtml
|
||||||
|
+'<div class="confirm-input"><label>Megerősítéshez írja be a csatlakoztatási nevet: <strong class="mono">'+esc(opts.mountName)+'</strong></label>'
|
||||||
|
+'<input type="text" id="confirm-type" class="form-control" autocomplete="off" placeholder="'+esc(opts.mountName)+'" oninput="document.getElementById(\'confirm-go\').disabled=(this.value!==\''+esc(opts.mountName)+'\')"></div>'
|
||||||
|
+'<div class="form-actions"><button id="confirm-go" class="btn btn-danger-outline" disabled>Megerősítés</button>'
|
||||||
|
+'<button class="btn btn-outline" onclick="__closeConfirm()">Mégsem</button></div>'
|
||||||
|
+'<div id="confirm-result" style="margin-top:.6rem"></div></div></div>';
|
||||||
|
document.getElementById('confirm-go').onclick=opts.onConfirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.confirmEject=function(where){
|
||||||
|
var name=where.replace(/^\/mnt\//,'');
|
||||||
|
openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name,
|
||||||
|
danger:'A meghajtó leválasztásra kerül. Az adatok megmaradnak, de az ott tárolt alkalmazások elvesztik a tárhelyüket, amíg újra nem csatolja.',
|
||||||
|
onConfirm:async function(){
|
||||||
|
var out=document.getElementById('confirm-result'); out.innerHTML='<p class="form-hint">Leválasztás folyamatban…</p>';
|
||||||
|
try{
|
||||||
|
var r=await fetch('/api/storage/eject',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})});
|
||||||
|
var j=await r.json(); if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; return; }
|
||||||
|
location.reload();
|
||||||
|
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
|
||||||
|
}});
|
||||||
|
};
|
||||||
|
window.confirmWipe=function(device, where){
|
||||||
|
var name=where.replace(/^\/mnt\//,'');
|
||||||
|
openConfirm({title:'Meghajtó törlése (formázás)', mount:where, mountName:name,
|
||||||
|
danger:'FIGYELEM: a meghajtón lévő ÖSSZES ADAT véglegesen törlődik (formázás). Ez nem vonható vissza.',
|
||||||
|
onConfirm:async function(){
|
||||||
|
var out=document.getElementById('confirm-result'); out.innerHTML='<p class="form-hint">Törlés folyamatban…</p>';
|
||||||
|
try{
|
||||||
|
var r=await fetch('/api/storage/wipe',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({device:device, where:where, mount_name:name})});
|
||||||
|
var j=await r.json(); if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; return; }
|
||||||
|
location.reload();
|
||||||
|
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
|
||||||
|
}});
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -50,21 +50,30 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
var selDevice = "", selFSType = "";
|
var selDevice = "", selFSType = "";
|
||||||
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
|
function classBadge(d){
|
||||||
|
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
|
||||||
|
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDisks(){
|
async function loadDisks(){
|
||||||
try{
|
try{
|
||||||
var r = await fetch('/api/disks'); var j = await r.json();
|
var r = await fetch('/api/disks'); var j = await r.json();
|
||||||
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
||||||
var disks = (j.data&&j.data.disks)||[];
|
var disks = (j.data&&j.data.disks)||[];
|
||||||
// Attachable: has a backing device, an fs-UUID identity (durable_id "uuid:…"), and isn't mounted yet.
|
// Attachable: a user-data drive with a backing device, an fs-UUID identity, not mounted yet.
|
||||||
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path; });
|
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path && d.role==='user-data'; });
|
||||||
if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) meghajtó.</p>'; return; }
|
if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) felhasználói adatmeghajtó.</p>'; return; }
|
||||||
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Osztály</th></tr></thead><tbody>';
|
var html='<div class="drive-list">';
|
||||||
attachable.forEach(function(d){
|
attachable.forEach(function(d,i){
|
||||||
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" onchange="pickDisk(this)"></td>'
|
var sub = esc(d.type)+' · '+esc(d.backing_device);
|
||||||
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td><td>'+(d.class||'—')+'</td></tr>';
|
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||||
|
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||||
|
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||||
|
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+'</div></div></label>';
|
||||||
});
|
});
|
||||||
html+='</tbody></table>';
|
html+='</div>';
|
||||||
document.getElementById('disk-list').innerHTML=html;
|
document.getElementById('disk-list').innerHTML=html;
|
||||||
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
|
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
|
||||||
}
|
}
|
||||||
@@ -72,6 +81,8 @@ async function loadDisks(){
|
|||||||
function pickDisk(radio){
|
function pickDisk(radio){
|
||||||
selDevice=radio.value;
|
selDevice=radio.value;
|
||||||
document.getElementById('sel-device').textContent=selDevice;
|
document.getElementById('sel-device').textContent=selDevice;
|
||||||
|
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
|
||||||
|
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
|
||||||
document.getElementById('cfg-card').style.display='block';
|
document.getElementById('cfg-card').style.display='block';
|
||||||
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3>1. Meghajtó kiválasztása</h3>
|
<h3>1. Meghajtó kiválasztása</h3>
|
||||||
<p class="settings-card-desc">Válassza ki a formázandó meghajtót. A formázás biztonságát a host-ügynök
|
<p class="settings-card-desc">Válassza ki a formázandó <strong>felhasználói adatmeghajtót</strong>. Rendszer- és
|
||||||
garantálja: <strong>adatot tartalmazó meghajtó nem formázható operátori aláírás nélkül</strong>.</p>
|
biztonsági-mentés meghajtók itt nem jelennek meg — azok védettek. Ha a meghajtó adatot tartalmaz, a törlést
|
||||||
|
Önnek meg kell erősítenie.</p>
|
||||||
<div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
|
<div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
|
||||||
<div id="disk-list">Betöltés…</div>
|
<div id="disk-list">Betöltés…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
|
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="alert alert-warning" id="warn-databearing" style="display:none;margin-bottom:1rem">
|
<div class="alert alert-warning" id="warn-databearing" style="display:none;margin-bottom:1rem">
|
||||||
⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. A formázás védelmi okból csak
|
⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő
|
||||||
operátori aláírással hajtható végre — a rendszer megmutatja a szükséges parancsot.
|
összes adatot — a folytatáshoz meg kell erősítenie.
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" style="gap:.75rem">
|
<div class="form-actions" style="gap:.75rem">
|
||||||
<button type="submit" class="btn btn-danger-outline" id="init-btn">Inicializálás indítása</button>
|
<button type="submit" class="btn btn-danger-outline" id="init-btn">Inicializálás indítása</button>
|
||||||
@@ -61,28 +62,36 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
var selDevice = "", selDataBearing = false;
|
var selDevice = "", selDataBearing = false;
|
||||||
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
|
|
||||||
function badge(d){
|
function dataBadge(d){
|
||||||
if(d.backing_device===""){ return '<span class="badge">—</span>'; }
|
|
||||||
return d.data_bearing
|
return d.data_bearing
|
||||||
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>'
|
? '<span class="badge badge-error" title="'+esc(d.data_reason)+'">Adatot tartalmaz</span>'
|
||||||
: '<span class="badge badge-ok">Üres — formázható</span>';
|
: '<span class="badge badge-ok">Üres — formázható</span>';
|
||||||
}
|
}
|
||||||
|
function classBadge(d){
|
||||||
|
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
|
||||||
|
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDisks(){
|
async function loadDisks(){
|
||||||
try{
|
try{
|
||||||
var r = await fetch('/api/disks'); var j = await r.json();
|
var r = await fetch('/api/disks'); var j = await r.json();
|
||||||
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
||||||
var disks = (j.data&&j.data.disks)||[];
|
var disks = (j.data&&j.data.disks)||[];
|
||||||
var formattable = disks.filter(function(d){return d.backing_device!=="";});
|
// Only USER-DATA drives with a block device are valid init (format) targets.
|
||||||
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható (blokkeszközzel rendelkező) meghajtó.</p>'; return; }
|
var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data'; });
|
||||||
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Állapot</th><th>Osztály</th><th>Adat</th></tr></thead><tbody>';
|
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható felhasználói adatmeghajtó. (A rendszer- és biztonsági-mentés meghajtók védettek.)</p>'; return; }
|
||||||
|
var html='<div class="drive-list">';
|
||||||
formattable.forEach(function(d,i){
|
formattable.forEach(function(d,i){
|
||||||
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" data-db="'+(d.data_bearing?'1':'0')+'" onchange="pickDisk(this)"></td>'
|
var sub = esc(d.type)+' · '+esc(d.backing_device)+(d.mount_path?' · '+esc(d.mount_path):'');
|
||||||
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td>'
|
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||||
+'<td>'+(d.state==='attached'?'csatlakoztatva':d.state)+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td></tr>';
|
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
||||||
|
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||||
|
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+dataBadge(d)+'</div></div></label>';
|
||||||
});
|
});
|
||||||
html+='</tbody></table>';
|
html+='</div>';
|
||||||
document.getElementById('disk-list').innerHTML=html;
|
document.getElementById('disk-list').innerHTML=html;
|
||||||
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
|
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
|
||||||
}
|
}
|
||||||
@@ -91,33 +100,64 @@ function pickDisk(radio){
|
|||||||
selDevice=radio.value; selDataBearing=radio.getAttribute('data-db')==='1';
|
selDevice=radio.value; selDataBearing=radio.getAttribute('data-db')==='1';
|
||||||
document.getElementById('sel-device').textContent=selDevice;
|
document.getElementById('sel-device').textContent=selDevice;
|
||||||
document.getElementById('warn-databearing').style.display=selDataBearing?'block':'none';
|
document.getElementById('warn-databearing').style.display=selDataBearing?'block':'none';
|
||||||
|
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
|
||||||
|
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
|
||||||
document.getElementById('cfg-card').style.display='block';
|
document.getElementById('cfg-card').style.display='block';
|
||||||
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postInit runs the init POST. confirmed/durableId carry the customer's wipe confirmation (user-data).
|
||||||
|
async function postInit(confirmed, durableId){
|
||||||
|
var body={device:selDevice, fstype:document.getElementById('fstype').value,
|
||||||
|
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
|
||||||
|
set_default:document.getElementById('set-default').checked, confirmed:!!confirmed, durable_id:durableId||""};
|
||||||
|
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
|
||||||
|
return {status:r.status, j:await r.json()};
|
||||||
|
}
|
||||||
|
|
||||||
async function submitInit(ev){
|
async function submitInit(ev){
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
|
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
|
||||||
btn.disabled=true; out.innerHTML='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>';
|
btn.disabled=true; out.innerHTML='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>';
|
||||||
try{
|
try{
|
||||||
var body={device:selDevice, fstype:document.getElementById('fstype').value,
|
var res=await postInit(false, "");
|
||||||
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
|
// USER-DATA data-bearing → the customer must confirm the wipe (type-to-confirm), then re-submit.
|
||||||
set_default:document.getElementById('set-default').checked};
|
if(res.status===409 && res.j.data && res.j.data.needs_confirmation){
|
||||||
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
|
renderConfirm(res.j.data.durable_id, out, btn);
|
||||||
var j=await r.json();
|
return false;
|
||||||
if(r.status===409 && j.data && j.data.refused){
|
}
|
||||||
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong><br>'
|
// SYSTEM/BACKUP (shouldn't reach here — filtered out — but surface the opsign if it does).
|
||||||
+'A meghajtó adatot tartalmaz, ezért a formázás védelmi okból nem hajtható végre automatikusan'
|
if(res.status===409 && res.j.data && res.j.data.refused){
|
||||||
+(j.data.reason?(' ('+j.data.reason+')'):'')+'.<br><br>Az engedélyezéshez futtassa offline az operátor gépén:'
|
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
|
||||||
+'<pre class="mono" style="white-space:pre-wrap;background:var(--bg-primary);padding:.75rem;border-radius:6px;margin-top:.5rem">'+(j.data.opsign||'(nem elérhető)')+'</pre>'
|
+(res.j.data.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-primary);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(res.j.data.opsign)+'</pre>'):'')+'</div>';
|
||||||
+'Az aláírás után a Hub végrehajtja a műveletet; ezután térjen vissza ide.</div>';
|
|
||||||
btn.disabled=false; return false;
|
btn.disabled=false; return false;
|
||||||
}
|
}
|
||||||
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
finishInit(res.j, out);
|
||||||
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
|
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=false; }
|
||||||
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderConfirm(durableId, out, btn){
|
||||||
|
var name=document.getElementById('mount-name').value;
|
||||||
|
out.innerHTML='<div class="alert alert-warning">⚠️ A meghajtó adatot tartalmaz. A formázás <strong>véglegesen törli</strong> a rajta lévő összes adatot.</div>'
|
||||||
|
+'<div class="confirm-input"><label>Megerősítéshez írja be a csatlakoztatási nevet: <strong class="mono">'+esc(name)+'</strong></label>'
|
||||||
|
+'<input type="text" id="init-confirm-type" class="form-control" autocomplete="off" oninput="document.getElementById(\'init-confirm-go\').disabled=(this.value!==\''+esc(name)+'\')"></div>'
|
||||||
|
+'<div class="form-actions" style="gap:.6rem;margin-top:.75rem"><button id="init-confirm-go" class="btn btn-danger-outline" disabled>Törlés és inicializálás megerősítése</button></div>'
|
||||||
|
+'<div id="init-confirm-result" style="margin-top:.6rem"></div>';
|
||||||
|
document.getElementById('init-confirm-go').onclick=async function(){
|
||||||
|
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás folyamatban…</p>';
|
||||||
|
try{
|
||||||
|
var res2=await postInit(true, durableId);
|
||||||
|
if(!res2.j.ok){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(res2.j.error||'')+'</div>'; return; }
|
||||||
|
finishInit(res2.j, out);
|
||||||
|
}catch(e){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishInit(j, out){
|
||||||
|
if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; document.getElementById('init-btn').disabled=false; return; }
|
||||||
|
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(j.data&&j.data.where)+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
|
||||||
|
}
|
||||||
loadDisks();
|
loadDisks();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3086,3 +3086,58 @@ a.stat-card:hover {
|
|||||||
border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem;
|
border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.btn-danger-outline:hover { background: var(--red-bg); }
|
.btn-danger-outline:hover { background: var(--red-bg); }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Agent drive lists (overview + init/attach selector) — storage authz redesign.
|
||||||
|
Reuses the existing card/badge/bar tokens; no new design system.
|
||||||
|
============================================================================ */
|
||||||
|
.drive-list { display: flex; flex-direction: column; gap: .6rem; margin-top: .25rem; }
|
||||||
|
.drive-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: 4px solid var(--gray, #6e7681);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
display: flex; flex-direction: column; gap: .55rem;
|
||||||
|
}
|
||||||
|
.drive-card.role-user-data { border-left-color: var(--accent-blue); }
|
||||||
|
.drive-card.role-system,
|
||||||
|
.drive-card.role-backup { border-left-color: var(--yellow); }
|
||||||
|
.drive-card.is-selectable { cursor: pointer; transition: border-color .15s, background .15s; }
|
||||||
|
.drive-card.is-selectable:hover { border-color: var(--accent-light); }
|
||||||
|
.drive-card.is-picked { border-color: var(--accent-light); background: rgba(0, 136, 204, 0.06); }
|
||||||
|
.drive-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||||
|
.drive-id { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
|
||||||
|
.drive-name { font-size: .95rem; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.drive-sub {
|
||||||
|
font-size: .78rem; color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace; word-break: break-all;
|
||||||
|
}
|
||||||
|
.drive-badges { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; justify-content: flex-end; }
|
||||||
|
.drive-cap { display: flex; flex-direction: column; gap: .3rem; }
|
||||||
|
.drive-cap .system-bar { height: 8px; }
|
||||||
|
.drive-cap-label { font-size: .75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
|
||||||
|
.drive-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||||
|
.drive-select { display: flex; align-items: center; gap: .5rem; }
|
||||||
|
|
||||||
|
/* badges missing from the global sheet */
|
||||||
|
.badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; }
|
||||||
|
.badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); }
|
||||||
|
.badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); }
|
||||||
|
.badge .lock-ico { margin-right: .25rem; }
|
||||||
|
span.mono, .mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Type-to-confirm modal (destructive user-data eject/wipe) */
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;
|
||||||
|
}
|
||||||
|
.confirm-box {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 14px;
|
||||||
|
max-width: 480px; width: 100%; padding: 1.4rem; box-shadow: 0 12px 40px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
.confirm-box h3 { margin: 0 0 .75rem; font-size: 1.05rem; }
|
||||||
|
.confirm-box .confirm-apps { margin: .5rem 0; padding-left: 1.1rem; }
|
||||||
|
.confirm-box .confirm-apps li { margin: .15rem 0; }
|
||||||
|
.confirm-box .confirm-input { margin: .9rem 0 .4rem; }
|
||||||
|
.confirm-box .form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user