mrok 0.6.0__py3-none-any.whl → 0.7.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.
@@ -1,5 +1,7 @@
1
+ import argparse
1
2
  import json
2
3
  from collections import OrderedDict, deque
4
+ from statistics import mean
3
5
  from typing import Literal
4
6
  from uuid import uuid4
5
7
 
@@ -7,16 +9,19 @@ import zmq.asyncio
7
9
  from rich.table import Table
8
10
  from textual import on, work
9
11
  from textual.app import App, ComposeResult
10
- from textual.containers import VerticalScroll
12
+ from textual.binding import Binding
13
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
11
14
  from textual.events import Resize
15
+ from textual.screen import ModalScreen
12
16
  from textual.widgets import (
17
+ Button,
13
18
  Collapsible,
19
+ ContentSwitcher,
14
20
  DataTable,
15
21
  Digits,
16
22
  Header,
17
23
  Label,
18
- Placeholder,
19
- Sparkline,
24
+ ProgressBar,
20
25
  Static,
21
26
  TabbedContent,
22
27
  TabPane,
@@ -27,30 +32,25 @@ from textual.widgets.data_table import ColumnKey
27
32
  from textual.worker import get_current_worker
28
33
 
29
34
  from mrok import __version__
30
- from mrok.proxy.models import Event, HTTPHeaders, HTTPResponse, ServiceMetadata, WorkerMetrics
31
-
32
-
33
- def build_tree(node, data):
34
- if isinstance(data, dict):
35
- for key, value in data.items():
36
- child = node.add(str(key))
37
- build_tree(child, value)
38
- elif isinstance(data, list):
39
- for index, value in enumerate(data):
40
- child = node.add(f"[{index}]")
41
- build_tree(child, value)
42
- else:
43
- node.add(repr(data))
44
-
35
+ from mrok.agent.devtools.inspector.utils import (
36
+ build_tree,
37
+ get_highlighter_language_by_content_type,
38
+ hexdump,
39
+ humanize_bytes,
40
+ parse_content_type,
41
+ parse_form_data,
42
+ )
43
+ from mrok.proxy.models import (
44
+ Event,
45
+ HTTPHeaders,
46
+ HTTPRequest,
47
+ HTTPResponse,
48
+ ServiceMetadata,
49
+ WorkerMetrics,
50
+ )
45
51
 
46
- def hexdump(data, width=16):
47
- lines = []
48
- for i in range(0, len(data), width):
49
- chunk = data[i : i + width]
50
- hex_part = " ".join(f"{b:02x}" for b in chunk)
51
- ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk)
52
- lines.append(f"{hex_part:<{width * 3}} {ascii_part}")
53
- return "\n".join(lines)
52
+ MIN_COLS = 160
53
+ MIN_ROWS = 45
54
54
 
55
55
 
56
56
  class Counter(Digits):
@@ -84,57 +84,43 @@ class Counter(Digits):
84
84
  self.border_title = self.counter_name
85
85
 
86
86
 
87
- class HostInfo(Static):
88
- BORDER_TITLE = "Process"
87
+ class ProcessInfoBar(Static):
89
88
  DEFAULT_CSS = """
90
- HostInfo {
91
- border: round #00BBFF;
92
- layout: grid;
93
- grid-size: 2 2;
94
- grid-gutter: 0;
95
- grid-columns: 1fr 2fr;
96
- height: 100%;
97
- width: 25;
98
- content-align: center middle;
89
+ ProcessInfoBar {
90
+ width: 100%;
91
+ align: center middle;
92
+ padding: 1;
93
+ }
94
+ ProgressBar {
95
+ width: 100%;
96
+ }
97
+ ProgressBar > Bar {
98
+ width: 24;
99
+ }
100
+ ProgressBar > Bar > .bar--complete {
101
+ color: red;
102
+ }
103
+ Vertical {
104
+ width: auto;
105
+ align: center middle;
99
106
  }
100
107
  """
101
108
 
102
- def __init__(
103
- self,
104
- content="",
105
- *,
106
- expand=False,
107
- shrink=False,
108
- markup=True,
109
- name=None,
110
- id=None,
111
- classes=None,
112
- disabled=False,
113
- ) -> None:
114
- super().__init__(
115
- content,
116
- expand=expand,
117
- shrink=shrink,
118
- markup=markup,
119
- name=name,
120
- id=id,
121
- classes=classes,
122
- disabled=disabled,
123
- )
124
- self.mem_values: deque = deque([0] * 100, maxlen=100)
125
- self.cpu_values: deque = deque([0] * 100, maxlen=100)
109
+ def __init__(self, label: str, **kwargs):
110
+ super().__init__(**kwargs)
111
+ self.label_text = label
126
112
 
