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:
2026-06-11 21:44:50 +02:00
parent 2c32c821fe
commit 12064dcd88
13 changed files with 696 additions and 182 deletions
+11 -4
View File
@@ -139,8 +139,10 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
// can show "operator authorization required".
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
FSType string `json:"fstype"`
Device string `json:"device"`
FSType string `json:"fstype"`
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != 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)
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) {
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)
return
}
+9 -2
View File
@@ -1022,12 +1022,19 @@ func (s *Server) countAppsUsingPath(storagePath string) int {
}
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
for _, stack := range s.stackMgr.GetStacks() {
for _, stack := range allStacks {
if !stack.Deployed {
continue
}
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if appCfg := loadCfg(stack.Name); appCfg != nil {
if appCfg.Env["HDD_PATH"] == storagePath {
names = append(names, stack.Meta.DisplayName)
}
+120 -9
View File
@@ -25,7 +25,7 @@ import (
// without a live agent). *agentapi.Client satisfies it.
type diskAgent interface {
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
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
}
@@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
type storageInitResult struct {
Registered bool `json:"registered"`
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"`
Reason string `json:"reason,omitempty"`
Opsign string `json:"opsign,omitempty"`
}
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID →
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
// NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
// 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] {
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.
fr, err := agent.FormatDisk(ctx, device, fstype)
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
// 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) {
res := storageInitResult{Refused: true, Reason: fr.Reason}
if fr.PendingOp != nil {
@@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageAttach(w, r)
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
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:
http.NotFound(w, r)
}
@@ -180,6 +196,10 @@ type storageProvReq struct {
MountName string `json:"mount_name"`
Label string `json:"label"`
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) {
@@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
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 {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
if res.NeedsConfirmation {
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
return
}
if res.Refused {
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
return
@@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
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) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+102 -13
View File
@@ -11,6 +11,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"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
@@ -24,21 +25,27 @@ func TestTemplatesParse(t *testing.T) {
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct {
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
formatCalls []formatCall
}
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) {
m.disksCalls++
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
}
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{}}
}
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register.
func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
// SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO
// assign/register (operator signature required — confirmation cannot help).
func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrFormatRefused,
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"},
},
}
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 {
t.Fatalf("unexpected error: %v", err)
}
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" {
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.
func TestRunStorageInit_Success(t *testing.T) {
s := testServer(t)
@@ -96,7 +152,7 @@ func TestRunStorageInit_Success(t *testing.T) {
{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 {
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) {
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
+98 -22
View File
@@ -354,46 +354,122 @@ function pollUntilBack() {
<div style="margin-top:1.5rem">
<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>
<div id="confirm-root"></div>
<script>
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
(function(){
function badge(d){
if(d.backing_device===""){ return ''; }
return d.data_bearing
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>'
: '<span class="badge badge-ok">Üres</span>';
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
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]; }
function usageColorClass(p){ if(p>=85) return 'system-bar-red'; if(p>=70) return 'system-bar-yellow'; return 'system-bar-green'; }
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 '';
}
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(){
var box=document.getElementById('agent-disks'); if(!box) return;
try{
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)||[];
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 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){
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>' : '';
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 sub = esc(d.type)+' · '+esc(d.backing_device||'—')+(d.mount_path?' · '+esc(d.mount_path):'');
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;
}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{
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){ alert('Hiba: '+(j.error||'')); return; }
var dep=(j.data&&j.data.dependent_guests)||[];
if(dep.length>0){ alert('Leválasztva. Figyelem: '+dep.length+' vendég (VMID: '+dep.join(', ')+') függött ettől a tárhelytől.'); }
location.reload();
}catch(e){ alert('Hiba: '+e.message); }
var r=await fetch('/api/storage/impact?where='+encodeURIComponent(opts.mount));
var j=await r.json(); if(j.ok && j.data && j.data.apps) apps=j.data.apps;
}catch(e){}
var appsHtml = apps.length
? '<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>'
: '<p class="form-hint">Ehhez a meghajtóhoz jelenleg nincs telepített alkalmazás rendelve.</p>';
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();
})();
@@ -50,21 +50,30 @@
<script>
var selDevice = "", selFSType = "";
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
// Attachable: has a backing device, an fs-UUID identity (durable_id "uuid:…"), and isn't mounted yet.
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path; });
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; }
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>';
attachable.forEach(function(d){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" onchange="pickDisk(this)"></td>'
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td><td>'+(d.class||'—')+'</td></tr>';
// 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 && 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) felhasználói adatmeghajtó.</p>'; return; }
var html='<div class="drive-list">';
attachable.forEach(function(d,i){
var sub = esc(d.type)+' · '+esc(d.backing_device);
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;
}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){
selDevice=radio.value;
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').scrollIntoView({behavior:'smooth'});
}
@@ -10,8 +10,9 @@
<div class="settings-card">
<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
garantálja: <strong>adatot tartalmazó meghajtó nem formázható operátori aláírás nélkül</strong>.</p>
<p class="settings-card-desc">Válassza ki a formázandó <strong>felhasználói adatmeghajtót</strong>. Rendszer- és
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-list">Betöltés…</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>
</label>
<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
operátori aláírással hajtható végre — a rendszer megmutatja a szükséges parancsot.
⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő
összes adatot — a folytatáshoz meg kell erősítenie.
</div>
<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>
@@ -61,28 +62,36 @@
<script>
var selDevice = "", selDataBearing = false;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function badge(d){
if(d.backing_device===""){ return '<span class="badge"></span>'; }
function dataBadge(d){
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>';
}
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(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
var formattable = disks.filter(function(d){return d.backing_device!=="";});
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható (blokkeszközzel rendelkező) meghajtó.</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>Állapot</th><th>Osztály</th><th>Adat</th></tr></thead><tbody>';
// Only USER-DATA drives with a block device are valid init (format) targets.
var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data'; });
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){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" data-db="'+(d.data_bearing?'1':'0')+'" onchange="pickDisk(this)"></td>'
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td>'
+'<td>'+(d.state==='attached'?'csatlakoztatva':d.state)+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td></tr>';
var sub = esc(d.type)+' · '+esc(d.backing_device)+(d.mount_path?' · '+esc(d.mount_path):'');
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-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;
}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';
document.getElementById('sel-device').textContent=selDevice;
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').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){
ev.preventDefault();
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>';
try{
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};
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
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>'
+'A meghajtó adatot tartalmaz, ezért a formázás védelmi okból nem hajtható végre automatikusan'
+(j.data.reason?(' ('+j.data.reason+')'):'')+'.<br><br>Az engedélyezéshez futtassa offline az operátor gépén:'
+'<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>'
+'Az aláírás után a Hub végrehajtja a műveletet; ezután térjen vissza ide.</div>';
var res=await postInit(false, "");
// USER-DATA data-bearing → the customer must confirm the wipe (type-to-confirm), then re-submit.
if(res.status===409 && res.j.data && res.j.data.needs_confirmation){
renderConfirm(res.j.data.durable_id, out, btn);
return false;
}
// SYSTEM/BACKUP (shouldn't reach here — filtered out — but surface the opsign if it does).
if(res.status===409 && res.j.data && res.j.data.refused){
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).'
+(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>';
btn.disabled=false; return false;
}
if(!j.ok){ throw new Error(j.error||'Hiba'); }
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: '+e.message+'</div>'; btn.disabled=false; }
finishInit(res.j, out);
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=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();
</script>
@@ -3086,3 +3086,58 @@ a.stat-card:hover {
border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem;
}
.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; }