369 lines
13 KiB
Go
369 lines
13 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type nvmeFormatMode struct {
|
|
Mode int `json:"mode"`
|
|
DataBytes int64 `json:"data_bytes"`
|
|
MetadataBytes int64 `json:"metadata_bytes"`
|
|
InUse bool `json:"in_use"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type nvmeFormatDisk struct {
|
|
Device string `json:"device"`
|
|
Model string `json:"model,omitempty"`
|
|
Serial string `json:"serial,omitempty"`
|
|
Size string `json:"size,omitempty"`
|
|
CurrentMode int `json:"current_mode"`
|
|
CurrentFormat string `json:"current_format"`
|
|
Modes []nvmeFormatMode `json:"modes"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type nvmeListJSON struct {
|
|
Devices []struct {
|
|
DevicePath string `json:"DevicePath"`
|
|
ModelNumber string `json:"ModelNumber"`
|
|
SerialNumber string `json:"SerialNumber"`
|
|
PhysicalSize int64 `json:"PhysicalSize"`
|
|
} `json:"Devices"`
|
|
}
|
|
|
|
var (
|
|
nvmeFormatDeviceRE = regexp.MustCompile(`^/dev/nvme[0-9]+n[0-9]+$`)
|
|
nvmeLBAFCompactLineRE = regexp.MustCompile(`(?im)^\s*lbaf\s+(\d+)\s*:\s*ms:(\d+)\s+lbads:(\d+).*$`)
|
|
nvmeLBAFVerboseLineRE = regexp.MustCompile(`(?im)^\s*LBA Format\s+(\d+)\s*:\s*Metadata Size:\s*(\d+)\s+bytes\s*-\s*Data Size:\s*(\d+)\s+bytes.*$`)
|
|
nvmeCommandContext = exec.CommandContext
|
|
nvmeListFormatsTimeout = 20 * time.Second
|
|
)
|
|
|
|
func listNVMeFormatDisks(ctx context.Context) ([]nvmeFormatDisk, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, nvmeListFormatsTimeout)
|
|
defer cancel()
|
|
out, err := nvmeCommandContext(ctx, "nvme", "list", "-o", "json").Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var root nvmeListJSON
|
|
if err := json.Unmarshal(out, &root); err != nil {
|
|
return nil, err
|
|
}
|
|
disks := make([]nvmeFormatDisk, 0, len(root.Devices))
|
|
seen := map[string]struct{}{}
|
|
for _, dev := range root.Devices {
|
|
path := strings.TrimSpace(dev.DevicePath)
|
|
if !nvmeFormatDeviceRE.MatchString(path) {
|
|
continue
|
|
}
|
|
if _, ok := seen[path]; ok {
|
|
continue
|
|
}
|
|
seen[path] = struct{}{}
|
|
disk := nvmeFormatDisk{
|
|
Device: path,
|
|
Model: strings.TrimSpace(dev.ModelNumber),
|
|
Serial: strings.TrimSpace(dev.SerialNumber),
|
|
Size: formatNVMeBytes(dev.PhysicalSize),
|
|
CurrentMode: -1,
|
|
}
|
|
modes, parseErr := readNVMeFormatModes(ctx, path)
|
|
if parseErr != nil {
|
|
disk.Error = parseErr.Error()
|
|
}
|
|
disk.Modes = modes
|
|
for _, mode := range modes {
|
|
if mode.InUse {
|
|
disk.CurrentMode = mode.Mode
|
|
disk.CurrentFormat = formatNVMeBlock(mode.DataBytes, mode.MetadataBytes)
|
|
break
|
|
}
|
|
}
|
|
disks = append(disks, disk)
|
|
}
|
|
sort.Slice(disks, func(i, j int) bool { return disks[i].Device < disks[j].Device })
|
|
return disks, nil
|
|
}
|
|
|
|
func readNVMeFormatModes(ctx context.Context, device string) ([]nvmeFormatMode, error) {
|
|
if !nvmeFormatDeviceRE.MatchString(device) {
|
|
return nil, fmt.Errorf("invalid NVMe device")
|
|
}
|
|
out, err := nvmeCommandContext(ctx, "nvme", "id-ns", device, "-H").CombinedOutput()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(string(out))
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return nil, fmt.Errorf("%s", msg)
|
|
}
|
|
modes := parseNVMeFormatModes(string(out))
|
|
if len(modes) == 0 {
|
|
return nil, fmt.Errorf("no LBA format modes found")
|
|
}
|
|
return modes, nil
|
|
}
|
|
|
|
func parseNVMeFormatModes(raw string) []nvmeFormatMode {
|
|
byMode := map[int]nvmeFormatMode{}
|
|
for _, m := range nvmeLBAFCompactLineRE.FindAllStringSubmatch(raw, -1) {
|
|
mode, errMode := strconv.Atoi(m[1])
|
|
metadata, errMS := strconv.ParseInt(m[2], 10, 64)
|
|
lbads, errLBADS := strconv.Atoi(m[3])
|
|
if errMode != nil || errMS != nil || errLBADS != nil || lbads < 0 || lbads >= 63 {
|
|
continue
|
|
}
|
|
data := int64(1) << lbads
|
|
line := m[0]
|
|
byMode[mode] = nvmeFormatMode{
|
|
Mode: mode,
|
|
DataBytes: data,
|
|
MetadataBytes: metadata,
|
|
InUse: strings.Contains(strings.ToLower(line), "in use"),
|
|
Label: fmt.Sprintf("MODE %d (%s)", mode, formatNVMeBlock(data, metadata)),
|
|
}
|
|
}
|
|
for _, m := range nvmeLBAFVerboseLineRE.FindAllStringSubmatch(raw, -1) {
|
|
mode, errMode := strconv.Atoi(m[1])
|
|
metadata, errMS := strconv.ParseInt(m[2], 10, 64)
|
|
data, errData := strconv.ParseInt(m[3], 10, 64)
|
|
if errMode != nil || errMS != nil || errData != nil || data <= 0 {
|
|
continue
|
|
}
|
|
line := m[0]
|
|
byMode[mode] = nvmeFormatMode{
|
|
Mode: mode,
|
|
DataBytes: data,
|
|
MetadataBytes: metadata,
|
|
InUse: strings.Contains(strings.ToLower(line), "in use"),
|
|
Label: fmt.Sprintf("MODE %d (%s)", mode, formatNVMeBlock(data, metadata)),
|
|
}
|
|
}
|
|
modes := make([]nvmeFormatMode, 0, len(byMode))
|
|
for _, mode := range byMode {
|
|
modes = append(modes, mode)
|
|
}
|
|
sort.Slice(modes, func(i, j int) bool { return modes[i].Mode < modes[j].Mode })
|
|
return modes
|
|
}
|
|
|
|
func runNVMeFormatTask(ctx context.Context, j *jobState, device string, lbaf int) error {
|
|
if !nvmeFormatDeviceRE.MatchString(device) {
|
|
return fmt.Errorf("invalid NVMe device")
|
|
}
|
|
modes, err := readNVMeFormatModes(ctx, device)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var selected nvmeFormatMode
|
|
found := false
|
|
for _, mode := range modes {
|
|
if mode.Mode == lbaf {
|
|
selected = mode
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("MODE %d is not available on %s", lbaf, device)
|
|
}
|
|
ms := 0
|
|
if selected.MetadataBytes > 0 {
|
|
ms = 1
|
|
}
|
|
j.append(fmt.Sprintf("Formatting %s to %s with --lbaf=%d --ms=%d --force", device, formatNVMeBlock(selected.DataBytes, selected.MetadataBytes), selected.Mode, ms))
|
|
cmd := nvmeCommandContext(ctx, "nvme", "format", device, fmt.Sprintf("--lbaf=%d", selected.Mode), fmt.Sprintf("--ms=%d", ms), "--force")
|
|
return streamCmdJob(j, cmd)
|
|
}
|
|
|
|
func (h *handler) handleAPINVMeFormats(w http.ResponseWriter, r *http.Request) {
|
|
disks, err := listNVMeFormatDisks(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, disks)
|
|
}
|
|
|
|
func (h *handler) handleAPINVMeFormatRun(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Device string `json:"device"`
|
|
LBAF int `json:"lbaf"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if !nvmeFormatDeviceRE.MatchString(req.Device) {
|
|
writeError(w, http.StatusBadRequest, "invalid NVMe device")
|
|
return
|
|
}
|
|
disks, err := listNVMeFormatDisks(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
var label string
|
|
allowed := false
|
|
for _, disk := range disks {
|
|
if disk.Device != req.Device {
|
|
continue
|
|
}
|
|
for _, mode := range disk.Modes {
|
|
if mode.Mode == req.LBAF {
|
|
allowed = true
|
|
label = mode.Label
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusBadRequest, "LBA format mode is not available for this device")
|
|
return
|
|
}
|
|
name := fmt.Sprintf("NVMe Format %s to %s", filepath.Base(req.Device), label)
|
|
t := &Task{
|
|
ID: newJobID("nvme-format"),
|
|
Name: name,
|
|
Target: "nvme-format",
|
|
Priority: defaultTaskPriority("nvme-format", taskParams{}),
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{
|
|
Device: req.Device,
|
|
LBAF: req.LBAF,
|
|
},
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
|
|
}
|
|
|
|
func formatNVMeBlock(dataBytes, metadataBytes int64) string {
|
|
return strconv.FormatInt(dataBytes, 10) + "+" + strconv.FormatInt(metadataBytes, 10)
|
|
}
|
|
|
|
func formatNVMeBytes(n int64) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
|
v := float64(n)
|
|
unit := 0
|
|
for v >= 1000 && unit < len(units)-1 {
|
|
v /= 1000
|
|
unit++
|
|
}
|
|
if unit == 0 {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
return fmt.Sprintf("%.1f %s", v, units[unit])
|
|
}
|
|
|
|
func renderNVMeFormatInline() string {
|
|
return `<div id="nvme-format-status" style="font-size:13px;color:var(--muted);margin-bottom:12px">Loading NVMe disks...</div>
|
|
<div id="nvme-format-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
|
<script>
|
|
function nvmeFormatEsc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
|
});
|
|
}
|
|
function loadNVMeFormats() {
|
|
var status = document.getElementById('nvme-format-status');
|
|
var table = document.getElementById('nvme-format-table');
|
|
status.textContent = 'Loading NVMe disks...';
|
|
status.style.color = 'var(--muted)';
|
|
table.innerHTML = '<p style="color:var(--muted);font-size:13px">Loading...</p>';
|
|
fetch('/api/tools/nvme-formats').then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }); }).then(function(disks) {
|
|
window._nvmeFormatDisks = Array.isArray(disks) ? disks : [];
|
|
if (!window._nvmeFormatDisks.length) {
|
|
status.textContent = 'No NVMe disks found.';
|
|
table.innerHTML = '';
|
|
return;
|
|
}
|
|
status.textContent = window._nvmeFormatDisks.length + ' NVMe disk(s) found.';
|
|
var rows = window._nvmeFormatDisks.map(function(d, idx) {
|
|
var current = d.current_format ? (d.current_format + ' / MODE ' + d.current_mode) : 'unknown';
|
|
var detail = [d.model || '', d.serial || '', d.size || ''].filter(Boolean).join(' | ');
|
|
var options = (d.modes || []).map(function(m) {
|
|
return '<option value="' + m.mode + '"' + (m.in_use ? ' selected' : '') + '>' + nvmeFormatEsc(m.label) + '</option>';
|
|
}).join('');
|
|
var disabled = options ? '' : ' disabled';
|
|
var err = d.error ? '<div style="font-size:12px;color:var(--crit-fg,#9f3a38);margin-top:4px">' + nvmeFormatEsc(d.error) + '</div>' : '';
|
|
return '<tr>'
|
|
+ '<td style="font-family:monospace;white-space:nowrap">' + nvmeFormatEsc(d.device) + (detail ? '<div style="font-family:inherit;font-size:12px;color:var(--muted)">' + nvmeFormatEsc(detail) + '</div>' : '') + '</td>'
|
|
+ '<td style="white-space:nowrap">' + nvmeFormatEsc(current) + err + '</td>'
|
|
+ '<td style="white-space:nowrap"><select id="nvme-format-select-' + idx + '"' + disabled + '>' + options + '</select></td>'
|
|
+ '<td style="white-space:nowrap"><button class="btn btn-sm btn-primary" onclick="nvmeFormatRun(' + idx + ', this)"' + disabled + '>Apply</button><div class="nvme-format-row-msg" style="margin-top:6px;font-size:12px;color:var(--muted)"></div></td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
table.innerHTML = '<table><tr><th>Disk</th><th>Current block / mode</th><th>New mode</th><th>Action</th></tr>' + rows + '</table>';
|
|
}).catch(function(e) {
|
|
status.textContent = 'Error loading NVMe disks: ' + e.message;
|
|
status.style.color = 'var(--crit-fg,#9f3a38)';
|
|
table.innerHTML = '';
|
|
});
|
|
}
|
|
function nvmeWaitTaskDone(taskID, rowMsg) {
|
|
var timer = setInterval(function() {
|
|
fetch('/api/tasks').then(function(r) { return r.json(); }).then(function(tasks) {
|
|
var task = (tasks || []).find(function(t) { return t.id === taskID; });
|
|
if (!task) return;
|
|
if (task.status === 'done' || task.status === 'failed' || task.status === 'cancelled') {
|
|
clearInterval(timer);
|
|
rowMsg.textContent = 'Task ' + taskID + ': ' + task.status + (task.error ? ' - ' + task.error : '');
|
|
rowMsg.style.color = task.status === 'done' ? 'var(--ok,green)' : 'var(--crit-fg,#9f3a38)';
|
|
loadNVMeFormats();
|
|
}
|
|
}).catch(function(){});
|
|
}, 1500);
|
|
}
|
|
function nvmeFormatRun(idx, btn) {
|
|
var disk = (window._nvmeFormatDisks || [])[idx];
|
|
var select = document.getElementById('nvme-format-select-' + idx);
|
|
var row = btn.closest('td');
|
|
var rowMsg = row.querySelector('.nvme-format-row-msg');
|
|
if (!disk || !select) return;
|
|
var lbaf = parseInt(select.value, 10);
|
|
var mode = (disk.modes || []).find(function(m) { return m.mode === lbaf; });
|
|
if (!mode) return;
|
|
if (!window.confirm('Format ' + disk.device + ' to ' + mode.label + '? This erases data on the namespace.')) return;
|
|
btn.disabled = true;
|
|
rowMsg.style.color = 'var(--muted)';
|
|
rowMsg.textContent = 'Queued...';
|
|
fetch('/api/tools/nvme-format/run', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({device: disk.device, lbaf: lbaf})
|
|
}).then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }); }).then(function(d) {
|
|
rowMsg.textContent = 'Task ' + d.task_id + ' queued.';
|
|
nvmeWaitTaskDone(d.task_id, rowMsg);
|
|
}).catch(function(e) {
|
|
rowMsg.style.color = 'var(--crit-fg,#9f3a38)';
|
|
rowMsg.textContent = 'Error: ' + e.message;
|
|
}).finally(function() {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
loadNVMeFormats();
|
|
</script>`
|
|
}
|
|
|
|
func renderNVMeFormatCard() string {
|
|
return `<div class="card"><div class="card-head">NVMe Block Format <button class="btn btn-sm btn-secondary" onclick="loadNVMeFormats()" style="margin-left:auto">↻ Refresh</button></div><div class="card-body">` +
|
|
`<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Lists NVMe namespaces and changes their LBA format through a queued task.</p>` +
|
|
renderNVMeFormatInline() + `</div></div>`
|
|
}
|