127
113
  def compose(self) -> ComposeResult:
128
- yield Label("CPU")
129
- yield Sparkline(self.cpu_values, id="cpu")
130
- yield Label("Memory")
131
- yield Sparkline(self.mem_values, id="mem")
114
+ with Vertical():
115
+ yield Static(f"[b]{self.label_text}[/b]")
116
+ yield ProgressBar(
117
+ total=100,
118
+ show_percentage=True,
119
+ show_eta=False,
120
+ )
132
121
 
133
- def update_info(self, cpu: int, mem: int):
134
- self.cpu_values.append(cpu)
135
- self.mem_values.append(mem)
136
- self.query_one("#cpu", Sparkline).data = self.cpu_values
137
- self.query_one("#mem", Sparkline).data = self.mem_values
122
+ def update_value(self, value: float) -> None:
123
+ self.query_one(ProgressBar).update(progress=value)
138
124
 
139
125
 
140
126
  class InfoPanel(Static):
@@ -178,20 +164,13 @@ class InfoPanel(Static):
178
164
  table = self.query_one(DataTable)
179
165
  table.add_columns("Label", "Value")
180
166
 
181
- # if len(self.workers_metrics):
182
- # hinfo = self.query_one(HostInfo)
183
- # hinfo.update_info(
184
- # cpu=int(mean([m.process.cpu for m in self.workers_metrics.values()])),
185
- # mem=int(mean([m.process.mem for m in self.workers_metrics.values()])),
186
- # )
187
-
188
167
  def update_meta(self, meta: ServiceMetadata) -> None:
189
168
  table = self.query_one(DataTable)
190
169
  if len(table.rows) == 0:
191
170
  table.add_row("URL", f"https://{meta.extension}.{meta.domain}")
192
171
  table.add_row("Extension ID", meta.extension.upper())
193
172
  table.add_row("Instance ID", meta.instance.upper())
194
- table.add_row("mrok version ID", __version__)
173
+ table.add_row("mrok version", __version__)
195
174
  self.loading = False
196
175
 
197
176
 
@@ -245,9 +224,14 @@ class Details(Static):
245
224
  ):
246
225
  yield Static(id="response-headers-table")
247
226
  with TabPane("Payload"):
248
- yield Placeholder("payload")
227
+ with ContentSwitcher(initial="payload-tree", id="payload-switcher"):
228
+ yield Tree("Payload", id="payload-tree")
229
+ yield DataTable(show_header=False, show_cursor=False, id="payload-formdata")
230
+ yield TextArea(id="payload-other", read_only=True)
249
231
  with TabPane("Preview"):
250
- yield Tree("Preview")
232
+ with ContentSwitcher(initial="preview-tree", id="preview-switcher"):
233
+ yield Tree("Preview", id="preview-tree")
234
+ yield TextArea(id="preview-other", read_only=True)
251
235
  with TabPane("Response"):
252
236
  yield TextArea(id="raw-response", read_only=True)
253
237
 
@@ -267,19 +251,71 @@ class Details(Static):
267
251
  self.query_one(f"#{type}-headers-table", Static).content = ""
268
252
 
269
253
  def update_preview(self, response: HTTPResponse):
270
- tree = self.query_one(Tree)
254
+ tree = self.query_one("#preview-tree", Tree)
271
255
  tree.clear()
