feat(webui): show current boot source
This commit is contained in:
@@ -80,6 +80,7 @@ type installer interface {
|
|||||||
ListInstallDisks() ([]platform.InstallDisk, error)
|
ListInstallDisks() ([]platform.InstallDisk, error)
|
||||||
InstallToDisk(ctx context.Context, device string, logFile string) error
|
InstallToDisk(ctx context.Context, device string, logFile string) error
|
||||||
IsLiveMediaInRAM() bool
|
IsLiveMediaInRAM() bool
|
||||||
|
LiveBootSource() platform.LiveBootSource
|
||||||
RunInstallToRAM(ctx context.Context, logFunc func(string)) error
|
RunInstallToRAM(ctx context.Context, logFunc func(string)) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,10 @@ func (a *App) IsLiveMediaInRAM() bool {
|
|||||||
return a.installer.IsLiveMediaInRAM()
|
return a.installer.IsLiveMediaInRAM()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) LiveBootSource() platform.LiveBootSource {
|
||||||
|
return a.installer.LiveBootSource()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RunInstallToRAM(ctx context.Context, logFunc func(string)) error {
|
func (a *App) RunInstallToRAM(ctx context.Context, logFunc func(string)) error {
|
||||||
return a.installer.RunInstallToRAM(ctx, logFunc)
|
return a.installer.RunInstallToRAM(ctx, logFunc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
|
|
||||||
// InstallDisk describes a candidate disk for installation.
|
// InstallDisk describes a candidate disk for installation.
|
||||||
type InstallDisk struct {
|
type InstallDisk struct {
|
||||||
Device string // e.g. /dev/sda
|
Device string // e.g. /dev/sda
|
||||||
Model string
|
Model string
|
||||||
Size string // human-readable, e.g. "500G"
|
Size string // human-readable, e.g. "500G"
|
||||||
SizeBytes int64 // raw byte count from lsblk
|
SizeBytes int64 // raw byte count from lsblk
|
||||||
MountedParts []string // partition mount points currently active
|
MountedParts []string // partition mount points currently active
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +117,61 @@ func findLiveBootDevice() string {
|
|||||||
return "/dev/" + strings.TrimSpace(string(out2))
|
return "/dev/" + strings.TrimSpace(string(out2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mountSource(target string) string {
|
||||||
|
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", target).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountFSType(target string) string {
|
||||||
|
out, err := exec.Command("findmnt", "-n", "-o", "FSTYPE", target).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockDeviceType(device string) string {
|
||||||
|
if strings.TrimSpace(device) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out, err := exec.Command("lsblk", "-dn", "-o", "TYPE", device).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockDeviceTransport(device string) string {
|
||||||
|
if strings.TrimSpace(device) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out, err := exec.Command("lsblk", "-dn", "-o", "TRAN", device).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferLiveBootKind(fsType, source, deviceType, transport string) string {
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(strings.TrimSpace(fsType), "tmpfs"):
|
||||||
|
return "ram"
|
||||||
|
case strings.EqualFold(strings.TrimSpace(deviceType), "rom"):
|
||||||
|
return "cdrom"
|
||||||
|
case strings.EqualFold(strings.TrimSpace(transport), "usb"):
|
||||||
|
return "usb"
|
||||||
|
case strings.HasPrefix(strings.TrimSpace(source), "/dev/sr"):
|
||||||
|
return "cdrom"
|
||||||
|
case strings.HasPrefix(strings.TrimSpace(source), "/dev/"):
|
||||||
|
return "disk"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MinInstallBytes returns the minimum recommended disk size for installation:
|
// MinInstallBytes returns the minimum recommended disk size for installation:
|
||||||
// squashfs size × 1.5 to allow for extracted filesystem and bootloader.
|
// squashfs size × 1.5 to allow for extracted filesystem and bootloader.
|
||||||
// Returns 0 if the squashfs is not available (non-live environment).
|
// Returns 0 if the squashfs is not available (non-live environment).
|
||||||
|
|||||||
@@ -12,11 +12,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *System) IsLiveMediaInRAM() bool {
|
func (s *System) IsLiveMediaInRAM() bool {
|
||||||
out, err := exec.Command("findmnt", "-n", "-o", "FSTYPE", "/run/live/medium").Output()
|
fsType := mountFSType("/run/live/medium")
|
||||||
if err != nil {
|
if fsType == "" {
|
||||||
return toramActive()
|
return toramActive()
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)) == "tmpfs"
|
return strings.EqualFold(fsType, "tmpfs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *System) LiveBootSource() LiveBootSource {
|
||||||
|
fsType := mountFSType("/run/live/medium")
|
||||||
|
source := mountSource("/run/live/medium")
|
||||||
|
device := findLiveBootDevice()
|
||||||
|
status := LiveBootSource{
|
||||||
|
InRAM: strings.EqualFold(fsType, "tmpfs"),
|
||||||
|
Source: source,
|
||||||
|
Device: device,
|
||||||
|
}
|
||||||
|
if fsType == "" && source == "" && device == "" {
|
||||||
|
if toramActive() {
|
||||||
|
status.InRAM = true
|
||||||
|
status.Kind = "ram"
|
||||||
|
status.Source = "tmpfs"
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
status.Kind = "unknown"
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
status.Kind = inferLiveBootKind(fsType, source, blockDeviceType(device), blockDeviceTransport(device))
|
||||||
|
if status.Kind == "" {
|
||||||
|
status.Kind = "unknown"
|
||||||
|
}
|
||||||
|
if status.InRAM && strings.TrimSpace(status.Source) == "" {
|
||||||
|
status.Source = "tmpfs"
|
||||||
|
}
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) error {
|
func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) error {
|
||||||
|
|||||||
28
audit/internal/platform/install_to_ram_test.go
Normal file
28
audit/internal/platform/install_to_ram_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestInferLiveBootKind(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fsType string
|
||||||
|
source string
|
||||||
|
deviceType string
|
||||||
|
transport string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "ram tmpfs", fsType: "tmpfs", source: "/dev/shm/bee-live", want: "ram"},
|
||||||
|
{name: "usb disk", source: "/dev/sdb1", deviceType: "disk", transport: "usb", want: "usb"},
|
||||||
|
{name: "cdrom rom", source: "/dev/sr0", deviceType: "rom", want: "cdrom"},
|
||||||
|
{name: "disk sata", source: "/dev/nvme0n1p1", deviceType: "disk", transport: "nvme", want: "disk"},
|
||||||
|
{name: "unknown", source: "overlay", want: "unknown"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := inferLiveBootKind(tc.fsType, tc.source, tc.deviceType, tc.transport)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("inferLiveBootKind(%q,%q,%q,%q)=%q want %q", tc.fsType, tc.source, tc.deviceType, tc.transport, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ package platform
|
|||||||
|
|
||||||
type System struct{}
|
type System struct{}
|
||||||
|
|
||||||
|
type LiveBootSource struct {
|
||||||
|
InRAM bool `json:"in_ram"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Device string `json:"device,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type InterfaceInfo struct {
|
type InterfaceInfo struct {
|
||||||
Name string
|
Name string
|
||||||
State string
|
State string
|
||||||
|
|||||||
@@ -526,9 +526,9 @@ func (h *handler) handleAPIRAMStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inRAM := h.opts.App.IsLiveMediaInRAM()
|
status := h.opts.App.LiveBootSource()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]bool{"in_ram": inRAM})
|
_ = json.NewEncoder(w).Encode(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1282,6 +1282,7 @@ func renderTools() string {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div style="margin-bottom:20px">
|
<div style="margin-bottom:20px">
|
||||||
<div style="font-weight:600;margin-bottom:8px">Install to RAM</div>
|
<div style="font-weight:600;margin-bottom:8px">Install to RAM</div>
|
||||||
|
<p id="boot-source-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Detecting boot source...</p>
|
||||||
<p id="ram-status-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Checking...</p>
|
<p id="ram-status-text" style="color:var(--muted);font-size:13px;margin-bottom:8px">Checking...</p>
|
||||||
<button id="ram-install-btn" class="btn btn-primary" onclick="installToRAM()" style="display:none">▶ Copy to RAM</button>
|
<button id="ram-install-btn" class="btn btn-primary" onclick="installToRAM()" style="display:none">▶ Copy to RAM</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1293,8 +1294,18 @@ func renderTools() string {
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
fetch('/api/system/ram-status').then(r=>r.json()).then(d=>{
|
fetch('/api/system/ram-status').then(r=>r.json()).then(d=>{
|
||||||
|
const boot = document.getElementById('boot-source-text');
|
||||||
const txt = document.getElementById('ram-status-text');
|
const txt = document.getElementById('ram-status-text');
|
||||||
const btn = document.getElementById('ram-install-btn');
|
const btn = document.getElementById('ram-install-btn');
|
||||||
|
let source = d.device || d.source || 'unknown source';
|
||||||
|
let kind = d.kind || 'unknown';
|
||||||
|
let label = source;
|
||||||
|
if (kind === 'ram') label = 'RAM';
|
||||||
|
else if (kind === 'usb') label = 'USB (' + source + ')';
|
||||||
|
else if (kind === 'cdrom') label = 'CD-ROM (' + source + ')';
|
||||||
|
else if (kind === 'disk') label = 'disk (' + source + ')';
|
||||||
|
else label = source;
|
||||||
|
boot.textContent = 'Current boot source: ' + label + '.';
|
||||||
if (d.in_ram) {
|
if (d.in_ram) {
|
||||||
txt.textContent = '✓ Running from RAM — installation media can be safely disconnected.';
|
txt.textContent = '✓ Running from RAM — installation media can be safely disconnected.';
|
||||||
txt.style.color = 'var(--ok, green)';
|
txt.style.color = 'var(--ok, green)';
|
||||||
|
|||||||
@@ -392,6 +392,9 @@ func TestToolsPageRendersRestartGPUDriversButton(t *testing.T) {
|
|||||||
if !strings.Contains(body, `svcAction('bee-nvidia', 'restart')`) {
|
if !strings.Contains(body, `svcAction('bee-nvidia', 'restart')`) {
|
||||||
t.Fatalf("tools page missing bee-nvidia restart action: %s", body)
|
t.Fatalf("tools page missing bee-nvidia restart action: %s", body)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(body, `id="boot-source-text"`) {
|
||||||
|
t.Fatalf("tools page missing boot source field: %s", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewerRendersLatestSnapshot(t *testing.T) {
|
func TestViewerRendersLatestSnapshot(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user