docker-orb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docker_orb/__init__.py +0 -0
- docker_orb/__main__.py +5 -0
- docker_orb/_scrollbar.py +136 -0
- docker_orb/cli.py +167 -0
- docker_orb/colors.py +60 -0
- docker_orb/compose.py +391 -0
- docker_orb/config.py +244 -0
- docker_orb/inject.py +160 -0
- docker_orb/jsonlog.py +125 -0
- docker_orb/models.py +132 -0
- docker_orb/viewer/__init__.py +0 -0
- docker_orb/viewer/app.py +824 -0
- docker_orb/viewer/panels/__init__.py +0 -0
- docker_orb/viewer/panels/health.py +239 -0
- docker_orb/viewer/panels/main_stream.py +389 -0
- docker_orb/viewer/panels/monitor.py +233 -0
- docker_orb/viewer/panels/search.py +182 -0
- docker_orb/viewer/viewer.tcss +205 -0
- docker_orb/viewer/widgets.py +668 -0
- docker_orb/wizard/__init__.py +0 -0
- docker_orb/wizard/app.py +40 -0
- docker_orb/wizard/screens.py +692 -0
- docker_orb/wizard/wizard.tcss +329 -0
- docker_orb-1.0.0.dist-info/METADATA +192 -0
- docker_orb-1.0.0.dist-info/RECORD +28 -0
- docker_orb-1.0.0.dist-info/WHEEL +4 -0
- docker_orb-1.0.0.dist-info/entry_points.txt +3 -0
- docker_orb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MonitorPanel — passive accumulation of monitor-string hits.
|
|
3
|
+
|
|
4
|
+
Lines matching monitor patterns are copied here without interrupting the main stream.
|
|
5
|
+
A single click selects a hit (Enter then shows ± context lines from the buffer,
|
|
6
|
+
like `grep -C`); double-clicking pauses the main stream and jumps to it.
|
|
7
|
+
Stream mode only.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import ScrollableContainer, Vertical
|
|
18
|
+
from textual.message import Message
|
|
19
|
+
from textual.widgets import Label
|
|
20
|
+
|
|
21
|
+
from ...config import matches
|
|
22
|
+
from ...models import LogLine
|
|
23
|
+
from ..widgets import DragResizeHeader
|
|
24
|
+
|
|
25
|
+
# Cap the number of rendered rows to avoid layout slowdown with large hit counts.
|
|
26
|
+
# _lines tracks the true total; only the most recent MAX_ROWS are shown.
|
|
27
|
+
_MAX_ROWS = 500
|
|
28
|
+
|
|
29
|
+
# Two clicks on the same row within this window count as a double-click.
|
|
30
|
+
_DOUBLE_CLICK_SECONDS = 0.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MonitorPanel(Vertical):
|
|
34
|
+
"""Collapsible monitor accumulation panel."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_HEIGHT = "20%"
|
|
37
|
+
|
|
38
|
+
class LineSelected(Message):
|
|
39
|
+
"""Posted when the user double-clicks a monitor hit."""
|
|
40
|
+
def __init__(self, line: LogLine) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.line = line
|
|
43
|
+
|
|
44
|
+
def __init__(self, **kwargs) -> None:
|
|
45
|
+
super().__init__(**kwargs)
|
|
46
|
+
self._collapsed = False
|
|
47
|
+
self._pre_collapse_height = None
|
|
48
|
+
self._lines: list[LogLine] = []
|
|
49
|
+
self._color_map: dict[str, str] = {}
|
|
50
|
+
self._color_full_line = False
|
|
51
|
+
self._patterns: list[re.Pattern] = []
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
yield _MonitorHeader(id="monitor-header")
|
|
55
|
+
with ScrollableContainer(id="monitor-scroll"):
|
|
56
|
+
yield Vertical(id="monitor-list")
|
|
57
|
+
|
|
58
|
+
def set_color_map(self, color_map: dict[str, str]) -> None:
|
|
59
|
+
self._color_map = color_map
|
|
60
|
+
|
|
61
|
+
def set_color_mode(self, full_line: bool) -> None:
|
|
62
|
+
self._color_full_line = full_line
|
|
63
|
+
|
|
64
|
+
def set_patterns(self, patterns: list[re.Pattern]) -> None:
|
|
65
|
+
self._patterns = patterns
|
|
66
|
+
|
|
67
|
+
def set_wrap(self, wrap: bool) -> None:
|
|
68
|
+
sc = self.query_one("#monitor-scroll", ScrollableContainer)
|
|
69
|
+
sc.set_class(wrap, "wrap-on")
|
|
70
|
+
sc.set_class(not wrap, "wrap-off")
|
|
71
|
+
|
|
72
|
+
def add_line(self, line: LogLine, color: str) -> None:
|
|
73
|
+
self._lines.append(line)
|
|
74
|
+
scroll = self.query_one("#monitor-scroll", ScrollableContainer)
|
|
75
|
+
inner = self.query_one("#monitor-list", Vertical)
|
|
76
|
+
|
|
77
|
+
at_top = scroll.scroll_y <= 1
|
|
78
|
+
|
|
79
|
+
text = _build_text(line, color, self._patterns, self._color_full_line)
|
|
80
|
+
# Prepend — newest hit at the top. mount(before=0) on Vertical is reliable.
|
|
81
|
+
inner.mount(_MonitorRow(text, line), before=0)
|
|
82
|
+
|
|
83
|
+
# Evict the oldest rendered row when we exceed the cap
|
|
84
|
+
if len(inner.children) > _MAX_ROWS:
|
|
85
|
+
inner.children[-1].remove()
|
|
86
|
+
|
|
87
|
+
if at_top:
|
|
88
|
+
scroll.scroll_home(animate=False)
|
|
89
|
+
|
|
90
|
+
self.query_one(_MonitorHeader).update_count(len(self._lines))
|
|
91
|
+
|
|
92
|
+
def rebuild(self, buffer: list[LogLine]) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Recompute the entire hit list from the full log buffer through the
|
|
95
|
+
current monitor patterns. Called when monitors are edited live so
|
|
96
|
+
unchecking a pattern drops its accumulated hits (and checking a new
|
|
97
|
+
one back-fills matches already in the buffer).
|
|
98
|
+
"""
|
|
99
|
+
inner = self.query_one("#monitor-list", Vertical)
|
|
100
|
+
inner.remove_children()
|
|
101
|
+
|
|
102
|
+
matched = (
|
|
103
|
+
[line for line in buffer if matches(line.content, self._patterns)]
|
|
104
|
+
if self._patterns
|
|
105
|
+
else []
|
|
106
|
+
)
|
|
107
|
+
self._lines = matched
|
|
108
|
+
|
|
109
|
+
# Render only the most recent _MAX_ROWS, prepending in order so the
|
|
110
|
+
# newest hit ends up on top (matching add_line's ordering).
|
|
111
|
+
for line in matched[-_MAX_ROWS:]:
|
|
112
|
+
color = self._color_map.get(line.container_name, "#ffffff")
|
|
113
|
+
inner.mount(_MonitorRow(_build_text(line, color, self._patterns, self._color_full_line), line), before=0)
|
|
114
|
+
|
|
115
|
+
self.query_one(_MonitorHeader).update_count(len(self._lines))
|
|
116
|
+
|
|
117
|
+
def toggle_collapsed(self) -> None:
|
|
118
|
+
self._collapsed = not self._collapsed
|
|
119
|
+
self.query_one("#monitor-scroll").display = not self._collapsed
|
|
120
|
+
self.set_class(self._collapsed, "-collapsed")
|
|
121
|
+
if self._collapsed:
|
|
122
|
+
self._pre_collapse_height = self.styles.height
|
|
123
|
+
self.styles.height = "auto"
|
|
124
|
+
else:
|
|
125
|
+
self.styles.height = self._pre_collapse_height
|
|
126
|
+
self.query_one(_MonitorHeader).update_collapsed(self._collapsed)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _MonitorRow(Label):
|
|
130
|
+
"""
|
|
131
|
+
A single monitor hit. Click selects (and focuses) it — Enter then opens
|
|
132
|
+
a modal with ± context lines from the buffer around it. Double-click
|
|
133
|
+
instead posts MonitorPanel.LineSelected, pausing and jumping to it in
|
|
134
|
+
the main stream.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
can_focus = True
|
|
138
|
+
|
|
139
|
+
BINDINGS = [
|
|
140
|
+
Binding("enter", "show_context", "Context", show=True),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def __init__(self, text: Text, line: LogLine, **kwargs) -> None:
|
|
144
|
+
super().__init__(text, **kwargs)
|
|
145
|
+
self.line = line
|
|
146
|
+
self._last_click = 0.0
|
|
147
|
+
|
|
148
|
+
def on_click(self) -> None:
|
|
149
|
+
self.focus()
|
|
150
|
+
now = time.monotonic()
|
|
151
|
+
if now - self._last_click < _DOUBLE_CLICK_SECONDS:
|
|
152
|
+
self.post_message(MonitorPanel.LineSelected(self.line))
|
|
153
|
+
self._last_click = 0.0 # require two fresh clicks for the next jump
|
|
154
|
+
else:
|
|
155
|
+
self._last_click = now
|
|
156
|
+
|
|
157
|
+
def action_show_context(self) -> None:
|
|
158
|
+
app = self.app
|
|
159
|
+
if hasattr(app, "show_monitor_context"):
|
|
160
|
+
app.show_monitor_context(self.line) # type: ignore[union-attr]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_text(
|
|
164
|
+
line: LogLine,
|
|
165
|
+
color: str,
|
|
166
|
+
patterns: list[re.Pattern],
|
|
167
|
+
full_line: bool = False,
|
|
168
|
+
) -> Text:
|
|
169
|
+
text = Text()
|
|
170
|
+
if full_line:
|
|
171
|
+
text.append(f"[{line.container_name}] ", style=f"bold {color}")
|
|
172
|
+
if patterns:
|
|
173
|
+
_append_highlighted(text, line.content, patterns,
|
|
174
|
+
base_style=color, hl_style=f"bold {color} on #333300")
|
|
175
|
+
else:
|
|
176
|
+
text.append(line.content, style=color)
|
|
177
|
+
else:
|
|
178
|
+
text.append(f"[{line.container_name}] ", style=color)
|
|
179
|
+
if patterns:
|
|
180
|
+
_append_highlighted(text, line.content, patterns)
|
|
181
|
+
else:
|
|
182
|
+
text.append(line.content)
|
|
183
|
+
return text
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _append_highlighted(
|
|
187
|
+
text: Text,
|
|
188
|
+
content: str,
|
|
189
|
+
patterns: list[re.Pattern],
|
|
190
|
+
base_style: str = "",
|
|
191
|
+
hl_style: str = "bold #ffff00 on #333300",
|
|
192
|
+
) -> None:
|
|
193
|
+
spans: list[tuple[int, int]] = []
|
|
194
|
+
for pat in patterns:
|
|
195
|
+
for m in pat.finditer(content):
|
|
196
|
+
if m.start() < m.end():
|
|
197
|
+
spans.append((m.start(), m.end()))
|
|
198
|
+
spans.sort()
|
|
199
|
+
merged: list[tuple[int, int]] = []
|
|
200
|
+
for start, end in spans:
|
|
201
|
+
if merged and start <= merged[-1][1]:
|
|
202
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
203
|
+
else:
|
|
204
|
+
merged.append((start, end))
|
|
205
|
+
cursor = 0
|
|
206
|
+
for start, end in merged:
|
|
207
|
+
if cursor < start:
|
|
208
|
+
text.append(content[cursor:start], style=base_style)
|
|
209
|
+
text.append(content[start:end], style=hl_style)
|
|
210
|
+
cursor = end
|
|
211
|
+
if cursor < len(content):
|
|
212
|
+
text.append(content[cursor:], style=base_style)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _MonitorHeader(DragResizeHeader):
|
|
216
|
+
def __init__(self, **kwargs) -> None:
|
|
217
|
+
super().__init__("", **kwargs)
|
|
218
|
+
self._count = 0
|
|
219
|
+
self._collapsed = False
|
|
220
|
+
self._refresh()
|
|
221
|
+
|
|
222
|
+
def update_count(self, count: int) -> None:
|
|
223
|
+
self._count = count
|
|
224
|
+
self._refresh()
|
|
225
|
+
|
|
226
|
+
def update_collapsed(self, collapsed: bool) -> None:
|
|
227
|
+
self._collapsed = collapsed
|
|
228
|
+
self._refresh()
|
|
229
|
+
|
|
230
|
+
def _refresh(self) -> None:
|
|
231
|
+
arrow = "▶" if self._collapsed else "▼"
|
|
232
|
+
suffix = f" ({self._count} hits)" if self._count else ""
|
|
233
|
+
self.update(f"{arrow} Monitor{suffix}")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SearchPanel — live search across the log buffer.
|
|
3
|
+
|
|
4
|
+
Filters the main stream buffer in real time as the user types.
|
|
5
|
+
Double-clicking a result pauses the main stream and jumps to that line.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.message import Message
|
|
18
|
+
from textual.widgets import Input, Label, ListItem, ListView
|
|
19
|
+
|
|
20
|
+
from ...models import LogLine
|
|
21
|
+
from ..widgets import DragResizeHeader
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SearchPanel(Vertical):
|
|
25
|
+
"""Collapsible search panel."""
|
|
26
|
+
|
|
27
|
+
DEFAULT_HEIGHT = "20%"
|
|
28
|
+
|
|
29
|
+
BINDINGS = [
|
|
30
|
+
Binding("escape", "close", "Close search", show=False),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
class LineSelected(Message):
|
|
34
|
+
"""Posted when user double-clicks a search result."""
|
|
35
|
+
def __init__(self, line: LogLine) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.line = line
|
|
38
|
+
|
|
39
|
+
def __init__(self, **kwargs) -> None:
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self._collapsed = False
|
|
42
|
+
self._pre_collapse_height = None
|
|
43
|
+
self._results: list[LogLine] = []
|
|
44
|
+
self._color_map: dict[str, str] = {}
|
|
45
|
+
self._color_full_line = False
|
|
46
|
+
self._pattern: re.Pattern | None = None
|
|
47
|
+
self._debounce_task: asyncio.Task | None = None
|
|
48
|
+
self._last_click: dict[int, float] = {}
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
yield _SearchHeader(id="search-header")
|
|
52
|
+
yield Input(placeholder="Search logs …", id="search-input")
|
|
53
|
+
yield ListView(id="search-results")
|
|
54
|
+
|
|
55
|
+
def set_color_map(self, color_map: dict[str, str]) -> None:
|
|
56
|
+
self._color_map = color_map
|
|
57
|
+
|
|
58
|
+
def set_color_mode(self, full_line: bool) -> None:
|
|
59
|
+
self._color_full_line = full_line
|
|
60
|
+
|
|
61
|
+
def set_wrap(self, wrap: bool) -> None:
|
|
62
|
+
lv = self.query_one("#search-results", ListView)
|
|
63
|
+
lv.set_class(wrap, "wrap-on")
|
|
64
|
+
lv.set_class(not wrap, "wrap-off")
|
|
65
|
+
|
|
66
|
+
def search(self, buffer: list[LogLine], query: str) -> None:
|
|
67
|
+
lv = self.query_one("#search-results", ListView)
|
|
68
|
+
lv.clear()
|
|
69
|
+
self._results = []
|
|
70
|
+
self._pattern = None
|
|
71
|
+
self._last_click.clear()
|
|
72
|
+
|
|
73
|
+
if not query:
|
|
74
|
+
self.query_one(_SearchHeader).update_count(0)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
79
|
+
except re.error:
|
|
80
|
+
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
|
81
|
+
|
|
82
|
+
self._pattern = pattern
|
|
83
|
+
matched = [line for line in buffer if pattern.search(line.content)]
|
|
84
|
+
self._results = matched
|
|
85
|
+
|
|
86
|
+
for line in matched[-500:]:
|
|
87
|
+
color = self._color_map.get(line.container_name, "#ffffff")
|
|
88
|
+
text = Text()
|
|
89
|
+
if self._color_full_line:
|
|
90
|
+
text.append(f"[{line.container_name}] ", style=f"bold {color}")
|
|
91
|
+
_append_highlighted(text, line.content, pattern,
|
|
92
|
+
base_style=color, hl_style=f"bold {color} on #333300")
|
|
93
|
+
else:
|
|
94
|
+
text.append(f"[{line.container_name}] ", style=color)
|
|
95
|
+
_append_highlighted(text, line.content, pattern)
|
|
96
|
+
lv.append(ListItem(Label(text)))
|
|
97
|
+
|
|
98
|
+
self.query_one(_SearchHeader).update_count(len(matched))
|
|
99
|
+
|
|
100
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
101
|
+
idx = event.list_view.index
|
|
102
|
+
if idx is None:
|
|
103
|
+
return
|
|
104
|
+
now = time.monotonic()
|
|
105
|
+
last = self._last_click.get(idx, 0.0)
|
|
106
|
+
self._last_click[idx] = now
|
|
107
|
+
if now - last < 0.5:
|
|
108
|
+
# Double-click
|
|
109
|
+
if idx < len(self._results):
|
|
110
|
+
self.post_message(self.LineSelected(self._results[idx]))
|
|
111
|
+
|
|
112
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
113
|
+
if event.input.id == "search-input":
|
|
114
|
+
if self._debounce_task is not None:
|
|
115
|
+
self._debounce_task.cancel()
|
|
116
|
+
self._debounce_task = asyncio.get_event_loop().create_task(
|
|
117
|
+
self._debounced_search(event.value)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def _debounced_search(self, query: str) -> None:
|
|
121
|
+
await asyncio.sleep(0.3)
|
|
122
|
+
app = self.app
|
|
123
|
+
if hasattr(app, "_buffer"):
|
|
124
|
+
self.search(app._buffer, query) # type: ignore[union-attr]
|
|
125
|
+
|
|
126
|
+
def toggle_collapsed(self) -> None:
|
|
127
|
+
self._collapsed = not self._collapsed
|
|
128
|
+
for widget in self.query("#search-input, #search-results"):
|
|
129
|
+
widget.display = not self._collapsed
|
|
130
|
+
self.set_class(self._collapsed, "-collapsed")
|
|
131
|
+
if self._collapsed:
|
|
132
|
+
self._pre_collapse_height = self.styles.height
|
|
133
|
+
self.styles.height = "auto"
|
|
134
|
+
else:
|
|
135
|
+
self.styles.height = self._pre_collapse_height
|
|
136
|
+
self.query_one(_SearchHeader).update_collapsed(self._collapsed)
|
|
137
|
+
|
|
138
|
+
def action_close(self) -> None:
|
|
139
|
+
self.display = False
|
|
140
|
+
try:
|
|
141
|
+
self.app.query_one("#stream-log").focus()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _append_highlighted(
|
|
147
|
+
text: Text,
|
|
148
|
+
content: str,
|
|
149
|
+
pattern: re.Pattern,
|
|
150
|
+
base_style: str = "",
|
|
151
|
+
hl_style: str = "bold #ffff00",
|
|
152
|
+
) -> None:
|
|
153
|
+
cursor = 0
|
|
154
|
+
for m in pattern.finditer(content):
|
|
155
|
+
if m.start() > cursor:
|
|
156
|
+
text.append(content[cursor:m.start()], style=base_style)
|
|
157
|
+
text.append(content[m.start():m.end()], style=hl_style)
|
|
158
|
+
cursor = m.end()
|
|
159
|
+
if cursor < len(content):
|
|
160
|
+
text.append(content[cursor:], style=base_style)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _SearchHeader(DragResizeHeader):
|
|
164
|
+
def __init__(self, **kwargs) -> None:
|
|
165
|
+
super().__init__("", markup=True, **kwargs)
|
|
166
|
+
self._count = 0
|
|
167
|
+
self._collapsed = False
|
|
168
|
+
self._refresh()
|
|
169
|
+
|
|
170
|
+
def update_count(self, count: int) -> None:
|
|
171
|
+
self._count = count
|
|
172
|
+
self._refresh()
|
|
173
|
+
|
|
174
|
+
def update_collapsed(self, collapsed: bool) -> None:
|
|
175
|
+
self._collapsed = collapsed
|
|
176
|
+
self._refresh()
|
|
177
|
+
|
|
178
|
+
def _refresh(self) -> None:
|
|
179
|
+
arrow = "▶" if self._collapsed else "▼"
|
|
180
|
+
suffix = f" ({self._count} results)" if self._count else ""
|
|
181
|
+
hint = "" if self._collapsed else " [dim]/ or Esc to close[/dim]"
|
|
182
|
+
self.update(f"{arrow} Search{suffix}{hint}")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/* Viewer layout */
|
|
2
|
+
|
|
3
|
+
Checkbox {
|
|
4
|
+
height: auto;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0 1;
|
|
7
|
+
background: transparent;
|
|
8
|
+
border: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Checkbox > .toggle--button {
|
|
12
|
+
color: $panel;
|
|
13
|
+
background: $panel-darken-1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Checkbox.-on > .toggle--button {
|
|
17
|
+
color: $text;
|
|
18
|
+
background: $success;
|
|
19
|
+
text-style: bold;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Screen {
|
|
23
|
+
layers: base overlay;
|
|
24
|
+
background: $background;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#panels {
|
|
28
|
+
height: 1fr;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ── Main stream ──────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
MainStreamPanel {
|
|
34
|
+
height: 1fr;
|
|
35
|
+
min-height: 20;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_StreamHeader {
|
|
39
|
+
background: $panel;
|
|
40
|
+
color: $text;
|
|
41
|
+
padding: 0 1;
|
|
42
|
+
text-style: bold;
|
|
43
|
+
height: 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Paused: bright, high-contrast bar that flashes (see _StreamHeader). */
|
|
47
|
+
_StreamHeader.-paused {
|
|
48
|
+
background: $warning;
|
|
49
|
+
color: black;
|
|
50
|
+
text-style: bold;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Every other tick the timer flips this on, dimming the bar to create a
|
|
54
|
+
visible flash without moving anything. */
|
|
55
|
+
_StreamHeader.-paused.-blink-off {
|
|
56
|
+
background: $panel;
|
|
57
|
+
color: $warning;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_LogDisplay {
|
|
61
|
+
height: 1fr;
|
|
62
|
+
scrollbar-gutter: stable;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── Search panel ─────────────────────────────────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
SearchPanel {
|
|
68
|
+
height: 10;
|
|
69
|
+
min-height: 4;
|
|
70
|
+
border-top: tall $accent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_SearchHeader {
|
|
74
|
+
background: $panel;
|
|
75
|
+
color: $accent;
|
|
76
|
+
padding: 0 1;
|
|
77
|
+
height: 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_SearchHeader:hover {
|
|
81
|
+
background: $accent 30%;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#search-input {
|
|
85
|
+
height: 3;
|
|
86
|
+
dock: top;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#search-results {
|
|
90
|
+
height: 1fr;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── Monitor panel ────────────────────────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
MonitorPanel {
|
|
96
|
+
height: 10;
|
|
97
|
+
min-height: 4;
|
|
98
|
+
border-top: tall $warning;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_MonitorHeader {
|
|
102
|
+
background: $panel;
|
|
103
|
+
color: $warning;
|
|
104
|
+
padding: 0 1;
|
|
105
|
+
height: 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_MonitorHeader:hover {
|
|
109
|
+
background: $warning 30%;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#monitor-scroll {
|
|
113
|
+
height: 1fr;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#monitor-list {
|
|
117
|
+
height: auto;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#monitor-scroll.wrap-on Label {
|
|
121
|
+
width: 1fr;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#monitor-scroll.wrap-off Label {
|
|
125
|
+
width: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ── Health panel ─────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
130
|
+
HealthPanel {
|
|
131
|
+
height: 8;
|
|
132
|
+
min-height: 4;
|
|
133
|
+
border-top: tall $error;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_HealthHeader {
|
|
137
|
+
background: $panel;
|
|
138
|
+
padding: 0 1;
|
|
139
|
+
height: 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_HealthHeader:hover {
|
|
143
|
+
background: $error 30%;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#health-table {
|
|
147
|
+
height: 1fr;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── Modal dialogs ────────────────────────────────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
ConfirmDialog {
|
|
153
|
+
align: center middle;
|
|
154
|
+
background: $surface;
|
|
155
|
+
border: tall $error;
|
|
156
|
+
padding: 1 2;
|
|
157
|
+
width: 60;
|
|
158
|
+
height: auto;
|
|
159
|
+
layer: overlay;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
SaveDialog {
|
|
163
|
+
align: center middle;
|
|
164
|
+
background: $surface;
|
|
165
|
+
border: tall $accent;
|
|
166
|
+
padding: 1 2;
|
|
167
|
+
width: 70;
|
|
168
|
+
height: auto;
|
|
169
|
+
layer: overlay;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ConfirmDialog Horizontal, SaveDialog Horizontal {
|
|
173
|
+
height: auto;
|
|
174
|
+
margin-top: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ConfirmDialog Button, SaveDialog Button {
|
|
178
|
+
margin-right: 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ── ListView wrap control ───────────────────────────────────────────────── */
|
|
182
|
+
|
|
183
|
+
ListView.wrap-on ListItem Label {
|
|
184
|
+
width: 1fr;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
ListView.wrap-off ListItem Label {
|
|
188
|
+
width: auto;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ── Collapsed state ─────────────────────────────────────────────────────── */
|
|
192
|
+
|
|
193
|
+
/* Panels shrink to header-only height when collapsed. auto (not a fixed 1)
|
|
194
|
+
because SearchPanel/MonitorPanel/HealthPanel also carry a border-top —
|
|
195
|
+
a hardcoded height: 1 leaves no room for that border row and clips the
|
|
196
|
+
header (with the collapse arrow) out of view entirely, along with any
|
|
197
|
+
way to click it back open. auto sizes to whatever the visible header
|
|
198
|
+
plus border chrome actually need. */
|
|
199
|
+
MainStreamPanel.-collapsed,
|
|
200
|
+
SearchPanel.-collapsed,
|
|
201
|
+
MonitorPanel.-collapsed,
|
|
202
|
+
HealthPanel.-collapsed {
|
|
203
|
+
height: auto;
|
|
204
|
+
min-height: 1;
|
|
205
|
+
}
|