Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c394845b34 |
@@ -36,6 +36,8 @@ var supportBundleCommands = []struct {
|
|||||||
{name: "system/dmesg-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}},
|
{name: "system/dmesg-tail.txt", cmd: []string{"sh", "-c", "dmesg | tail -n 200"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supportBundleGlob = "bee-support-*.tar.gz"
|
||||||
|
|
||||||
func BuildSupportBundle(exportDir string) (string, error) {
|
func BuildSupportBundle(exportDir string) (string, error) {
|
||||||
exportDir = strings.TrimSpace(exportDir)
|
exportDir = strings.TrimSpace(exportDir)
|
||||||
if exportDir == "" {
|
if exportDir == "" {
|
||||||
@@ -86,34 +88,64 @@ func BuildSupportBundle(exportDir string) (string, error) {
|
|||||||
return archivePath, nil
|
return archivePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LatestSupportBundlePath() (string, error) {
|
||||||
|
return latestSupportBundlePath(os.TempDir())
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupOldSupportBundles(dir string) error {
|
func cleanupOldSupportBundles(dir string) error {
|
||||||
matches, err := filepath.Glob(filepath.Join(dir, "bee-support-*.tar.gz"))
|
matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
type entry struct {
|
entries := supportBundleEntries(matches)
|
||||||
path string
|
for path, mod := range entries {
|
||||||
mod time.Time
|
if time.Since(mod) > 24*time.Hour {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
delete(entries, path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
list := make([]entry, 0, len(matches))
|
ordered := orderSupportBundles(entries)
|
||||||
|
if len(ordered) > 3 {
|
||||||
|
for _, old := range ordered[3:] {
|
||||||
|
_ = os.Remove(old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestSupportBundlePath(dir string) (string, error) {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(dir, supportBundleGlob))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ordered := orderSupportBundles(supportBundleEntries(matches))
|
||||||
|
if len(ordered) == 0 {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
return ordered[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportBundleEntries(matches []string) map[string]time.Time {
|
||||||
|
entries := make(map[string]time.Time, len(matches))
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
info, err := os.Stat(match)
|
info, err := os.Stat(match)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if time.Since(info.ModTime()) > 24*time.Hour {
|
entries[match] = info.ModTime()
|
||||||
_ = os.Remove(match)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
list = append(list, entry{path: match, mod: info.ModTime()})
|
|
||||||
}
|
}
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].mod.After(list[j].mod) })
|
return entries
|
||||||
if len(list) > 3 {
|
}
|
||||||
for _, old := range list[3:] {
|
|
||||||
_ = os.Remove(old.path)
|
func orderSupportBundles(entries map[string]time.Time) []string {
|
||||||
}
|
ordered := make([]string, 0, len(entries))
|
||||||
|
for path := range entries {
|
||||||
|
ordered = append(ordered, path)
|
||||||
}
|
}
|
||||||
return nil
|
sort.Slice(ordered, func(i, j int) bool {
|
||||||
|
return entries[ordered[i]].After(entries[ordered[j]])
|
||||||
|
})
|
||||||
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJournalDump(dst string) error {
|
func writeJournalDump(dst string) error {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package webui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -87,15 +86,16 @@ func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines.
|
// streamCmdJob runs an exec.Cmd and streams stdout+stderr lines into j.
|
||||||
func runCmdJob(j *jobState, cmd *exec.Cmd) {
|
func streamCmdJob(j *jobState, cmd *exec.Cmd) error {
|
||||||
pr, pw := io.Pipe()
|
pr, pw := io.Pipe()
|
||||||
cmd.Stdout = pw
|
cmd.Stdout = pw
|
||||||
cmd.Stderr = pw
|
cmd.Stderr = pw
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
j.finish(err.Error())
|
_ = pw.Close()
|
||||||
return
|
_ = pr.Close()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// Lower the CPU scheduling priority of stress/audit subprocesses to nice+10
|
// Lower the CPU scheduling priority of stress/audit subprocesses to nice+10
|
||||||
// so the X server and kernel interrupt handling remain responsive under load
|
// so the X server and kernel interrupt handling remain responsive under load
|
||||||
@@ -104,8 +104,10 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) {
|
|||||||
_ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10)
|
_ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scanDone := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
scanner := bufio.NewScanner(pr)
|
scanner := bufio.NewScanner(pr)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
// Split on \r to handle progress-bar style output (e.g. \r overwrites)
|
// Split on \r to handle progress-bar style output (e.g. \r overwrites)
|
||||||
// and strip ANSI escape codes so logs are readable in the browser.
|
// and strip ANSI escape codes so logs are readable in the browser.
|
||||||
@@ -117,15 +119,21 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
scanDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanDone <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
_ = pw.Close()
|
_ = pw.Close()
|
||||||
|
scanErr := <-scanDone
|
||||||
|
_ = pr.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.finish(err.Error())
|
return err
|
||||||
} else {
|
|
||||||
j.finish("")
|
|
||||||
}
|
}
|
||||||
|
return scanErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit ─────────────────────────────────────────────────────────────────────
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -417,15 +425,23 @@ func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
archive, err := app.BuildSupportBundle(h.opts.ExportDir)
|
if globalQueue.hasActiveTarget("support-bundle") {
|
||||||
if err != nil {
|
writeError(w, http.StatusConflict, "support bundle task is already pending or running")
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
t := &Task{
|
||||||
|
ID: newJobID("support-bundle"),
|
||||||
|
Name: "Support Bundle",
|
||||||
|
Target: "support-bundle",
|
||||||
|
Status: TaskPending,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
globalQueue.enqueue(t)
|
||||||
writeJSON(w, map[string]string{
|
writeJSON(w, map[string]string{
|
||||||
"status": "ok",
|
"status": "queued",
|
||||||
"path": archive,
|
"task_id": t.ID,
|
||||||
"url": "/export/support.tar.gz",
|
"job_id": t.ID,
|
||||||
|
"url": "/export/support.tar.gz",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,10 +529,7 @@ func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.installMu.Lock()
|
if globalQueue.hasActiveTarget("install") {
|
||||||
installRunning := h.installJob != nil && !h.installJob.isDone()
|
|
||||||
h.installMu.Unlock()
|
|
||||||
if installRunning {
|
|
||||||
writeError(w, http.StatusConflict, "install to disk is already running")
|
writeError(w, http.StatusConflict, "install to disk is already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -631,35 +644,23 @@ func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, "install to RAM task is already pending or running")
|
writeError(w, http.StatusConflict, "install to RAM task is already pending or running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if globalQueue.hasActiveTarget("install") {
|
||||||
h.installMu.Lock()
|
writeError(w, http.StatusConflict, "install task is already pending or running")
|
||||||
if h.installJob != nil && !h.installJob.isDone() {
|
|
||||||
h.installMu.Unlock()
|
|
||||||
writeError(w, http.StatusConflict, "install already running")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
j := &jobState{}
|
t := &Task{
|
||||||
h.installJob = j
|
ID: newJobID("install"),
|
||||||
h.installMu.Unlock()
|
Name: "Install to Disk",
|
||||||
|
Target: "install",
|
||||||
logFile := platform.InstallLogPath(req.Device)
|
Priority: 20,
|
||||||
go runCmdJob(j, exec.CommandContext(context.Background(), "bee-install", req.Device, logFile))
|
Status: TaskPending,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
w.WriteHeader(http.StatusNoContent)
|
params: taskParams{
|
||||||
}
|
Device: req.Device,
|
||||||
|
},
|
||||||
func (h *handler) handleAPIInstallStream(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.installMu.Lock()
|
|
||||||
j := h.installJob
|
|
||||||
h.installMu.Unlock()
|
|
||||||
if j == nil {
|
|
||||||
if !sseStart(w) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sseWrite(w, "done", "")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
streamJob(w, r, j)
|
globalQueue.enqueue(t)
|
||||||
|
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -62,3 +63,40 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) {
|
|||||||
t.Fatalf("burn profile=%q want smoke", got)
|
t.Fatalf("burn profile=%q want smoke", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAPIExportBundleQueuesTask(t *testing.T) {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
originalTasks := globalQueue.tasks
|
||||||
|
globalQueue.tasks = nil
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
globalQueue.tasks = originalTasks
|
||||||
|
globalQueue.mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
h := &handler{opts: HandlerOptions{ExportDir: t.TempDir()}}
|
||||||
|
req := httptest.NewRequest("POST", "/api/export/bundle", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAPIExportBundle(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if body["task_id"] == "" {
|
||||||
|
t.Fatalf("missing task_id in response: %v", body)
|
||||||
|
}
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
defer globalQueue.mu.Unlock()
|
||||||
|
if len(globalQueue.tasks) != 1 {
|
||||||
|
t.Fatalf("tasks=%d want 1", len(globalQueue.tasks))
|
||||||
|
}
|
||||||
|
if got := globalQueue.tasks[0].Target; got != "support-bundle" {
|
||||||
|
t.Fatalf("target=%q want support-bundle", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@ type MetricsDB struct {
|
|||||||
|
|
||||||
// openMetricsDB opens (or creates) the metrics database at the given path.
|
// openMetricsDB opens (or creates) the metrics database at the given path.
|
||||||
func openMetricsDB(path string) (*MetricsDB, error) {
|
func openMetricsDB(path string) (*MetricsDB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000")
|
db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -132,7 +137,7 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type sysRow struct {
|
type sysRow struct {
|
||||||
ts int64
|
ts int64
|
||||||
cpu, mem, pwr float64
|
cpu, mem, pwr float64
|
||||||
}
|
}
|
||||||
var sysRows []sysRow
|
var sysRows []sysRow
|
||||||
@@ -156,7 +161,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri
|
|||||||
maxTS := sysRows[len(sysRows)-1].ts
|
maxTS := sysRows[len(sysRows)-1].ts
|
||||||
|
|
||||||
// Load GPU rows in range
|
// Load GPU rows in range
|
||||||
type gpuKey struct{ ts int64; idx int }
|
type gpuKey struct {
|
||||||
|
ts int64
|
||||||
|
idx int
|
||||||
|
}
|
||||||
gpuData := map[gpuKey]platform.GPUMetricRow{}
|
gpuData := map[gpuKey]platform.GPUMetricRow{}
|
||||||
gRows, err := m.db.Query(
|
gRows, err := m.db.Query(
|
||||||
`SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`,
|
`SELECT ts,gpu_index,temp_c,usage_pct,mem_usage_pct,power_w FROM gpu_metrics WHERE ts>=? AND ts<=? ORDER BY ts,gpu_index`,
|
||||||
@@ -174,7 +182,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load fan rows in range
|
// Load fan rows in range
|
||||||
type fanKey struct{ ts int64; name string }
|
type fanKey struct {
|
||||||
|
ts int64
|
||||||
|
name string
|
||||||
|
}
|
||||||
fanData := map[fanKey]float64{}
|
fanData := map[fanKey]float64{}
|
||||||
fRows, err := m.db.Query(
|
fRows, err := m.db.Query(
|
||||||
`SELECT ts,name,rpm FROM fan_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS,
|
`SELECT ts,name,rpm FROM fan_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS,
|
||||||
@@ -192,7 +203,10 @@ func (m *MetricsDB) loadSamples(query string, args ...any) ([]platform.LiveMetri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load temp rows in range
|
// Load temp rows in range
|
||||||
type tempKey struct{ ts int64; name string }
|
type tempKey struct {
|
||||||
|
ts int64
|
||||||
|
name string
|
||||||
|
}
|
||||||
tempData := map[tempKey]platform.TempReading{}
|
tempData := map[tempKey]platform.TempReading{}
|
||||||
tRows, err := m.db.Query(
|
tRows, err := m.db.Query(
|
||||||
`SELECT ts,name,grp,celsius FROM temp_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS,
|
`SELECT ts,name,grp,celsius FROM temp_metrics WHERE ts>=? AND ts<=?`, minTS, maxTS,
|
||||||
|
|||||||
@@ -926,7 +926,7 @@ func renderExport(exportDir string) string {
|
|||||||
return `<div class="grid2">
|
return `<div class="grid2">
|
||||||
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
||||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
||||||
<a class="btn btn-primary" href="/export/support.tar.gz">↓ Download Support Bundle</a>
|
` + renderSupportBundleInline() + `
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
||||||
<table><tr><th>File</th></tr>` + rows.String() + `</table>
|
<table><tr><th>File</th></tr>` + rows.String() + `</table>
|
||||||
@@ -1024,6 +1024,77 @@ func listExportFiles(exportDir string) ([]string, error) {
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderSupportBundleInline() string {
|
||||||
|
return `<button id="support-bundle-btn" class="btn btn-primary" onclick="supportBundleBuild()">Build Support Bundle</button>
|
||||||
|
<a id="support-bundle-download" class="btn btn-secondary" href="/export/support.tar.gz" style="display:none">↓ Download Support Bundle</a>
|
||||||
|
<div id="support-bundle-status" style="margin-top:12px;font-size:13px;color:var(--muted)">No support bundle built in this session.</div>
|
||||||
|
<div id="support-bundle-log" class="terminal" style="display:none;margin-top:12px;max-height:260px"></div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var _supportBundleES = null;
|
||||||
|
window.supportBundleBuild = function() {
|
||||||
|
var btn = document.getElementById('support-bundle-btn');
|
||||||
|
var status = document.getElementById('support-bundle-status');
|
||||||
|
var log = document.getElementById('support-bundle-log');
|
||||||
|
var download = document.getElementById('support-bundle-download');
|
||||||
|
if (_supportBundleES) {
|
||||||
|
_supportBundleES.close();
|
||||||
|
_supportBundleES = null;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Building...';
|
||||||
|
status.textContent = 'Queueing support bundle task...';
|
||||||
|
status.style.color = 'var(--muted)';
|
||||||
|
log.style.display = '';
|
||||||
|
log.textContent = '';
|
||||||
|
download.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/export/bundle', {method:'POST'}).then(function(r){
|
||||||
|
return r.json().then(function(j){
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
}).then(function(data){
|
||||||
|
if (!data.task_id) throw new Error('missing task id');
|
||||||
|
status.textContent = 'Building support bundle...';
|
||||||
|
_supportBundleES = new EventSource('/api/tasks/' + data.task_id + '/stream');
|
||||||
|
_supportBundleES.onmessage = function(e) {
|
||||||
|
log.textContent += e.data + '\n';
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
};
|
||||||
|
_supportBundleES.addEventListener('done', function(e) {
|
||||||
|
_supportBundleES.close();
|
||||||
|
_supportBundleES = null;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Build Support Bundle';
|
||||||
|
if (e.data) {
|
||||||
|
status.textContent = 'Error: ' + e.data;
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Support bundle ready.';
|
||||||
|
status.style.color = 'var(--ok-fg)';
|
||||||
|
download.style.display = '';
|
||||||
|
});
|
||||||
|
_supportBundleES.onerror = function() {
|
||||||
|
if (_supportBundleES) _supportBundleES.close();
|
||||||
|
_supportBundleES = null;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Build Support Bundle';
|
||||||
|
status.textContent = 'Support bundle stream disconnected.';
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
};
|
||||||
|
}).catch(function(e){
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Build Support Bundle';
|
||||||
|
status.textContent = 'Error: ' + e;
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
// ── Display Resolution ────────────────────────────────────────────────────────
|
// ── Display Resolution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderDisplayInline() string {
|
func renderDisplayInline() string {
|
||||||
@@ -1113,7 +1184,7 @@ function installToRAM() {
|
|||||||
|
|
||||||
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
||||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Downloads a tar.gz archive of all audit files, SAT results, and logs.</p>
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Downloads a tar.gz archive of all audit files, SAT results, and logs.</p>
|
||||||
<a class="btn btn-primary" href="/export/support.tar.gz">↓ Download Support Bundle</a>
|
` + renderSupportBundleInline() + `
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
||||||
@@ -1292,21 +1363,23 @@ function installStart() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({device: _installSelected.device})
|
body: JSON.stringify({device: _installSelected.device})
|
||||||
}).then(function(r){
|
}).then(function(r){
|
||||||
if (r.status === 204) {
|
return r.json().then(function(j){
|
||||||
installStreamLog();
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
} else {
|
return j;
|
||||||
return r.json().then(function(j){ throw new Error(j.error || r.statusText); });
|
});
|
||||||
}
|
}).then(function(j){
|
||||||
|
if (!j.task_id) throw new Error('missing task id');
|
||||||
|
installStreamLog(j.task_id);
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
status.textContent = 'Error: ' + e;
|
status.textContent = 'Error: ' + e;
|
||||||
status.style.color = 'var(--crit-fg)';
|
status.style.color = 'var(--crit-fg)';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function installStreamLog() {
|
function installStreamLog(taskId) {
|
||||||
var term = document.getElementById('install-terminal');
|
var term = document.getElementById('install-terminal');
|
||||||
var status = document.getElementById('install-status');
|
var status = document.getElementById('install-status');
|
||||||
var es = new EventSource('/api/install/stream');
|
var es = new EventSource('/api/tasks/' + taskId + '/stream');
|
||||||
es.onmessage = function(e) {
|
es.onmessage = function(e) {
|
||||||
term.textContent += e.data + '\n';
|
term.textContent += e.data + '\n';
|
||||||
term.scrollTop = term.scrollHeight;
|
term.scrollTop = term.scrollHeight;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -143,9 +144,6 @@ type handler struct {
|
|||||||
latest *platform.LiveMetricSample
|
latest *platform.LiveMetricSample
|
||||||
// metrics persistence (nil if DB unavailable)
|
// metrics persistence (nil if DB unavailable)
|
||||||
metricsDB *MetricsDB
|
metricsDB *MetricsDB
|
||||||
// install job (at most one at a time)
|
|
||||||
installJob *jobState
|
|
||||||
installMu sync.Mutex
|
|
||||||
// pending network change (rollback on timeout)
|
// pending network change (rollback on timeout)
|
||||||
pendingNet *pendingNetChange
|
pendingNet *pendingNetChange
|
||||||
pendingNetMu sync.Mutex
|
pendingNetMu sync.Mutex
|
||||||
@@ -180,7 +178,11 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
if len(samples) > 0 {
|
if len(samples) > 0 {
|
||||||
h.setLatestMetric(samples[len(samples)-1])
|
h.setLatestMetric(samples[len(samples)-1])
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("metrics history unavailable", "path", metricsDBPath, "err", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("metrics db disabled", "path", metricsDBPath, "err", err)
|
||||||
}
|
}
|
||||||
h.startMetricsCollector()
|
h.startMetricsCollector()
|
||||||
|
|
||||||
@@ -266,7 +268,6 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
// Install
|
// Install
|
||||||
mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks)
|
mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks)
|
||||||
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
||||||
mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream)
|
|
||||||
|
|
||||||
// Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export
|
// Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export
|
||||||
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
||||||
@@ -366,9 +367,13 @@ func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
archive, err := app.BuildSupportBundle(h.opts.ExportDir)
|
archive, err := app.LatestSupportBundlePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "support bundle not built yet", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("locate support bundle: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|||||||
@@ -259,6 +259,17 @@ func TestSupportBundleEndpointReturnsArchive(t *testing.T) {
|
|||||||
if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
archive, err := os.CreateTemp(os.TempDir(), "bee-support-server-test-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Remove(archive.Name()) })
|
||||||
|
if _, err := archive.WriteString("support-bundle"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
handler := NewHandler(HandlerOptions{ExportDir: exportDir})
|
handler := NewHandler(HandlerOptions{ExportDir: exportDir})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,6 +41,7 @@ var taskNames = map[string]string{
|
|||||||
"sat-stress": "SAT Stress (stressapptest)",
|
"sat-stress": "SAT Stress (stressapptest)",
|
||||||
"platform-stress": "Platform Thermal Cycling",
|
"platform-stress": "Platform Thermal Cycling",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
|
"support-bundle": "Support Bundle",
|
||||||
"install": "Install to Disk",
|
"install": "Install to Disk",
|
||||||
"install-to-ram": "Install to RAM",
|
"install-to-ram": "Install to RAM",
|
||||||
}
|
}
|
||||||
@@ -213,6 +215,10 @@ var (
|
|||||||
runSATStressPackCtx = func(a *app.App, ctx context.Context, baseDir string, durationSec int, logFunc func(string)) (string, error) {
|
runSATStressPackCtx = func(a *app.App, ctx context.Context, baseDir string, durationSec int, logFunc func(string)) (string, error) {
|
||||||
return a.RunSATStressPackCtx(ctx, baseDir, durationSec, logFunc)
|
return a.RunSATStressPackCtx(ctx, baseDir, durationSec, logFunc)
|
||||||
}
|
}
|
||||||
|
buildSupportBundle = app.BuildSupportBundle
|
||||||
|
installCommand = func(ctx context.Context, device string, logPath string) *exec.Cmd {
|
||||||
|
return exec.CommandContext(ctx, "bee-install", device, logPath)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// enqueue adds a task to the queue and notifies the worker.
|
// enqueue adds a task to the queue and notifies the worker.
|
||||||
@@ -410,9 +416,9 @@ func setCPUGovernor(governor string) {
|
|||||||
|
|
||||||
// runTask executes the work for a task, writing output to j.
|
// runTask executes the work for a task, writing output to j.
|
||||||
func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
||||||
if q.opts == nil || q.opts.App == nil {
|
if q.opts == nil {
|
||||||
j.append("ERROR: app not configured")
|
j.append("ERROR: handler options not configured")
|
||||||
j.finish("app not configured")
|
j.finish("handler options not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a := q.opts.App
|
a := q.opts.App
|
||||||
@@ -429,6 +435,10 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
|
|
||||||
switch t.Target {
|
switch t.Target {
|
||||||
case "nvidia":
|
case "nvidia":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
diagLevel := t.params.DiagLevel
|
diagLevel := t.params.DiagLevel
|
||||||
if t.params.BurnProfile != "" && diagLevel <= 0 {
|
if t.params.BurnProfile != "" && diagLevel <= 0 {
|
||||||
diagLevel = resolveBurnPreset(t.params.BurnProfile).NvidiaDiag
|
diagLevel = resolveBurnPreset(t.params.BurnProfile).NvidiaDiag
|
||||||
@@ -446,6 +456,10 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
archive, err = a.RunNvidiaAcceptancePack("", j.append)
|
archive, err = a.RunNvidiaAcceptancePack("", j.append)
|
||||||
}
|
}
|
||||||
case "nvidia-stress":
|
case "nvidia-stress":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
dur := t.params.Duration
|
dur := t.params.Duration
|
||||||
if t.params.BurnProfile != "" && dur <= 0 {
|
if t.params.BurnProfile != "" && dur <= 0 {
|
||||||
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
||||||
@@ -457,10 +471,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
ExcludeGPUIndices: t.params.ExcludeGPUIndices,
|
ExcludeGPUIndices: t.params.ExcludeGPUIndices,
|
||||||
}, j.append)
|
}, j.append)
|
||||||
case "memory":
|
case "memory":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
archive, err = runMemoryAcceptancePackCtx(a, ctx, "", j.append)
|
archive, err = runMemoryAcceptancePackCtx(a, ctx, "", j.append)
|
||||||
case "storage":
|
case "storage":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
archive, err = runStorageAcceptancePackCtx(a, ctx, "", j.append)
|
archive, err = runStorageAcceptancePackCtx(a, ctx, "", j.append)
|
||||||
case "cpu":
|
case "cpu":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
dur := t.params.Duration
|
dur := t.params.Duration
|
||||||
if t.params.BurnProfile != "" && dur <= 0 {
|
if t.params.BurnProfile != "" && dur <= 0 {
|
||||||
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
||||||
@@ -471,33 +497,65 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
j.append(fmt.Sprintf("CPU stress duration: %ds", dur))
|
j.append(fmt.Sprintf("CPU stress duration: %ds", dur))
|
||||||
archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append)
|
archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append)
|
||||||
case "amd":
|
case "amd":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append)
|
archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append)
|
||||||
case "amd-mem":
|
case "amd-mem":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
archive, err = runAMDMemIntegrityPackCtx(a, ctx, "", j.append)
|
archive, err = runAMDMemIntegrityPackCtx(a, ctx, "", j.append)
|
||||||
case "amd-bandwidth":
|
case "amd-bandwidth":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
archive, err = runAMDMemBandwidthPackCtx(a, ctx, "", j.append)
|
archive, err = runAMDMemBandwidthPackCtx(a, ctx, "", j.append)
|
||||||
case "amd-stress":
|
case "amd-stress":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
dur := t.params.Duration
|
dur := t.params.Duration
|
||||||
if t.params.BurnProfile != "" && dur <= 0 {
|
if t.params.BurnProfile != "" && dur <= 0 {
|
||||||
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
||||||
}
|
}
|
||||||
archive, err = runAMDStressPackCtx(a, ctx, "", dur, j.append)
|
archive, err = runAMDStressPackCtx(a, ctx, "", dur, j.append)
|
||||||
case "memory-stress":
|
case "memory-stress":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
dur := t.params.Duration
|
dur := t.params.Duration
|
||||||
if t.params.BurnProfile != "" && dur <= 0 {
|
if t.params.BurnProfile != "" && dur <= 0 {
|
||||||
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
||||||
}
|
}
|
||||||
archive, err = runMemoryStressPackCtx(a, ctx, "", dur, j.append)
|
archive, err = runMemoryStressPackCtx(a, ctx, "", dur, j.append)
|
||||||
case "sat-stress":
|
case "sat-stress":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
dur := t.params.Duration
|
dur := t.params.Duration
|
||||||
if t.params.BurnProfile != "" && dur <= 0 {
|
if t.params.BurnProfile != "" && dur <= 0 {
|
||||||
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
dur = resolveBurnPreset(t.params.BurnProfile).DurationSec
|
||||||
}
|
}
|
||||||
archive, err = runSATStressPackCtx(a, ctx, "", dur, j.append)
|
archive, err = runSATStressPackCtx(a, ctx, "", dur, j.append)
|
||||||
case "platform-stress":
|
case "platform-stress":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
opts := resolvePlatformStressPreset(t.params.BurnProfile)
|
opts := resolvePlatformStressPreset(t.params.BurnProfile)
|
||||||
archive, err = a.RunPlatformStress(ctx, "", opts, j.append)
|
archive, err = a.RunPlatformStress(ctx, "", opts, j.append)
|
||||||
case "audit":
|
case "audit":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
result, e := a.RunAuditNow(q.opts.RuntimeMode)
|
result, e := a.RunAuditNow(q.opts.RuntimeMode)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err = e
|
err = e
|
||||||
@@ -506,7 +564,22 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) {
|
|||||||
j.append(line)
|
j.append(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "support-bundle":
|
||||||
|
j.append("Building support bundle...")
|
||||||
|
archive, err = buildSupportBundle(q.opts.ExportDir)
|
||||||
|
case "install":
|
||||||
|
if strings.TrimSpace(t.params.Device) == "" {
|
||||||
|
err = fmt.Errorf("device is required")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
installLogPath := platform.InstallLogPath(t.params.Device)
|
||||||
|
j.append("Install log: " + installLogPath)
|
||||||
|
err = streamCmdJob(j, installCommand(ctx, t.params.Device, installLogPath))
|
||||||
case "install-to-ram":
|
case "install-to-ram":
|
||||||
|
if a == nil {
|
||||||
|
err = fmt.Errorf("app not configured")
|
||||||
|
break
|
||||||
|
}
|
||||||
err = a.RunInstallToRAM(ctx, j.append)
|
err = a.RunInstallToRAM(ctx, j.append)
|
||||||
default:
|
default:
|
||||||
j.append("ERROR: unknown target: " + t.Target)
|
j.append("ERROR: unknown target: " + t.Target)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -113,8 +115,6 @@ func TestTaskDisplayNameUsesNvidiaStressLoader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunTaskHonorsCancel(t *testing.T) {
|
func TestRunTaskHonorsCancel(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
blocked := make(chan struct{})
|
blocked := make(chan struct{})
|
||||||
released := make(chan struct{})
|
released := make(chan struct{})
|
||||||
aRun := func(_ any, ctx context.Context, _ string, _ int, _ func(string)) (string, error) {
|
aRun := func(_ any, ctx context.Context, _ string, _ int, _ func(string)) (string, error) {
|
||||||
@@ -173,8 +173,6 @@ func TestRunTaskHonorsCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
|
func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var gotDuration int
|
var gotDuration int
|
||||||
q := &taskQueue{
|
q := &taskQueue{
|
||||||
opts: &HandlerOptions{App: &app.App{}},
|
opts: &HandlerOptions{App: &app.App{}},
|
||||||
@@ -202,3 +200,82 @@ func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) {
|
|||||||
t.Fatalf("duration=%d want %d", gotDuration, 5*60)
|
t.Fatalf("duration=%d want %d", gotDuration, 5*60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunTaskBuildsSupportBundleWithoutApp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
q := &taskQueue{
|
||||||
|
opts: &HandlerOptions{ExportDir: dir},
|
||||||
|
}
|
||||||
|
tk := &Task{
|
||||||
|
ID: "support-bundle-1",
|
||||||
|
Name: "Support Bundle",
|
||||||
|
Target: "support-bundle",
|
||||||
|
Status: TaskRunning,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
j := &jobState{}
|
||||||
|
|
||||||
|
var gotExportDir string
|
||||||
|
orig := buildSupportBundle
|
||||||
|
buildSupportBundle = func(exportDir string) (string, error) {
|
||||||
|
gotExportDir = exportDir
|
||||||
|
return filepath.Join(exportDir, "bundle.tar.gz"), nil
|
||||||
|
}
|
||||||
|
defer func() { buildSupportBundle = orig }()
|
||||||
|
|
||||||
|
q.runTask(tk, j, context.Background())
|
||||||
|
|
||||||
|
if gotExportDir != dir {
|
||||||
|
t.Fatalf("exportDir=%q want %q", gotExportDir, dir)
|
||||||
|
}
|
||||||
|
if j.err != "" {
|
||||||
|
t.Fatalf("unexpected error: %q", j.err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(j.lines, "\n"), "Archive: "+filepath.Join(dir, "bundle.tar.gz")) {
|
||||||
|
t.Fatalf("lines=%v", j.lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTaskInstallUsesSharedCommandStreaming(t *testing.T) {
|
||||||
|
q := &taskQueue{
|
||||||
|
opts: &HandlerOptions{},
|
||||||
|
}
|
||||||
|
tk := &Task{
|
||||||
|
ID: "install-1",
|
||||||
|
Name: "Install to Disk",
|
||||||
|
Target: "install",
|
||||||
|
Status: TaskRunning,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
params: taskParams{Device: "/dev/sda"},
|
||||||
|
}
|
||||||
|
j := &jobState{}
|
||||||
|
|
||||||
|
var gotDevice string
|
||||||
|
var gotLogPath string
|
||||||
|
orig := installCommand
|
||||||
|
installCommand = func(ctx context.Context, device string, logPath string) *exec.Cmd {
|
||||||
|
gotDevice = device
|
||||||
|
gotLogPath = logPath
|
||||||
|
return exec.CommandContext(ctx, "sh", "-c", "printf 'line1\nline2\n'")
|
||||||
|
}
|
||||||
|
defer func() { installCommand = orig }()
|
||||||
|
|
||||||
|
q.runTask(tk, j, context.Background())
|
||||||
|
|
||||||
|
if gotDevice != "/dev/sda" {
|
||||||
|
t.Fatalf("device=%q want /dev/sda", gotDevice)
|
||||||
|
}
|
||||||
|
if gotLogPath == "" {
|
||||||
|
t.Fatal("expected install log path")
|
||||||
|
}
|
||||||
|
logs := strings.Join(j.lines, "\n")
|
||||||
|
if !strings.Contains(logs, "Install log: ") {
|
||||||
|
t.Fatalf("missing install log line: %v", j.lines)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "line1") || !strings.Contains(logs, "line2") {
|
||||||
|
t.Fatalf("missing streamed output: %v", j.lines)
|
||||||
|
}
|
||||||
|
if j.err != "" {
|
||||||
|
t.Fatalf("unexpected error: %q", j.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user