272
- if (
273
- response.headers.get("Content-Type", "").startswith("application/json")
274
- and response.body
275
- ):
276
- build_tree(tree.root, json.loads(response.body.decode()))
256
+ self.query_one("#preview-switcher", ContentSwitcher).current = "preview-tree"
257
+ if not response.body:
258
+ return
259
+ content_type_info = parse_content_type(response.headers["Content-Type"])
260
+ if content_type_info.content_type == "application/json":
261
+ build_tree(
262
+ tree.root,
263
+ json.loads(response.body.decode(encoding=content_type_info.charset)), # type: ignore[arg-type]
264
+ )
265
+ return
266
+ text = (
267
+ hexdump(response.body)
268
+ if content_type_info.binary
269
+ else response.body.decode(encoding=content_type_info.charset) # type: ignore[arg-type]
270
+ )
271
+ txtarea = self.query_one("#preview-other", TextArea)
272
+ txtarea.text = text
273
+ txtarea.language = get_highlighter_language_by_content_type(content_type_info.content_type)
274
+ self.query_one("#preview-switcher", ContentSwitcher).current = "preview-other"
275
+
276
+ def update_payload(self, request: HTTPRequest):
277
+ tree = self.query_one("#payload-tree", Tree)
278
+ tree.clear()
279
+ form_data_table = self.query_one("#payload-formdata", DataTable)
280
+ form_data_table.clear()
281
+ self.query_one("#payload-switcher", ContentSwitcher).current = "payload-tree"
282
+ if not request.body:
283
+ return
284
+ content_type_info = parse_content_type(request.headers["Content-Type"])
285
+ if content_type_info.content_type == "application/json":
286
+ build_tree(
287
+ tree.root,
288
+ json.loads(request.body.decode(encoding=content_type_info.charset)), # type: ignore[arg-type]
289
+ )
290
+ return
291
+ if content_type_info.content_type == "multipart/form-data" and not request.body_truncated:
292
+ for name, value in parse_form_data(request.body, content_type_info.boundary): # type: ignore[arg-type]
293
+ form_data_table.add_row(name, value)
294
+ self.query_one("#payload-switcher", ContentSwitcher).current = "payload-formdata"
295
+ return
296
+ text = (
297
+ hexdump(request.body)
298
+ if content_type_info.binary
299
+ else request.body.decode(encoding=content_type_info.charset) # type: ignore[arg-type]
300
+ )
301
+ txtarea = self.query_one("#payload-other", TextArea)
302
+ txtarea.text = text
303
+ self.query_one("#payload-switcher", ContentSwitcher).current = "payload-other"
277
304
 
278
305
  def update_response(self, response: HTTPResponse):
279
306
  text_area = self.query_one("#raw-response", TextArea)
280
307
  text_area.clear()
281
308
  if response.body:
282
- text_area.text = response.body.decode()
309
+ content_type_info = parse_content_type(response.headers["Content-Type"])
310
+ text = (
311
+ hexdump(response.body)
312
+ if content_type_info.binary
313
+ else response.body.decode(encoding=content_type_info.charset) # type: ignore[arg-type]
314
+ )
315
+ text_area.text = text
316
+ text_area.language = get_highlighter_language_by_content_type(
317
+ content_type_info.content_type
318
+ )
283
319
 
284
320
  def update_info(self, response: HTTPResponse):
285
321
  general_table = self.query_one("#general-data", DataTable)
@@ -292,6 +328,7 @@ class Details(Static):
292
328
  general_table.add_row("Status Code", self.format_status(response.status))
293
329
  self.update_headers("request", response.request.headers)
294
330
  self.update_headers("response", response.headers)
331
+ self.update_payload(response.request)
295
332
  self.update_preview(response)
296
333
  self.update_response(response)
297
334
 
@@ -315,6 +352,8 @@ class Details(Static):
315
352
  def on_mount(self) -> None:
316
353
  general_table = self.query_one("#general-data", DataTable)
317
354
  general_table.add_columns("Label", "Value")
355
+ form_data_table = self.query_one("#payload-formdata", DataTable)
356
+ form_data_table.add_columns("Name", "Value")
318
357
 
319
358
 
320
359
  class LeftPanel(Static):
@@ -322,11 +361,11 @@ class LeftPanel(Static):
322
361
  LeftPanel {
323
362
  layout: grid;
324
363
  grid-size: 1 3;
325
- grid-rows: 1fr 2fr 4fr;
364
+ grid-rows: 6 2fr 4fr;
326
365
  grid-columns: 1fr;
327
366
  background: black;
328
367
  height: 100%;
329
- width: 100%;
368
+ width: 5fr;
330
369
  }
