Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cece524c | ||
|
|
c27449c60e | ||
|
|
5ef879e307 | ||
|
|
e7df63bae1 | ||
|
|
17ff3811f8 |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,35 @@ var supportBundleCommands = []struct {
|
|||||||
cmd []string
|
cmd []string
|
||||||
}{
|
}{
|
||||||
{name: "system/uname.txt", cmd: []string{"uname", "-a"}},
|
{name: "system/uname.txt", cmd: []string{"uname", "-a"}},
|
||||||
|
{name: "system/cmdline.txt", cmd: []string{"cat", "/proc/cmdline"}},
|
||||||
{name: "system/lsmod.txt", cmd: []string{"lsmod"}},
|
{name: "system/lsmod.txt", cmd: []string{"lsmod"}},
|
||||||
{name: "system/lspci-nn.txt", cmd: []string{"lspci", "-nn"}},
|
{name: "system/lspci-nn.txt", cmd: []string{"lspci", "-nn"}},
|
||||||
|
{name: "system/lspci-vvv.txt", cmd: []string{"lspci", "-vvv"}},
|
||||||
{name: "system/ip-addr.txt", cmd: []string{"ip", "addr"}},
|
{name: "system/ip-addr.txt", cmd: []string{"ip", "addr"}},
|
||||||
{name: "system/ip-route.txt", cmd: []string{"ip", "route"}},
|
{name: "system/ip-route.txt", cmd: []string{"ip", "route"}},
|
||||||
{name: "system/mount.txt", cmd: []string{"mount"}},
|
{name: "system/mount.txt", cmd: []string{"mount"}},
|
||||||
{name: "system/df-h.txt", cmd: []string{"df", "-h"}},
|
{name: "system/df-h.txt", cmd: []string{"df", "-h"}},
|
||||||
{name: "system/dmesg-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}},
|
{name: "system/dmesg.txt", cmd: []string{"dmesg"}},
|
||||||
|
{name: "system/nvidia-smi-q.txt", cmd: []string{"nvidia-smi", "-q"}},
|
||||||
|
{name: "system/pcie-nvidia-link.txt", cmd: []string{"sh", "-c", `
|
||||||
|
for d in /sys/bus/pci/devices/*/; do
|
||||||
|
vendor=$(cat "$d/vendor" 2>/dev/null)
|
||||||
|
[ "$vendor" = "0x10de" ] || continue
|
||||||
|
dev=$(basename "$d")
|
||||||
|
echo "=== $dev ==="
|
||||||
|
for f in current_link_speed current_link_width max_link_speed max_link_width; do
|
||||||
|
printf " %-22s %s\n" "$f" "$(cat "$d/$f" 2>/dev/null)"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
`}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportBundleOptionalFiles = []struct {
|
||||||
|
name string
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{name: "system/kern.log", src: "/var/log/kern.log"},
|
||||||
|
{name: "system/syslog.txt", src: "/var/log/syslog"},
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportBundleGlob = "bee-support-*.tar.gz"
|
const supportBundleGlob = "bee-support-*.tar.gz"
|
||||||
@@ -77,6 +99,9 @@ func BuildSupportBundle(exportDir string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, item := range supportBundleOptionalFiles {
|
||||||
|
_ = copyOptionalFile(item.src, filepath.Join(stageRoot, item.name))
|
||||||
|
}
|
||||||
if err := writeManifest(filepath.Join(stageRoot, "manifest.txt"), exportDir, stageRoot); err != nil {
|
if err := writeManifest(filepath.Join(stageRoot, "manifest.txt"), exportDir, stageRoot); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -184,6 +209,24 @@ func writeCommandOutput(dst string, cmd []string) error {
|
|||||||
return os.WriteFile(dst, raw, 0644)
|
return os.WriteFile(dst, raw, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyOptionalFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func writeManifest(dst, exportDir, stageRoot string) error {
|
func writeManifest(dst, exportDir, stageRoot string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
|||||||
if !sseStart(w) {
|
if !sseStart(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
streamSubscribedJob(w, r, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamSubscribedJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
||||||
existing, ch := j.subscribe()
|
existing, ch := j.subscribe()
|
||||||
for _, line := range existing {
|
for _, line := range existing {
|
||||||
sseWrite(w, "", line)
|
sseWrite(w, "", line)
|
||||||
@@ -428,7 +432,6 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, entries)
|
writeJSON(w, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) {
|
func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) {
|
||||||
if h.opts.App == nil {
|
if h.opts.App == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
@@ -523,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) {
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ func renderNetwork() string {
|
|||||||
// ── Services ──────────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderServicesInline() string {
|
func renderServicesInline() string {
|
||||||
return `<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><button class="btn btn-sm btn-secondary" onclick="loadServices()">↻ Refresh</button></div>
|
return `<div style="display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-bottom:8px"><button class="btn btn-sm btn-secondary" onclick="restartGPUDrivers()">Restart GPU Drivers</button><button class="btn btn-sm btn-secondary" onclick="loadServices()">↻ Refresh</button></div>
|
||||||
<div id="svc-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
<div id="svc-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||||||
<div id="svc-out" style="display:none;margin-top:8px" class="card">
|
<div id="svc-out" style="display:none;margin-top:8px" class="card">
|
||||||
<div class="card-head">Output</div>
|
<div class="card-head">Output</div>
|
||||||
@@ -1054,6 +1054,9 @@ function svcAction(name, action) {
|
|||||||
setTimeout(loadServices, 1000);
|
setTimeout(loadServices, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function restartGPUDrivers() {
|
||||||
|
svcAction('bee-nvidia', 'restart');
|
||||||
|
}
|
||||||
loadServices();
|
loadServices();
|
||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
@@ -1279,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>
|
||||||
@@ -1290,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)';
|
||||||
@@ -1559,23 +1573,37 @@ func renderTasks() string {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div id="tasks-table"><p style="color:var(--muted);font-size:13px;padding:16px">Loading...</p></div>
|
<div id="tasks-table"><p style="color:var(--muted);font-size:13px;padding:16px">Loading...</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="task-log-section" style="display:none;margin-top:16px" class="card">
|
<div id="task-log-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:120;align-items:center;justify-content:center;padding:16px">
|
||||||
<div class="card-head">Logs — <span id="task-log-title"></span>
|
<div style="background:#fff;border-radius:6px;box-shadow:0 24px 60px rgba(0,0,0,.35);width:calc(100vw - 32px);max-width:1600px;height:calc(100vh - 32px);display:flex;flex-direction:column;overflow:hidden;position:relative">
|
||||||
<button class="btn btn-sm btn-secondary" onclick="closeTaskLog()" style="margin-left:auto">✕</button>
|
<div class="card-head" style="padding:14px 18px;font-size:14px">Logs — <span id="task-log-title"></span>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="closeTaskLog()" style="margin-left:auto">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding:16px;flex:1;min-height:0"><div id="task-log-terminal" class="terminal" style="height:100%;max-height:none"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body"><div id="task-log-terminal" class="terminal" style="max-height:500px"></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var _taskLogES = null;
|
var _taskLogES = null;
|
||||||
var _taskRefreshTimer = null;
|
var _taskRefreshTimer = null;
|
||||||
|
var _tasksAll = [];
|
||||||
|
var _taskPage = 1;
|
||||||
|
var _taskPageSize = 50;
|
||||||
|
var _taskLogID = '';
|
||||||
|
|
||||||
function loadTasks() {
|
function loadTasks() {
|
||||||
fetch('/api/tasks').then(r=>r.json()).then(tasks => {
|
fetch('/api/tasks').then(r=>r.json()).then(tasks => {
|
||||||
if (!tasks || tasks.length === 0) {
|
_tasksAll = Array.isArray(tasks) ? tasks : [];
|
||||||
|
if (_tasksAll.length === 0) {
|
||||||
|
_taskPage = 1;
|
||||||
document.getElementById('tasks-table').innerHTML = '<p style="color:var(--muted);font-size:13px;padding:16px">No tasks.</p>';
|
document.getElementById('tasks-table').innerHTML = '<p style="color:var(--muted);font-size:13px;padding:16px">No tasks.</p>';
|
||||||
|
syncTaskLogFromHash();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = tasks.map(t => {
|
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
||||||
|
if (_taskPage > totalPages) _taskPage = totalPages;
|
||||||
|
if (_taskPage < 1) _taskPage = 1;
|
||||||
|
const start = (_taskPage - 1) * _taskPageSize;
|
||||||
|
const pageTasks = _tasksAll.slice(start, start + _taskPageSize);
|
||||||
|
const rows = pageTasks.map(t => {
|
||||||
const dur = t.elapsed_sec ? formatDurSec(t.elapsed_sec) : '';
|
const dur = t.elapsed_sec ? formatDurSec(t.elapsed_sec) : '';
|
||||||
const statusClass = {running:'badge-ok',pending:'badge-unknown',done:'badge-ok',failed:'badge-err',cancelled:'badge-unknown'}[t.status]||'badge-unknown';
|
const statusClass = {running:'badge-ok',pending:'badge-unknown',done:'badge-ok',failed:'badge-err',cancelled:'badge-unknown'}[t.status]||'badge-unknown';
|
||||||
const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status;
|
const statusLabel = {running:'▶ running',pending:'pending',done:'✓ done',failed:'✗ failed',cancelled:'cancelled'}[t.status]||t.status;
|
||||||
@@ -1594,8 +1622,20 @@ function loadTasks() {
|
|||||||
'<td>'+t.priority+'</td>' +
|
'<td>'+t.priority+'</td>' +
|
||||||
'<td>'+actions+'</td></tr>';
|
'<td>'+actions+'</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
const showingFrom = start + 1;
|
||||||
|
const showingTo = Math.min(start + pageTasks.length, _tasksAll.length);
|
||||||
|
const pager =
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 14px;border-top:1px solid var(--border-lite);background:var(--surface-2)">' +
|
||||||
|
'<div style="font-size:12px;color:var(--muted)">Showing '+showingFrom+'-'+showingTo+' of '+_tasksAll.length+' tasks</div>' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:8px">' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage-1)+')" '+(_taskPage <= 1 ? 'disabled' : '')+'>Previous</button>' +
|
||||||
|
'<span style="font-size:12px;color:var(--muted)">Page '+_taskPage+' / '+totalPages+'</span>' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="setTaskPage('+(_taskPage+1)+')" '+(_taskPage >= totalPages ? 'disabled' : '')+'>Next</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
document.getElementById('tasks-table').innerHTML =
|
document.getElementById('tasks-table').innerHTML =
|
||||||
'<table><tr><th>Name</th><th>Status</th><th>Created</th><th>Duration</th><th>Priority</th><th>Actions</th></tr>'+rows+'</table>';
|
'<table><tr><th>Name</th><th>Status</th><th>Created</th><th>Duration</th><th>Priority</th><th>Actions</th></tr>'+rows+'</table>' + pager;
|
||||||
|
syncTaskLogFromHash();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,6 +1647,11 @@ function formatDurSec(sec) {
|
|||||||
const m = Math.floor(sec/60), ss = sec%60;
|
const m = Math.floor(sec/60), ss = sec%60;
|
||||||
return m+'m '+ss+'s';
|
return m+'m '+ss+'s';
|
||||||
}
|
}
|
||||||
|
function setTaskPage(page) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(_tasksAll.length / _taskPageSize));
|
||||||
|
_taskPage = Math.min(totalPages, Math.max(1, page));
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
function cancelTask(id) {
|
function cancelTask(id) {
|
||||||
fetch('/api/tasks/'+id+'/cancel',{method:'POST'}).then(()=>loadTasks());
|
fetch('/api/tasks/'+id+'/cancel',{method:'POST'}).then(()=>loadTasks());
|
||||||
@@ -1633,24 +1678,59 @@ function setPriority(id, delta) {
|
|||||||
fetch('/api/tasks/'+id+'/priority',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delta:delta})})
|
fetch('/api/tasks/'+id+'/priority',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delta:delta})})
|
||||||
.then(()=>loadTasks());
|
.then(()=>loadTasks());
|
||||||
}
|
}
|
||||||
|
function resetTaskLog(term, text) {
|
||||||
|
term.textContent = text ? text + '\n' : '';
|
||||||
|
if (text) term.dataset.placeholder = '1';
|
||||||
|
else delete term.dataset.placeholder;
|
||||||
|
}
|
||||||
|
function prependTaskLogLine(term, line) {
|
||||||
|
if (term.dataset.placeholder === '1') {
|
||||||
|
term.textContent = '';
|
||||||
|
delete term.dataset.placeholder;
|
||||||
|
}
|
||||||
|
term.prepend(document.createTextNode(line + '\n'));
|
||||||
|
term.scrollTop = 0;
|
||||||
|
}
|
||||||
function viewLog(id, name) {
|
function viewLog(id, name) {
|
||||||
if (_taskLogES) { _taskLogES.close(); _taskLogES = null; }
|
if (_taskLogES) { _taskLogES.close(); _taskLogES = null; }
|
||||||
document.getElementById('task-log-section').style.display = '';
|
_taskLogID = id;
|
||||||
|
window.location.hash = id;
|
||||||
|
document.getElementById('task-log-overlay').style.display = 'flex';
|
||||||
document.getElementById('task-log-title').textContent = name;
|
document.getElementById('task-log-title').textContent = name;
|
||||||
const term = document.getElementById('task-log-terminal');
|
const term = document.getElementById('task-log-terminal');
|
||||||
term.textContent = 'Connecting...\n';
|
resetTaskLog(term, 'Connecting...');
|
||||||
_taskLogES = new EventSource('/api/tasks/'+id+'/stream');
|
_taskLogES = new EventSource('/api/tasks/'+id+'/stream');
|
||||||
_taskLogES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
_taskLogES.onopen = () => {
|
||||||
|
if (term.dataset.placeholder === '1') resetTaskLog(term, 'Connected. Waiting for output...');
|
||||||
|
};
|
||||||
|
_taskLogES.onmessage = e => { prependTaskLogLine(term, e.data); };
|
||||||
_taskLogES.addEventListener('done', e => {
|
_taskLogES.addEventListener('done', e => {
|
||||||
_taskLogES.close(); _taskLogES=null;
|
_taskLogES.close(); _taskLogES=null;
|
||||||
term.textContent += (e.data ? '\nERROR: '+e.data : '\nDone.')+'\n';
|
prependTaskLogLine(term, e.data ? 'ERROR: '+e.data : 'Done.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function syncTaskLogFromHash() {
|
||||||
|
const id = (window.location.hash || '').replace(/^#/, '');
|
||||||
|
if (!id || id === _taskLogID) return;
|
||||||
|
const task = _tasksAll.find(t => t.id === id);
|
||||||
|
if (!task) return;
|
||||||
|
viewLog(task.id, task.name || task.id);
|
||||||
|
}
|
||||||
function closeTaskLog() {
|
function closeTaskLog() {
|
||||||
if (_taskLogES) { _taskLogES.close(); _taskLogES=null; }
|
if (_taskLogES) { _taskLogES.close(); _taskLogES=null; }
|
||||||
document.getElementById('task-log-section').style.display='none';
|
_taskLogID = '';
|
||||||
|
if (window.location.hash) history.replaceState(null, '', '/tasks');
|
||||||
|
document.getElementById('task-log-overlay').style.display='none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('task-log-overlay').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeTaskLog();
|
||||||
|
});
|
||||||
|
window.addEventListener('hashchange', syncTaskLogFromHash);
|
||||||
|
window.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('task-log-overlay').style.display !== 'none') closeTaskLog();
|
||||||
|
});
|
||||||
|
|
||||||
loadTasks();
|
loadTasks();
|
||||||
_taskRefreshTimer = setInterval(loadTasks, 2000);
|
_taskRefreshTimer = setInterval(loadTasks, 2000);
|
||||||
</script>`
|
</script>`
|
||||||
|
|||||||
@@ -359,6 +359,44 @@ func TestAuditPageRendersViewerFrameAndActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTasksPageRendersLogModalAndPaginationControls(t *testing.T) {
|
||||||
|
handler := NewHandler(HandlerOptions{})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `id="task-log-overlay"`) {
|
||||||
|
t.Fatalf("tasks page missing log modal overlay: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `_taskPageSize = 50`) {
|
||||||
|
t.Fatalf("tasks page missing pagination size config: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `Previous</button>`) || !strings.Contains(body, `Next</button>`) {
|
||||||
|
t.Fatalf("tasks page missing pagination controls: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsPageRendersRestartGPUDriversButton(t *testing.T) {
|
||||||
|
handler := NewHandler(HandlerOptions{})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tools", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `Restart GPU Drivers`) {
|
||||||
|
t.Fatalf("tools page missing restart gpu drivers button: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `svcAction('bee-nvidia', 'restart')`) {
|
||||||
|
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) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "audit.json")
|
path := filepath.Join(dir, "audit.json")
|
||||||
|
|||||||
@@ -291,6 +291,30 @@ func (q *taskQueue) findJob(id string) (*jobState, bool) {
|
|||||||
return t.job, true
|
return t.job, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type taskStreamSource struct {
|
||||||
|
status string
|
||||||
|
errMsg string
|
||||||
|
logPath string
|
||||||
|
job *jobState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *taskQueue) taskStreamSource(id string) (taskStreamSource, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
for _, t := range q.tasks {
|
||||||
|
if t.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return taskStreamSource{
|
||||||
|
status: t.Status,
|
||||||
|
errMsg: t.ErrMsg,
|
||||||
|
logPath: t.LogPath,
|
||||||
|
job: t.job,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return taskStreamSource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func (q *taskQueue) hasActiveTarget(target string) bool {
|
func (q *taskQueue) hasActiveTarget(target string) bool {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
@@ -305,7 +329,7 @@ func (q *taskQueue) hasActiveTarget(target string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// snapshot returns a copy of all tasks sorted for display (running first, then pending by priority, then done by doneAt desc).
|
// snapshot returns a copy of all tasks sorted for display with newest tasks first.
|
||||||
func (q *taskQueue) snapshot() []Task {
|
func (q *taskQueue) snapshot() []Task {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
@@ -315,6 +339,9 @@ func (q *taskQueue) snapshot() []Task {
|
|||||||
out[i].ElapsedSec = taskElapsedSec(&out[i], time.Now())
|
out[i].ElapsedSec = taskElapsedSec(&out[i], time.Now())
|
||||||
}
|
}
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||||
|
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||||
|
}
|
||||||
si := statusOrder(out[i].Status)
|
si := statusOrder(out[i].Status)
|
||||||
sj := statusOrder(out[j].Status)
|
sj := statusOrder(out[j].Status)
|
||||||
if si != sj {
|
if si != sj {
|
||||||
@@ -323,7 +350,7 @@ func (q *taskQueue) snapshot() []Task {
|
|||||||
if out[i].Priority != out[j].Priority {
|
if out[i].Priority != out[j].Priority {
|
||||||
return out[i].Priority > out[j].Priority
|
return out[i].Priority > out[j].Priority
|
||||||
}
|
}
|
||||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
return out[i].Name < out[j].Name
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -750,21 +777,49 @@ func (h *handler) handleAPITasksKillWorkers(w http.ResponseWriter, _ *http.Reque
|
|||||||
|
|
||||||
func (h *handler) handleAPITasksStream(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPITasksStream(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
// Wait up to 5s for the task to get a job (it may be pending)
|
src, ok := globalQueue.taskStreamSource(id)
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
if !ok {
|
||||||
var j *jobState
|
http.Error(w, "task not found", http.StatusNotFound)
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if jj, ok := globalQueue.findJob(id); ok {
|
|
||||||
j = jj
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if j == nil {
|
|
||||||
http.Error(w, "task not found or not yet started", http.StatusNotFound)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamJob(w, r, j)
|
if src.job != nil {
|
||||||
|
streamJob(w, r, src.job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.status == TaskDone || src.status == TaskFailed || src.status == TaskCancelled {
|
||||||
|
j := newTaskJobState(src.logPath)
|
||||||
|
j.finish(src.errMsg)
|
||||||
|
streamJob(w, r, j)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sseStart(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWrite(w, "", "Task is queued. Waiting for worker...")
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
src, ok = globalQueue.taskStreamSource(id)
|
||||||
|
if !ok {
|
||||||
|
sseWrite(w, "done", "task not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.job != nil {
|
||||||
|
streamSubscribedJob(w, r, src.job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.status == TaskDone || src.status == TaskFailed || src.status == TaskCancelled {
|
||||||
|
j := newTaskJobState(src.logPath)
|
||||||
|
j.finish(src.errMsg)
|
||||||
|
streamSubscribedJob(w, r, j)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *taskQueue) assignTaskLogPathLocked(t *Task) {
|
func (q *taskQueue) assignTaskLogPathLocked(t *Task) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package webui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -122,6 +124,130 @@ func TestNewTaskJobStateLoadsExistingLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskQueueSnapshotSortsNewestFirst(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
q := &taskQueue{
|
||||||
|
tasks: []*Task{
|
||||||
|
{
|
||||||
|
ID: "old-running",
|
||||||
|
Name: "Old Running",
|
||||||
|
Status: TaskRunning,
|
||||||
|
Priority: 10,
|
||||||
|
CreatedAt: now.Add(-3 * time.Minute),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "new-done",
|
||||||
|
Name: "New Done",
|
||||||
|
Status: TaskDone,
|
||||||
|
Priority: 0,
|
||||||
|
CreatedAt: now.Add(-1 * time.Minute),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "mid-pending",
|
||||||
|
Name: "Mid Pending",
|
||||||
|
Status: TaskPending,
|
||||||
|
Priority: 1,
|
||||||
|
CreatedAt: now.Add(-2 * time.Minute),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := q.snapshot()
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("snapshot len=%d want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "new-done" || got[1].ID != "mid-pending" || got[2].ID != "old-running" {
|
||||||
|
t.Fatalf("snapshot order=%q,%q,%q", got[0].ID, got[1].ID, got[2].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPITasksStreamReplaysPersistedLogWithoutLiveJob(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
logPath := filepath.Join(dir, "task.log")
|
||||||
|
if err := os.WriteFile(logPath, []byte("line1\nline2\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
origTasks := globalQueue.tasks
|
||||||
|
globalQueue.tasks = []*Task{{
|
||||||
|
ID: "done-1",
|
||||||
|
Name: "Done Task",
|
||||||
|
Status: TaskDone,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
LogPath: logPath,
|
||||||
|
}}
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
globalQueue.tasks = origTasks
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tasks/done-1/stream", nil)
|
||||||
|
req.SetPathValue("id", "done-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h := &handler{}
|
||||||
|
h.handleAPITasksStream(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "data: line1\n\n") || !strings.Contains(body, "data: line2\n\n") {
|
||||||
|
t.Fatalf("body=%q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "event: done\n") {
|
||||||
|
t.Fatalf("missing done event: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAPITasksStreamPendingTaskStartsSSEImmediately(t *testing.T) {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
origTasks := globalQueue.tasks
|
||||||
|
globalQueue.tasks = []*Task{{
|
||||||
|
ID: "pending-1",
|
||||||
|
Name: "Pending Task",
|
||||||
|
Status: TaskPending,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}}
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
globalQueue.tasks = origTasks
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/tasks/pending-1/stream", nil).WithContext(ctx)
|
||||||
|
req.SetPathValue("id", "pending-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
h := &handler{}
|
||||||
|
h.handleAPITasksStream(rec, req)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if strings.Contains(rec.Body.String(), "Task is queued. Waiting for worker...") {
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
t.Fatalf("stream did not emit queued status promptly, body=%q", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveBurnPreset(t *testing.T) {
|
func TestResolveBurnPreset(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
profile string
|
profile string
|
||||||
|
|||||||
Reference in New Issue
Block a user