[RFC] curses based interactive piglit result browser thing

Submitted by Rob Clark on Aug. 28, 2018, 12:41 a.m.

Details

Message ID 20180828004140.963-1-robdclark@gmail.com
State New
Headers show
Series "curses based interactive piglit result browser thing" ( rev: 1 ) in Piglit

Not browsing as part of any series.

Commit Message

Rob Clark Aug. 28, 2018, 12:41 a.m.
So for a while I've been wishing for a better way to view large piglit
results, compared to just ./piglit-summary.py (which is pretty limited
to just showing test name and status (or status transition), and html
results (which are generally huge and annoying to click thru).  (Or
just bzless on the results.json.bz2 which is also huge and annoying.)

And I've also wanted a way to filter on combination of previous result
status plus test name and re-run matching set of tests while trying to
debug/fix failing tests (since full piglit runs take too long while
you are in the experiment/test loop, and I prefer to leave that until
I get to the point where I have reasonably high confidence that I've
figured out how to make the hw do what I want it to do).

So I hacked up a curses based interactive results browser.  Which does
at least the first half of what I want.  The second part, of re-running
tests, I'm less sure about the best way to proceed.  I obviously have
the cmdline of the individual tests, which I guess is good enough.  But
it would be nice to run the tests the way piglit-runner runs them, and
have the corresponding result parsing, so I could have the option to
merge the results back into the current results.  (Ie. if I fix half of
the failed border-color tests, maybe next round I only want to re-run
the ones that are still fails.)

Current issues:

 + This py code probably looks like what you'd expect from a C
   programmer writing py code.  (But otoh, curses.. it ain't gonna
   be pretty?)

 + What about compare mode, maybe we could have multiple status
   columns shown the status from each of multiple sets of piglit
   results..  Not really sure how status filtering UI should look
   for this.  It isn't so much the use-case I was going for so I
   ignored it for now, but I can see the value in comparing prev
   two results and re-running the regressions.

 + The re-running tests functionality not wired up yet.  This is
   kind of the killer functionality I want, but not sure if there
   is a better way than parsing the test name and heuristics to
   know which Test subclass to use.. maybe piglit-runner could
   emit more info in the results to help with this??
---
For a rough approximation of what this looks like, see:

  http://showterm.io/614e854d92fa43309bdb3#slow

Unfortunately cycling thru results dialog looks super-slow.  It could
probably be optimized to avoid a redraw or two, but on a real console
(ie. not tty.js) it is actually pretty smooth, but that is beyond the
limits of showterm.io so you'll have to apply the patch and try it
yourself.

PS. sorry for using really old a530 piglit results, I was too lazy
to copy over something more recent to my laptop.

 piglit-browser.py | 397 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 397 insertions(+)
 create mode 100755 piglit-browser.py

Patch hide | download patch | download mbox

diff --git a/piglit-browser.py b/piglit-browser.py
new file mode 100755
index 000000000..f84bd171b
--- /dev/null
+++ b/piglit-browser.py
@@ -0,0 +1,397 @@ 
+#!/usr/bin/python3
+
+import sys
+import curses
+import curses.textpad
+
+from framework.summary.common import Results
+from framework import grouptools, backends
+
+# helper to get a color_pair attribute
+def color_pair(p,fg, bg):
+    curses.init_pair(p, fg, bg)
+    return curses.color_pair(p)
+
+def get_result(results, test):
+    try:
+        return results.tests[test]
+    except KeyError as e:
+        name, subtest = grouptools.splitname(test)
+        try:
+            return results.tests[name]
+        except KeyError:
+            raise e
+
+def indent_lines(str):
+    return "".join(map(lambda x: "    " + x + "\n", str.split("\n")))
+
+COLUMNS = [
+        "status",
+        "test",
+]
+
+STATUSES = [
+        "pass",
+        "fail",
+        "crash",
+        "skip",
+        "timeout",
+        "warn",
+        "incomplete",
+        "dmesg-warn",
+        "dmesg-fail",
+]
+
+class ResultDialog(object):
+    def __init__(self, browser, results, test):
+        self.results = results
+        self.test = test
+        (ph, pw) = browser.win.getmaxyx()
+        dh = ph - 6
+        dw = pw - 6
+        self.visible_rows = dh - 2
+        self.visible_cols = dw - 2
+        self.win = curses.newwin(dh, dw, int((ph - dh)/2), int((pw - dw)/2))
+        self.win.scrollok(True)
+        self.win.box(0, 0)
+        self.win.refresh()
+        self.win.keypad(True)
+        self.row = 0
+
+        result = get_result(self.results.results[0], self.test)
+        status = str(self.results.get_result(self.test)[0])
+        contents = ("test:    " + grouptools.format(self.test) + "\n" +
+                    "status:  " + status + "\n" +
+                    "command: " + result.command + "\n" +
+                    "stderr:  \n" + indent_lines(result.err) + "\n" +
+                    "stdout:  \n" + indent_lines(result.out) + "\n")
+        self.lines = []
+        # linewrap and pad to width of result dialog:
+        cols = dw - 2;
+        for line in contents.split("\n"):
+            while len(line) > cols:
+                subline = line[0:cols-1]
+                self.lines.append(subline)
+                line = line[cols:]
+            self.lines.append(line)
+
+
+    def redraw(self):
+        for row in range(0, self.visible_rows):
+            self.win.move(row + 1, 1)
+            if row < len(self.lines):
+                str = self.lines[row + self.row]
+            else:
+                str = ""
+            self.win.addstr(str)
+            self.win.hline(" ", self.visible_cols - len(str))
+
+    def run(self):
+        self.redraw()
+
+        while True:
+            #self.update_size()
+            c = self.win.getch()
+            if c == ord('q'):
+                break;
+            # for any nav keys, return those back to main window to handle:
+            elif c == curses.KEY_HOME or c == curses.KEY_LEFT or c == curses.KEY_RIGHT or c == curses.KEY_DOWN or c == curses.KEY_UP or c == curses.KEY_NPAGE or c == curses.KEY_PPAGE:
+                return c
+
+        return None
+
+class FilterStatusDialog(object):
+    def __init__(self, browser, current_filter):
+        if not current_filter:
+            current_filter = STATUSES.copy()
+        self.current_filter = current_filter
+        self.SELECTED = browser.SELECTED
+        self.UNSELECTED = browser.UNSELECTED
+        (ph, pw) = browser.win.getmaxyx()
+        dh = len(STATUSES) + 2
+        dw = 16
+        self.win = curses.newwin(dh, dw, int((ph - dh)/2), int((pw - dw)/2))
+        self.win.scrollok(True)
+        self.win.box(0, 0)
+        self.win.refresh()
+        self.win.keypad(True)
+        self.row = 0
+
+    def redraw(self):
+        row = 0
+        for status in STATUSES:
+            self.win.move(row + 1, 1)
+            if row == self.row:
+                attr = self.SELECTED
+            else:
+                attr = self.UNSELECTED
+            if status in self.current_filter:
+                self.win.addstr("[X] " + status, attr)
+            else:
+                self.win.addstr("[ ] " + status, attr)
+            row += 1
+
+    def scroll(self, offset):
+        self.row += offset
+        if self.row < 0:
+            self.row = 0;
+        elif self.row >= len(STATUSES):
+            self.row = len(STATUSES) - 1
+
+    def toggle(self):
+        status = STATUSES[self.row]
+        if status in self.current_filter:
+            self.current_filter.remove(status)
+        else:
+            self.current_filter.append(status)
+
+    def run(self):
+        while True:
+            self.redraw()
+            c = self.win.getch()
+            if c == ord('q'):
+                return self.current_filter
+            elif c == ord('\n') or c == ord(' '):
+                self.toggle()
+            elif c == curses.KEY_DOWN:
+                self.scroll(1)
+            elif c == curses.KEY_UP:
+                self.scroll(-1)
+
+class FilterTestDialog(object):
+    def __init__(self, browser, current_filter):
+        if not current_filter:
+            current_filter = "";
+        (ph, pw) = browser.win.getmaxyx()
+        dh = 1
+        dw = 35
+        dx = int((pw - dw)/2)
+        dy = int((ph - dh)/2)
+        curses.textpad.rectangle(browser.win, dy-1, dx-1, dy+dh, dx+dw)
+        self.win = curses.newwin(dh, dw, dy, dx)
+        self.win.scrollok(True)
+        self.win.keypad(True)
+        browser.set_footer("Enter test name filter.  Ctrl-G to finish")
+        self.box = curses.textpad.Textbox(self.win)
+
+    def run(self):
+        contents = self.box.edit()
+        if contents == "":
+            return None
+        return contents.strip()
+
+class Browser(object):
+    def __init__(self, stdscr, results):
+        # Would be nice to have the color_pair's global/const, but we can't
+        # construct them until curses is initialized
+        self.HEADER = color_pair(1, curses.COLOR_WHITE, curses.COLOR_GREEN)
+        self.HEADER_INV = color_pair(2, curses.COLOR_GREEN, curses.COLOR_WHITE)
+        self.FOOTER = color_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE)
+        self.SELECTED = color_pair(4, curses.COLOR_BLACK, curses.COLOR_WHITE)
+        self.UNSELECTED = color_pair(5, curses.COLOR_WHITE, curses.COLOR_BLACK)
+        self.PASS = color_pair(6, curses.COLOR_GREEN, curses.COLOR_BLACK)
+        self.WARN = color_pair(7, curses.COLOR_YELLOW, curses.COLOR_BLACK)
+        self.FAIL = color_pair(8, curses.COLOR_RED, curses.COLOR_BLACK)
+        self.win = stdscr
+        self.cursor = 0    # selected row (in screen-space)
+        self.row = 0       # scroll offset
+        self.col = 1       # selected column
+        self.w = 0         # width of screen
+        self.h = 0         # height of screen
+        self.status_w = 10 # width of status column
+        self.visible_rows = 0
+        self.num_rows = 0;
+        self.set_footer("Loading " + results)
+        self.win.refresh()
+        self.status_filter = None
+        self.test_filter = None
+        self.results = Results([backends.load(results)])
+        self.update_filtered_names()
+
+    def update_filtered_names(self):
+        l = self.results.names.all
+        def get_status(val):
+            return self.results.get_result(val)[0]
+        if self.status_filter:
+            l = filter(lambda val: get_status(val) in self.status_filter, l)
+        if self.test_filter:
+            l = filter(lambda val: self.test_filter in grouptools.format(val), l)
+        if self.col == 0:   # sort by status:
+            l = sorted(l, key=get_status)
+        else:               # sort by test name:
+            l = sorted(l)
+        self.filtered = l
+        self.num_rows = len(l)
+
+    def update_size(self):
+        (self.h, self.w) = self.win.getmaxyx()
+        # results scrolling area is everything but one line of header and
+        # one line of footer:
+        self.visible_rows = self.h - 2
+
+    def set_footer(self, left, right=None):
+        self.update_size()
+        self.win.move(self.h - 1, 0)
+        self.win.attron(self.FOOTER)
+        self.win.addstr(left)
+        self.win.hline(" ", self.w - len(left))
+        if right:
+            self.win.move(self.h - 1, self.w - len(right) - 1)
+            self.win.addstr(right)
+        self.win.attroff(self.FOOTER)
+        self.win.refresh()
+
+    def redraw_header(self):
+        self.win.move(0, 0)
+        if self.col == 0:
+            stat_attr = self.HEADER_INV
+            test_attr = self.HEADER
+        else:
+            stat_attr = self.HEADER
+            test_attr = self.HEADER_INV
+        stat = "STATUS"
+        test = "TEST"
+        self.win.addstr(stat, stat_attr)
+        self.win.hline(" ", self.status_w - len(stat), stat_attr)
+        self.win.move(0, self.status_w)
+        self.win.addstr("|", self.HEADER)
+        self.win.addstr(test, test_attr)
+        self.win.hline(" ", self.w - self.status_w - len(test) - 1, test_attr)
+
+    def redraw_body(self):
+        for row in range(0, self.visible_rows):
+            idx = row + self.row
+            if idx < len(self.filtered):
+                test = self.filtered[idx]
+                name = grouptools.format(test)
+                status = str(self.results.get_result(test)[0])
+            else:
+                name = ""
+                status = ""
+            if row == self.cursor:
+                attr = self.SELECTED
+            elif status == "pass":
+                attr = self.PASS
+            elif status == "fail" or status == "crash" or status == "dmesg-fail":
+                attr = self.FAIL
+            elif status == "timeout" or status == "warn" or status == "dmesg-warn":
+                attr = self.WARN
+            else:
+                attr = self.UNSELECTED
+            self.win.move(row + 1, 0)
+            self.win.addstr(status, attr)
+            self.win.hline(" ", self.status_w - len(status), attr)
+            self.win.move(row + 1, self.status_w)
+            self.win.addstr("| ", attr)
+            self.win.addstr(name, attr)
+            self.win.hline(" ", self.w - self.status_w - len(name), attr)
+
+    def redraw(self):
+        self.update_size()
+        self.redraw_header()
+        self.redraw_body()
+        col = COLUMNS[self.col]
+        footer = ("(S)ort by " + COLUMNS[self.col] +
+                ", (R)un filtered tests"
+                ", (F)ilter on " + COLUMNS[self.col] +
+                ", (C)lear filters" +
+                ", (Q)uit")
+        if self.num_rows > 0:
+            position = int(10000 * (self.row + self.visible_rows) / self.num_rows) / 100
+        else:
+            position = 0
+        if position > 100.0:
+            position = 100.0
+        self.set_footer(footer, str(position) + "%")
+
+    def validate_coords(self):
+        if self.row < 0:
+            self.row = 0
+        if self.row >= self.num_rows:
+            self.row = self.num_rows - 1
+        if self.col < 0:
+            self.col = 0
+        if self.col >= len(COLUMNS):
+            self.col = len(COLUMNS) - 1
+
+    def scroll(self, offset):
+        self.cursor += offset
+
+        # bounds check cursor, and scroll if needed to keep the
+        # cursor in a valid position:
+        if self.cursor < 0:
+            # add negative offset to scroll position to keep
+            # cursor on screen:
+            self.row += self.cursor
+            self.cursor = 0
+        elif self.cursor > self.visible_rows - 1:
+            # add excess to scroll position to keep cursor
+            # on screen
+            self.row += self.cursor - self.visible_rows - 1
+            self.cursor = self.visible_rows - 1
+
+        # also don't scroll past last row of results:
+        self.validate_coords()
+        if (self.row + self.cursor) > self.num_rows:
+            self.cursor = self.num_rows - self.row - 1
+
+    def handle_nav_key(self, c):
+        if c == curses.KEY_HOME:
+            self.row = self.col = self.cursor = 0
+        elif c == curses.KEY_LEFT:
+            self.col -= 1
+        elif c == curses.KEY_RIGHT:
+            self.col += 1
+        elif c == curses.KEY_DOWN:
+            self.scroll(1)
+        elif c == curses.KEY_UP:
+            self.scroll(-1)
+        elif c == curses.KEY_NPAGE:  # page-down
+            self.scroll(self.visible_rows)
+        elif c == curses.KEY_PPAGE:  # page-up
+            self.scroll(-self.visible_rows)
+        self.validate_coords()
+
+    def run(self):
+        self.redraw()
+
+        while True:
+            self.update_size()
+            c = self.win.getch()
+            if c == ord('q'):
+                break;
+            elif c == ord('s'):
+                self.update_filtered_names()
+            elif c == ord('c'):
+                self.status_filter = None
+                self.test_filter = None
+                self.update_filtered_names()
+            elif c == ord('f'):
+                if self.col == 0:  # filter on status
+                    d = FilterStatusDialog(self, self.status_filter)
+                    self.status_filter = d.run()
+                else:              # filter on test name
+                    d = FilterTestDialog(self, self.test_filter)
+                    self.test_filter = d.run()
+                self.row = 0
+                self.update_filtered_names()
+            elif c == ord('\n'):
+                while True:
+                    self.set_footer("(R)un selected test, (Q) close test dialog")
+                    test = self.filtered[self.cursor + self.row]
+                    d = ResultDialog(self, self.results, test)
+                    c2 = d.run()
+                    if not c2:
+                        break
+                    self.handle_nav_key(c2)
+                    self.redraw()
+            else:
+                self.handle_nav_key(c)
+
+            self.redraw()
+
+def main(stdscr):
+    browser = Browser(stdscr, sys.argv[1])
+    browser.run()
+
+curses.wrapper(main)