Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion cmd/hiveview/assets/lib/app-suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,18 @@ function formatClientLogsList(suiteData, testIndex, clientInfo) {
for (let instanceID in clientInfo) {
let instanceInfo = clientInfo[instanceID];
let logfile = routes.resultsRoot + instanceInfo.logFile;
let url = routes.clientLog(suiteData.suiteID, suiteData.name, testIndex, logfile);
let url = routes.clientLog(
suiteData.suiteID,
suiteData.name,
testIndex,
logfile,
instanceInfo.logOffsets // Pass byte offsets if available
);
let link = html.makeLink(url, instanceInfo.name);
link.classList.add('log-link');
if (instanceInfo.logOffsets) {
link.title = `Filtered: bytes ${instanceInfo.logOffsets.begin}-${instanceInfo.logOffsets.end}`;
}
links.push(link.outerHTML);
}
return links.join(', ');
Expand Down
81 changes: 66 additions & 15 deletions cmd/hiveview/assets/lib/app-viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ $(document).ready(function () {
line = parseInt(window.location.hash.substr(2));
}

// Check for byte range parameters (for multi-test client log highlighting).
let byteBegin = queryParam('begin');
let byteEnd = queryParam('end');
let byteRange = null;
if (byteBegin !== null && byteEnd !== null) {
let begin = parseInt(byteBegin, 10);
let end = parseInt(byteEnd, 10);
if (!isNaN(begin) && !isNaN(end)) {
byteRange = { begin, end };
}
}

// Get suite context.
let suiteFile = queryParam('suiteid');
let suiteName = queryParam('suitename');
Expand All @@ -42,35 +54,39 @@ $(document).ready(function () {
if (file) {
$('#fileload').val(file);
showText('Loading file...');
fetchFile(file, line);
fetchFile(file, line, byteRange);
return;
}

// Show default text because nothing was loaded.
showText(document.getElementById('exampletext').innerHTML);
});

// setHL sets the highlight on a line number.
function setHL(num, scroll) {
// setHL sets the highlight on a line number or range.
function setHL(startNum, scroll, endNum) {
// out with the old
$('.highlighted').removeClass('highlighted');
if (!num) {
if (!startNum) {
return;
}

let contentArea = document.getElementById('file-content');
let gutter = document.getElementById('gutter');
let numElem = gutter.children[num - 1];
if (!numElem) {
console.error('invalid line number:', num);
return;
endNum = endNum || startNum; // Single line if no end specified

for (let num = startNum; num <= endNum; num++) {
let numElem = gutter.children[num - 1];
let lineElem = contentArea.children[num - 1];
if (numElem) $(numElem).addClass('highlighted');
if (lineElem) $(lineElem).addClass('highlighted');
}
// in with the new
let lineElem = contentArea.children[num - 1];
$(numElem).addClass('highlighted');
$(lineElem).addClass('highlighted');

if (scroll) {
numElem.scrollIntoView();
let contextLines = 5; // Show a few lines before the highlight for context.
let scrollTarget = Math.max(0, startNum - 1 - contextLines);
if (gutter.children[scrollTarget]) {
gutter.children[scrollTarget].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}

Expand Down Expand Up @@ -168,7 +184,7 @@ function lineNumberClicked() {
}

// fetchFile loads up a new file to view
async function fetchFile(url, line /* optional jump to line */ ) {
async function fetchFile(url, line, byteRange) {
let resultsRE = new RegExp('^' + routes.resultsRoot);
let text;
try {
Expand All @@ -181,7 +197,42 @@ async function fetchFile(url, line /* optional jump to line */ ) {
let title = url.replace(resultsRE, '');
showTitle(null, title);
showText(text);
setHL(line, true);

// Highlight byte range if provided, otherwise use line number
if (byteRange) {
let lineRange = byteRangeToLineNumbers(text, byteRange);
setHL(lineRange.start, true, lineRange.end);
} else {
setHL(line, true);
}
}

// byteRangeToLineNumbers converts byte offsets to line numbers.
// Encodes text to UTF-8 bytes once, then counts newlines (0x0A) directly.
// The range [begin, end) is exclusive on end, so a trailing newline at
// position end-1 does not extend the highlight into the next line.
function byteRangeToLineNumbers(text, byteRange) {
const encoder = new TextEncoder();
const bytes = encoder.encode(text);

let startLine = 1, endLine = 1, foundStart = false;
const newlineByte = 0x0A; // '\n' in UTF-8

for (let bytePos = 0; bytePos < bytes.length && bytePos < byteRange.end; bytePos++) {
if (!foundStart && bytePos >= byteRange.begin) {
startLine = endLine;
foundStart = true;
}
if (bytes[bytePos] === newlineByte) {
endLine++;
}
}
// If end falls right after a newline, the line counter has already been
// bumped to the next line which belongs to the following test. Back up.
if (byteRange.end > 0 && byteRange.end <= bytes.length && bytes[byteRange.end - 1] === newlineByte) {
endLine = Math.max(startLine, endLine - 1);
}
return { start: startLine, end: endLine };
}

// fetchTestLog loads the suite file and displays the output of a test.
Expand Down
6 changes: 5 additions & 1 deletion cmd/hiveview/assets/lib/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ export function testLog(suiteID, suiteName, testIndex) {
return 'viewer.html?' + params.toString();
}

export function clientLog(suiteID, suiteName, testIndex, file) {
export function clientLog(suiteID, suiteName, testIndex, file, logOffsets) {
let params = new URLSearchParams({
'suiteid': suiteID,
'suitename': suiteName,
'testid': testIndex,
'file': file,
});
if (logOffsets) {
params.set('begin', logOffsets.begin);
params.set('end', logOffsets.end);
}
return 'viewer.html?' + params.toString();
}

Expand Down
74 changes: 74 additions & 0 deletions internal/libhive/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func newSimulationAPI(b ContainerBackend, env SimEnv, tm *TestManager, hive Hive
router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}", api.stopClient).Methods("DELETE")
router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.pauseClient).Methods("POST")
router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.unpauseClient).Methods("DELETE")
router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/register/{target}", api.registerMultiTestNode).Methods("POST")
router.HandleFunc("/testsuite/{suite}/test", api.startTest).Methods("POST")
// post because the delete http verb does not always support a message body
router.HandleFunc("/testsuite/{suite}/test/{test}", api.endTest).Methods("POST")
Expand Down Expand Up @@ -296,12 +297,15 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) {
// Start it!
info, err := api.backend.StartContainer(ctx, containerID, options)
if info != nil {
// Capture the current log file size as the starting offset for this test.
logBegin := logFileSize(logFilePath)
clientInfo := &ClientInfo{
ID: info.ID,
IP: info.IP,
Name: clientDef.Name,
InstantiatedAt: time.Now(),
LogFile: logPath,
LogOffsets: &TestLogOffsets{Begin: logBegin},
wait: info.Wait,
}

Expand Down Expand Up @@ -421,6 +425,76 @@ func (api *simAPI) unpauseClient(w http.ResponseWriter, r *http.Request) {
}
}

// registerMultiTestNode registers a client from one test with another test.
// This enables client reuse across multiple tests while maintaining proper
// clientInfo association for UI visibility in each individual test.
func (api *simAPI) registerMultiTestNode(w http.ResponseWriter, r *http.Request) {
suiteID, sourceTestID, err := api.requestSuiteAndTest(r)
if err != nil {
serveError(w, err, http.StatusBadRequest)
return
}

vars := mux.Vars(r)
node := vars["node"]
targetStr := vars["target"]
targetTestID, err := strconv.Atoi(targetStr)
if err != nil {
serveError(w, fmt.Errorf("invalid target test ID: %v", err), http.StatusBadRequest)
return
}

// Verify the target test belongs to this suite.
if _, ok := api.tm.IsTestInSuite(suiteID, TestID(targetTestID)); !ok {
serveError(w, fmt.Errorf("target test %d not in suite %d", targetTestID, suiteID), http.StatusBadRequest)
return
}

// Get the client info from the source test.
nodeInfo, err := api.tm.GetNodeInfo(suiteID, sourceTestID, node)
if err != nil {
slog.Error("API: can't find node", "node", node, "sourceTest", sourceTestID, "error", err)
serveError(w, err, http.StatusNotFound)
return
}

// Create a copy of the client info without the wait function, so the target
// test won't stop the container when it ends. A new LogOffsets is created with
// Begin set to the current log file size, giving each test its own byte range.
var logOffsets *TestLogOffsets
if nodeInfo.LogFile != "" {
logFilePath := filepath.Join(api.tm.config.LogDir, filepath.FromSlash(nodeInfo.LogFile))
logOffsets = &TestLogOffsets{Begin: logFileSize(logFilePath)}
}
multiTestNodeInfo := &ClientInfo{
ID: nodeInfo.ID,
IP: nodeInfo.IP,
Name: nodeInfo.Name,
InstantiatedAt: nodeInfo.InstantiatedAt,
LogFile: nodeInfo.LogFile,
LogOffsets: logOffsets,
// wait is intentionally nil - target tests shouldn't stop the client
}

// Register the client with the target test.
err = api.tm.RegisterNode(TestID(targetTestID), node, multiTestNodeInfo)
if err != nil {
slog.Error("API: can't register multi-test node", "node", node, "targetTest", targetTestID, "error", err)
serveError(w, err, http.StatusInternalServerError)
return
}

// Mark the source test as a multi-test context (lifecycle owner).
if err := api.tm.SetMultiTestContext(sourceTestID); err != nil {
slog.Error("API: can't set multi-test context", "sourceTest", sourceTestID, "error", err)
serveError(w, err, http.StatusInternalServerError)
return
}

slog.Info("API: multi-test node registered", "node", node, "sourceTest", sourceTestID, "targetTest", targetTestID)
serveOK(w)
}

// getNodeStatus returns the status of a client container.
func (api *simAPI) getNodeStatus(w http.ResponseWriter, r *http.Request) {
suiteID, testID, err := api.requestSuiteAndTest(r)
Expand Down
11 changes: 10 additions & 1 deletion internal/libhive/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ type TestCase struct {
End time.Time `json:"end"`
SummaryResult TestResult `json:"summaryResult"` // The result of the whole test case.
ClientInfo map[string]*ClientInfo `json:"clientInfo"` // Info about each client.

// MultiTestContext is true when this test case is the lifecycle owner
// for clients shared across multiple tests (via registerMultiTestNode).
MultiTestContext bool `json:"multiTestContext,omitempty"`
}

// TestResult represents the result of a test case.
Expand All @@ -170,7 +174,12 @@ type ClientInfo struct {
IP string `json:"ip"`
Name string `json:"name"`
InstantiatedAt time.Time `json:"instantiatedAt"`
LogFile string `json:"logFile"` //Absolute path to the logfile.
LogFile string `json:"logFile"` // Relative path to the logfile.

// LogOffsets contains byte offsets into LogFile for the portion of the log
// relevant to this test case. This enables filtering log output when a single
// client container serves multiple tests.
LogOffsets *TestLogOffsets `json:"logOffsets,omitempty"`

wait func()
}
Expand Down
25 changes: 25 additions & 0 deletions internal/libhive/logfilesize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package libhive

import (
"os"
"path/filepath"
"testing"
)

func TestLogFileSize(t *testing.T) {
// Non-existent file should return 0.
if got := logFileSize("/nonexistent/path/file.log"); got != 0 {
t.Fatalf("logFileSize(nonexistent) = %d, want 0", got)
}

// Create a temp file with known content.
tmpFile := filepath.Join(t.TempDir(), "test.log")
content := []byte("hello world\n")
if err := os.WriteFile(tmpFile, content, 0644); err != nil {
t.Fatal("WriteFile:", err)
}

if got := logFileSize(tmpFile); got != int64(len(content)) {
t.Fatalf("logFileSize = %d, want %d", got, len(content))
}
}
45 changes: 45 additions & 0 deletions internal/libhive/testmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ func (manager *TestManager) IsTestSuiteRunning(testSuite TestSuiteID) (*TestSuit
return suite, ok
}

// IsTestInSuite checks if a test belongs to the given running suite.
func (manager *TestManager) IsTestInSuite(testSuite TestSuiteID, test TestID) (*TestCase, bool) {
manager.testSuiteMutex.RLock()
defer manager.testSuiteMutex.RUnlock()
suite, ok := manager.runningTestSuites[testSuite]
if !ok {
return nil, false
}
manager.testCaseMutex.RLock()
defer manager.testCaseMutex.RUnlock()
tc, ok := suite.TestCases[test]
return tc, ok
}

// IsTestRunning checks if the test is still running and returns it if so.
func (manager *TestManager) IsTestRunning(test TestID) (*TestCase, bool) {
manager.testCaseMutex.RLock()
Expand Down Expand Up @@ -211,6 +225,18 @@ func (manager *TestManager) Terminate() error {
return nil
}

// SetMultiTestContext marks a test case as a multi-test lifecycle owner.
func (manager *TestManager) SetMultiTestContext(test TestID) error {
manager.testCaseMutex.Lock()
defer manager.testCaseMutex.Unlock()
tc, ok := manager.runningTestCases[test]
if !ok {
return ErrNoSuchTestCase
}
tc.MultiTestContext = true
return nil
}

// GetNodeInfo gets some info on a client belonging to some test
func (manager *TestManager) GetNodeInfo(testSuite TestSuiteID, test TestID, nodeID string) (*ClientInfo, error) {
manager.testCaseMutex.RLock()
Expand Down Expand Up @@ -505,6 +531,16 @@ func (manager *TestManager) StartTest(testSuiteID TestSuiteID, name string, desc
return newCaseID, nil
}

// logFileSize returns the current size of a log file in bytes.
// Returns 0 if the file doesn't exist or cannot be read.
func logFileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}

// EndTest finishes the test case
func (manager *TestManager) EndTest(suiteID TestSuiteID, testID TestID, result *TestResult) error {
manager.testCaseMutex.Lock()
Expand Down Expand Up @@ -533,6 +569,15 @@ func (manager *TestManager) EndTest(suiteID TestSuiteID, testID TestID, result *
}
testCase.SummaryResult = *result

// Capture log end offsets for all clients before stopping them.
// This enables log filtering when a client serves multiple tests.
for _, v := range testCase.ClientInfo {
if v.LogOffsets != nil && manager.config.LogDir != "" {
logFilePath := filepath.Join(manager.config.LogDir, filepath.FromSlash(v.LogFile))
v.LogOffsets.End = logFileSize(logFilePath)

@fjl fjl Mar 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't have another method handy, but just want to note that this way of getting the offsets might be unreliable. The way logs are handled is that hive attaches to the docker output stream and writes the output to the file in a background goroutine. This process is not at all synchronized with the running of tests. So depending on machine load, docker latency, etc., the collected offsets can be way behind. Basically what's happening here is, we are taking the current size of the output file, as reported by the OS, and recording that.

We could improve this by adding more API surface in the docker backend to allow reading the current output size of the container. This would remove the OS filesystem from the equation at least.
Nothing further can be done though, since we fundamentally rely on docker to provide us with the output from the client container.

There is also the fundamental issue that client logs may not always be emitted in time with the tests.

}
}

// Stop running clients.
for _, v := range testCase.ClientInfo {
if v.wait != nil {
Expand Down
Loading