331
370
  Details {
332
371
  background: black;
@@ -341,15 +380,74 @@ class LeftPanel(Static):
341
380
  yield Details()
342
381
 
343
382
 
344
- class MetricsPanel(Static):
345
- BORDER_TITLE = "Metrics"
383
+ class ProcessMetrics(Static):
384
+ BORDER_TITLE = "Process"
385
+ DEFAULT_CSS = """
386
+ ProcessMetrics {
387
+ border: round #00BBFF;
388
+ layout: grid;
389
+ grid-size: 1 2;
390
+ grid-gutter: 0;
391
+ height: 100%;
392
+ align: center middle;
393
+ }
394
+ """
395
+
396
+ def __init__(
397
+ self,
398
+ content="",
399
+ *,
400
+ expand=False,
401
+ shrink=False,
402
+ markup=True,
403
+ name=None,
404
+ id=None,
405
+ classes=None,
406
+ disabled=False,
407
+ ) -> None:
408
+ super().__init__(
409
+ content,
410
+ expand=expand,
411
+ shrink=shrink,
412
+ markup=markup,
413
+ name=name,
414
+ id=id,
415
+ classes=classes,
416
+ disabled=disabled,
417
+ )
418
+ self.mem_values: deque = deque([0] * 10, maxlen=10)
419
+ self.cpu_values: deque = deque([0] * 10, maxlen=10)
420
+ self.workers_metrics: dict[str, WorkerMetrics] = {}
421
+
422
+ def compose(self) -> ComposeResult:
423
+ yield ProcessInfoBar("CPU", id="cpu")
424
+ yield ProcessInfoBar("Memory", id="mem")
425
+
426
+ def on_mount(self) -> None:
427
+ self.query_one("#cpu", ProcessInfoBar).update_value(0)
428
+ self.query_one("#mem", ProcessInfoBar).update_value(0)
429
+
430
+ def update_stats(self) -> None:
431
+ cpu = mean([m.process.cpu for m in self.workers_metrics.values()])
432
+ mem = mean([m.process.mem for m in self.workers_metrics.values()])
433
+
434
+ self.query_one("#cpu", ProcessInfoBar).update_value(cpu)
435
+ self.query_one("#mem", ProcessInfoBar).update_value(mem)
436
+
437
+ def update_metrics(self, metrics: WorkerMetrics) -> None:
438
+ self.workers_metrics[metrics.worker_id] = metrics
439
+ self.update_stats()
440
+
441
+
442
+ class RequestsMetrics(Static):
443
+ BORDER_TITLE = "Requests"
346
444
  DEFAULT_CSS = """
347
- MetricsPanel {
445
+ RequestsMetrics {
348
446
  background: black;
349
447
  layout: grid;
350
448
  grid-size: 1 4;
351
449
  grid-gutter: 0;
352
- content-align: center top;
450
+ align-vertical: middle;
353
451
  border: round #0088FF;
354
452
  height: 100%;
355
453
  grid-rows: auto auto auto auto;
@@ -396,7 +494,7 @@ class MetricsPanel(Static):
396
494
  yield Counter("Failed", id="requests-ko")
397
495
 
398
496
  def on_mount(self) -> None:
399
- self.set_interval(5.0, self.update_stats)
497
+ self.update_stats()
400
498
 
401
499
  def update_stats(self) -> None:
402
500
  self.query_one("#requests-rps", Digits).update(
@@ -413,26 +511,175 @@ class MetricsPanel(Static):
413
511
  )
414
512
  self.loading = False
415
513
 
416
- # if len(self.workers_metrics):
417
- # hinfo = self.query_one(HostInfo)
418
- # hinfo.update_info(
419
- # cpu=int(mean([m.process.cpu for m in self.workers_metrics.values()])),
420
- # mem=int(mean([m.process.mem for m in self.workers_metrics.values()])),
421
- # )
514
+ def update_metrics(self, metrics: WorkerMetrics) -> None:
515
+ self.workers_metrics[metrics.worker_id] = metrics
516
+ self.update_stats()
517
+
518
+
519
+ class DataTransferMetrics(Static):
520
+ BORDER_TITLE = "Transfer"
521
+ DEFAULT_CSS = """
522
+ DataTransferMetrics {
523
+ background: black;
524
+ layout: grid;
525
+ grid-size: 1 2;
526
+ grid-gutter: 0;
527
+ align-vertical: middle;
528
+ border: round #0088FF;
529
+ height: 100%;
530
+ grid-rows: auto auto auto auto;
531
+ }
532
+ """
533
+
534
+ def __init__(
535
+ self,
536
+ content="",
537
+ *,
538
+ expand=False,
539
+ shrink=False,
540
+ markup=True,
541
+ name=None,
542
+ id=None,
543
+ classes=None,
544
+ disabled=False,
545
+ ) -> None:
546
+ super().__init__(
547
+ content,
548
+ expand=expand,
549
+ shrink=shrink,
550
+ markup=markup,
551
+ name=name,
552
+ id=id,
553
+ classes=classes,
554
+ disabled=disabled,
555
+ )
556
+ self.workers_metrics: dict[str, WorkerMetrics] = {}
557
+
558
+ def compose(self) -> ComposeResult:
559
+ yield Counter("In", id="in")
560
+ yield Counter("Out", id="out")
561
+
562
+ def on_mount(self) -> None:
563
+ self.update_stats()
564
+
565
+ def update_stats(self) -> None:
566
+ amount, unit = humanize_bytes(
567
+ sum(m.data_transfer.bytes_in for m in self.workers_metrics.values())
568
+ )
569
+ self.query_one("#in", Digits).border_title = f"In ({unit})"
570
+ self.query_one("#in", Digits).update(str(amount))
571
+ amount, unit = humanize_bytes(
572
+ sum(m.data_transfer.bytes_out for m in self.workers_metrics.values())
573
+ )
574
+ self.query_one("#out", Digits).border_title = f"Out ({unit})"
575
+ self.query_one("#out", Digits).update(str(amount))
576
+ self.loading = False
422
577
 
423
578
  def update_metrics(self, metrics: WorkerMetrics) -> None:
424
579
  self.workers_metrics[metrics.worker_id] = metrics
580
+ self.update_stats()
581
+
582
+
583
+ class RightPanel(Static):
584
+ DEFAULT_CSS = """
585
+ RightPanel {
586
+ layout: grid;
587
+ grid-size: 1 3;
588
+ grid-rows: 1fr 22 12;
589
+ grid-columns: 1fr;
590
+ background: black;
591
+ height: 100%;
592
+ width: 33;
593
+ }
594
+ .hidden {
595
+ display: none;
596
+ }
597
+ """
598
+
599
+ def compose(self):
600
+ yield ProcessMetrics()
601
+ yield RequestsMetrics()
602
+ yield DataTransferMetrics()
603
+
604
+
605
+ class TooSmallScreen(ModalScreen):
606
+ CSS = """
607
+ TooSmallScreen {
608
+ align: center middle;
609
+ }
610
+ #dialog {
611
+ grid-size: 1 3;
612
+ grid-rows: 1fr 2fr 3;
613
+ padding: 0 1;
614
+ width: 60;
615
+ height: 15;
616
+ border: thick $background 80%;
617
+ background: $surface;
618
+ }
619
+ #title {
620
+ column-span: 1;
621
+ height: 1fr;
622
+ width: 1fr;
623
+ content-align: center middle;
624
+ background: $panel;
625
+ color: $foreground;
626
+ text-style: bold;
627
+ }
628
+ #message {
629
+ margin-top: 1;
630
+ column-span: 1;
631
+ height: 1fr;
632
+ width: 1fr;
633
+ content-align: center top;
634
+ }
635
+ Button {
636
+ width: 100%;
637
+ }
638
+ """
639
+
640
+ def __init__(self):
641
+ super().__init__()
642
+ self.dialog_title = "Terminal too small"
643
+ self.dialog_message = (
644
+ f"Your current terminal size is {self.app.size.width}x{self.app.size.height}. "
645
+ f"For the best experience please resize your terminal to {MIN_COLS}x{MIN_ROWS}."
646
+ )
647
+ self.btn_label = "dismiss"
648
+ self.btn_variant = "primary"
649
+
650
+ def compose(self) -> ComposeResult:
651
+ yield Grid(
652
+ Label(self.dialog_title, id="title"),
653
+ Label(self.dialog_message, id="message"),
654
+ Button(self.btn_label, variant=self.btn_variant, id="dismiss"),
655
+ id="dialog",
656
+ )
657
+
658
+ @on(Button.Pressed)
659
+ def on_button_pressed(self, event: Button.Pressed) -> None:
660
+ event.stop()
661
+ if event.button.id == "dismiss":
662
+ self.app.pop_screen()
663
+
664
+ def update_message(self):
665
+ self.query_one("#message", Label).content = (
666
+ f"Your current terminal size is {self.app.size.width}x{self.app.size.height}. "
667
+ f"For the best experience please resize your terminal to {MIN_COLS}x{MIN_ROWS}."
668
+ )
425
669
 
426
670
 
427
671
  class InspectorApp(App):
428
672
  TITLE = "mrok Dev Console"
429
673
  CSS = """
