feat: server search/filter + test-all health column (#27, #28)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s

This commit is contained in:
Cowork Supervisor
2026-07-08 11:53:31 -04:00
parent 4c6fe7c5aa
commit 668fb903d0
3 changed files with 315 additions and 2 deletions
+116 -2
View File
@@ -72,6 +72,12 @@ WARN = "#fbbf24"
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
STATUS_GLYPH = {"ok": "", "missing": "", "warn": "", "remote": "", "unknown": ""}
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
# server tables' "Health" column -- distinct from the PATH-dependency Status
# column above.
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
HEALTH_GLYPH = {"ok": "", "failed": "", "untested": ""}
STYLESHEET = f"""
/* No font-family here on purpose: Qt already uses the native system UI font
on every platform (San Francisco / Segoe UI / desktop default). Naming
@@ -1416,6 +1422,12 @@ class MainWindow(QMainWindow):
self._focused_table = None
self._row_of_index: dict[int, tuple] = {}
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
self._filter_query = ""
self._health: dict[str, tuple[str, str]] = {} # server name -> (HealthStatus, summary)
self._test_all_queue: list[core.ServerEntry] = []
self._test_all_total = 0
self._test_all_done = 0
self._health_tester: SpawnTester | None = None
central = QWidget()
self.setCentralWidget(central)
@@ -1533,10 +1545,10 @@ class MainWindow(QMainWindow):
# --- left (server table) -------------------------------------------- #
def _make_server_table(self, object_name=None):
t = QTableWidget(0, 4)
t = QTableWidget(0, 5)
if object_name:
t.setObjectName(object_name)
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"])
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"])
t.verticalHeader().setVisible(False)
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -1546,6 +1558,7 @@ class MainWindow(QMainWindow):
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
h.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl))
t.itemChanged.connect(self._table_item_changed)
return t
@@ -1560,6 +1573,12 @@ class MainWindow(QMainWindow):
head.setObjectName("h1")
v.addWidget(head)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._on_search_changed)
v.addWidget(self.search_box)
# Active and Disabled sections live in a vertical splitter so the user
# can drag the divider instead of being stuck with a fixed-height
# disabled list.
@@ -1616,12 +1635,17 @@ class MainWindow(QMainWindow):
self.undo_btn = QPushButton("Undo")
self.undo_btn.setEnabled(False)
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
self.test_all_btn = QPushButton("Test all")
self.test_all_btn.setToolTip(
"Spawn-test every enabled local server, one at a time, and fill in the Health column"
)
self.add_btn.clicked.connect(self.add_server)
self.dup_btn.clicked.connect(self.duplicate_server)
self.del_btn.clicked.connect(self.delete_server)
self.paste_btn.clicked.connect(self.paste_json)
self.copy_btn.clicked.connect(self.copy_to_menu)
self.undo_btn.clicked.connect(self._undo)
self.test_all_btn.clicked.connect(self._test_all_servers)
for b in (
self.add_btn,
self.dup_btn,
@@ -1629,6 +1653,7 @@ class MainWindow(QMainWindow):
self.paste_btn,
self.copy_btn,
self.undo_btn,
self.test_all_btn,
):
bar.addWidget(b)
# Ctrl+Z shortcut
@@ -1733,6 +1758,7 @@ class MainWindow(QMainWindow):
self.restart_btn.hide()
self._undo_stack.clear()
self.undo_btn.setEnabled(False)
self._health.clear() # health results are per-profile; a fresh load invalidates them
self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False)
if repaired:
@@ -1769,6 +1795,14 @@ class MainWindow(QMainWindow):
QColor(STATUS_COLORS.get(dep["status"], MUTED))
) # status stays colored even when off
table.setItem(r, 3, st)
health_status, health_summary = self._health.get(s.name, (core.HealthStatus.UNTESTED, ""))
if s.kind == "remote" and s.name not in self._health:
health_summary = "remote server — use “Test connection” in the editor"
health_item = QTableWidgetItem(HEALTH_GLYPH.get(health_status, ""))
health_item.setForeground(QColor(HEALTH_COLORS.get(health_status, MUTED)))
health_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
health_item.setToolTip(health_summary or health_status)
table.setItem(r, 4, health_item)
self._row_of_index[master_idx] = (table, r)
def _refresh_tables(self, select_index=None):
@@ -1777,8 +1811,16 @@ class MainWindow(QMainWindow):
self._row_of_index = {}
self.active_table.setRowCount(0)
self.disabled_table.setRowCount(0)
query = self._filter_query
n_active = n_disabled = 0
total_active = total_disabled = 0
for i, s in enumerate(self.servers):
if s.enabled:
total_active += 1
else:
total_disabled += 1
if not core.server_matches_filter(s, query):
continue
if s.enabled:
self._add_row(self.active_table, i, s)
n_active += 1
@@ -1787,8 +1829,18 @@ class MainWindow(QMainWindow):
n_disabled += 1
self.active_table.setVisible(n_active > 0)
self.active_empty.setVisible(n_active == 0)
self.active_empty.setText(
"No matches."
if query.strip() and total_active and not n_active
else "No active servers. Add one, or Paste JSON."
)
self.disabled_table.setVisible(n_disabled > 0)
self.disabled_empty.setVisible(n_disabled == 0)
self.disabled_empty.setText(
"No matches."
if query.strip() and total_disabled and not n_disabled
else "Nothing disabled."
)
self._refresh_badges()
self._suppress_table = False
self._suppress_sel = False
@@ -1800,6 +1852,58 @@ class MainWindow(QMainWindow):
self._load_editor_from_selection()
self._validate()
# --- search / filter -------------------------------------------------- #
def _on_search_changed(self, text):
self._filter_query = text
cur = self._current_index()
self._refresh_tables(select_index=cur if cur >= 0 else None)
# --- test all (spawn-test every enabled local server) ---------------- #
def _test_all_servers(self):
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
if not targets:
self.status.setText("No enabled local servers to test.")
return
self._test_all_queue = list(targets)
self._test_all_total = len(targets)
self._test_all_done = 0
self.test_all_btn.setEnabled(False)
self.test_all_btn.setText(f"Testing 0/{self._test_all_total}")
self._run_next_health_test()
def _run_next_health_test(self):
if not self._test_all_queue:
self.test_all_btn.setEnabled(True)
self.test_all_btn.setText("Test all")
self.status.setText(f"Tested {self._test_all_done} server(s).")
return
entry = self._test_all_queue.pop(0)
self._health_tester = SpawnTester(dict(entry.data), timeout=3.0)
self._health_tester.done.connect(
lambda result, name=entry.name: self._on_health_test_done(name, result)
)
self._health_tester.start()
def _on_health_test_done(self, name, result):
status, summary = core.health_from_spawn_result(result)
self._health[name] = (status, summary)
self._test_all_done += 1
self.test_all_btn.setText(f"Testing {self._test_all_done}/{self._test_all_total}")
self._update_health_cell(name, status, summary)
self._run_next_health_test()
def _update_health_cell(self, name, status, summary):
idx = next((i for i, s in enumerate(self.servers) if s.name == name), None)
if idx is None or idx not in self._row_of_index:
return
table, row = self._row_of_index[idx]
item = table.item(row, 4)
if item is None:
return
item.setText(HEALTH_GLYPH.get(status, ""))
item.setForeground(QColor(HEALTH_COLORS.get(status, MUTED)))
item.setToolTip(summary or status)
def _section_html(self, title, n, miss, warn):
base = f"{title} · {n}"
if miss:
@@ -1906,6 +2010,9 @@ class MainWindow(QMainWindow):
entry.data = self.editor.dump_data()
# The server stays in its section (enable state unchanged), so update
# its existing row in place rather than re-rendering.
# An edit invalidates any cached "Test all" result -- the server that
# was spawn-tested no longer matches what's on disk once saved.
self._health.pop(entry.name, None)
loc = self._row_of_index.get(idx)
if loc:
table, row = loc
@@ -1916,6 +2023,13 @@ class MainWindow(QMainWindow):
st = table.item(row, 3)
st.setText(f"{STATUS_GLYPH.get(dep['status'], '')} {dep['label']}")
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
health_item = table.item(row, 4)
if health_item is not None:
health_item.setText(HEALTH_GLYPH.get(core.HealthStatus.UNTESTED, ""))
health_item.setForeground(
QColor(HEALTH_COLORS.get(core.HealthStatus.UNTESTED, MUTED))
)
health_item.setToolTip("Not tested since last edit.")
self._suppress_table = False
self._refresh_badges()
self._mark_dirty()