430
674
  Screen {
431
- layout: grid;
432
- grid-size: 2 1;
433
- grid-columns: 5fr 1fr;
434
675
  background: black;
435
676
  }
677
+ # Screen {
678
+ # layout: grid;
679
+ # grid-size: 2 1;
680
+ # grid-columns: 5fr 1fr;
681
+ # background: black;
682
+ # }
436
683
  DataTable {
437
684
  background: black;
438
685
  color: #dddddd;
@@ -480,8 +727,21 @@ class InspectorApp(App):
480
727
  background: black;
481
728
  border: round #00BBFF;
482
729
  }
730
+ Tree {
731
+ background: black;
732
+ }
733
+ TextArea {
734
+ background: black;
735
+ }
736
+ RightPanel.-hidden {
737
+ display: none;
738
+ }
483
739
  """
484
740
 
741
+ BINDINGS = [
742
+ Binding("m", "toggle_metrics()", "Toggle Metrics"),
743
+ ]
744
+
485
745
  def __init__(
486
746
  self,
487
747
  subscriber_port: int,
@@ -500,29 +760,35 @@ class InspectorApp(App):
500
760
 
501
761
  def compose(self) -> ComposeResult:
502
762
  yield Header()
503
- yield LeftPanel()
504
- yield MetricsPanel()
763
+ with Horizontal():
764
+ yield LeftPanel()
765
+ yield RightPanel()
505
766
 
506
767
  async def on_mount(self):
507
- self.query_one(InfoPanel).loading = True
508
- self.query_one(MetricsPanel).loading = True
768
+ self._check_minimum_size(self.size.width, self.size.height)
509
769
  self.socket.subscribe("")
510
770
  self.consumer()
511
771
 
772
+ async def on_unmount(self):
773
+ self.socket.close()
774
+ self.ctx.term()
775
+
512
776
  @work(exclusive=True)
513
777
  async def consumer(self):
514
778
  worker = get_current_worker()
515
779
  requests_table = self.query_one("#requests", DataTable)
516
780
  while not worker.is_cancelled:
517
781
  try:
518
- self.log("Waiting for event")
519
782
  event = Event.model_validate_json(await self.socket.recv_string())
520
- self.log(f"Event received: {event}")
521
783
  if event.type == "status":
522
784
  info_widget = self.query_one(InfoPanel)
523
785
  info_widget.update_meta(event.data.meta)
524
- metrics_widget = self.query_one(MetricsPanel)
525
- metrics_widget.update_metrics(event.data.metrics)
786
+ process_metrics_widget = self.query_one(ProcessMetrics)
787
+ process_metrics_widget.update_metrics(event.data.metrics)
788
+ requests_metrics_widget = self.query_one(RequestsMetrics)
789
+ requests_metrics_widget.update_metrics(event.data.metrics)
790
+ data_transfer_metrics_widget = self.query_one(DataTransferMetrics)
791
+ data_transfer_metrics_widget.update_metrics(event.data.metrics)
526
792
  continue
527
793
 
528
794
  response = event.data
@@ -542,6 +808,9 @@ class InspectorApp(App):
542
808
  except Exception as e:
543
809
  self.log(e)
544
810
 
811
+ def action_toggle_metrics(self):
812
+ self.query_one(RightPanel).toggle_class("-hidden")
813
+
545
814
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
546
815
  if not event.row_key:
547
816
  return
@@ -550,7 +819,33 @@ class InspectorApp(App):
550
819
  details.update_info(response)
551
820
 
552
821
  @on(Resize)
553
- def resize_requests_table(self, event: Resize):
822
+ def handle_resize(self, event: Resize):
823
+ self._check_minimum_size(event.size.width, event.size.height)
554
824
  width = event.size.width
555
825
  table = self.query_one("#requests", DataTable)
556
826
  table.columns[ColumnKey("path")].width = width - 69
827
+
828
+ def _check_minimum_size(self, width: int, height: int):
829
+ if width < MIN_COLS or height < MIN_ROWS:
830
+ if not isinstance(self.screen, TooSmallScreen):
831
+ self.push_screen(TooSmallScreen())
832
+ else:
833
+ self.screen.update_message()
834
+ return
835
+
836
+ if isinstance(self.screen, TooSmallScreen):
837
+ self.pop_screen()
838
+
839
+
840
+ def module_main(argv: list[str]) -> None:
841
+ parser = argparse.ArgumentParser(description="mrok devtools agent")
842
+ parser.add_argument(
843
+ "-p",
844
+ "--subscriber-port",
845
+ type=int,
846
+ default=50001,
847
+ help="Port for subscriber (default: 50001)",
848
+ )
849
+ args = parser.parse_args(argv)
850
+ app = InspectorApp(args.subscriber_port)
851
+ app.run()