Skip to content

Reference

This section contains the automatically generated API documentation for ectop.

Core

Main application class for ectop.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Ectop

Bases: App

A Textual-based TUI for monitoring and controlling ecFlow.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/app.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
class Ectop(App):
    """
    A Textual-based TUI for monitoring and controlling ecFlow.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    CSS = f"""
    Screen {{
        background: {COLOR_BG};
    }}

    #zombie_container {{
        padding: 1 2;
        background: {COLOR_BG};
        border: thick {COLOR_BORDER};
        width: 90%;
        height: 80%;
    }}

    #zombie_title {{
        text-align: center;
        background: {COLOR_HEADER_BG};
        color: white;
        margin-bottom: 1;
    }}

    .modal_actions {{
        align: center middle;
        height: 3;
        margin-top: 1;
    }}

    .modal_actions Button {{
        margin: 0 1;
    }}

    StatusBar {{
        dock: bottom;
        height: 1;
        background: {COLOR_STATUS_BAR_BG};
        color: {COLOR_TEXT};
    }}

    /* Left Sidebar (Tree) */
    #sidebar {{
        width: 30%;
        height: 100%;
        border-right: solid {COLOR_BORDER};
        background: {COLOR_SIDEBAR_BG};
    }}

    Tree {{
        background: {COLOR_SIDEBAR_BG};
        color: {COLOR_TEXT};
        padding: 1;
    }}

    /* Right Content (Tabs) */
    #main_content {{
        width: 70%;
        height: 100%;
    }}

    TabbedContent {{
        height: 100%;
    }}

    /* Content Areas */
    RichLog {{
        background: {COLOR_CONTENT_BG};
        color: {COLOR_TEXT_HIGHLIGHT};
        border: none;
    }}

    .code_view {{
        background: {COLOR_CONTENT_BG};
        padding: 1;
        width: 100%;
        height: auto;
    }}

    #search_box {{
        dock: top;
        display: none;
        background: {COLOR_CONTENT_BG};
        color: {COLOR_TEXT_HIGHLIGHT};
        border: tall {COLOR_BORDER};
    }}

    #search_box.visible {{
        display: block;
    }}

    #why_container {{
        padding: 1 2;
        background: {COLOR_BG};
        border: thick {COLOR_BORDER};
        width: 60%;
        height: 60%;
    }}

    #why_title {{
        text-align: center;
        background: {COLOR_HEADER_BG};
        color: white;
        margin-bottom: 1;
    }}

    #confirm_container {{
        padding: 1 2;
        background: {COLOR_BG};
        border: thick {COLOR_BORDER};
        width: 40%;
        height: 20%;
    }}

    #confirm_message {{
        text-align: center;
        margin-bottom: 1;
    }}

    #confirm_actions {{
        align: center middle;
    }}

    #confirm_actions Button {{
        margin: 0 1;
    }}

    #var_container {{
        padding: 1 2;
        background: {COLOR_BG};
        border: thick {COLOR_BORDER};
        width: 80%;
        height: 80%;
    }}

    #var_title {{
        text-align: center;
        background: {COLOR_HEADER_BG};
        color: white;
        margin-bottom: 1;
    }}

    #var_input.hidden {{
        display: none;
    }}
    """

    COMMANDS = App.COMMANDS | {EctopCommands}

    BINDINGS = [
        Binding("q", "quit", "Quit"),
        Binding("p", "command_palette", "Command Palette"),
        Binding("r", "refresh", "Refresh Tree"),
        Binding("l", "load_node", "Load Logs/Script"),
        Binding("s", "suspend", "Suspend"),
        Binding("u", "resume", "Resume"),
        Binding("k", "kill", "Kill"),
        Binding("x", "run", "Execute"),
        Binding("f", "force", "Force Complete"),
        Binding("a", "force_aborted", "Force Aborted"),
        Binding("F", "cycle_filter", "Cycle Filter"),
        Binding("H", "toggle_focus", "Focus Mode"),
        Binding("R", "requeue", "Requeue"),
        Binding("c", "copy_path", "Copy Path"),
        Binding("S", "restart_server", "Start Server"),
        Binding("X", "halt_server", "Halt Server"),
        Binding("/", "search", "Search"),
        Binding("w", "why", "Why?"),
        Binding("e", "edit_script", "Edit & Rerun"),
        Binding("t", "toggle_live", "Toggle Live Log"),
        Binding("v", "variables", "Variables"),
        Binding("b", "begin", "Begin Suite"),
        Binding("L", "load_defs", "Load Defs"),
        Binding("Z", "zombies", "Zombies"),
        Binding("ctrl+f", "search_content", "Search in Content"),
    ]

    def __init__(
        self,
        host: str = DEFAULT_HOST,
        port: int = DEFAULT_PORT,
        refresh_interval: float = DEFAULT_REFRESH_INTERVAL,
        **kwargs: Any,
    ) -> None:
        """
        Initialize the application.

        Args:
            host: The ecFlow server hostname. Defaults to DEFAULT_HOST.
            port: The ecFlow server port. Defaults to DEFAULT_PORT.
            refresh_interval: The interval for live log updates. Defaults to DEFAULT_REFRESH_INTERVAL.
            **kwargs: Additional keyword arguments for the Textual App.
        """
        super().__init__(**kwargs)
        self.host = host
        self.port = port
        self.refresh_interval = refresh_interval
        self.ecflow_client: EcflowClient | None = None

    def compose(self) -> ComposeResult:
        """
        Compose the UI layout.

        Returns:
            The UI components.
        """
        yield Header(show_clock=True)
        yield SearchBox(placeholder="Search nodes...", id="search_box")
        yield Horizontal(
            Container(SuiteTree("ecFlow Server", id="suite_tree"), id="sidebar"),
            MainContent(id="main_content"),
        )
        yield StatusBar(id="status_bar")
        yield Footer()

    def on_mount(self) -> None:
        """
        Handle the mount event to start the application.
        """
        self._initial_connect()
        self.set_interval(self.refresh_interval, self._live_log_tick)

    def on_tree_node_selected(self, event: SuiteTree.NodeSelected[str]) -> None:
        """
        Handle node selection to automatically load content.

        Args:
            event: The node selection event.
        """
        if event.node.data:
            self.action_load_node()

    @work
    async def _initial_connect(self) -> None:
        """
        Perform initial connection to the ecFlow server.

        Returns:
            None

        Raises:
            RuntimeError: If connection to the server fails.
            Exception: For unexpected errors.

        Notes:
            This is an async background worker.
        """
        try:
            # Instantiate client in a thread to keep UI thread responsive
            self.ecflow_client = await asyncio.to_thread(EcflowClient, self.host, self.port)
            await self.ecflow_client.ping()
            # Initial refresh
            self.action_refresh()
        except RuntimeError as e:
            self.notify(f"{ERROR_CONNECTION_FAILED}: {e}", severity="error", timeout=10)
            tree = self.query_one("#suite_tree", SuiteTree)
            self._update_tree_error(tree)
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    def _update_tree_error(self, tree: SuiteTree) -> None:
        """
        Update tree root to show error.

        Args:
            tree: The suite tree widget.
        """
        tree.root.label = f"[red]{ERROR_CONNECTION_FAILED} (Check Host/Port)[/]"

    @work(exclusive=True)
    async def action_refresh(self) -> None:
        """
        Fetch suites from server and rebuild the tree.

        Returns:
            None

        Raises:
            RuntimeError: If synchronization with the server fails.
            Exception: For unexpected errors.

        Notes:
            This is an async background worker.
        """
        if not self.ecflow_client:
            return

        self.notify("Refreshing tree...")

        tree = self.query_one("#suite_tree", SuiteTree)
        status_bar = self.query_one("#status_bar", StatusBar)
        try:
            await self.ecflow_client.sync_local()
            defs = await self.ecflow_client.get_defs()
            status = "Connected"
            version = "Unknown"
            if defs:
                status = str(defs.get_server_state())
            try:
                version = await self.ecflow_client.server_version()
            except RuntimeError:
                pass

            tree.update_tree(self.ecflow_client.host, self.ecflow_client.port, defs)
            status_bar.update_status(self.ecflow_client.host, self.ecflow_client.port, status=status, version=version)
            self.notify("Tree Refreshed")
        except RuntimeError as e:
            status_bar.update_status(self.ecflow_client.host, self.ecflow_client.port, status=STATUS_SYNC_ERROR)
            self.notify(f"Refresh Error: {e}", severity="error")
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    @work
    async def action_restart_server(self) -> None:
        """
        Restart the ecFlow server (RUNNING).

        Returns:
            None

        Raises:
            Exception: If restarting the server fails.

        Notes:
            This is an async background worker.
        """
        if not self.ecflow_client:
            return
        try:
            await self.ecflow_client.restart_server()
            self.notify("Server Started (RUNNING)")
            self.action_refresh()
        except Exception as e:
            self.notify(f"Restart Error: {e}", severity="error")

    @work
    async def action_halt_server(self) -> None:
        """
        Halt the ecFlow server (HALT).

        Returns:
            None

        Raises:
            Exception: If halting the server fails.

        Notes:
            This is an async background worker.
        """
        if not self.ecflow_client:
            return
        try:
            await self.ecflow_client.halt_server()
            self.notify("Server Halted (HALT)")
            self.action_refresh()
        except Exception as e:
            self.notify(f"Halt Error: {e}", severity="error")

    def get_selected_path(self) -> str | None:
        """
        Helper to get the ecFlow path of the selected node.

        Returns:
            The absolute path of the selected node, or None if no node is selected.
        """
        try:
            node = self.query_one("#suite_tree", SuiteTree).cursor_node
            return node.data if node else None
        except Exception:
            return None

    def action_load_node(self) -> None:
        """
        Fetch Output, Script, and Job files for the selected node.
        """
        path = self.get_selected_path()
        if not path:
            self.notify("No node selected", severity="warning")
            return
        self._load_node_worker(path)

    @work(exclusive=True)
    async def _load_node_worker(self, path: str) -> None:
        """
        Worker to fetch files for a node in parallel.

        Args:
            path: The ecFlow node path.

        Returns:
            None

        Notes:
            This is an async background worker. It uses asyncio.gather to
            fetch jobout, script, job, and timeline data concurrently.
        """
        if not self.ecflow_client:
            return

        self.notify(f"Loading files for {path}...")
        content_area = self.query_one("#main_content", MainContent)

        try:
            # Sync to get latest try numbers for filenames
            await self.ecflow_client.sync_local()
        except RuntimeError:
            pass

        async def _fetch_file(file_type: str, widget_id: str, update_fn: Any) -> None:
            """
            Internal helper to fetch a specific file type and update the UI.

            Args:
                file_type: The ecFlow file type ('jobout', 'script', 'job').
                widget_id: The ID of the widget to show errors in.
                update_fn: The function to call with the fetched content.
            """
            try:
                assert self.ecflow_client is not None
                content = await self.ecflow_client.file(path, file_type)
                update_fn(content)
            except RuntimeError:
                content_area.show_error(widget_id, f"File type '{file_type}' not available.")

        async def _fetch_timeline() -> None:
            """
            Internal helper to gather timeline data and update the UI.
            """
            tree = self.query_one("#suite_tree", SuiteTree)
            if tree.defs:
                node = tree.defs.find_abs_node(path)
                if node:
                    timeline_data = await asyncio.to_thread(gather_timeline_data, node)
                    content_area.update_timeline(timeline_data)

        await asyncio.gather(
            _fetch_file("jobout", "#log_output", content_area.update_log),
            _fetch_file("script", "#view_script", content_area.update_script),
            _fetch_file("job", "#view_job", content_area.update_job),
            _fetch_timeline(),
        )

    @work
    async def _run_client_command(self, command_name: str, path: str | None) -> None:
        """
        Generic helper to run ecflow commands in a worker thread.

        Args:
            command_name: The name of the command to run on the EcflowClient.
            path: The absolute path to the node.

        Notes:
            This is an async background worker.
        """
        if not path or not self.ecflow_client:
            return
        try:
            method = getattr(self.ecflow_client, command_name)
            await method(path)
            self.notify(f"{command_name.replace('_', ' ').capitalize()}: {path}")
            self.action_refresh()
        except RuntimeError as e:
            self.notify(f"Command Error: {e}", severity="error")
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    def action_suspend(self) -> None:
        """
        Suspend the selected node.
        """
        self._run_client_command("suspend", self.get_selected_path())

    def action_resume(self) -> None:
        """
        Resume the selected node.
        """
        self._run_client_command("resume", self.get_selected_path())

    def action_kill(self) -> None:
        """
        Kill the selected node.
        """
        self._run_client_command("kill", self.get_selected_path())

    def action_force(self) -> None:
        """
        Force complete the selected node.
        """
        self._run_client_command("force_complete", self.get_selected_path())

    def action_force_aborted(self) -> None:
        """
        Force abort the selected node.
        """
        self._run_client_command("force_aborted", self.get_selected_path())

    def action_run(self) -> None:
        """
        Execute the selected node immediately.
        """
        self._run_client_command("run", self.get_selected_path())

    def action_cycle_filter(self) -> None:
        """
        Cycle through tree filters.
        """
        self.query_one("#suite_tree", SuiteTree).action_cycle_filter()

    def action_toggle_focus(self) -> None:
        """
        Toggle Focus Mode (hide complete nodes).
        """
        self.query_one("#suite_tree", SuiteTree).action_toggle_focus()

    def action_search(self) -> None:
        """
        Show the search box.
        """
        search_box = self.query_one("#search_box", SearchBox)
        search_box.add_class("visible")
        search_box.focus()

    def action_requeue(self) -> None:
        """
        Requeue the selected node.
        """
        self._run_client_command("requeue", self.get_selected_path())

    def action_copy_path(self) -> None:
        """
        Copy the selected node path to the clipboard.
        """
        path = self.get_selected_path()
        if path:
            if hasattr(self, "copy_to_clipboard"):
                self.copy_to_clipboard(path)
                self.notify(f"Copied to clipboard: {path}")
            else:
                self.notify(f"Node path: {path}")
        else:
            self.notify("No node selected", severity="warning")

    def action_toggle_live(self) -> None:
        """
        Toggle live log updates.
        """
        content_area = self.query_one("#main_content", MainContent)
        content_area.is_live = not content_area.is_live
        state = "ON" if content_area.is_live else "OFF"
        self.notify(f"Live Log: {state}")
        if content_area.is_live:
            content_area.active = "tab_output"

    def _live_log_tick(self) -> None:
        """
        Periodic tick to update the live log if enabled.
        """
        if not self.ecflow_client:
            return
        content_area = self.query_one("#main_content", MainContent)
        if content_area.is_live and content_area.active == "tab_output":
            path = self.get_selected_path()
            if not path:
                return

            # Skip if node is in a final state and we already have content
            tree = self.query_one("#suite_tree", SuiteTree)
            if tree.defs:
                node = tree.defs.find_abs_node(path)
                if node:
                    state = str(node.get_state())
                    if state in ("complete", "aborted") and content_area._content_cache.get("output"):
                        return

            self._live_log_worker(path)

    @work(exclusive=True)
    async def _live_log_worker(self, path: str) -> None:
        """
        Worker to fetch the latest log content for live updates.

        Args:
            path: The ecFlow node path.

        Returns:
            None

        Raises:
            RuntimeError: If fetching the script fails.
            Exception: For unexpected errors.

        Notes:
            This is an async background worker. Delta calculation is offloaded
            to a thread to keep the UI responsive.
        """
        if not self.ecflow_client:
            return
        try:
            content = await self.ecflow_client.file(path, "jobout")
            content_area = self.query_one("#main_content", MainContent)

            cached = content_area._content_cache.get("output", "")
            last_size = content_area.last_log_size

            def _calculate_delta() -> str | None:
                """
                Calculate the log delta by comparing new content with cached content.

                Returns:
                    The new log content to append, or None if no incremental
                    update is possible.
                """
                if content.startswith(cached) and len(content) > last_size:
                    return content[last_size:]
                return None

            delta = await asyncio.to_thread(_calculate_delta)
            content_area.update_log(content, delta=delta)
        except RuntimeError:
            pass

    def action_why(self) -> None:
        """
        Show the 'Why' inspector for the selected node.
        """
        path = self.get_selected_path()
        if not path or not self.ecflow_client:
            self.notify("No node selected", severity="warning")
            return
        self.push_screen(WhyInspector(path, self.ecflow_client))

    def action_variables(self) -> None:
        """
        Show the variable tweaker for the selected node.
        """
        path = self.get_selected_path()
        if not path or not self.ecflow_client:
            self.notify("No node selected", severity="warning")
            return
        self.push_screen(VariableTweaker(path, self.ecflow_client))

    def action_begin(self) -> None:
        """
        Begin playback for the selected suite.
        """
        if not self.ecflow_client:
            return

        path = self.get_selected_path()
        if not path or path == "/":
            self.notify("No suite selected", severity="warning")
            return

        # Check if it's a suite (starts with / and has no more /)
        if path.count("/") != 1:
            self.notify("Please select a suite to begin", severity="warning")
            return

        suite_name = path.strip("/")
        self._run_client_command("begin_suite", suite_name)

    def action_load_defs(self) -> None:
        """
        Show the load definition modal.
        """
        if not self.ecflow_client:
            return
        self.push_screen(LoadDefsModal())

    def action_zombies(self) -> None:
        """
        Show the zombie management dashboard.
        """
        if not self.ecflow_client:
            self.notify("Client not initialized", severity="warning")
            return
        self.push_screen(ZombieDashboard(self.ecflow_client))

    @work
    async def _load_defs_worker(self, filepath: str) -> None:
        """
        Worker to load definitions in a background thread.

        Args:
            filepath: The path to the .def file.

        Returns:
            None

        Raises:
            RuntimeError: If loading the definition fails.
            Exception: For unexpected errors.

        Notes:
            This is an async background worker.
        """
        if not self.ecflow_client:
            return
        try:
            await self.ecflow_client.load_defs(filepath)
            self.notify(f"Loaded: {filepath}")
            self.action_refresh()
        except RuntimeError as e:
            self.notify(f"Load Error: {e}", severity="error")
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    def action_search_content(self) -> None:
        """
        Trigger content search in the main content area.
        """
        self.query_one("#main_content", MainContent).action_search()

    def action_edit_script(self) -> None:
        """
        Open the node script in an editor and update it on the server.
        """
        path = self.get_selected_path()
        if not path:
            self.notify("No node selected", severity="warning")
            return
        self._edit_script_worker(path)

    @work(exclusive=True)
    async def _edit_script_worker(self, path: str) -> None:
        """
        Worker to fetch script and prepare for editing.

        Args:
            path: The ecFlow node path.

        Returns:
            None

        Notes:
            This is an async background worker.
        """
        if not self.ecflow_client:
            return

        try:
            content = await self.ecflow_client.file(path, "script")

            def _write_temp() -> str:
                with tempfile.NamedTemporaryFile(suffix=".ecf", delete=False, mode="w") as f:
                    f.write(content)
                    return f.name

            temp_path = await asyncio.to_thread(_write_temp)

            await self._run_editor(temp_path, path, content)

        except RuntimeError as e:
            self.notify(f"Edit Error: {e}", severity="error")
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    async def _run_editor(self, temp_path: str, path: str, old_content: str) -> None:
        """
        Run the editor in a suspended state.

        Args:
            temp_path: Path to the temporary file.
            path: The ecFlow node path.
            old_content: The original content of the script.

        Returns:
            None

        Raises:
            RuntimeError: If the editor process fails to start or return.

        Notes:
            This is an async method that uses `asyncio.create_subprocess_exec`
            to avoid blocking the event loop while the TUI is suspended.
        """
        from textual.app import SuspendNotSupported

        editor = os.environ.get("EDITOR", DEFAULT_EDITOR)
        try:
            with self.suspend():
                process = await asyncio.create_subprocess_exec(editor, temp_path)
                await process.wait()
        except SuspendNotSupported:
            # Fallback for environments that do not support suspend (e.g., some tests)
            process = await asyncio.create_subprocess_exec(editor, temp_path)
            await process.wait()

        await self._finish_edit(temp_path, path, old_content)

    @work
    async def _finish_edit(self, temp_path: str, path: str, old_content: str) -> None:
        """
        Process the edited script and update the server.

        Args:
            temp_path: Path to the temporary file.
            path: The ecFlow node path.
            old_content: The original content of the script.

        Returns:
            None

        Raises:
            RuntimeError: If updating the script on the server fails.
            Exception: For unexpected errors.

        Notes:
            This is an async background worker.
        """
        try:
            # We can use asyncio.to_thread for reading the file to stay non-blocking
            def _read_file():
                with open(temp_path) as f:
                    return f.read()

            new_content = await asyncio.to_thread(_read_file)

            if await asyncio.to_thread(os.path.exists, temp_path):
                await asyncio.to_thread(os.unlink, temp_path)

            if new_content != old_content:
                if self.ecflow_client:
                    await self.ecflow_client.alter(path, "change", "script", "", new_content)
                    self.notify("Script updated on server")
                    self._prompt_requeue(path)
            else:
                self.notify("No changes detected")
        except RuntimeError as e:
            self.notify(f"Update Error: {e}", severity="error")
        except Exception as e:
            self.notify(f"Unexpected Error: {e}", severity="error")

    def _prompt_requeue(self, path: str) -> None:
        """
        Prompt the user to requeue the node after a script edit.

        Args:
            path: The absolute path to the node.
        """
        from ectop.widgets.modals.confirm import ConfirmModal

        def do_requeue() -> None:
            if self.ecflow_client:
                # We should probably run this in a worker too, but for simplicity
                # we'll call a worker-wrapped method
                self._run_client_command("requeue", path)

        self.push_screen(ConfirmModal(f"Re-queue {path} now?", do_requeue))

    def on_input_submitted(self, event: Input.Submitted) -> None:
        """
        Handle search submission.

        Args:
            event: The input submission event.
        """
        if event.input.id == "search_box":
            query = event.value
            if query:
                tree = self.query_one("#suite_tree", SuiteTree)
                tree.find_and_select(query)

    def on_input_changed(self, event: Input.Changed) -> None:
        """
        Handle search input changes for live search.

        Args:
            event: The input changed event.
        """
        if event.input.id == "search_box":
            query = event.value
            if query:
                tree = self.query_one("#suite_tree", SuiteTree)
                tree.find_and_select(query)

__init__(host=DEFAULT_HOST, port=DEFAULT_PORT, refresh_interval=DEFAULT_REFRESH_INTERVAL, **kwargs)

Initialize the application.

Parameters:

Name Type Description Default
host str

The ecFlow server hostname. Defaults to DEFAULT_HOST.

DEFAULT_HOST
port int

The ecFlow server port. Defaults to DEFAULT_PORT.

DEFAULT_PORT
refresh_interval float

The interval for live log updates. Defaults to DEFAULT_REFRESH_INTERVAL.

DEFAULT_REFRESH_INTERVAL
**kwargs Any

Additional keyword arguments for the Textual App.

{}
Source code in src/ectop/app.py
def __init__(
    self,
    host: str = DEFAULT_HOST,
    port: int = DEFAULT_PORT,
    refresh_interval: float = DEFAULT_REFRESH_INTERVAL,
    **kwargs: Any,
) -> None:
    """
    Initialize the application.

    Args:
        host: The ecFlow server hostname. Defaults to DEFAULT_HOST.
        port: The ecFlow server port. Defaults to DEFAULT_PORT.
        refresh_interval: The interval for live log updates. Defaults to DEFAULT_REFRESH_INTERVAL.
        **kwargs: Additional keyword arguments for the Textual App.
    """
    super().__init__(**kwargs)
    self.host = host
    self.port = port
    self.refresh_interval = refresh_interval
    self.ecflow_client: EcflowClient | None = None

action_begin()

Begin playback for the selected suite.

Source code in src/ectop/app.py
def action_begin(self) -> None:
    """
    Begin playback for the selected suite.
    """
    if not self.ecflow_client:
        return

    path = self.get_selected_path()
    if not path or path == "/":
        self.notify("No suite selected", severity="warning")
        return

    # Check if it's a suite (starts with / and has no more /)
    if path.count("/") != 1:
        self.notify("Please select a suite to begin", severity="warning")
        return

    suite_name = path.strip("/")
    self._run_client_command("begin_suite", suite_name)

action_copy_path()

Copy the selected node path to the clipboard.

Source code in src/ectop/app.py
def action_copy_path(self) -> None:
    """
    Copy the selected node path to the clipboard.
    """
    path = self.get_selected_path()
    if path:
        if hasattr(self, "copy_to_clipboard"):
            self.copy_to_clipboard(path)
            self.notify(f"Copied to clipboard: {path}")
        else:
            self.notify(f"Node path: {path}")
    else:
        self.notify("No node selected", severity="warning")

action_cycle_filter()

Cycle through tree filters.

Source code in src/ectop/app.py
def action_cycle_filter(self) -> None:
    """
    Cycle through tree filters.
    """
    self.query_one("#suite_tree", SuiteTree).action_cycle_filter()

action_edit_script()

Open the node script in an editor and update it on the server.

Source code in src/ectop/app.py
def action_edit_script(self) -> None:
    """
    Open the node script in an editor and update it on the server.
    """
    path = self.get_selected_path()
    if not path:
        self.notify("No node selected", severity="warning")
        return
    self._edit_script_worker(path)

action_force()

Force complete the selected node.

Source code in src/ectop/app.py
def action_force(self) -> None:
    """
    Force complete the selected node.
    """
    self._run_client_command("force_complete", self.get_selected_path())

action_force_aborted()

Force abort the selected node.

Source code in src/ectop/app.py
def action_force_aborted(self) -> None:
    """
    Force abort the selected node.
    """
    self._run_client_command("force_aborted", self.get_selected_path())

action_halt_server() async

Halt the ecFlow server (HALT).

Returns:

Type Description
None

None

Raises:

Type Description
Exception

If halting the server fails.

Notes

This is an async background worker.

Source code in src/ectop/app.py
@work
async def action_halt_server(self) -> None:
    """
    Halt the ecFlow server (HALT).

    Returns:
        None

    Raises:
        Exception: If halting the server fails.

    Notes:
        This is an async background worker.
    """
    if not self.ecflow_client:
        return
    try:
        await self.ecflow_client.halt_server()
        self.notify("Server Halted (HALT)")
        self.action_refresh()
    except Exception as e:
        self.notify(f"Halt Error: {e}", severity="error")

action_kill()

Kill the selected node.

Source code in src/ectop/app.py
def action_kill(self) -> None:
    """
    Kill the selected node.
    """
    self._run_client_command("kill", self.get_selected_path())

action_load_defs()

Show the load definition modal.

Source code in src/ectop/app.py
def action_load_defs(self) -> None:
    """
    Show the load definition modal.
    """
    if not self.ecflow_client:
        return
    self.push_screen(LoadDefsModal())

action_load_node()

Fetch Output, Script, and Job files for the selected node.

Source code in src/ectop/app.py
def action_load_node(self) -> None:
    """
    Fetch Output, Script, and Job files for the selected node.
    """
    path = self.get_selected_path()
    if not path:
        self.notify("No node selected", severity="warning")
        return
    self._load_node_worker(path)

action_refresh() async

Fetch suites from server and rebuild the tree.

Returns:

Type Description
None

None

Raises:

Type Description
RuntimeError

If synchronization with the server fails.

Exception

For unexpected errors.

Notes

This is an async background worker.

Source code in src/ectop/app.py
@work(exclusive=True)
async def action_refresh(self) -> None:
    """
    Fetch suites from server and rebuild the tree.

    Returns:
        None

    Raises:
        RuntimeError: If synchronization with the server fails.
        Exception: For unexpected errors.

    Notes:
        This is an async background worker.
    """
    if not self.ecflow_client:
        return

    self.notify("Refreshing tree...")

    tree = self.query_one("#suite_tree", SuiteTree)
    status_bar = self.query_one("#status_bar", StatusBar)
    try:
        await self.ecflow_client.sync_local()
        defs = await self.ecflow_client.get_defs()
        status = "Connected"
        version = "Unknown"
        if defs:
            status = str(defs.get_server_state())
        try:
            version = await self.ecflow_client.server_version()
        except RuntimeError:
            pass

        tree.update_tree(self.ecflow_client.host, self.ecflow_client.port, defs)
        status_bar.update_status(self.ecflow_client.host, self.ecflow_client.port, status=status, version=version)
        self.notify("Tree Refreshed")
    except RuntimeError as e:
        status_bar.update_status(self.ecflow_client.host, self.ecflow_client.port, status=STATUS_SYNC_ERROR)
        self.notify(f"Refresh Error: {e}", severity="error")
    except Exception as e:
        self.notify(f"Unexpected Error: {e}", severity="error")

action_requeue()

Requeue the selected node.

Source code in src/ectop/app.py
def action_requeue(self) -> None:
    """
    Requeue the selected node.
    """
    self._run_client_command("requeue", self.get_selected_path())

action_restart_server() async

Restart the ecFlow server (RUNNING).

Returns:

Type Description
None

None

Raises:

Type Description
Exception

If restarting the server fails.

Notes

This is an async background worker.

Source code in src/ectop/app.py
@work
async def action_restart_server(self) -> None:
    """
    Restart the ecFlow server (RUNNING).

    Returns:
        None

    Raises:
        Exception: If restarting the server fails.

    Notes:
        This is an async background worker.
    """
    if not self.ecflow_client:
        return
    try:
        await self.ecflow_client.restart_server()
        self.notify("Server Started (RUNNING)")
        self.action_refresh()
    except Exception as e:
        self.notify(f"Restart Error: {e}", severity="error")

action_resume()

Resume the selected node.

Source code in src/ectop/app.py
def action_resume(self) -> None:
    """
    Resume the selected node.
    """
    self._run_client_command("resume", self.get_selected_path())

action_run()

Execute the selected node immediately.

Source code in src/ectop/app.py
def action_run(self) -> None:
    """
    Execute the selected node immediately.
    """
    self._run_client_command("run", self.get_selected_path())

Show the search box.

Source code in src/ectop/app.py
def action_search(self) -> None:
    """
    Show the search box.
    """
    search_box = self.query_one("#search_box", SearchBox)
    search_box.add_class("visible")
    search_box.focus()

action_search_content()

Trigger content search in the main content area.

Source code in src/ectop/app.py
def action_search_content(self) -> None:
    """
    Trigger content search in the main content area.
    """
    self.query_one("#main_content", MainContent).action_search()

action_suspend()

Suspend the selected node.

Source code in src/ectop/app.py
def action_suspend(self) -> None:
    """
    Suspend the selected node.
    """
    self._run_client_command("suspend", self.get_selected_path())

action_toggle_focus()

Toggle Focus Mode (hide complete nodes).

Source code in src/ectop/app.py
def action_toggle_focus(self) -> None:
    """
    Toggle Focus Mode (hide complete nodes).
    """
    self.query_one("#suite_tree", SuiteTree).action_toggle_focus()

action_toggle_live()

Toggle live log updates.

Source code in src/ectop/app.py
def action_toggle_live(self) -> None:
    """
    Toggle live log updates.
    """
    content_area = self.query_one("#main_content", MainContent)
    content_area.is_live = not content_area.is_live
    state = "ON" if content_area.is_live else "OFF"
    self.notify(f"Live Log: {state}")
    if content_area.is_live:
        content_area.active = "tab_output"

action_variables()

Show the variable tweaker for the selected node.

Source code in src/ectop/app.py
def action_variables(self) -> None:
    """
    Show the variable tweaker for the selected node.
    """
    path = self.get_selected_path()
    if not path or not self.ecflow_client:
        self.notify("No node selected", severity="warning")
        return
    self.push_screen(VariableTweaker(path, self.ecflow_client))

action_why()

Show the 'Why' inspector for the selected node.

Source code in src/ectop/app.py
def action_why(self) -> None:
    """
    Show the 'Why' inspector for the selected node.
    """
    path = self.get_selected_path()
    if not path or not self.ecflow_client:
        self.notify("No node selected", severity="warning")
        return
    self.push_screen(WhyInspector(path, self.ecflow_client))

action_zombies()

Show the zombie management dashboard.

Source code in src/ectop/app.py
def action_zombies(self) -> None:
    """
    Show the zombie management dashboard.
    """
    if not self.ecflow_client:
        self.notify("Client not initialized", severity="warning")
        return
    self.push_screen(ZombieDashboard(self.ecflow_client))

compose()

Compose the UI layout.

Returns:

Type Description
ComposeResult

The UI components.

Source code in src/ectop/app.py
def compose(self) -> ComposeResult:
    """
    Compose the UI layout.

    Returns:
        The UI components.
    """
    yield Header(show_clock=True)
    yield SearchBox(placeholder="Search nodes...", id="search_box")
    yield Horizontal(
        Container(SuiteTree("ecFlow Server", id="suite_tree"), id="sidebar"),
        MainContent(id="main_content"),
    )
    yield StatusBar(id="status_bar")
    yield Footer()

get_selected_path()

Helper to get the ecFlow path of the selected node.

Returns:

Type Description
str | None

The absolute path of the selected node, or None if no node is selected.

Source code in src/ectop/app.py
def get_selected_path(self) -> str | None:
    """
    Helper to get the ecFlow path of the selected node.

    Returns:
        The absolute path of the selected node, or None if no node is selected.
    """
    try:
        node = self.query_one("#suite_tree", SuiteTree).cursor_node
        return node.data if node else None
    except Exception:
        return None

on_input_changed(event)

Handle search input changes for live search.

Parameters:

Name Type Description Default
event Changed

The input changed event.

required
Source code in src/ectop/app.py
def on_input_changed(self, event: Input.Changed) -> None:
    """
    Handle search input changes for live search.

    Args:
        event: The input changed event.
    """
    if event.input.id == "search_box":
        query = event.value
        if query:
            tree = self.query_one("#suite_tree", SuiteTree)
            tree.find_and_select(query)

on_input_submitted(event)

Handle search submission.

Parameters:

Name Type Description Default
event Submitted

The input submission event.

required
Source code in src/ectop/app.py
def on_input_submitted(self, event: Input.Submitted) -> None:
    """
    Handle search submission.

    Args:
        event: The input submission event.
    """
    if event.input.id == "search_box":
        query = event.value
        if query:
            tree = self.query_one("#suite_tree", SuiteTree)
            tree.find_and_select(query)

on_mount()

Handle the mount event to start the application.

Source code in src/ectop/app.py
def on_mount(self) -> None:
    """
    Handle the mount event to start the application.
    """
    self._initial_connect()
    self.set_interval(self.refresh_interval, self._live_log_tick)

on_tree_node_selected(event)

Handle node selection to automatically load content.

Parameters:

Name Type Description Default
event NodeSelected[str]

The node selection event.

required
Source code in src/ectop/app.py
def on_tree_node_selected(self, event: SuiteTree.NodeSelected[str]) -> None:
    """
    Handle node selection to automatically load content.

    Args:
        event: The node selection event.
    """
    if event.node.data:
        self.action_load_node()

EctopCommands

Bases: Provider

Command provider for ectop.

Source code in src/ectop/app.py
class EctopCommands(Provider):
    """
    Command provider for ectop.
    """

    async def search(self, query: str) -> Hits:
        """
        Search for commands.

        Args:
            query: The search query.

        Yields:
            A command hit.
        """
        matcher = self.matcher(query)
        app = self.app
        assert isinstance(app, Ectop)

        commands = [
            ("Refresh Tree", app.action_refresh, "Refresh the ecFlow suite tree"),
            ("Search Nodes", app.action_search, "Search for a node by name or path"),
            ("Suspend Node", app.action_suspend, "Suspend the currently selected node"),
            ("Resume Node", app.action_resume, "Resume the currently selected node"),
            ("Kill Node", app.action_kill, "Kill the currently selected node"),
            ("Execute", app.action_run, "Immediately run the currently selected node"),
            ("Force Complete", app.action_force, "Force complete the currently selected node"),
            ("Force Aborted", app.action_force_aborted, "Force abort the currently selected node"),
            ("Cycle Filter", app.action_cycle_filter, "Cycle status filters (All, Aborted, Active...)"),
            ("Requeue", app.action_requeue, "Requeue the currently selected node"),
            ("Copy Path", app.action_copy_path, "Copy the selected node path"),
            ("Why?", app.action_why, "Inspect why a node is not running"),
            ("Variables", app.action_variables, "View/Edit node variables"),
            ("Edit Script", app.action_edit_script, "Edit and rerun node script"),
            ("Begin Suite", app.action_begin, "Begin playback of the selected suite"),
            ("Load Defs", app.action_load_defs, "Load an ecFlow definition file"),
            ("Restart Server", app.action_restart_server, "Start server scheduling (RUNNING)"),
            ("Halt Server", app.action_halt_server, "Stop server scheduling (HALT)"),
            ("Toggle Live Log", app.action_toggle_live, "Toggle live log updates"),
            ("Zombies", app.action_zombies, "Manage ecFlow zombies"),
            ("Quit", app.action_quit, "Quit the application"),
        ]

        for name, action, help_text in commands:
            score = matcher.match(name)
            if score > 0:
                yield Hit(
                    score,
                    matcher.highlight(name),
                    action,
                    help=help_text,
                )

search(query) async

Search for commands.

Parameters:

Name Type Description Default
query str

The search query.

required

Yields:

Type Description
Hits

A command hit.

Source code in src/ectop/app.py
async def search(self, query: str) -> Hits:
    """
    Search for commands.

    Args:
        query: The search query.

    Yields:
        A command hit.
    """
    matcher = self.matcher(query)
    app = self.app
    assert isinstance(app, Ectop)

    commands = [
        ("Refresh Tree", app.action_refresh, "Refresh the ecFlow suite tree"),
        ("Search Nodes", app.action_search, "Search for a node by name or path"),
        ("Suspend Node", app.action_suspend, "Suspend the currently selected node"),
        ("Resume Node", app.action_resume, "Resume the currently selected node"),
        ("Kill Node", app.action_kill, "Kill the currently selected node"),
        ("Execute", app.action_run, "Immediately run the currently selected node"),
        ("Force Complete", app.action_force, "Force complete the currently selected node"),
        ("Force Aborted", app.action_force_aborted, "Force abort the currently selected node"),
        ("Cycle Filter", app.action_cycle_filter, "Cycle status filters (All, Aborted, Active...)"),
        ("Requeue", app.action_requeue, "Requeue the currently selected node"),
        ("Copy Path", app.action_copy_path, "Copy the selected node path"),
        ("Why?", app.action_why, "Inspect why a node is not running"),
        ("Variables", app.action_variables, "View/Edit node variables"),
        ("Edit Script", app.action_edit_script, "Edit and rerun node script"),
        ("Begin Suite", app.action_begin, "Begin playback of the selected suite"),
        ("Load Defs", app.action_load_defs, "Load an ecFlow definition file"),
        ("Restart Server", app.action_restart_server, "Start server scheduling (RUNNING)"),
        ("Halt Server", app.action_halt_server, "Stop server scheduling (HALT)"),
        ("Toggle Live Log", app.action_toggle_live, "Toggle live log updates"),
        ("Zombies", app.action_zombies, "Manage ecFlow zombies"),
        ("Quit", app.action_quit, "Quit the application"),
    ]

    for name, action, help_text in commands:
        score = matcher.match(name)
        if score > 0:
            yield Hit(
                score,
                matcher.highlight(name),
                action,
                help=help_text,
            )

ecFlow Client Wrapper for ectop.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

EcflowClient

A wrapper around the ecflow.Client to provide a cleaner API and error handling.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Attributes:

Name Type Description
host str

The hostname of the ecFlow server.

port int

The port number of the ecFlow server.

client Client

The underlying ecFlow client instance.

Source code in src/ectop/client.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
class EcflowClient:
    """
    A wrapper around the ecflow.Client to provide a cleaner API and error handling.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.

    Attributes:
        host: The hostname of the ecFlow server.
        port: The port number of the ecFlow server.
        client: The underlying ecFlow client instance.
    """

    def __init__(self, host: str = "localhost", port: int = 3141) -> None:
        """
        Initialize the EcflowClient.

        Args:
            host: The hostname of the ecFlow server. Defaults to "localhost".
            port: The port number of the ecFlow server. Defaults to 3141.

        Raises:
            RuntimeError: If the ecFlow client cannot be initialized.
        """
        self.host: str = host
        self.port: int = port
        self._lock: threading.Lock = threading.Lock()
        try:
            self.client: ecflow.Client = ecflow.Client(host, port)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to initialize ecFlow client for {host}:{port}: {e}") from e

    def ping_sync(self) -> None:
        """
        Synchronously ping the ecFlow server.

        Raises:
            RuntimeError: If the server is unreachable or the ping fails.
        """
        with self._lock:
            try:
                self.client.ping()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to ping ecFlow server at {self.host}:{self.port}: {e}") from e

    async def ping(self) -> None:
        """
        Ping the ecFlow server to check connectivity.

        Raises:
            RuntimeError: If the server is unreachable or the ping fails.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.ping_sync)

    def sync_local_sync(self) -> None:
        """
        Synchronously synchronize the local definition with the server.

        Raises:
            RuntimeError: If synchronization fails.
        """
        with self._lock:
            try:
                self.client.sync_local()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to sync with ecFlow server: {e}") from e

    async def sync_local(self) -> None:
        """
        Synchronize the local definition with the server.

        Raises:
            RuntimeError: If synchronization fails.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.sync_local_sync)

    def get_defs_sync(self) -> Defs | None:
        """
        Synchronously retrieve the current definitions from the client.

        Returns:
            The ecFlow definitions, or None if not available.

        Raises:
            RuntimeError: If the definitions cannot be retrieved.
        """
        with self._lock:
            try:
                return self.client.get_defs()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to get definitions from client: {e}") from e

    async def get_defs(self) -> Defs | None:
        """
        Retrieve the current definitions from the client.

        Returns:
            The ecFlow definitions, or None if not available.

        Raises:
            RuntimeError: If the definitions cannot be retrieved.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        return await asyncio.to_thread(self.get_defs_sync)

    def file_sync(self, path: str, file_type: str) -> str:
        """
        Synchronously retrieve a file (log, script, job) for a specific node.

        Args:
            path: The absolute path to the node.
            file_type: The type of file to retrieve ('jobout', 'script', 'job').

        Returns:
            The content of the requested file.

        Raises:
            RuntimeError: If the file cannot be retrieved.
        """
        with self._lock:
            try:
                return self.client.get_file(path, file_type)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to retrieve {file_type} for {path}: {e}") from e

    async def file(self, path: str, file_type: str) -> str:
        """
        Retrieve a file (log, script, job) for a specific node.

        Args:
            path: The absolute path to the node.
            file_type: The type of file to retrieve ('jobout', 'script', 'job').

        Returns:
            The content of the requested file.

        Raises:
            RuntimeError: If the file cannot be retrieved.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        return await asyncio.to_thread(self.file_sync, path, file_type)

    def suspend_sync(self, path: str) -> None:
        """
        Synchronously suspend a node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be suspended.
        """
        with self._lock:
            try:
                self.client.suspend(path)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to suspend {path}: {e}") from e

    async def suspend(self, path: str) -> None:
        """
        Suspend a node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be suspended.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.suspend_sync, path)

    def resume_sync(self, path: str) -> None:
        """
        Synchronously resume a suspended node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be resumed.
        """
        with self._lock:
            try:
                self.client.resume(path)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to resume {path}: {e}") from e

    async def resume(self, path: str) -> None:
        """
        Resume a suspended node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be resumed.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.resume_sync, path)

    def kill_sync(self, path: str) -> None:
        """
        Synchronously kill a running task.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be killed.
        """
        with self._lock:
            try:
                self.client.kill(path)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to kill {path}: {e}") from e

    async def kill(self, path: str) -> None:
        """
        Kill a running task.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be killed.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.kill_sync, path)

    def force_complete_sync(self, path: str) -> None:
        """
        Synchronously force a node to the complete state.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node state cannot be forced.
        """
        with self._lock:
            try:
                try:
                    self.client.force_complete(path)
                except AttributeError:
                    self.client.force_state(path, ecflow.State.complete)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to force complete {path}: {e}") from e

    async def force_complete(self, path: str) -> None:
        """
        Force a node to the complete state.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node state cannot be forced.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.force_complete_sync, path)

    def force_aborted_sync(self, path: str) -> None:
        """
        Synchronously force a node to the aborted state.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node state cannot be forced.
        """
        with self._lock:
            try:
                self.client.force_state(path, ecflow.State.aborted)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to force aborted {path}: {e}") from e

    async def force_aborted(self, path: str) -> None:
        """
        Force a node to the aborted state.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node state cannot be forced.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.force_aborted_sync, path)

    def run_sync(self, path: str, force: bool = False) -> None:
        """
        Synchronously run a node (bypass triggers).

        Args:
            path: The absolute path to the node.
            force: If True, run even if nodes are active or submitted. Defaults to False.

        Raises:
            RuntimeError: If the node cannot be run.
        """
        with self._lock:
            try:
                self.client.run(path, force)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to run {path}: {e}") from e

    async def run(self, path: str, force: bool = False) -> None:
        """
        Run a node (bypass triggers).

        Args:
            path: The absolute path to the node.
            force: If True, run even if nodes are active or submitted. Defaults to False.

        Raises:
            RuntimeError: If the node cannot be run.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.run_sync, path, force)

    def alter_sync(self, path: str, alter_type: str, attr_type: str, name: str = "", value: str | None = None) -> None:
        """
        Synchronously alter a node attribute or variable.

        Args:
            path: The absolute path to the node.
            alter_type: The type of alteration (e.g., 'change', 'add', 'delete').
            attr_type: The type of attribute (e.g., 'variable', 'label').
            name: The name of the attribute or variable.
            value: The new value. Defaults to None.

        Raises:
            RuntimeError: If the alteration fails.
        """
        with self._lock:
            try:
                if value is None:
                    self.client.alter(path, alter_type, attr_type, name)
                else:
                    self.client.alter(path, alter_type, attr_type, name, value)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to alter {path} ({alter_type} {attr_type} {name}={value}): {e}") from e

    async def alter(self, path: str, alter_type: str, attr_type: str, name: str = "", value: str | None = None) -> None:
        """
        Alter a node attribute or variable.

        Args:
            path: The absolute path to the node.
            alter_type: The type of alteration (e.g., 'change', 'add', 'delete').
            attr_type: The type of attribute (e.g., 'variable', 'label').
            name: The name of the attribute or variable.
            value: The new value. Defaults to None.

        Raises:
            RuntimeError: If the alteration fails.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.alter_sync, path, alter_type, attr_type, name, value)

    def requeue_sync(self, path: str) -> None:
        """
        Synchronously requeue a node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be requeued.
        """
        with self._lock:
            try:
                self.client.requeue(path)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to requeue {path}: {e}") from e

    async def requeue(self, path: str) -> None:
        """
        Requeue a node.

        Args:
            path: The absolute path to the node.

        Raises:
            RuntimeError: If the node cannot be requeued.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.requeue_sync, path)

    def restart_server_sync(self) -> None:
        """
        Synchronously restart the ecFlow server.

        Raises:
            RuntimeError: If the server cannot be restarted.
        """
        with self._lock:
            try:
                self.client.restart_server()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to restart server: {e}") from e

    async def restart_server(self) -> None:
        """
        Restart the ecFlow server (resume from HALTED state).

        Raises:
            RuntimeError: If the server cannot be restarted.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.restart_server_sync)

    def halt_server_sync(self) -> None:
        """
        Synchronously halt the ecFlow server.

        Raises:
            RuntimeError: If the server cannot be halted.
        """
        with self._lock:
            try:
                self.client.halt_server()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to halt server: {e}") from e

    async def halt_server(self) -> None:
        """
        Halt the ecFlow server (suspend scheduling).

        Raises:
            RuntimeError: If the server cannot be halted.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.halt_server_sync)

    def version_sync(self) -> str:
        """
        Synchronously retrieve the ecFlow client version.

        Returns:
            The client version string.

        Raises:
            RuntimeError: If the version cannot be retrieved.
        """
        with self._lock:
            try:
                return str(self.client.version())
            except RuntimeError as e:
                raise RuntimeError(f"Failed to get client version: {e}") from e

    async def version(self) -> str:
        """
        Retrieve the ecFlow client version.

        Returns:
            The client version string.

        Raises:
            RuntimeError: If the version cannot be retrieved.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        return await asyncio.to_thread(self.version_sync)

    def server_version_sync(self) -> str:
        """
        Synchronously retrieve the ecFlow server version.

        Returns:
            The server version string.

        Raises:
            RuntimeError: If the server version cannot be retrieved.
        """
        with self._lock:
            try:
                return str(self.client.server_version())
            except RuntimeError as e:
                raise RuntimeError(f"Failed to get server version: {e}") from e

    async def server_version(self) -> str:
        """
        Retrieve the ecFlow server version.

        Returns:
            The server version string.

        Raises:
            RuntimeError: If the server version cannot be retrieved.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        return await asyncio.to_thread(self.server_version_sync)

    def load_defs_sync(self, filepath: str) -> None:
        """
        Synchronously load an ecFlow definition file to the server.

        Args:
            filepath: The path to the .def file.

        Raises:
            RuntimeError: If the file cannot be loaded.
        """
        with self._lock:
            try:
                self.client.load(filepath)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to load definition file {filepath}: {e}") from e

    async def load_defs(self, filepath: str) -> None:
        """
        Load an ecFlow definition file to the server.

        Args:
            filepath: The path to the .def file.

        Raises:
            RuntimeError: If the file cannot be loaded.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.load_defs_sync, filepath)

    def begin_suite_sync(self, name: str) -> None:
        """
        Synchronously begin playback of a suite.

        Args:
            name: The name of the suite to begin.

        Raises:
            RuntimeError: If the suite cannot be started.
        """
        with self._lock:
            try:
                self.client.begin_suite(name)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to begin suite {name}: {e}") from e

    async def begin_suite(self, name: str) -> None:
        """
        Begin playback of a suite.

        Args:
            name: The name of the suite to begin.

        Raises:
            RuntimeError: If the suite cannot be started.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        await asyncio.to_thread(self.begin_suite_sync, name)

    def zombie_get_sync(self) -> list[ecflow.Zombie]:
        """
        Synchronously retrieve the list of zombies from the server.

        Returns:
            List of zombie objects.

        Raises:
            RuntimeError: If retrieval fails.
        """
        with self._lock:
            try:
                return self.client.zombie_get()
            except RuntimeError as e:
                raise RuntimeError(f"Failed to get zombies: {e}") from e

    async def zombie_get(self) -> list[ecflow.Zombie]:
        """
        Retrieve the list of zombies from the server.

        Returns:
            List of zombie objects.

        Raises:
            RuntimeError: If retrieval fails.

        Notes:
            This is an async method that runs the blocking call in a separate thread.
        """
        return await asyncio.to_thread(self.zombie_get_sync)

    def zombie_fob_sync(self, zombie: ecflow.Zombie) -> None:
        """
        Synchronously FOB a zombie.

        Args:
            zombie: The zombie object.
        """
        with self._lock:
            try:
                self.client.zombie_fob(zombie)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to FOB zombie: {e}") from e

    async def zombie_fob(self, zombie: ecflow.Zombie) -> None:
        """
        FOB a zombie.

        Args:
            zombie: The zombie object.
        """
        await asyncio.to_thread(self.zombie_fob_sync, zombie)

    def zombie_fail_sync(self, zombie: ecflow.Zombie) -> None:
        """
        Synchronously fail a zombie.

        Args:
            zombie: The zombie object.
        """
        with self._lock:
            try:
                self.client.zombie_fail(zombie)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to fail zombie: {e}") from e

    async def zombie_fail(self, zombie: ecflow.Zombie) -> None:
        """
        Fail a zombie.

        Args:
            zombie: The zombie object.
        """
        await asyncio.to_thread(self.zombie_fail_sync, zombie)

    def zombie_adopt_sync(self, zombie: ecflow.Zombie) -> None:
        """
        Synchronously adopt a zombie.

        Args:
            zombie: The zombie object.
        """
        with self._lock:
            try:
                self.client.zombie_adopt(zombie)
            except RuntimeError as e:
                raise RuntimeError(f"Failed to adopt zombie: {e}") from e

    async def zombie_adopt(self, zombie: ecflow.Zombie) -> None:
        """
        Adopt a zombie.

        Args:
            zombie: The zombie object.
        """
        await asyncio.to_thread(self.zombie_adopt_sync, zombie)

__init__(host='localhost', port=3141)

Initialize the EcflowClient.

Parameters:

Name Type Description Default
host str

The hostname of the ecFlow server. Defaults to "localhost".

'localhost'
port int

The port number of the ecFlow server. Defaults to 3141.

3141

Raises:

Type Description
RuntimeError

If the ecFlow client cannot be initialized.

Source code in src/ectop/client.py
def __init__(self, host: str = "localhost", port: int = 3141) -> None:
    """
    Initialize the EcflowClient.

    Args:
        host: The hostname of the ecFlow server. Defaults to "localhost".
        port: The port number of the ecFlow server. Defaults to 3141.

    Raises:
        RuntimeError: If the ecFlow client cannot be initialized.
    """
    self.host: str = host
    self.port: int = port
    self._lock: threading.Lock = threading.Lock()
    try:
        self.client: ecflow.Client = ecflow.Client(host, port)
    except RuntimeError as e:
        raise RuntimeError(f"Failed to initialize ecFlow client for {host}:{port}: {e}") from e

alter(path, alter_type, attr_type, name='', value=None) async

Alter a node attribute or variable.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
alter_type str

The type of alteration (e.g., 'change', 'add', 'delete').

required
attr_type str

The type of attribute (e.g., 'variable', 'label').

required
name str

The name of the attribute or variable.

''
value str | None

The new value. Defaults to None.

None

Raises:

Type Description
RuntimeError

If the alteration fails.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def alter(self, path: str, alter_type: str, attr_type: str, name: str = "", value: str | None = None) -> None:
    """
    Alter a node attribute or variable.

    Args:
        path: The absolute path to the node.
        alter_type: The type of alteration (e.g., 'change', 'add', 'delete').
        attr_type: The type of attribute (e.g., 'variable', 'label').
        name: The name of the attribute or variable.
        value: The new value. Defaults to None.

    Raises:
        RuntimeError: If the alteration fails.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.alter_sync, path, alter_type, attr_type, name, value)

alter_sync(path, alter_type, attr_type, name='', value=None)

Synchronously alter a node attribute or variable.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
alter_type str

The type of alteration (e.g., 'change', 'add', 'delete').

required
attr_type str

The type of attribute (e.g., 'variable', 'label').

required
name str

The name of the attribute or variable.

''
value str | None

The new value. Defaults to None.

None

Raises:

Type Description
RuntimeError

If the alteration fails.

Source code in src/ectop/client.py
def alter_sync(self, path: str, alter_type: str, attr_type: str, name: str = "", value: str | None = None) -> None:
    """
    Synchronously alter a node attribute or variable.

    Args:
        path: The absolute path to the node.
        alter_type: The type of alteration (e.g., 'change', 'add', 'delete').
        attr_type: The type of attribute (e.g., 'variable', 'label').
        name: The name of the attribute or variable.
        value: The new value. Defaults to None.

    Raises:
        RuntimeError: If the alteration fails.
    """
    with self._lock:
        try:
            if value is None:
                self.client.alter(path, alter_type, attr_type, name)
            else:
                self.client.alter(path, alter_type, attr_type, name, value)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to alter {path} ({alter_type} {attr_type} {name}={value}): {e}") from e

begin_suite(name) async

Begin playback of a suite.

Parameters:

Name Type Description Default
name str

The name of the suite to begin.

required

Raises:

Type Description
RuntimeError

If the suite cannot be started.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def begin_suite(self, name: str) -> None:
    """
    Begin playback of a suite.

    Args:
        name: The name of the suite to begin.

    Raises:
        RuntimeError: If the suite cannot be started.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.begin_suite_sync, name)

begin_suite_sync(name)

Synchronously begin playback of a suite.

Parameters:

Name Type Description Default
name str

The name of the suite to begin.

required

Raises:

Type Description
RuntimeError

If the suite cannot be started.

Source code in src/ectop/client.py
def begin_suite_sync(self, name: str) -> None:
    """
    Synchronously begin playback of a suite.

    Args:
        name: The name of the suite to begin.

    Raises:
        RuntimeError: If the suite cannot be started.
    """
    with self._lock:
        try:
            self.client.begin_suite(name)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to begin suite {name}: {e}") from e

file(path, file_type) async

Retrieve a file (log, script, job) for a specific node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
file_type str

The type of file to retrieve ('jobout', 'script', 'job').

required

Returns:

Type Description
str

The content of the requested file.

Raises:

Type Description
RuntimeError

If the file cannot be retrieved.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def file(self, path: str, file_type: str) -> str:
    """
    Retrieve a file (log, script, job) for a specific node.

    Args:
        path: The absolute path to the node.
        file_type: The type of file to retrieve ('jobout', 'script', 'job').

    Returns:
        The content of the requested file.

    Raises:
        RuntimeError: If the file cannot be retrieved.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    return await asyncio.to_thread(self.file_sync, path, file_type)

file_sync(path, file_type)

Synchronously retrieve a file (log, script, job) for a specific node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
file_type str

The type of file to retrieve ('jobout', 'script', 'job').

required

Returns:

Type Description
str

The content of the requested file.

Raises:

Type Description
RuntimeError

If the file cannot be retrieved.

Source code in src/ectop/client.py
def file_sync(self, path: str, file_type: str) -> str:
    """
    Synchronously retrieve a file (log, script, job) for a specific node.

    Args:
        path: The absolute path to the node.
        file_type: The type of file to retrieve ('jobout', 'script', 'job').

    Returns:
        The content of the requested file.

    Raises:
        RuntimeError: If the file cannot be retrieved.
    """
    with self._lock:
        try:
            return self.client.get_file(path, file_type)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to retrieve {file_type} for {path}: {e}") from e

force_aborted(path) async

Force a node to the aborted state.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node state cannot be forced.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def force_aborted(self, path: str) -> None:
    """
    Force a node to the aborted state.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node state cannot be forced.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.force_aborted_sync, path)

force_aborted_sync(path)

Synchronously force a node to the aborted state.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node state cannot be forced.

Source code in src/ectop/client.py
def force_aborted_sync(self, path: str) -> None:
    """
    Synchronously force a node to the aborted state.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node state cannot be forced.
    """
    with self._lock:
        try:
            self.client.force_state(path, ecflow.State.aborted)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to force aborted {path}: {e}") from e

force_complete(path) async

Force a node to the complete state.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node state cannot be forced.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def force_complete(self, path: str) -> None:
    """
    Force a node to the complete state.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node state cannot be forced.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.force_complete_sync, path)

force_complete_sync(path)

Synchronously force a node to the complete state.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node state cannot be forced.

Source code in src/ectop/client.py
def force_complete_sync(self, path: str) -> None:
    """
    Synchronously force a node to the complete state.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node state cannot be forced.
    """
    with self._lock:
        try:
            try:
                self.client.force_complete(path)
            except AttributeError:
                self.client.force_state(path, ecflow.State.complete)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to force complete {path}: {e}") from e

get_defs() async

Retrieve the current definitions from the client.

Returns:

Type Description
Defs | None

The ecFlow definitions, or None if not available.

Raises:

Type Description
RuntimeError

If the definitions cannot be retrieved.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def get_defs(self) -> Defs | None:
    """
    Retrieve the current definitions from the client.

    Returns:
        The ecFlow definitions, or None if not available.

    Raises:
        RuntimeError: If the definitions cannot be retrieved.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    return await asyncio.to_thread(self.get_defs_sync)

get_defs_sync()

Synchronously retrieve the current definitions from the client.

Returns:

Type Description
Defs | None

The ecFlow definitions, or None if not available.

Raises:

Type Description
RuntimeError

If the definitions cannot be retrieved.

Source code in src/ectop/client.py
def get_defs_sync(self) -> Defs | None:
    """
    Synchronously retrieve the current definitions from the client.

    Returns:
        The ecFlow definitions, or None if not available.

    Raises:
        RuntimeError: If the definitions cannot be retrieved.
    """
    with self._lock:
        try:
            return self.client.get_defs()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to get definitions from client: {e}") from e

halt_server() async

Halt the ecFlow server (suspend scheduling).

Raises:

Type Description
RuntimeError

If the server cannot be halted.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def halt_server(self) -> None:
    """
    Halt the ecFlow server (suspend scheduling).

    Raises:
        RuntimeError: If the server cannot be halted.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.halt_server_sync)

halt_server_sync()

Synchronously halt the ecFlow server.

Raises:

Type Description
RuntimeError

If the server cannot be halted.

Source code in src/ectop/client.py
def halt_server_sync(self) -> None:
    """
    Synchronously halt the ecFlow server.

    Raises:
        RuntimeError: If the server cannot be halted.
    """
    with self._lock:
        try:
            self.client.halt_server()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to halt server: {e}") from e

kill(path) async

Kill a running task.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be killed.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def kill(self, path: str) -> None:
    """
    Kill a running task.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be killed.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.kill_sync, path)

kill_sync(path)

Synchronously kill a running task.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be killed.

Source code in src/ectop/client.py
def kill_sync(self, path: str) -> None:
    """
    Synchronously kill a running task.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be killed.
    """
    with self._lock:
        try:
            self.client.kill(path)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to kill {path}: {e}") from e

load_defs(filepath) async

Load an ecFlow definition file to the server.

Parameters:

Name Type Description Default
filepath str

The path to the .def file.

required

Raises:

Type Description
RuntimeError

If the file cannot be loaded.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def load_defs(self, filepath: str) -> None:
    """
    Load an ecFlow definition file to the server.

    Args:
        filepath: The path to the .def file.

    Raises:
        RuntimeError: If the file cannot be loaded.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.load_defs_sync, filepath)

load_defs_sync(filepath)

Synchronously load an ecFlow definition file to the server.

Parameters:

Name Type Description Default
filepath str

The path to the .def file.

required

Raises:

Type Description
RuntimeError

If the file cannot be loaded.

Source code in src/ectop/client.py
def load_defs_sync(self, filepath: str) -> None:
    """
    Synchronously load an ecFlow definition file to the server.

    Args:
        filepath: The path to the .def file.

    Raises:
        RuntimeError: If the file cannot be loaded.
    """
    with self._lock:
        try:
            self.client.load(filepath)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to load definition file {filepath}: {e}") from e

ping() async

Ping the ecFlow server to check connectivity.

Raises:

Type Description
RuntimeError

If the server is unreachable or the ping fails.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def ping(self) -> None:
    """
    Ping the ecFlow server to check connectivity.

    Raises:
        RuntimeError: If the server is unreachable or the ping fails.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.ping_sync)

ping_sync()

Synchronously ping the ecFlow server.

Raises:

Type Description
RuntimeError

If the server is unreachable or the ping fails.

Source code in src/ectop/client.py
def ping_sync(self) -> None:
    """
    Synchronously ping the ecFlow server.

    Raises:
        RuntimeError: If the server is unreachable or the ping fails.
    """
    with self._lock:
        try:
            self.client.ping()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to ping ecFlow server at {self.host}:{self.port}: {e}") from e

requeue(path) async

Requeue a node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be requeued.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def requeue(self, path: str) -> None:
    """
    Requeue a node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be requeued.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.requeue_sync, path)

requeue_sync(path)

Synchronously requeue a node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be requeued.

Source code in src/ectop/client.py
def requeue_sync(self, path: str) -> None:
    """
    Synchronously requeue a node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be requeued.
    """
    with self._lock:
        try:
            self.client.requeue(path)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to requeue {path}: {e}") from e

restart_server() async

Restart the ecFlow server (resume from HALTED state).

Raises:

Type Description
RuntimeError

If the server cannot be restarted.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def restart_server(self) -> None:
    """
    Restart the ecFlow server (resume from HALTED state).

    Raises:
        RuntimeError: If the server cannot be restarted.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.restart_server_sync)

restart_server_sync()

Synchronously restart the ecFlow server.

Raises:

Type Description
RuntimeError

If the server cannot be restarted.

Source code in src/ectop/client.py
def restart_server_sync(self) -> None:
    """
    Synchronously restart the ecFlow server.

    Raises:
        RuntimeError: If the server cannot be restarted.
    """
    with self._lock:
        try:
            self.client.restart_server()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to restart server: {e}") from e

resume(path) async

Resume a suspended node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be resumed.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def resume(self, path: str) -> None:
    """
    Resume a suspended node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be resumed.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.resume_sync, path)

resume_sync(path)

Synchronously resume a suspended node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be resumed.

Source code in src/ectop/client.py
def resume_sync(self, path: str) -> None:
    """
    Synchronously resume a suspended node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be resumed.
    """
    with self._lock:
        try:
            self.client.resume(path)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to resume {path}: {e}") from e

run(path, force=False) async

Run a node (bypass triggers).

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
force bool

If True, run even if nodes are active or submitted. Defaults to False.

False

Raises:

Type Description
RuntimeError

If the node cannot be run.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def run(self, path: str, force: bool = False) -> None:
    """
    Run a node (bypass triggers).

    Args:
        path: The absolute path to the node.
        force: If True, run even if nodes are active or submitted. Defaults to False.

    Raises:
        RuntimeError: If the node cannot be run.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.run_sync, path, force)

run_sync(path, force=False)

Synchronously run a node (bypass triggers).

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required
force bool

If True, run even if nodes are active or submitted. Defaults to False.

False

Raises:

Type Description
RuntimeError

If the node cannot be run.

Source code in src/ectop/client.py
def run_sync(self, path: str, force: bool = False) -> None:
    """
    Synchronously run a node (bypass triggers).

    Args:
        path: The absolute path to the node.
        force: If True, run even if nodes are active or submitted. Defaults to False.

    Raises:
        RuntimeError: If the node cannot be run.
    """
    with self._lock:
        try:
            self.client.run(path, force)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to run {path}: {e}") from e

server_version() async

Retrieve the ecFlow server version.

Returns:

Type Description
str

The server version string.

Raises:

Type Description
RuntimeError

If the server version cannot be retrieved.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def server_version(self) -> str:
    """
    Retrieve the ecFlow server version.

    Returns:
        The server version string.

    Raises:
        RuntimeError: If the server version cannot be retrieved.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    return await asyncio.to_thread(self.server_version_sync)

server_version_sync()

Synchronously retrieve the ecFlow server version.

Returns:

Type Description
str

The server version string.

Raises:

Type Description
RuntimeError

If the server version cannot be retrieved.

Source code in src/ectop/client.py
def server_version_sync(self) -> str:
    """
    Synchronously retrieve the ecFlow server version.

    Returns:
        The server version string.

    Raises:
        RuntimeError: If the server version cannot be retrieved.
    """
    with self._lock:
        try:
            return str(self.client.server_version())
        except RuntimeError as e:
            raise RuntimeError(f"Failed to get server version: {e}") from e

suspend(path) async

Suspend a node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be suspended.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def suspend(self, path: str) -> None:
    """
    Suspend a node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be suspended.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.suspend_sync, path)

suspend_sync(path)

Synchronously suspend a node.

Parameters:

Name Type Description Default
path str

The absolute path to the node.

required

Raises:

Type Description
RuntimeError

If the node cannot be suspended.

Source code in src/ectop/client.py
def suspend_sync(self, path: str) -> None:
    """
    Synchronously suspend a node.

    Args:
        path: The absolute path to the node.

    Raises:
        RuntimeError: If the node cannot be suspended.
    """
    with self._lock:
        try:
            self.client.suspend(path)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to suspend {path}: {e}") from e

sync_local() async

Synchronize the local definition with the server.

Raises:

Type Description
RuntimeError

If synchronization fails.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def sync_local(self) -> None:
    """
    Synchronize the local definition with the server.

    Raises:
        RuntimeError: If synchronization fails.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    await asyncio.to_thread(self.sync_local_sync)

sync_local_sync()

Synchronously synchronize the local definition with the server.

Raises:

Type Description
RuntimeError

If synchronization fails.

Source code in src/ectop/client.py
def sync_local_sync(self) -> None:
    """
    Synchronously synchronize the local definition with the server.

    Raises:
        RuntimeError: If synchronization fails.
    """
    with self._lock:
        try:
            self.client.sync_local()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to sync with ecFlow server: {e}") from e

version() async

Retrieve the ecFlow client version.

Returns:

Type Description
str

The client version string.

Raises:

Type Description
RuntimeError

If the version cannot be retrieved.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def version(self) -> str:
    """
    Retrieve the ecFlow client version.

    Returns:
        The client version string.

    Raises:
        RuntimeError: If the version cannot be retrieved.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    return await asyncio.to_thread(self.version_sync)

version_sync()

Synchronously retrieve the ecFlow client version.

Returns:

Type Description
str

The client version string.

Raises:

Type Description
RuntimeError

If the version cannot be retrieved.

Source code in src/ectop/client.py
def version_sync(self) -> str:
    """
    Synchronously retrieve the ecFlow client version.

    Returns:
        The client version string.

    Raises:
        RuntimeError: If the version cannot be retrieved.
    """
    with self._lock:
        try:
            return str(self.client.version())
        except RuntimeError as e:
            raise RuntimeError(f"Failed to get client version: {e}") from e

zombie_adopt(zombie) async

Adopt a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
async def zombie_adopt(self, zombie: ecflow.Zombie) -> None:
    """
    Adopt a zombie.

    Args:
        zombie: The zombie object.
    """
    await asyncio.to_thread(self.zombie_adopt_sync, zombie)

zombie_adopt_sync(zombie)

Synchronously adopt a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
def zombie_adopt_sync(self, zombie: ecflow.Zombie) -> None:
    """
    Synchronously adopt a zombie.

    Args:
        zombie: The zombie object.
    """
    with self._lock:
        try:
            self.client.zombie_adopt(zombie)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to adopt zombie: {e}") from e

zombie_fail(zombie) async

Fail a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
async def zombie_fail(self, zombie: ecflow.Zombie) -> None:
    """
    Fail a zombie.

    Args:
        zombie: The zombie object.
    """
    await asyncio.to_thread(self.zombie_fail_sync, zombie)

zombie_fail_sync(zombie)

Synchronously fail a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
def zombie_fail_sync(self, zombie: ecflow.Zombie) -> None:
    """
    Synchronously fail a zombie.

    Args:
        zombie: The zombie object.
    """
    with self._lock:
        try:
            self.client.zombie_fail(zombie)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to fail zombie: {e}") from e

zombie_fob(zombie) async

FOB a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
async def zombie_fob(self, zombie: ecflow.Zombie) -> None:
    """
    FOB a zombie.

    Args:
        zombie: The zombie object.
    """
    await asyncio.to_thread(self.zombie_fob_sync, zombie)

zombie_fob_sync(zombie)

Synchronously FOB a zombie.

Parameters:

Name Type Description Default
zombie Zombie

The zombie object.

required
Source code in src/ectop/client.py
def zombie_fob_sync(self, zombie: ecflow.Zombie) -> None:
    """
    Synchronously FOB a zombie.

    Args:
        zombie: The zombie object.
    """
    with self._lock:
        try:
            self.client.zombie_fob(zombie)
        except RuntimeError as e:
            raise RuntimeError(f"Failed to FOB zombie: {e}") from e

zombie_get() async

Retrieve the list of zombies from the server.

Returns:

Type Description
list[Zombie]

List of zombie objects.

Raises:

Type Description
RuntimeError

If retrieval fails.

Notes

This is an async method that runs the blocking call in a separate thread.

Source code in src/ectop/client.py
async def zombie_get(self) -> list[ecflow.Zombie]:
    """
    Retrieve the list of zombies from the server.

    Returns:
        List of zombie objects.

    Raises:
        RuntimeError: If retrieval fails.

    Notes:
        This is an async method that runs the blocking call in a separate thread.
    """
    return await asyncio.to_thread(self.zombie_get_sync)

zombie_get_sync()

Synchronously retrieve the list of zombies from the server.

Returns:

Type Description
list[Zombie]

List of zombie objects.

Raises:

Type Description
RuntimeError

If retrieval fails.

Source code in src/ectop/client.py
def zombie_get_sync(self) -> list[ecflow.Zombie]:
    """
    Synchronously retrieve the list of zombies from the server.

    Returns:
        List of zombie objects.

    Raises:
        RuntimeError: If retrieval fails.
    """
    with self._lock:
        try:
            return self.client.zombie_get()
        except RuntimeError as e:
            raise RuntimeError(f"Failed to get zombies: {e}") from e

CLI entry point for ectop.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

main()

Run the ectop application.

Parses command-line arguments and environment variables for server configuration.

Source code in src/ectop/cli.py
def main() -> None:
    """
    Run the ectop application.

    Parses command-line arguments and environment variables for server configuration.
    """
    parser = argparse.ArgumentParser(description="ectop — High-performance TUI for ECMWF ecFlow")
    parser.add_argument(
        "--host",
        type=str,
        default=os.environ.get("ECF_HOST", DEFAULT_HOST),
        help=f"ecFlow server hostname (default: {DEFAULT_HOST} or ECF_HOST)",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=int(os.environ.get("ECF_PORT", DEFAULT_PORT)),
        help=f"ecFlow server port (default: {DEFAULT_PORT} or ECF_PORT)",
    )
    parser.add_argument(
        "--refresh",
        type=float,
        default=float(os.environ.get("ECTOP_REFRESH", DEFAULT_REFRESH_INTERVAL)),
        help=f"Automatic refresh interval in seconds (default: {DEFAULT_REFRESH_INTERVAL} or ECTOP_REFRESH)",
    )

    args = parser.parse_args()

    app = Ectop(host=args.host, port=args.port, refresh_interval=args.refresh)
    app.run()

Constants for the ectop application.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

DEFAULT_EDITOR = 'vi' module-attribute

Default editor for script editing.

DEFAULT_SHELL = 'bash' module-attribute

Default shell for script execution.

ERROR_CONNECTION_FAILED = 'Connection Failed' module-attribute

Standard error message for connection failures.

INHERITED_VAR_PREFIX = 'inh_' module-attribute

Prefix for inherited variable keys in the VariableTweaker.

LOADING_PLACEHOLDER = 'loading...' module-attribute

Placeholder text for lazy-loaded tree nodes.

STATUS_SYNC_ERROR = 'Sync Error' module-attribute

Standard status message for synchronization errors.

SYNTAX_THEME = 'monokai' module-attribute

Default theme for syntax highlighting.

TREE_FILTERS = [None, 'aborted', 'active', 'queued', 'submitted', 'suspended'] module-attribute

Default status filters for the SuiteTree.

Widgets

Main content area for displaying ecFlow node information.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

MainContent

Bases: Vertical

A container to display Output logs, Scripts, and Job files in tabs.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Attributes:

Name Type Description
is_live reactive[bool]

Whether live log updates are enabled.

last_log_size int

The size of the log content at the last update.

search_query reactive[str]

The current search query.

search_results reactive[list[tuple[int, int]]]

List of (start, end) offsets for search matches.

current_result_index reactive[int]

The index of the currently active search match.

Source code in src/ectop/widgets/content.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
class MainContent(Vertical):
    """
    A container to display Output logs, Scripts, and Job files in tabs.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.

    Attributes:
        is_live: Whether live log updates are enabled.
        last_log_size: The size of the log content at the last update.
        search_query: The current search query.
        search_results: List of (start, end) offsets for search matches.
        current_result_index: The index of the currently active search match.
    """

    BINDINGS = [
        Binding("n", "search_next", "Next Match"),
        Binding("N", "search_prev", "Prev Match"),
    ]

    is_live: reactive[bool] = reactive(False, init=False)
    """Whether live log updates are enabled."""

    log_content: reactive[str] = reactive("", init=False)
    """The content of the output log."""

    script_content: reactive[str] = reactive("", init=False)
    """The content of the script."""

    job_content: reactive[str] = reactive("", init=False)
    """The content of the job file."""

    search_query: reactive[str] = reactive("", init=False)
    """The current search query."""

    search_results: reactive[list[tuple[int, int]]] = reactive([], init=False)
    """List of (start, end) offsets for search matches."""

    current_result_index: reactive[int] = reactive(0, init=False)
    """The index of the currently active search match."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """
        Initialize the MainContent widget.

        Args:
            *args: Positional arguments for Vertical.
            **kwargs: Keyword arguments for Vertical.
        """
        super().__init__(*args, **kwargs)
        self.last_log_size: int = 0
        self._content_cache: dict[str, str] = {}

    def compose(self) -> ComposeResult:
        """
        Compose the tabs for Output, Script, and Job.

        Returns:
            The UI components for the tabs.
        """
        yield Input(placeholder="Search in content...", id="content_search", classes="hidden")
        with TabbedContent(id="content_tabs"):
            with TabPane("Output", id="tab_output"):
                yield RichLog(markup=True, highlight=True, id="log_output")
            with TabPane("Script (.ecf)", id="tab_script"):
                with VerticalScroll():
                    yield Static("", id="view_script", classes="code_view")
            with TabPane("Job (Processed)", id="tab_job"):
                with VerticalScroll():
                    yield Static("", id="view_job", classes="code_view")
            with TabPane("Timeline", id="tab_timeline"):
                yield TimelineTab(id="view_timeline", classes="code_view")

    @property
    def active(self) -> str | None:
        """
        Get the active tab ID.

        Returns:
            The ID of the active tab.
        """
        return self.query_one("#content_tabs", TabbedContent).active

    @active.setter
    def active(self, value: str) -> None:
        """
        Set the active tab ID.

        Args:
            value: The ID of the tab to activate.
        """
        self.query_one("#content_tabs", TabbedContent).active = value

    def watch_log_content(self, content: str) -> None:
        """
        Watch for changes in log content and update the widget.

        Args:
            content: The new log content.
        """
        if not content or content == self._content_cache.get("output"):
            return

        self.update_log(content)

    def watch_script_content(self, content: str) -> None:
        """
        Watch for changes in script content and update the widget.

        Args:
            content: The new script content.
        """
        if content == self._content_cache.get("script") and not self.search_query:
            return

        self._content_cache["script"] = content
        widget = self.query_one("#view_script", Static)
        if self.search_query and self.active == "tab_script":
            widget.update(self._get_highlighted_content(content))
        else:
            syntax = Syntax(content, DEFAULT_SHELL, theme=SYNTAX_THEME, line_numbers=True)
            widget.update(syntax)

    def watch_job_content(self, content: str) -> None:
        """
        Watch for changes in job content and update the widget.

        Args:
            content: The new job content.
        """
        if content == self._content_cache.get("job") and not self.search_query:
            return

        self._content_cache["job"] = content
        widget = self.query_one("#view_job", Static)
        if self.search_query and self.active == "tab_job":
            widget.update(self._get_highlighted_content(content))
        else:
            syntax = Syntax(content, DEFAULT_SHELL, theme=SYNTAX_THEME, line_numbers=True)
            widget.update(syntax)

    def update_log(self, content: str, delta: str | None = None) -> None:
        """
        Update the Output log tab.

        Args:
            content: The full log content.
            delta: Optional new content to append. If provided, expensive
                full-content comparisons and clears are avoided.
        """
        widget = self.query_one("#log_output", RichLog)

        if self.search_query:
            # When searching, we don't use delta because we need to highlight the full text
            widget.clear()
            self._content_cache["output"] = content
            widget.write(self._get_highlighted_content(content))
            self.last_log_size = len(content)
            return

        if delta is not None:
            if delta:
                widget.write(delta)
                self._content_cache["output"] = content
                self.last_log_size = len(content)
            return

        # Optimization: Return early if content is identical
        if content == self._content_cache.get("output"):
            return

        widget.clear()
        self._content_cache["output"] = content
        widget.write(content)
        self.last_log_size = len(content)

    def update_script(self, content: str) -> None:
        """
        Update the Script tab.

        Args:
            content: The script content.
        """
        self.script_content = content

    def update_job(self, content: str) -> None:
        """
        Update the Job tab.

        Args:
            content: The job content.
        """
        self.job_content = content

    def update_timeline(self, data: TimelineData | None) -> None:
        """
        Update the Timeline tab.

        Args:
            data: The pre-processed timeline data.
        """
        self.query_one("#view_timeline", TimelineTab).update_timeline(data)

    def _get_highlighted_content(self, content: str) -> Text:
        """
        Apply search highlights to the content.

        Args:
            content: The raw content to highlight.

        Returns:
            A Rich Text object with highlights applied.
        """
        text = Text(content)
        if not self.search_query or not self.search_results:
            return text

        for i, (start, end) in enumerate(self.search_results):
            # Use orange for the current match, yellow for others
            style = "bold black on orange3" if i == self.current_result_index else "bold black on yellow"
            text.stylize(style, start, end)

        return text

    def action_search_next(self) -> None:
        """
        Navigate to the next search match.
        """
        if not self.search_results:
            return
        self.current_result_index = (self.current_result_index + 1) % len(self.search_results)
        self._refresh_active_content()
        self._scroll_to_current_match()

    def action_search_prev(self) -> None:
        """
        Navigate to the previous search match.
        """
        if not self.search_results:
            return
        self.current_result_index = (self.current_result_index - 1) % len(self.search_results)
        self._refresh_active_content()
        self._scroll_to_current_match()

    def _refresh_active_content(self) -> None:
        """
        Refresh the currently active tab's content to update highlights.
        """
        active_tab = self.active
        if active_tab == "tab_output":
            self.update_log(self._content_cache.get("output", ""))
        elif active_tab == "tab_script":
            self.watch_script_content(self._content_cache.get("script", ""))
        elif active_tab == "tab_job":
            self.watch_job_content(self._content_cache.get("job", ""))

    def _scroll_to_current_match(self) -> None:
        """
        Scroll the active view to the current search match.
        """
        if not self.search_results:
            return

        start, _ = self.search_results[self.current_result_index]
        active_tab = self.active
        content = ""
        scroll_container = None

        if active_tab == "tab_output":
            content = self._content_cache.get("output", "")
            scroll_container = self.query_one("#log_output", RichLog)
        elif active_tab == "tab_script":
            content = self._content_cache.get("script", "")
            scroll_container = self.query_one("#tab_script VerticalScroll", VerticalScroll)
        elif active_tab == "tab_job":
            content = self._content_cache.get("job", "")
            scroll_container = self.query_one("#tab_job VerticalScroll", VerticalScroll)

        if scroll_container and content:
            line_no = content.count("\n", 0, start)
            scroll_container.scroll_to(y=line_no, animate=False)

    def action_search(self) -> None:
        """
        Toggle the content search input.
        """
        search_input = self.query_one("#content_search", Input)
        if "hidden" in search_input.classes:
            search_input.remove_class("hidden")
            search_input.focus()
        else:
            search_input.add_class("hidden")
            self.search_query = ""
            self.search_results = []
            self.current_result_index = 0
            self._refresh_active_content()
            # Refocus the active tab's content
            active_tab = self.active
            if active_tab == "tab_output":
                self.query_one("#log_output").focus()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        """
        Handle content search submission.

        Args:
            event: The input submission event.
        """
        if event.input.id == "content_search":
            query = event.value
            if not query:
                return

            active_tab = self.active
            cache_key = "output"
            label = "Output"
            if active_tab == "tab_script":
                cache_key = "script"
                label = "Script"
            elif active_tab == "tab_job":
                cache_key = "job"
                label = "Job"

            content = self._content_cache.get(cache_key, "")
            self._run_search_worker(query, content, label)

    @work(thread=True)
    def _run_search_worker(self, query: str, content: str, label: str) -> None:
        """
        Run the search in a background worker.

        Args:
            query: The search query.
            content: The content to search.
            label: The label of the content being searched.

        Returns:
            None

        Notes:
            This is a threaded background worker.
        """
        import re

        # Find all match offsets (start, end) case-insensitively
        try:
            matches = [(m.start(), m.end()) for m in re.finditer(re.escape(query), content, re.IGNORECASE)]
        except Exception:
            matches = []

        def _update_ui() -> None:
            self.search_query = query
            self.search_results = matches
            self.current_result_index = 0
            if matches:
                self.app.notify(f"Found {len(matches)} matches for '{query}' in {label}", severity="information")
                # Trigger a refresh of the current tab content with highlights
                active_tab = self.active
                if active_tab == "tab_output":
                    self.update_log(content)
                elif active_tab == "tab_script":
                    self.watch_script_content(content)
                elif active_tab == "tab_job":
                    self.watch_job_content(content)
            else:
                self.app.notify(f"No matches found for '{query}' in {label}", severity="warning")

        safe_call_app(self.app, _update_ui)

    def show_error(self, widget_id: str, message: str) -> None:
        """
        Display an error message in a specific widget and clear cache.

        Args:
            widget_id: The ID of the widget where the error should be shown.
            message: The error message to display.
        """
        cache_key = None
        if widget_id == "#log_output":
            cache_key = "output"
        elif widget_id == "#view_script":
            cache_key = "script"
        elif widget_id == "#view_job":
            cache_key = "job"

        if cache_key:
            self._content_cache[cache_key] = ""

        widget = self.query_one(widget_id)
        if isinstance(widget, RichLog):
            widget.write(f"[italic red]{message}[/]")
        elif isinstance(widget, Static):
            widget.update(f"[italic red]{message}[/]")

active property writable

Get the active tab ID.

Returns:

Type Description
str | None

The ID of the active tab.

current_result_index = reactive(0, init=False) class-attribute instance-attribute

The index of the currently active search match.

is_live = reactive(False, init=False) class-attribute instance-attribute

Whether live log updates are enabled.

job_content = reactive('', init=False) class-attribute instance-attribute

The content of the job file.

log_content = reactive('', init=False) class-attribute instance-attribute

The content of the output log.

script_content = reactive('', init=False) class-attribute instance-attribute

The content of the script.

search_query = reactive('', init=False) class-attribute instance-attribute

The current search query.

search_results = reactive([], init=False) class-attribute instance-attribute

List of (start, end) offsets for search matches.

__init__(*args, **kwargs)

Initialize the MainContent widget.

Parameters:

Name Type Description Default
*args Any

Positional arguments for Vertical.

()
**kwargs Any

Keyword arguments for Vertical.

{}
Source code in src/ectop/widgets/content.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """
    Initialize the MainContent widget.

    Args:
        *args: Positional arguments for Vertical.
        **kwargs: Keyword arguments for Vertical.
    """
    super().__init__(*args, **kwargs)
    self.last_log_size: int = 0
    self._content_cache: dict[str, str] = {}

Toggle the content search input.

Source code in src/ectop/widgets/content.py
def action_search(self) -> None:
    """
    Toggle the content search input.
    """
    search_input = self.query_one("#content_search", Input)
    if "hidden" in search_input.classes:
        search_input.remove_class("hidden")
        search_input.focus()
    else:
        search_input.add_class("hidden")
        self.search_query = ""
        self.search_results = []
        self.current_result_index = 0
        self._refresh_active_content()
        # Refocus the active tab's content
        active_tab = self.active
        if active_tab == "tab_output":
            self.query_one("#log_output").focus()

action_search_next()

Navigate to the next search match.

Source code in src/ectop/widgets/content.py
def action_search_next(self) -> None:
    """
    Navigate to the next search match.
    """
    if not self.search_results:
        return
    self.current_result_index = (self.current_result_index + 1) % len(self.search_results)
    self._refresh_active_content()
    self._scroll_to_current_match()

action_search_prev()

Navigate to the previous search match.

Source code in src/ectop/widgets/content.py
def action_search_prev(self) -> None:
    """
    Navigate to the previous search match.
    """
    if not self.search_results:
        return
    self.current_result_index = (self.current_result_index - 1) % len(self.search_results)
    self._refresh_active_content()
    self._scroll_to_current_match()

compose()

Compose the tabs for Output, Script, and Job.

Returns:

Type Description
ComposeResult

The UI components for the tabs.

Source code in src/ectop/widgets/content.py
def compose(self) -> ComposeResult:
    """
    Compose the tabs for Output, Script, and Job.

    Returns:
        The UI components for the tabs.
    """
    yield Input(placeholder="Search in content...", id="content_search", classes="hidden")
    with TabbedContent(id="content_tabs"):
        with TabPane("Output", id="tab_output"):
            yield RichLog(markup=True, highlight=True, id="log_output")
        with TabPane("Script (.ecf)", id="tab_script"):
            with VerticalScroll():
                yield Static("", id="view_script", classes="code_view")
        with TabPane("Job (Processed)", id="tab_job"):
            with VerticalScroll():
                yield Static("", id="view_job", classes="code_view")
        with TabPane("Timeline", id="tab_timeline"):
            yield TimelineTab(id="view_timeline", classes="code_view")

on_input_submitted(event)

Handle content search submission.

Parameters:

Name Type Description Default
event Submitted

The input submission event.

required
Source code in src/ectop/widgets/content.py
def on_input_submitted(self, event: Input.Submitted) -> None:
    """
    Handle content search submission.

    Args:
        event: The input submission event.
    """
    if event.input.id == "content_search":
        query = event.value
        if not query:
            return

        active_tab = self.active
        cache_key = "output"
        label = "Output"
        if active_tab == "tab_script":
            cache_key = "script"
            label = "Script"
        elif active_tab == "tab_job":
            cache_key = "job"
            label = "Job"

        content = self._content_cache.get(cache_key, "")
        self._run_search_worker(query, content, label)

show_error(widget_id, message)

Display an error message in a specific widget and clear cache.

Parameters:

Name Type Description Default
widget_id str

The ID of the widget where the error should be shown.

required
message str

The error message to display.

required
Source code in src/ectop/widgets/content.py
def show_error(self, widget_id: str, message: str) -> None:
    """
    Display an error message in a specific widget and clear cache.

    Args:
        widget_id: The ID of the widget where the error should be shown.
        message: The error message to display.
    """
    cache_key = None
    if widget_id == "#log_output":
        cache_key = "output"
    elif widget_id == "#view_script":
        cache_key = "script"
    elif widget_id == "#view_job":
        cache_key = "job"

    if cache_key:
        self._content_cache[cache_key] = ""

    widget = self.query_one(widget_id)
    if isinstance(widget, RichLog):
        widget.write(f"[italic red]{message}[/]")
    elif isinstance(widget, Static):
        widget.update(f"[italic red]{message}[/]")

update_job(content)

Update the Job tab.

Parameters:

Name Type Description Default
content str

The job content.

required
Source code in src/ectop/widgets/content.py
def update_job(self, content: str) -> None:
    """
    Update the Job tab.

    Args:
        content: The job content.
    """
    self.job_content = content

update_log(content, delta=None)

Update the Output log tab.

Parameters:

Name Type Description Default
content str

The full log content.

required
delta str | None

Optional new content to append. If provided, expensive full-content comparisons and clears are avoided.

None
Source code in src/ectop/widgets/content.py
def update_log(self, content: str, delta: str | None = None) -> None:
    """
    Update the Output log tab.

    Args:
        content: The full log content.
        delta: Optional new content to append. If provided, expensive
            full-content comparisons and clears are avoided.
    """
    widget = self.query_one("#log_output", RichLog)

    if self.search_query:
        # When searching, we don't use delta because we need to highlight the full text
        widget.clear()
        self._content_cache["output"] = content
        widget.write(self._get_highlighted_content(content))
        self.last_log_size = len(content)
        return

    if delta is not None:
        if delta:
            widget.write(delta)
            self._content_cache["output"] = content
            self.last_log_size = len(content)
        return

    # Optimization: Return early if content is identical
    if content == self._content_cache.get("output"):
        return

    widget.clear()
    self._content_cache["output"] = content
    widget.write(content)
    self.last_log_size = len(content)

update_script(content)

Update the Script tab.

Parameters:

Name Type Description Default
content str

The script content.

required
Source code in src/ectop/widgets/content.py
def update_script(self, content: str) -> None:
    """
    Update the Script tab.

    Args:
        content: The script content.
    """
    self.script_content = content

update_timeline(data)

Update the Timeline tab.

Parameters:

Name Type Description Default
data TimelineData | None

The pre-processed timeline data.

required
Source code in src/ectop/widgets/content.py
def update_timeline(self, data: TimelineData | None) -> None:
    """
    Update the Timeline tab.

    Args:
        data: The pre-processed timeline data.
    """
    self.query_one("#view_timeline", TimelineTab).update_timeline(data)

watch_job_content(content)

Watch for changes in job content and update the widget.

Parameters:

Name Type Description Default
content str

The new job content.

required
Source code in src/ectop/widgets/content.py
def watch_job_content(self, content: str) -> None:
    """
    Watch for changes in job content and update the widget.

    Args:
        content: The new job content.
    """
    if content == self._content_cache.get("job") and not self.search_query:
        return

    self._content_cache["job"] = content
    widget = self.query_one("#view_job", Static)
    if self.search_query and self.active == "tab_job":
        widget.update(self._get_highlighted_content(content))
    else:
        syntax = Syntax(content, DEFAULT_SHELL, theme=SYNTAX_THEME, line_numbers=True)
        widget.update(syntax)

watch_log_content(content)

Watch for changes in log content and update the widget.

Parameters:

Name Type Description Default
content str

The new log content.

required
Source code in src/ectop/widgets/content.py
def watch_log_content(self, content: str) -> None:
    """
    Watch for changes in log content and update the widget.

    Args:
        content: The new log content.
    """
    if not content or content == self._content_cache.get("output"):
        return

    self.update_log(content)

watch_script_content(content)

Watch for changes in script content and update the widget.

Parameters:

Name Type Description Default
content str

The new script content.

required
Source code in src/ectop/widgets/content.py
def watch_script_content(self, content: str) -> None:
    """
    Watch for changes in script content and update the widget.

    Args:
        content: The new script content.
    """
    if content == self._content_cache.get("script") and not self.search_query:
        return

    self._content_cache["script"] = content
    widget = self.query_one("#view_script", Static)
    if self.search_query and self.active == "tab_script":
        widget.update(self._get_highlighted_content(content))
    else:
        syntax = Syntax(content, DEFAULT_SHELL, theme=SYNTAX_THEME, line_numbers=True)
        widget.update(syntax)

Search box widget for finding nodes in the suite tree.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

SearchBox

Bases: Input

An input widget for searching nodes in the tree.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/search.py
class SearchBox(Input):
    """
    An input widget for searching nodes in the tree.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    BINDINGS = [
        Binding("escape", "cancel", "Cancel Search"),
        Binding("enter", "submit", "Search Next"),
    ]

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """
        Initialize the SearchBox.

        Args:
            *args: Positional arguments for the Input widget.
            **kwargs: Keyword arguments for the Input widget.
        """
        super().__init__(*args, **kwargs)

    def action_cancel(self) -> None:
        """
        Clear search, hide box, and return focus to the tree.
        """
        self.value = ""
        self.remove_class("visible")
        self.app.set_focus(self.app.query_one("#suite_tree"))

    def on_blur(self) -> None:
        """
        Hide the search box when it loses focus.
        """
        self.remove_class("visible")

__init__(*args, **kwargs)

Initialize the SearchBox.

Parameters:

Name Type Description Default
*args Any

Positional arguments for the Input widget.

()
**kwargs Any

Keyword arguments for the Input widget.

{}
Source code in src/ectop/widgets/search.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """
    Initialize the SearchBox.

    Args:
        *args: Positional arguments for the Input widget.
        **kwargs: Keyword arguments for the Input widget.
    """
    super().__init__(*args, **kwargs)

action_cancel()

Clear search, hide box, and return focus to the tree.

Source code in src/ectop/widgets/search.py
def action_cancel(self) -> None:
    """
    Clear search, hide box, and return focus to the tree.
    """
    self.value = ""
    self.remove_class("visible")
    self.app.set_focus(self.app.query_one("#suite_tree"))

on_blur()

Hide the search box when it loses focus.

Source code in src/ectop/widgets/search.py
def on_blur(self) -> None:
    """
    Hide the search box when it loses focus.
    """
    self.remove_class("visible")

Sidebar widget for the ecFlow suite tree.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

NodeDTO dataclass

Data Transfer Object for ecFlow Node state, decoupling UI from C++ API.

Attributes:

Name Type Description
name str

The name of the node.

path str

The absolute path of the node.

state str

The current state of the node.

is_container bool

Whether the node is a Suite or Family.

has_children bool

Whether the node has children.

Source code in src/ectop/widgets/sidebar.py
@dataclass
class NodeDTO:
    """
    Data Transfer Object for ecFlow Node state, decoupling UI from C++ API.

    Attributes:
        name: The name of the node.
        path: The absolute path of the node.
        state: The current state of the node.
        is_container: Whether the node is a Suite or Family.
        has_children: Whether the node has children.
    """

    name: str
    path: str
    state: str
    is_container: bool
    has_children: bool

SuiteTree

Bases: Tree[str]

A tree widget to display ecFlow suites and nodes.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Attributes:

Name Type Description
current_filter reactive[str | None]

The current status filter applied to the tree.

defs reactive[Defs | None]

The ecFlow definitions to display.

Source code in src/ectop/widgets/sidebar.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
class SuiteTree(Tree[str]):
    """
    A tree widget to display ecFlow suites and nodes.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.

    Attributes:
        current_filter: The current status filter applied to the tree.
        defs: The ecFlow definitions to display.
    """

    current_filter: reactive[str | None] = reactive(None, init=False)
    """The current status filter applied to the tree."""

    focus_mode: reactive[bool] = reactive(False, init=False)
    """Whether Focus Mode is active (hides complete nodes)."""

    defs: reactive[Defs | None] = reactive(None, init=False)
    """The ecFlow definitions to display."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """
        Initialize the SuiteTree.

        Args:
            *args: Positional arguments for the Tree widget.
            **kwargs: Keyword arguments for the Tree widget.
        """
        super().__init__(*args, **kwargs)
        self.filters: list[str | None] = TREE_FILTERS
        self.host: str = ""
        self.port: int = 0
        self._all_paths_cache: list[str] | None = None
        self._visibility_cache: dict[str, set[str]] = {}
        self._search_paths_lower: list[str] = []
        self._last_selected_path: str | None = None

    def update_tree(self, client_host: str, client_port: int, defs: Defs | None) -> None:
        """
        Update the tree data.

        Args:
            client_host: The hostname of the ecFlow server.
            client_port: The port of the ecFlow server.
            defs: The ecFlow definitions to display.

        Notes:
            This method triggers the reactive watchers.
        """
        self.host = client_host
        self.port = client_port
        self.defs = defs

    def watch_defs(self, new_defs: Defs | None) -> None:
        """
        Watch for changes in definitions and rebuild the tree.

        Args:
            new_defs: The new ecFlow definitions.

        Returns:
            None
        """
        self._rebuild_tree()

    def watch_current_filter(self, new_filter: str | None) -> None:
        """
        Watch for changes in the current filter and rebuild the tree.

        Args:
            new_filter: The new filter value.

        Returns:
            None
        """
        self._rebuild_tree()

    def watch_focus_mode(self, focus_mode: bool) -> None:
        """
        Watch for changes in focus mode and rebuild the tree.

        Args:
            focus_mode: The new focus mode value.

        Returns:
            None
        """
        self._rebuild_tree()

    def _rebuild_tree(self) -> None:
        """
        Rebuild the tree from ecFlow definitions using lazy loading.

        Returns:
            None

        Notes:
            This method captures the current selection path to restore it
            after the background population worker finishes.
        """
        # Capture current selection to restore it after rebuild
        try:
            cursor_node = getattr(self, "cursor_node", None)
            if cursor_node and cursor_node.data:
                self._last_selected_path = str(cursor_node.data)
            elif cursor_node == self.root:
                self._last_selected_path = "/"
        except (AttributeError, RuntimeError):
            # Fail gracefully if cursor_node is inaccessible during clear/rebuild
            self._last_selected_path = None

        self.clear()
        if not self.defs:
            self.root.label = "Server Empty"
            self._all_paths_cache = None
            self._visibility_cache = {}
            self._search_paths_lower = []
            return

        filter_str = f" [Filter: {self.current_filter}]" if self.current_filter else ""
        focus_str = " [Focus]" if self.focus_mode else ""
        self.root.label = f"{ICON_SERVER} {self.host}:{self.port}{filter_str}{focus_str}"

        # Combine cache building and population triggering
        self._build_caches_and_populate()

    @work(exclusive=True, thread=True)
    def _build_caches_and_populate(self) -> None:
        """
        Build search and visibility caches in background and then populate root.

        Returns:
            None

        Notes:
            This is a background worker that builds the visibility and search
            caches using a single-pass traversal of the ecFlow definitions.
            It is thread-safe and offloads CPU-intensive work from the UI thread.
        """
        if not self.defs:
            return

        all_paths: list[str] = []
        # Pre-calculate visibility for all filters to avoid re-calculation on filter cycle
        visibility: dict[str, set[str]] = {f: set() for f in self.filters if f is not None}

        # Optimized single-pass traversal using get_all_nodes()
        # and post-order visibility propagation.
        try:
            # get_all_nodes() returns all nodes in the definitions
            # If it's a mock, it might not have get_all_nodes or it might return a list
            all_nodes_raw = self.defs.get_all_nodes()
            all_nodes = list(all_nodes_raw)

            # For visibility propagation, we need to go from leaves to root.
            # get_all_nodes() usually returns in pre-order.
            # We reverse it for a post-order effect.
            for node in reversed(all_nodes):
                path = node.get_abs_node_path()
                all_paths.append(path)

                state = str(node.get_state())
                if state in visibility:
                    visibility[state].add(path)

                # If this node matches a filter, its parent should also be visible for that filter.
                # Since we are going in reverse (leaves to root), parents will be processed after children.
                parent = node.get_parent()
                if parent:
                    parent_path = parent.get_abs_node_path()
                    # Propagation: if any child is visible for a filter, parent is visible too.
                    # We check all filters.
                    for f in visibility:
                        if path in visibility[f]:
                            visibility[f].add(parent_path)

            # all_paths was collected in reverse, fix it
            all_paths.reverse()

        except Exception:
            # Fallback to per-suite traversal if get_all_nodes() fails or behavior is unexpected
            all_paths = []
            for suite in self.defs.suites:
                suite_path = suite.get_abs_node_path()
                all_paths.append(suite_path)

                # Check suite state for visibility
                suite_state = str(suite.get_state())
                if suite_state in visibility:
                    visibility[suite_state].add(suite_path)

                suite_nodes = list(suite.get_all_nodes())
                for node in suite_nodes:
                    path = node.get_abs_node_path()
                    all_paths.append(path)
                    state = str(node.get_state())
                    if state in visibility:
                        curr: ecflow.Node | None = node
                        while curr:
                            curr_path = curr.get_abs_node_path()
                            if curr_path in visibility[state]:
                                break
                            visibility[state].add(curr_path)
                            curr = curr.get_parent()

        self._all_paths_cache = all_paths
        self._search_paths_lower = [p.lower() for p in all_paths]
        self._visibility_cache = visibility

        # Now that caches are ready, populate the tree root on main thread
        self._safe_call(self._populate_root)

    def _populate_root(self) -> None:
        """
        Populate the tree root with suites.

        Returns:
            None
        """
        self._populate_tree_worker()

    @work(exclusive=True, thread=True)
    def _populate_tree_worker(self) -> None:
        """
        Worker to populate the tree root with suites in a background thread.

        Returns:
            None

        Notes:
            This is a background worker that performs recursive filtering.
        """
        if not self.defs:
            return
        suites = [s for s in cast("list[ecflow.Suite]", self.defs.suites) if self._should_show_node(s)]
        batch_size = 50
        for i in range(0, len(suites), batch_size):
            batch_nodes = suites[i : i + batch_size]
            batch_dtos = [self._to_dto(s) for s in batch_nodes]
            self._safe_call(self._add_nodes_batch, self.root, batch_dtos)

        # Restore selection if we have a saved path
        if self._last_selected_path:
            path_to_restore = self._last_selected_path
            self._last_selected_path = None
            self._select_by_path_logic(path_to_restore)

    def _add_nodes_batch(self, parent_ui_node: TreeNode[str], node_dtos: list[NodeDTO]) -> None:
        """
        Batch add nodes to the UI to reduce main thread pressure.

        Args:
            parent_ui_node: The parent UI node.
            node_dtos: List of NodeDTO objects to add.

        Returns:
            None
        """
        for dto in node_dtos:
            self._add_node_to_ui(parent_ui_node, dto)

    def _to_dto(self, node: ecflow.Node) -> NodeDTO:
        """
        Convert an ecflow.Node to a NodeDTO.

        Args:
            node: The ecFlow node to convert.

        Returns:
            The corresponding NodeDTO.
        """
        has_children = False
        is_container = isinstance(node, ecflow.Suite | ecflow.Family)
        if is_container:
            try:
                # Optimized check: check if nodes iterator is non-empty
                # Note: ecFlow nodes iterator might not support bool() or direct any()
                # in all environments, so we use a standard iterator check.
                for _ in node.nodes:
                    has_children = True
                    break
            except (StopIteration, RuntimeError):
                pass

        return NodeDTO(
            name=node.name(),
            path=node.get_abs_node_path(),
            state=str(node.get_state()),
            is_container=is_container,
            has_children=has_children,
        )

    def _should_show_node(self, node: Node) -> bool:
        """
        Determine if a node should be shown based on the current filter.

        Args:
            node: The ecFlow node to check.

        Returns:
            True if the node or any of its descendants match the filter.
        """
        if self.focus_mode and str(node.get_state()) == "complete":
            return False

        if self.current_filter is None:
            return True

        visible_paths = self._visibility_cache.get(self.current_filter)
        if not visible_paths:
            # Cache not ready or filter unknown, fallback to slow check
            state = str(node.get_state())
            if state == self.current_filter:
                return True
            if isinstance(node, ecflow.Suite | ecflow.Family):
                return any(self._should_show_node(child) for child in node.nodes)
            return False

        return node.get_abs_node_path() in visible_paths

    def action_cycle_filter(self) -> None:
        """
        Cycle through available status filters and refresh the tree.

        Returns:
            None
        """
        current_idx = self.filters.index(self.current_filter)
        next_idx = (current_idx + 1) % len(self.filters)
        self.current_filter = self.filters[next_idx]

        self.app.notify(f"Filter: {self.current_filter or 'All'}")

    def action_toggle_focus(self) -> None:
        """
        Toggle Focus Mode and refresh the tree.

        Returns:
            None
        """
        self.focus_mode = not self.focus_mode
        state = "ON" if self.focus_mode else "OFF"
        self.app.notify(f"Focus Mode: {state}")

    def _add_node_to_ui(self, parent_ui_node: TreeNode[str], dto: NodeDTO) -> TreeNode[str]:
        """
        Add a single ecflow node to the UI tree using a DTO.

        Args:
            parent_ui_node: The parent node in the Textual tree.
            dto: The NodeDTO to add.

        Returns:
            The newly created UI node.
        """
        icon = STATE_MAP.get(dto.state, ICON_UNKNOWN_STATE)
        type_icon = ICON_FAMILY if dto.is_container else ICON_TASK

        label = Text(f"{icon} {type_icon} {dto.name} ")
        label.append(f"[{dto.state}]", style="bold italic")

        new_ui_node = parent_ui_node.add(
            label,
            data=dto.path,
            expand=False,
        )

        # If it's a container and has children, add a placeholder for lazy loading
        if dto.is_container and dto.has_children:
            new_ui_node.add(LOADING_PLACEHOLDER, allow_expand=False)

        return new_ui_node

    def on_tree_node_expanded(self, event: Tree.NodeExpanded[str]) -> None:
        """
        Handle node expansion to load children on demand.

        Args:
            event: The expansion event.

        Returns:
            None
        """
        node = event.node
        self._load_children(node)

    def _load_children(self, ui_node: TreeNode[str], sync: bool = False) -> None:
        """
        Load children for a UI node if they haven't been loaded yet.

        Args:
            ui_node: The UI node to load children for.
            sync: Whether to load children synchronously. Defaults to False.

        Returns:
            None

        Notes:
            Uses `_load_children_worker` for async loading.
        """
        if not ui_node.data or not self.defs:
            return

        # Check if we have the placeholder
        if len(ui_node.children) == 1 and str(ui_node.children[0].label) == LOADING_PLACEHOLDER:
            # UI modification must be scheduled on the main thread
            placeholder = ui_node.children[0]
            self._safe_call(placeholder.remove)

            if sync:
                ecflow_node = self.defs.find_abs_node(ui_node.data)
                if ecflow_node and isinstance(ecflow_node, ecflow.Suite | ecflow.Family):
                    # Use batching even for sync loading to keep implementation consistent
                    nodes = list(ecflow_node.nodes)
                    dtos = [self._to_dto(n) for n in nodes]
                    self._safe_call(self._add_nodes_batch, ui_node, dtos)
            else:
                self._load_children_worker(ui_node, ui_node.data)

    @work(exclusive=True, thread=True)
    def _load_children_worker(self, ui_node: TreeNode[str], node_path: str) -> None:
        """
        Worker to load children nodes in a background thread.

        Args:
            ui_node: The UI node to populate.
            node_path: The absolute path of the ecFlow node.

        Returns:
            None

        Notes:
            UI updates are scheduled back to the main thread using `call_from_thread`.
        """
        if not self.defs:
            return

        ecflow_node = self.defs.find_abs_node(node_path)
        if ecflow_node and isinstance(ecflow_node, ecflow.Suite | ecflow.Family):
            children = [c for c in cast("list[ecflow.Node]", ecflow_node.nodes) if self._should_show_node(c)]
            batch_size = 50
            for i in range(0, len(children), batch_size):
                batch_nodes = children[i : i + batch_size]
                batch_dtos = [self._to_dto(c) for c in batch_nodes]
                self._safe_call(self._add_nodes_batch, ui_node, batch_dtos)

    @work(exclusive=True, thread=True)
    def find_and_select(self, query: str) -> None:
        """
        Find nodes matching query in the ecFlow definitions and select them.

        This handles searching through unloaded parts of the tree in a
        background thread to keep the UI responsive.

        Args:
            query: The search query.

        Returns:
            None

        Notes:
            This is a background worker.
        """
        self._find_and_select_logic(query)

    def _find_and_select_logic(self, query: str) -> None:
        """
        The actual search logic split out for testing.

        Args:
            query: The search query.

        Returns:
            None
        """
        if not self.defs:
            return

        query = query.lower()

        # Build or use cached paths
        if self._all_paths_cache is None:
            # Fallback if cache isn't ready yet
            all_paths: list[str] = []
            for suite in self.defs.suites:
                all_paths.append(suite.get_abs_node_path())
                all_paths.extend(n.get_abs_node_path() for n in suite.get_all_nodes())
            self._all_paths_cache = all_paths
            self._search_paths_lower = [p.lower() for p in self._all_paths_cache]

        all_paths = self._all_paths_cache
        search_paths = self._search_paths_lower

        # Get current cursor state on main thread
        cursor_node = getattr(self, "cursor_node", None)
        current_path = cursor_node.data if cursor_node else None

        start_index = 0
        if current_path and current_path in all_paths:
            try:
                start_index = all_paths.index(current_path) + 1
            except ValueError:
                start_index = 0

        # Search from start_index to end, then wrap around
        found_path = None
        for i in range(len(all_paths)):
            idx = (start_index + i) % len(all_paths)
            if query in search_paths[idx]:
                found_path = all_paths[idx]
                break

        if found_path:
            self._select_by_path_logic(found_path)
        else:
            self._safe_call(self.app.notify, f"No match found for '{query}'", severity="warning")

    def _safe_call(self, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        """
        Safely call a UI-related function from either the main thread or a worker.

        Args:
            callback: The function to call.
            *args: Positional arguments.
            **kwargs: Keyword arguments.

        Returns:
            The result of the call if synchronous, or None if scheduled.
        """
        try:
            return safe_call_app(self.app, callback, *args, **kwargs)
        except (AttributeError, RuntimeError, Exception):
            # App might not be fully initialized in some tests
            # Fallback to direct call if app is not available
            return callback(*args, **kwargs)

    @work(thread=True)
    def select_by_path(self, path: str) -> None:
        """
        Select a node by its absolute ecFlow path, expanding parents as needed.

        Args:
            path: The absolute path of the node to select.

        Returns:
            None

        Notes:
            This is a background worker to avoid blocking the UI thread when
            loading many nested nodes synchronously.
        """
        self._select_by_path_logic(path)

    def _select_by_path_logic(self, path: str) -> None:
        """
        The actual logic for selecting a node by path.

        Args:
            path: The absolute path of the node to select.

        Returns:
            None

        Notes:
            This method should be called from a background thread as it performs
            synchronous child loading.
        """
        if path == "/":
            self.app.call_from_thread(self.select_node, self.root)
            return

        parts = path.strip("/").split("/")
        current_ui_node = self.root

        current_path = ""
        for part in parts:
            current_path += "/" + part
            # Load children synchronously within the worker thread
            self._load_children(current_ui_node, sync=True)
            self._safe_call(current_ui_node.expand)

            found = False
            for child in current_ui_node.children:
                if child.data == current_path:
                    current_ui_node = child
                    found = True
                    break
            if not found:
                return

        self._safe_call(self._select_and_reveal, current_ui_node)

    def _select_and_reveal(self, node: TreeNode[str]) -> None:
        """
        Select a node and expand all its parents.

        Args:
            node: The node to select and reveal.

        Returns:
            None
        """
        self.select_node(node)
        parent = node.parent
        while parent:
            parent.expand()
            parent = parent.parent
        self.scroll_to_node(node)

current_filter = reactive(None, init=False) class-attribute instance-attribute

The current status filter applied to the tree.

defs = reactive(None, init=False) class-attribute instance-attribute

The ecFlow definitions to display.

focus_mode = reactive(False, init=False) class-attribute instance-attribute

Whether Focus Mode is active (hides complete nodes).

__init__(*args, **kwargs)

Initialize the SuiteTree.

Parameters:

Name Type Description Default
*args Any

Positional arguments for the Tree widget.

()
**kwargs Any

Keyword arguments for the Tree widget.

{}
Source code in src/ectop/widgets/sidebar.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """
    Initialize the SuiteTree.

    Args:
        *args: Positional arguments for the Tree widget.
        **kwargs: Keyword arguments for the Tree widget.
    """
    super().__init__(*args, **kwargs)
    self.filters: list[str | None] = TREE_FILTERS
    self.host: str = ""
    self.port: int = 0
    self._all_paths_cache: list[str] | None = None
    self._visibility_cache: dict[str, set[str]] = {}
    self._search_paths_lower: list[str] = []
    self._last_selected_path: str | None = None

action_cycle_filter()

Cycle through available status filters and refresh the tree.

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def action_cycle_filter(self) -> None:
    """
    Cycle through available status filters and refresh the tree.

    Returns:
        None
    """
    current_idx = self.filters.index(self.current_filter)
    next_idx = (current_idx + 1) % len(self.filters)
    self.current_filter = self.filters[next_idx]

    self.app.notify(f"Filter: {self.current_filter or 'All'}")

action_toggle_focus()

Toggle Focus Mode and refresh the tree.

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def action_toggle_focus(self) -> None:
    """
    Toggle Focus Mode and refresh the tree.

    Returns:
        None
    """
    self.focus_mode = not self.focus_mode
    state = "ON" if self.focus_mode else "OFF"
    self.app.notify(f"Focus Mode: {state}")

find_and_select(query)

Find nodes matching query in the ecFlow definitions and select them.

This handles searching through unloaded parts of the tree in a background thread to keep the UI responsive.

Parameters:

Name Type Description Default
query str

The search query.

required

Returns:

Type Description
None

None

Notes

This is a background worker.

Source code in src/ectop/widgets/sidebar.py
@work(exclusive=True, thread=True)
def find_and_select(self, query: str) -> None:
    """
    Find nodes matching query in the ecFlow definitions and select them.

    This handles searching through unloaded parts of the tree in a
    background thread to keep the UI responsive.

    Args:
        query: The search query.

    Returns:
        None

    Notes:
        This is a background worker.
    """
    self._find_and_select_logic(query)

on_tree_node_expanded(event)

Handle node expansion to load children on demand.

Parameters:

Name Type Description Default
event NodeExpanded[str]

The expansion event.

required

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def on_tree_node_expanded(self, event: Tree.NodeExpanded[str]) -> None:
    """
    Handle node expansion to load children on demand.

    Args:
        event: The expansion event.

    Returns:
        None
    """
    node = event.node
    self._load_children(node)

select_by_path(path)

Select a node by its absolute ecFlow path, expanding parents as needed.

Parameters:

Name Type Description Default
path str

The absolute path of the node to select.

required

Returns:

Type Description
None

None

Notes

This is a background worker to avoid blocking the UI thread when loading many nested nodes synchronously.

Source code in src/ectop/widgets/sidebar.py
@work(thread=True)
def select_by_path(self, path: str) -> None:
    """
    Select a node by its absolute ecFlow path, expanding parents as needed.

    Args:
        path: The absolute path of the node to select.

    Returns:
        None

    Notes:
        This is a background worker to avoid blocking the UI thread when
        loading many nested nodes synchronously.
    """
    self._select_by_path_logic(path)

update_tree(client_host, client_port, defs)

Update the tree data.

Parameters:

Name Type Description Default
client_host str

The hostname of the ecFlow server.

required
client_port int

The port of the ecFlow server.

required
defs Defs | None

The ecFlow definitions to display.

required
Notes

This method triggers the reactive watchers.

Source code in src/ectop/widgets/sidebar.py
def update_tree(self, client_host: str, client_port: int, defs: Defs | None) -> None:
    """
    Update the tree data.

    Args:
        client_host: The hostname of the ecFlow server.
        client_port: The port of the ecFlow server.
        defs: The ecFlow definitions to display.

    Notes:
        This method triggers the reactive watchers.
    """
    self.host = client_host
    self.port = client_port
    self.defs = defs

watch_current_filter(new_filter)

Watch for changes in the current filter and rebuild the tree.

Parameters:

Name Type Description Default
new_filter str | None

The new filter value.

required

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def watch_current_filter(self, new_filter: str | None) -> None:
    """
    Watch for changes in the current filter and rebuild the tree.

    Args:
        new_filter: The new filter value.

    Returns:
        None
    """
    self._rebuild_tree()

watch_defs(new_defs)

Watch for changes in definitions and rebuild the tree.

Parameters:

Name Type Description Default
new_defs Defs | None

The new ecFlow definitions.

required

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def watch_defs(self, new_defs: Defs | None) -> None:
    """
    Watch for changes in definitions and rebuild the tree.

    Args:
        new_defs: The new ecFlow definitions.

    Returns:
        None
    """
    self._rebuild_tree()

watch_focus_mode(focus_mode)

Watch for changes in focus mode and rebuild the tree.

Parameters:

Name Type Description Default
focus_mode bool

The new focus mode value.

required

Returns:

Type Description
None

None

Source code in src/ectop/widgets/sidebar.py
def watch_focus_mode(self, focus_mode: bool) -> None:
    """
    Watch for changes in focus mode and rebuild the tree.

    Args:
        focus_mode: The new focus mode value.

    Returns:
        None
    """
    self._rebuild_tree()

Status bar widget for ectop.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

StatusBar

Bases: Static

A status bar widget to display server information and health.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/statusbar.py
class StatusBar(Static):
    """
    A status bar widget to display server information and health.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    server_info: reactive[str] = reactive("Disconnected")
    """The host:port string of the ecFlow server."""

    last_sync: reactive[str] = reactive("Never")
    """The timestamp of the last successful synchronization."""

    status: reactive[str] = reactive("Unknown")
    """The current status of the ecFlow server."""

    server_version: reactive[str] = reactive("Unknown")
    """The version of the ecFlow server."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """
        Initialize the StatusBar.

        Args:
            *args: Positional arguments for the Static widget.
            **kwargs: Keyword arguments for the Static widget.
        """
        super().__init__(*args, **kwargs)

    def update_status(self, host: str, port: int, status: str = "Connected", version: str = "Unknown") -> None:
        """
        Update the status bar information.

        Args:
            host: The ecFlow server hostname.
            port: The ecFlow server port.
            status: The server status message. Defaults to "Connected".
            version: The ecFlow server version. Defaults to "Unknown".
        """
        self.server_info = f"{host}:{port}"
        self.status = str(status)
        self.server_version = str(version)
        self.last_sync = datetime.now().strftime("%H:%M:%S")

    def render(self) -> Text:
        """
        Render the status bar.

        Returns:
            The rendered status bar content.
        """
        status_color = "red"
        if self.status == "RUNNING":
            status_color = "green"
        elif self.status == "HALTED":
            status_color = COLOR_STATUS_HALTED
        elif "Connected" in self.status:
            status_color = "green"

        return Text.assemble(
            (" Server: ", "bold"),
            (self.server_info, "cyan"),
            (" (v", "bold"),
            (self.server_version, "magenta"),
            (")", "bold"),
            (" | Status: ", "bold"),
            (self.status, status_color),
            (" | Last Sync: ", "bold"),
            (self.last_sync, "yellow"),
        )

last_sync = reactive('Never') class-attribute instance-attribute

The timestamp of the last successful synchronization.

server_info = reactive('Disconnected') class-attribute instance-attribute

The host:port string of the ecFlow server.

server_version = reactive('Unknown') class-attribute instance-attribute

The version of the ecFlow server.

status = reactive('Unknown') class-attribute instance-attribute

The current status of the ecFlow server.

__init__(*args, **kwargs)

Initialize the StatusBar.

Parameters:

Name Type Description Default
*args Any

Positional arguments for the Static widget.

()
**kwargs Any

Keyword arguments for the Static widget.

{}
Source code in src/ectop/widgets/statusbar.py
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """
    Initialize the StatusBar.

    Args:
        *args: Positional arguments for the Static widget.
        **kwargs: Keyword arguments for the Static widget.
    """
    super().__init__(*args, **kwargs)

render()

Render the status bar.

Returns:

Type Description
Text

The rendered status bar content.

Source code in src/ectop/widgets/statusbar.py
def render(self) -> Text:
    """
    Render the status bar.

    Returns:
        The rendered status bar content.
    """
    status_color = "red"
    if self.status == "RUNNING":
        status_color = "green"
    elif self.status == "HALTED":
        status_color = COLOR_STATUS_HALTED
    elif "Connected" in self.status:
        status_color = "green"

    return Text.assemble(
        (" Server: ", "bold"),
        (self.server_info, "cyan"),
        (" (v", "bold"),
        (self.server_version, "magenta"),
        (")", "bold"),
        (" | Status: ", "bold"),
        (self.status, status_color),
        (" | Last Sync: ", "bold"),
        (self.last_sync, "yellow"),
    )

update_status(host, port, status='Connected', version='Unknown')

Update the status bar information.

Parameters:

Name Type Description Default
host str

The ecFlow server hostname.

required
port int

The ecFlow server port.

required
status str

The server status message. Defaults to "Connected".

'Connected'
version str

The ecFlow server version. Defaults to "Unknown".

'Unknown'
Source code in src/ectop/widgets/statusbar.py
def update_status(self, host: str, port: int, status: str = "Connected", version: str = "Unknown") -> None:
    """
    Update the status bar information.

    Args:
        host: The ecFlow server hostname.
        port: The ecFlow server port.
        status: The server status message. Defaults to "Connected".
        version: The ecFlow server version. Defaults to "Unknown".
    """
    self.server_info = f"{host}:{port}"
    self.status = str(status)
    self.server_version = str(version)
    self.last_sync = datetime.now().strftime("%H:%M:%S")

Timeline widget for visualizing task runtimes.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

TimelineData dataclass

Aggregated data for the timeline visualization.

Attributes:

Name Type Description
title str

The title of the timeline.

events list[TimelineEvent]

A list of timeline events.

Source code in src/ectop/widgets/timeline.py
@dataclass
class TimelineData:
    """
    Aggregated data for the timeline visualization.

    Attributes:
        title: The title of the timeline.
        events: A list of timeline events.
    """

    title: str
    events: list[TimelineEvent]

TimelineEvent dataclass

Data structure for a single timeline event.

Attributes:

Name Type Description
name str

The name of the node.

state str

The current state of the node.

time datetime

The timestamp of the last state change.

path str

The absolute path of the node.

Source code in src/ectop/widgets/timeline.py
@dataclass
class TimelineEvent:
    """
    Data structure for a single timeline event.

    Attributes:
        name: The name of the node.
        state: The current state of the node.
        time: The timestamp of the last state change.
        path: The absolute path of the node.
    """

    name: str
    state: str
    time: datetime
    path: str

TimelineTab

Bases: Static

A widget to display a horizontal timeline of task runtimes.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/timeline.py
class TimelineTab(Static):
    """
    A widget to display a horizontal timeline of task runtimes.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    def update_timeline(self, data: TimelineData | None) -> None:
        """
        Update the timeline visualization with pre-processed data.

        Args:
            data: The pre-processed timeline data, or None if no data is available.

        Returns:
            None
        """
        if not data or not data.events:
            self.update(Text("No tasks to display in timeline.", style="italic"))
            return

        # Sort by time
        sorted_events = sorted(data.events, key=lambda x: x.time)

        min_time = sorted_events[0].time
        max_time = sorted_events[-1].time
        total_duration = (max_time - min_time).total_seconds()

        width = self.size.width - 20 if self.size.width > 40 else 60

        output = Text()
        output.append(f"📊 {data.title}\n\n", style="bold underline")

        for item in sorted_events:
            offset = 0
            if total_duration > 0:
                offset = int(((item.time - min_time).total_seconds() / total_duration) * (width - 1))

            state_icon = STATE_MAP.get(item.state, "⚪")
            line = Text()
            line.append(f"{item.name[:15]:<15} ")
            line.append(" " * offset)
            line.append("â–†", style="bold")  # Representing the point of state change
            line.append(f" {state_icon} {item.state} ({item.time.strftime('%H:%M:%S')})")
            output.append(line)
            output.append("\n")

        self.update(output)

update_timeline(data)

Update the timeline visualization with pre-processed data.

Parameters:

Name Type Description Default
data TimelineData | None

The pre-processed timeline data, or None if no data is available.

required

Returns:

Type Description
None

None

Source code in src/ectop/widgets/timeline.py
def update_timeline(self, data: TimelineData | None) -> None:
    """
    Update the timeline visualization with pre-processed data.

    Args:
        data: The pre-processed timeline data, or None if no data is available.

    Returns:
        None
    """
    if not data or not data.events:
        self.update(Text("No tasks to display in timeline.", style="italic"))
        return

    # Sort by time
    sorted_events = sorted(data.events, key=lambda x: x.time)

    min_time = sorted_events[0].time
    max_time = sorted_events[-1].time
    total_duration = (max_time - min_time).total_seconds()

    width = self.size.width - 20 if self.size.width > 40 else 60

    output = Text()
    output.append(f"📊 {data.title}\n\n", style="bold underline")

    for item in sorted_events:
        offset = 0
        if total_duration > 0:
            offset = int(((item.time - min_time).total_seconds() / total_duration) * (width - 1))

        state_icon = STATE_MAP.get(item.state, "⚪")
        line = Text()
        line.append(f"{item.name[:15]:<15} ")
        line.append(" " * offset)
        line.append("â–†", style="bold")  # Representing the point of state change
        line.append(f" {state_icon} {item.state} ({item.time.strftime('%H:%M:%S')})")
        output.append(line)
        output.append("\n")

    self.update(output)

gather_timeline_data(node)

Gather timeline data for a node and its related nodes.

Parameters:

Name Type Description Default
node Node

The ecFlow node to gather data for.

required

Returns:

Name Type Description
TimelineData TimelineData

An object containing the extracted information.

Raises:

Type Description
RuntimeError

If there is an issue accessing ecFlow node attributes.

Notes

This function performs I/O-like operations on ecFlow objects and should be called from a background thread to maintain UI responsiveness.

Source code in src/ectop/widgets/timeline.py
def gather_timeline_data(node: ecflow.Node) -> TimelineData:
    """
    Gather timeline data for a node and its related nodes.

    Args:
        node: The ecFlow node to gather data for.

    Returns:
        TimelineData: An object containing the extracted information.

    Raises:
        RuntimeError: If there is an issue accessing ecFlow node attributes.

    Notes:
        This function performs I/O-like operations on ecFlow objects and
        should be called from a background thread to maintain UI responsiveness.
    """
    import ecflow

    # In ecFlow Python API, Task, Family and Suite all have a 'nodes' attribute (iterator).
    # However, for a Task it is always empty.
    is_task = isinstance(node, ecflow.Task)
    parent = node.get_parent()

    if is_task and parent:
        nodes_to_show = list(parent.nodes)
        title = f"Timeline for {parent.get_abs_node_path()}"
    else:
        nodes_to_show = list(node.nodes)
        if not nodes_to_show:
            nodes_to_show = [node]
        title = f"Timeline for {node.get_abs_node_path()}"

    events = []
    for n in nodes_to_show:
        try:
            # We only have the LAST state change time from ecFlow Node API
            time_str = n.get_state_change_time("iso")
            if time_str == "not-a-date-time":
                continue

            dt = datetime.fromisoformat(time_str)
            events.append(
                TimelineEvent(
                    name=n.name(),
                    state=str(n.get_state()),
                    time=dt,
                    path=n.get_abs_node_path(),
                )
            )
        except (ValueError, AttributeError):
            continue

    return TimelineData(title=title, events=events)

Modals

Confirmation modal dialog.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

ConfirmModal

Bases: ModalScreen[None]

A modal screen for confirmation actions.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/modals/confirm.py
class ConfirmModal(ModalScreen[None]):
    """
    A modal screen for confirmation actions.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    BINDINGS = [
        Binding("escape", "close", "Cancel"),
        Binding("y", "confirm", "Yes"),
        Binding("n", "close", "No"),
    ]

    def __init__(self, message: str, callback: Callable[[], None]) -> None:
        """
        Initialize the ConfirmModal.

        Args:
            message: The message to display in the modal.
            callback: The function to call if confirmed.
        """
        super().__init__()
        self.message: str = message
        self.callback: Callable[[], None] = callback

    def compose(self) -> ComposeResult:
        """
        Compose the modal UI.

        Returns:
            The UI components for the modal.
        """
        with Vertical(id="confirm_container"):
            yield Static(self.message, id="confirm_message")
            with Horizontal(id="confirm_actions"):
                yield Button("Yes (y)", variant="success", id="yes_btn")
                yield Button("No (n)", variant="error", id="no_btn")

    def action_close(self) -> None:
        """Close the modal without confirming."""
        self.app.pop_screen()

    def action_confirm(self) -> None:
        """Confirm the action and call the callback."""
        self.callback()
        self.app.pop_screen()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """
        Handle button press events.

        Args:
            event: The button press event.
        """
        if event.button.id == "yes_btn":
            self.action_confirm()
        else:
            self.action_close()

__init__(message, callback)

Initialize the ConfirmModal.

Parameters:

Name Type Description Default
message str

The message to display in the modal.

required
callback Callable[[], None]

The function to call if confirmed.

required
Source code in src/ectop/widgets/modals/confirm.py
def __init__(self, message: str, callback: Callable[[], None]) -> None:
    """
    Initialize the ConfirmModal.

    Args:
        message: The message to display in the modal.
        callback: The function to call if confirmed.
    """
    super().__init__()
    self.message: str = message
    self.callback: Callable[[], None] = callback

action_close()

Close the modal without confirming.

Source code in src/ectop/widgets/modals/confirm.py
def action_close(self) -> None:
    """Close the modal without confirming."""
    self.app.pop_screen()

action_confirm()

Confirm the action and call the callback.

Source code in src/ectop/widgets/modals/confirm.py
def action_confirm(self) -> None:
    """Confirm the action and call the callback."""
    self.callback()
    self.app.pop_screen()

compose()

Compose the modal UI.

Returns:

Type Description
ComposeResult

The UI components for the modal.

Source code in src/ectop/widgets/modals/confirm.py
def compose(self) -> ComposeResult:
    """
    Compose the modal UI.

    Returns:
        The UI components for the modal.
    """
    with Vertical(id="confirm_container"):
        yield Static(self.message, id="confirm_message")
        with Horizontal(id="confirm_actions"):
            yield Button("Yes (y)", variant="success", id="yes_btn")
            yield Button("No (n)", variant="error", id="no_btn")

on_button_pressed(event)

Handle button press events.

Parameters:

Name Type Description Default
event Pressed

The button press event.

required
Source code in src/ectop/widgets/modals/confirm.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    """
    Handle button press events.

    Args:
        event: The button press event.
    """
    if event.button.id == "yes_btn":
        self.action_confirm()
    else:
        self.action_close()

Modal screen for loading ecFlow definition files.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

LoadDefsModal

Bases: ModalScreen[None]

A modal screen for loading .def files to the ecFlow server.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/modals/load.py
class LoadDefsModal(ModalScreen[None]):
    """
    A modal screen for loading .def files to the ecFlow server.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    BINDINGS = [
        Binding("escape", "close", "Cancel"),
    ]

    def compose(self) -> ComposeResult:
        """
        Compose the modal UI.

        Returns:
            The UI components for the modal.
        """
        with Vertical(id="confirm_container"):
            yield Static("Load Definition File", id="confirm_message")
            yield Input(placeholder="Path to .def file...", id="load_input")
            with Horizontal(id="confirm_actions"):
                yield Button("Load", variant="success", id="load_btn")
                yield Button("Cancel", variant="error", id="cancel_btn")

    def on_mount(self) -> None:
        """
        Focus the input field on mount.
        """
        self.query_one("#load_input", Input).focus()

    def action_close(self) -> None:
        """
        Close the modal.
        """
        self.app.pop_screen()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """
        Handle button press events.

        Args:
            event: The button press event.
        """
        if event.button.id == "load_btn":
            self._handle_load()
        else:
            self.action_close()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        """
        Handle input submission.

        Args:
            event: The input submission event.
        """
        if event.input.id == "load_input":
            self._handle_load()

    def _handle_load(self) -> None:
        """
        Process the load request.
        """
        path = self.query_one("#load_input", Input).value.strip()
        if not path:
            self.app.notify("Please enter a file path", severity="warning")
            return

        if not os.path.exists(path):
            self.app.notify(f"File not found: {path}", severity="error")
            return

        app = self.app
        assert isinstance(app, Ectop)
        app._load_defs_worker(path)
        self.app.pop_screen()

action_close()

Close the modal.

Source code in src/ectop/widgets/modals/load.py
def action_close(self) -> None:
    """
    Close the modal.
    """
    self.app.pop_screen()

compose()

Compose the modal UI.

Returns:

Type Description
ComposeResult

The UI components for the modal.

Source code in src/ectop/widgets/modals/load.py
def compose(self) -> ComposeResult:
    """
    Compose the modal UI.

    Returns:
        The UI components for the modal.
    """
    with Vertical(id="confirm_container"):
        yield Static("Load Definition File", id="confirm_message")
        yield Input(placeholder="Path to .def file...", id="load_input")
        with Horizontal(id="confirm_actions"):
            yield Button("Load", variant="success", id="load_btn")
            yield Button("Cancel", variant="error", id="cancel_btn")

on_button_pressed(event)

Handle button press events.

Parameters:

Name Type Description Default
event Pressed

The button press event.

required
Source code in src/ectop/widgets/modals/load.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    """
    Handle button press events.

    Args:
        event: The button press event.
    """
    if event.button.id == "load_btn":
        self._handle_load()
    else:
        self.action_close()

on_input_submitted(event)

Handle input submission.

Parameters:

Name Type Description Default
event Submitted

The input submission event.

required
Source code in src/ectop/widgets/modals/load.py
def on_input_submitted(self, event: Input.Submitted) -> None:
    """
    Handle input submission.

    Args:
        event: The input submission event.
    """
    if event.input.id == "load_input":
        self._handle_load()

on_mount()

Focus the input field on mount.

Source code in src/ectop/widgets/modals/load.py
def on_mount(self) -> None:
    """
    Focus the input field on mount.
    """
    self.query_one("#load_input", Input).focus()

Modal screen for viewing and editing ecFlow variables.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

VariableTweaker

Bases: ModalScreen[None]

A modal screen for managing ecFlow node variables.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/modals/variables.py
class VariableTweaker(ModalScreen[None]):
    """
    A modal screen for managing ecFlow node variables.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    BINDINGS = [
        Binding("escape", "close", "Close"),
        Binding("v", "close", "Close"),
        Binding("a", "add_variable", "Add Variable"),
        Binding("d", "delete_variable", "Delete Variable"),
    ]

    def __init__(self, node_path: str, client: EcflowClient) -> None:
        """
        Initialize the VariableTweaker.

        Args:
            node_path: The absolute path to the ecFlow node.
            client: The ecFlow client instance.
        """
        super().__init__()
        self.node_path: str = node_path
        self.client: EcflowClient = client
        self.selected_var_name: str | None = None

    def compose(self) -> ComposeResult:
        """
        Compose the modal UI.

        Returns:
            The UI components for the modal.
        """
        with Vertical(id="var_container"):
            yield Static(f"Variables for {self.node_path}", id="var_title")
            yield DataTable(id="var_table")
            yield Input(placeholder="Enter new value...", id="var_input")
            with Horizontal(id="var_actions"):
                yield Button("Close", variant="primary", id="close_btn")

    def on_mount(self) -> None:
        """
        Handle the mount event to initialize the table.
        """
        table = self.query_one("#var_table", DataTable)
        table.add_columns("Name", "Value", "Type")
        table.cursor_type = "row"
        self.refresh_vars()
        self.query_one("#var_input").add_class("hidden")

    def action_close(self) -> None:
        """
        Close the modal.
        """
        self.app.pop_screen()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """
        Handle button press events.

        Args:
            event: The button press event.
        """
        if event.button.id == "close_btn":
            self.app.pop_screen()

    @work(thread=True)
    def refresh_vars(self) -> None:
        """
        Fetch variables from the server and refresh the table in a background worker.

        Notes:
            This is a background thread worker.
        """
        self._refresh_vars_logic()

    def _refresh_vars_logic(self) -> None:
        """
        The actual logic for fetching variables and updating the UI.

        Raises:
            RuntimeError: If server synchronization fails.

        Notes:
            This method is called from a background worker.
        """
        try:
            self.client.sync_local_sync()
            defs = self.client.get_defs_sync()
            if not defs:
                return
            node = defs.find_abs_node(self.node_path)

            if not node:
                safe_call_app(self.app, self.app.notify, "Node not found", severity="error")
                return

            rows: list[tuple[str, str, str, str]] = []
            seen_vars: set[str] = set()

            # User variables
            for var in node.variables:
                rows.append((var.name(), var.value(), VAR_TYPE_USER, var.name()))
                seen_vars.add(var.name())

            # Generated variables
            for var in node.get_generated_variables():
                rows.append((var.name(), var.value(), VAR_TYPE_GENERATED, var.name()))
                seen_vars.add(var.name())

            # Inherited variables (climb up the tree)
            parent = node.get_parent()
            while parent:
                for var in parent.variables:
                    # Only add if not already present (overridden)
                    if var.name() not in seen_vars:
                        rows.append(
                            (
                                var.name(),
                                var.value(),
                                f"{VAR_TYPE_INHERITED} ({parent.name()})",
                                f"{INHERITED_VAR_PREFIX}{var.name()}",
                            )
                        )
                        seen_vars.add(var.name())
                parent = parent.get_parent()

            safe_call_app(self.app, self._update_table, rows)

        except RuntimeError as e:
            safe_call_app(self.app, self.app.notify, f"Error fetching variables: {e}", severity="error")
        except Exception as e:
            safe_call_app(self.app, self.app.notify, f"Unexpected Error: {e}", severity="error")

    def _update_table(self, rows: list[tuple[str, str, str, str]]) -> None:
        """
        Update the DataTable with new rows.

        Args:
            rows: The rows to add to the table.
        """
        table = self.query_one("#var_table", DataTable)
        table.clear()
        for row in rows:
            table.add_row(row[0], row[1], row[2], key=row[3])

    def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
        """
        Handle row selection to start editing a variable.

        Args:
            event: The row selection event.
        """
        row_key = event.row_key.value
        if row_key and row_key.startswith(INHERITED_VAR_PREFIX):
            self.app.notify("Cannot edit inherited variables directly. Add it to this node to override.", severity="warning")
            return

        self.selected_var_name = row_key
        input_field = self.query_one("#var_input", Input)
        input_field.remove_class("hidden")
        input_field.focus()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        """
        Handle variable submission (add or update).

        Args:
            event: The input submission event.
        """
        if event.input.id == "var_input":
            self._submit_variable_worker(event.value)

    @work(thread=True)
    def _submit_variable_worker(self, value: str) -> None:
        """
        Worker to submit a new or updated variable in a background thread.

        Args:
            value: The new value or 'name=value' string.

        Notes:
            This is a background thread worker.
        """
        self._submit_variable_logic(value)

    def _submit_variable_logic(self, value: str) -> None:
        """
        The actual logic for submitting a variable update or addition.

        Args:
            value: The new value or 'name=value' string.

        Raises:
            RuntimeError: If the server alteration fails.

        Notes:
            This method is called from a background worker.
        """
        try:
            if self.selected_var_name:
                # Editing existing
                self.client.alter_sync(self.node_path, "add", "variable", self.selected_var_name, value)
                safe_call_app(self.app, self.app.notify, f"Updated {self.selected_var_name}")
            else:
                # Adding new (expecting name=value)
                if "=" in value:
                    name, val = value.split("=", 1)
                    self.client.alter_sync(self.node_path, "add", "variable", name.strip(), val.strip())
                    safe_call_app(self.app, self.app.notify, f"Added {name.strip()}")
                else:
                    safe_call_app(self.app, self.app.notify, "Use name=value format to add", severity="warning")
                    return

            safe_call_app(self.app, self._reset_input)
            self.refresh_vars()
        except RuntimeError as e:
            safe_call_app(self.app, self.app.notify, f"Error: {e}", severity="error")
        except Exception as e:
            safe_call_app(self.app, self.app.notify, f"Unexpected Error: {e}", severity="error")

    def _reset_input(self) -> None:
        """
        Reset the input field state.
        """
        input_field = self.query_one("#var_input", Input)
        input_field.add_class("hidden")
        input_field.value = ""
        input_field.placeholder = "Enter new value..."
        self.query_one("#var_table").focus()

    def action_add_variable(self) -> None:
        """
        Show the input field to add a new variable.
        """
        input_field = self.query_one("#var_input", Input)
        input_field.placeholder = "Enter name=value to add"
        input_field.remove_class("hidden")
        input_field.focus()
        self.selected_var_name = None

    def action_delete_variable(self) -> None:
        """
        Delete the selected variable from the server.
        """
        table = self.query_one("#var_table", DataTable)
        row_index = table.cursor_row
        if row_index is not None:
            # Get row key from the index
            row_keys = list(table.rows.keys())
            row_key = row_keys[row_index].value
            if row_key:
                self._delete_variable_worker(row_key)

    @work(thread=True)
    def _delete_variable_worker(self, row_key: str) -> None:
        """
        Worker to delete a variable from the server in a background thread.

        Args:
            row_key: The name (or key) of the variable to delete.

        Notes:
            This is a background thread worker.
        """
        self._delete_variable_logic(row_key)

    def _delete_variable_logic(self, row_key: str) -> None:
        """
        The actual logic for deleting a variable.

        Args:
            row_key: The name (or key) of the variable to delete.

        Raises:
            RuntimeError: If the server alteration fails.

        Notes:
            This method is called from a background worker.
        """
        if row_key.startswith(INHERITED_VAR_PREFIX):
            safe_call_app(self.app, self.app.notify, "Cannot delete inherited variables", severity="error")
            return

        try:
            self.client.alter_sync(self.node_path, "delete", "variable", row_key)
            safe_call_app(self.app, self.app.notify, f"Deleted {row_key}")
            self.refresh_vars()
        except RuntimeError as e:
            safe_call_app(self.app, self.app.notify, f"Error: {e}", severity="error")
        except Exception as e:
            safe_call_app(self.app, self.app.notify, f"Unexpected Error: {e}", severity="error")

__init__(node_path, client)

Initialize the VariableTweaker.

Parameters:

Name Type Description Default
node_path str

The absolute path to the ecFlow node.

required
client EcflowClient

The ecFlow client instance.

required
Source code in src/ectop/widgets/modals/variables.py
def __init__(self, node_path: str, client: EcflowClient) -> None:
    """
    Initialize the VariableTweaker.

    Args:
        node_path: The absolute path to the ecFlow node.
        client: The ecFlow client instance.
    """
    super().__init__()
    self.node_path: str = node_path
    self.client: EcflowClient = client
    self.selected_var_name: str | None = None

action_add_variable()

Show the input field to add a new variable.

Source code in src/ectop/widgets/modals/variables.py
def action_add_variable(self) -> None:
    """
    Show the input field to add a new variable.
    """
    input_field = self.query_one("#var_input", Input)
    input_field.placeholder = "Enter name=value to add"
    input_field.remove_class("hidden")
    input_field.focus()
    self.selected_var_name = None

action_close()

Close the modal.

Source code in src/ectop/widgets/modals/variables.py
def action_close(self) -> None:
    """
    Close the modal.
    """
    self.app.pop_screen()

action_delete_variable()

Delete the selected variable from the server.

Source code in src/ectop/widgets/modals/variables.py
def action_delete_variable(self) -> None:
    """
    Delete the selected variable from the server.
    """
    table = self.query_one("#var_table", DataTable)
    row_index = table.cursor_row
    if row_index is not None:
        # Get row key from the index
        row_keys = list(table.rows.keys())
        row_key = row_keys[row_index].value
        if row_key:
            self._delete_variable_worker(row_key)

compose()

Compose the modal UI.

Returns:

Type Description
ComposeResult

The UI components for the modal.

Source code in src/ectop/widgets/modals/variables.py
def compose(self) -> ComposeResult:
    """
    Compose the modal UI.

    Returns:
        The UI components for the modal.
    """
    with Vertical(id="var_container"):
        yield Static(f"Variables for {self.node_path}", id="var_title")
        yield DataTable(id="var_table")
        yield Input(placeholder="Enter new value...", id="var_input")
        with Horizontal(id="var_actions"):
            yield Button("Close", variant="primary", id="close_btn")

on_button_pressed(event)

Handle button press events.

Parameters:

Name Type Description Default
event Pressed

The button press event.

required
Source code in src/ectop/widgets/modals/variables.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    """
    Handle button press events.

    Args:
        event: The button press event.
    """
    if event.button.id == "close_btn":
        self.app.pop_screen()

on_data_table_row_selected(event)

Handle row selection to start editing a variable.

Parameters:

Name Type Description Default
event RowSelected

The row selection event.

required
Source code in src/ectop/widgets/modals/variables.py
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
    """
    Handle row selection to start editing a variable.

    Args:
        event: The row selection event.
    """
    row_key = event.row_key.value
    if row_key and row_key.startswith(INHERITED_VAR_PREFIX):
        self.app.notify("Cannot edit inherited variables directly. Add it to this node to override.", severity="warning")
        return

    self.selected_var_name = row_key
    input_field = self.query_one("#var_input", Input)
    input_field.remove_class("hidden")
    input_field.focus()

on_input_submitted(event)

Handle variable submission (add or update).

Parameters:

Name Type Description Default
event Submitted

The input submission event.

required
Source code in src/ectop/widgets/modals/variables.py
def on_input_submitted(self, event: Input.Submitted) -> None:
    """
    Handle variable submission (add or update).

    Args:
        event: The input submission event.
    """
    if event.input.id == "var_input":
        self._submit_variable_worker(event.value)

on_mount()

Handle the mount event to initialize the table.

Source code in src/ectop/widgets/modals/variables.py
def on_mount(self) -> None:
    """
    Handle the mount event to initialize the table.
    """
    table = self.query_one("#var_table", DataTable)
    table.add_columns("Name", "Value", "Type")
    table.cursor_type = "row"
    self.refresh_vars()
    self.query_one("#var_input").add_class("hidden")

refresh_vars()

Fetch variables from the server and refresh the table in a background worker.

Notes

This is a background thread worker.

Source code in src/ectop/widgets/modals/variables.py
@work(thread=True)
def refresh_vars(self) -> None:
    """
    Fetch variables from the server and refresh the table in a background worker.

    Notes:
        This is a background thread worker.
    """
    self._refresh_vars_logic()

Modal screen for inspecting why an ecFlow node is not running.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

DepData dataclass

Intermediate data structure for dependency information.

Attributes:

Name Type Description
label str

The text to display for this dependency.

path str | None

The ecFlow path if this represents a node, otherwise None.

is_met bool

Whether this dependency is currently satisfied.

children list[DepData]

Nested dependencies.

icon str | None

Optional icon override.

Source code in src/ectop/widgets/modals/why.py
@dataclass
class DepData:
    """
    Intermediate data structure for dependency information.

    Attributes:
        label: The text to display for this dependency.
        path: The ecFlow path if this represents a node, otherwise None.
        is_met: Whether this dependency is currently satisfied.
        children: Nested dependencies.
        icon: Optional icon override.
    """

    label: str
    path: str | None = None
    is_met: bool = True
    children: list[DepData] = field(default_factory=list)
    icon: str | None = None

WhyInspector

Bases: ModalScreen[None]

A modal screen to inspect dependencies and triggers of an ecFlow node.

.. note:: If you modify features, API, or usage, you MUST update the documentation immediately.

Source code in src/ectop/widgets/modals/why.py
class WhyInspector(ModalScreen[None]):
    """
    A modal screen to inspect dependencies and triggers of an ecFlow node.

    .. note::
        If you modify features, API, or usage, you MUST update the documentation immediately.
    """

    BINDINGS = [
        Binding("escape", "close", "Close"),
        Binding("w", "close", "Close"),
    ]

    def __init__(self, node_path: str, client: EcflowClient) -> None:
        """
        Initialize the WhyInspector.

        Args:
            node_path: The absolute path to the ecFlow node.
            client: The ecFlow client instance.
        """
        super().__init__()
        self.node_path: str = node_path
        self.client: EcflowClient = client
        self._state_map = {
            "unknown": ecflow.State.unknown,
            "complete": ecflow.State.complete,
            "queued": ecflow.State.queued,
            "aborted": ecflow.State.aborted,
            "submitted": ecflow.State.submitted,
            "active": ecflow.State.active,
        }

    def compose(self) -> ComposeResult:
        """
        Compose the modal UI.

        Returns:
            The UI components for the modal.
        """
        with Vertical(id="why_container"):
            yield Static(f"Why is {self.node_path} not running?", id="why_title")
            yield Tree("Dependencies", id="dep_tree")
            with Horizontal(id="why_actions"):
                yield Button("Close", variant="primary", id="close_btn")

    def on_mount(self) -> None:
        """
        Handle the mount event to initialize the dependency tree.
        """
        self.refresh_deps()

    def action_close(self) -> None:
        """
        Close the modal.
        """
        self.app.pop_screen()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """
        Handle button press events.

        Args:
            event: The button press event.
        """
        if event.button.id == "close_btn":
            self.app.pop_screen()

    def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
        """
        Jump to the selected dependency node in the main tree.

        Args:
            event: The tree node selection event.
        """
        node_path = event.node.data
        if node_path:
            from ectop.widgets.sidebar import SuiteTree

            try:
                tree = self.app.query_one("#suite_tree", SuiteTree)
                tree.select_by_path(node_path)
                self.app.notify(f"Jumped to {node_path}")
                self.app.pop_screen()
            except Exception as e:
                self.app.notify(f"Failed to jump: {e}", severity="error")

    def refresh_deps(self) -> None:
        """
        Fetch dependencies from the server and rebuild the tree.
        """
        self._refresh_deps_worker()

    @work(thread=True)
    def _refresh_deps_worker(self) -> None:
        """
        Worker to fetch dependencies from the server and rebuild the tree.

        Notes:
            This is a background thread worker.
        """
        self._refresh_deps_logic()

    def _refresh_deps_logic(self) -> None:
        """
        The actual logic for fetching dependencies and updating the UI tree.

        Raises:
            RuntimeError: If server synchronization fails.
        """
        tree = self.query_one("#dep_tree", Tree)
        try:
            self.client.sync_local_sync()
            defs = self.client.get_defs_sync()
            if not defs:
                safe_call_app(self.app, self._update_tree_ui, tree, DepData("Server Empty"))
                return

            node = defs.find_abs_node(self.node_path)
            if not node:
                safe_call_app(self.app, self._update_tree_ui, tree, DepData("Node not found"))
                return

            # Gather data (this is currently CPU-bound parsing)
            dep_data = self._gather_dependency_data(node, defs)

            # Update UI
            safe_call_app(self.app, self._update_tree_ui, tree, dep_data)

        except RuntimeError as e:
            safe_call_app(self.app, self._update_tree_ui, tree, DepData(f"Error: {e}"))
        except Exception as e:
            safe_call_app(self.app, self._update_tree_ui, tree, DepData(f"Unexpected Error: {e}"))

    def _gather_dependency_data(self, node: Node, defs: Defs) -> DepData:
        """
        Gather dependency data from an ecFlow node.

        Args:
            node: The ecFlow node to inspect.
            defs: The ecFlow definitions for node lookups.

        Returns:
            The root dependency data object.
        """
        root = DepData("Dependencies")

        # Reason
        try:
            why_str = node.get_why()
            if why_str:
                root.children.append(DepData(f"Reason: {why_str}", icon=ICON_REASON))
        except (AttributeError, RuntimeError):
            pass

        # Triggers
        try:
            trigger = node.get_trigger()
            if trigger:
                trigger_root = DepData("Triggers")
                self._parse_expression_data(trigger_root, trigger.get_expression(), defs)
                root.children.append(trigger_root)
        except (AttributeError, RuntimeError) as e:
            root.children.append(DepData(f"Trigger Error: {e}", icon=ICON_NOT_MET, is_met=False))

        # Complete
        try:
            complete = node.get_complete()
            if complete:
                complete_root = DepData("Complete Expression")
                self._parse_expression_data(complete_root, complete.get_expression(), defs)
                root.children.append(complete_root)
        except (AttributeError, RuntimeError) as e:
            root.children.append(DepData(f"Complete Expr Error: {e}", icon=ICON_NOT_MET, is_met=False))

        # Limits
        try:
            inlimits = list(node.inlimits)
            if inlimits:
                limit_root = DepData("Limits")
                for il in inlimits:
                    # InLimit.value() does not exist in ecFlow Python API, but name() and path_to_node() do.
                    # Or we just use str(il) which often gives 'inlimit name path'
                    path = il.path_to_node() or "Self"
                    limit_root.children.append(DepData(f"Limit: {il.name()} (Path: {path})", icon="🔒"))
                root.children.append(limit_root)
        except (AttributeError, RuntimeError):
            pass

        # Times, Dates, Crons
        try:
            time_root = DepData("Time Dependencies")
            has_time = False

            # We try to use the most common methods/attributes.
            # Real ecFlow has .times, .dates, .crons attributes.
            # Some older/mock versions use get_times(), etc.

            times = []
            for attr in ["times", "get_times"]:
                try:
                    val = getattr(node, attr)
                    times = list(val() if callable(val) else val)
                    if times:
                        break
                except (AttributeError, RuntimeError, TypeError):
                    continue
            for t in times:
                time_root.children.append(DepData(f"Time: {t}", icon=ICON_TIME))
                has_time = True

            dates = []
            for attr in ["dates", "get_dates"]:
                try:
                    val = getattr(node, attr)
                    dates = list(val() if callable(val) else val)
                    if dates:
                        break
                except (AttributeError, RuntimeError, TypeError):
                    continue
            for d in dates:
                time_root.children.append(DepData(f"Date: {d}", icon=ICON_DATE))
                has_time = True

            crons = []
            for attr in ["crons", "get_crons"]:
                try:
                    val = getattr(node, attr)
                    crons = list(val() if callable(val) else val)
                    if crons:
                        break
                except (AttributeError, RuntimeError, TypeError):
                    continue
            for c in crons:
                time_root.children.append(DepData(f"Cron: {c}", icon=ICON_CRON))
                has_time = True

            if has_time:
                root.children.append(time_root)
        except (AttributeError, RuntimeError):
            pass

        return root

    def _parse_expression_data(self, parent: DepData, expr_str: str, defs: Defs) -> bool:
        """
        Parse an ecFlow expression and populate DepData objects.

        Args:
            parent: The parent DepData object.
            expr_str: The expression string to parse.
            defs: The ecFlow definitions for node lookups.

        Returns:
            True if the expression is currently met.
        """
        try:
            tree = _get_expr_tree(expr_str)
            state_map: dict[str, Any] = {}
            if defs:
                # Build a full path-to-state map for robustness.
                # This is more reliable than find_abs_node in all versions.
                for n in defs.get_all_nodes():
                    state_map[n.get_abs_node_path()] = n.get_state()
            return self._evaluate_expr_tree(parent, tree, state_map)
        except Exception as e:
            parent.children.append(DepData(f"Parse Error: {expr_str} ({e})", is_met=False, icon=ICON_NOT_MET))
            return False

    def _evaluate_expr_tree(self, parent: DepData, tree: dict, state_map: dict[str, Any]) -> bool:
        """
        Evaluate a parsed expression tree against a pre-gathered state map.

        Args:
            parent: The parent DepData object.
            tree: The parsed expression tree.
            state_map: A mapping of node paths to their current states.

        Returns:
            True if the expression is currently met.
        """
        expr_type = tree["type"]

        if expr_type == "empty":
            return True

        if expr_type == "not":
            not_node = DepData("NOT (Must be false)")
            inner_met = self._evaluate_expr_tree(not_node, tree["child"], state_map)
            is_met = not inner_met
            not_node.is_met = is_met
            parent.children.append(not_node)
            return is_met

        if expr_type in ("and", "or"):
            label = EXPR_AND_LABEL if expr_type == "and" else EXPR_OR_LABEL
            op_node = DepData(label)
            is_met_left = self._evaluate_expr_tree(op_node, tree["left"], state_map)
            is_met_right = self._evaluate_expr_tree(op_node, tree["right"], state_map)
            is_met = (is_met_left and is_met_right) if expr_type == "and" else (is_met_left or is_met_right)
            op_node.is_met = is_met
            parent.children.append(op_node)
            return is_met

        if expr_type == "leaf":
            negation = tree["negation"]
            path = tree["path"]
            op = tree["op"]
            expected_state_str = tree["expected"]

            if path in state_map:
                actual_state = state_map[path]

                # For tests where actual_state might be a string
                if isinstance(actual_state, str):
                    expected_state = expected_state_str
                else:
                    expected_state = self._state_map.get(expected_state_str, ecflow.State.complete)

                is_met = False
                if op == "==":
                    is_met = actual_state == expected_state
                elif op == "!=":
                    is_met = actual_state != expected_state
                elif op == "<":
                    is_met = actual_state < expected_state
                elif op == ">":
                    is_met = actual_state > expected_state
                elif op == "<=":
                    is_met = actual_state <= expected_state
                elif op == ">=":
                    is_met = actual_state >= expected_state

                if negation == "!":
                    is_met = not is_met

                neg_str = "! " if negation == "!" else ""
                label = f"{neg_str}{path} {op} {str(actual_state)} (Expected: {expected_state_str})"
                if str(actual_state) == "aborted":
                    label = f"[b red]{label} (STOPPED HERE)[/]"

                parent.children.append(DepData(label, path=path, is_met=is_met))
                return is_met
            else:
                parent.children.append(DepData(f"{path} (Not found)", is_met=False, icon=ICON_UNKNOWN))
                return False

        if expr_type == "literal":
            parent.children.append(DepData(tree["value"], icon=ICON_NOTE))
            return True

        return True

    def _update_tree_ui(self, tree: Tree, data: DepData) -> None:
        """
        Update the tree UI from DepData.

        Args:
            tree: The tree widget.
            data: The root dependency data.
        """
        tree.clear()
        tree.root.label = data.label
        for child in data.children:
            self._add_to_tree(tree.root, child)
        tree.root.expand_all()

    def _add_to_tree(self, parent_node: TreeNode[str], data: DepData) -> None:
        """
        Recursively add DepData to the Textual Tree.

        Args:
            parent_node: The parent TreeNode.
            data: The DepData to add.
        """
        icon = data.icon or (ICON_MET if data.is_met else ICON_NOT_MET)
        label = f"{icon} {data.label}"
        new_node = parent_node.add(label, data=data.path, expand=True)
        for child in data.children:
            self._add_to_tree(new_node, child)

__init__(node_path, client)

Initialize the WhyInspector.

Parameters:

Name Type Description Default
node_path str

The absolute path to the ecFlow node.

required
client EcflowClient

The ecFlow client instance.

required
Source code in src/ectop/widgets/modals/why.py
def __init__(self, node_path: str, client: EcflowClient) -> None:
    """
    Initialize the WhyInspector.

    Args:
        node_path: The absolute path to the ecFlow node.
        client: The ecFlow client instance.
    """
    super().__init__()
    self.node_path: str = node_path
    self.client: EcflowClient = client
    self._state_map = {
        "unknown": ecflow.State.unknown,
        "complete": ecflow.State.complete,
        "queued": ecflow.State.queued,
        "aborted": ecflow.State.aborted,
        "submitted": ecflow.State.submitted,
        "active": ecflow.State.active,
    }

action_close()

Close the modal.

Source code in src/ectop/widgets/modals/why.py
def action_close(self) -> None:
    """
    Close the modal.
    """
    self.app.pop_screen()

compose()

Compose the modal UI.

Returns:

Type Description
ComposeResult

The UI components for the modal.

Source code in src/ectop/widgets/modals/why.py
def compose(self) -> ComposeResult:
    """
    Compose the modal UI.

    Returns:
        The UI components for the modal.
    """
    with Vertical(id="why_container"):
        yield Static(f"Why is {self.node_path} not running?", id="why_title")
        yield Tree("Dependencies", id="dep_tree")
        with Horizontal(id="why_actions"):
            yield Button("Close", variant="primary", id="close_btn")

on_button_pressed(event)

Handle button press events.

Parameters:

Name Type Description Default
event Pressed

The button press event.

required
Source code in src/ectop/widgets/modals/why.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    """
    Handle button press events.

    Args:
        event: The button press event.
    """
    if event.button.id == "close_btn":
        self.app.pop_screen()

on_mount()

Handle the mount event to initialize the dependency tree.

Source code in src/ectop/widgets/modals/why.py
def on_mount(self) -> None:
    """
    Handle the mount event to initialize the dependency tree.
    """
    self.refresh_deps()

on_tree_node_selected(event)

Jump to the selected dependency node in the main tree.

Parameters:

Name Type Description Default
event NodeSelected[str]

The tree node selection event.

required
Source code in src/ectop/widgets/modals/why.py
def on_tree_node_selected(self, event: Tree.NodeSelected[str]) -> None:
    """
    Jump to the selected dependency node in the main tree.

    Args:
        event: The tree node selection event.
    """
    node_path = event.node.data
    if node_path:
        from ectop.widgets.sidebar import SuiteTree

        try:
            tree = self.app.query_one("#suite_tree", SuiteTree)
            tree.select_by_path(node_path)
            self.app.notify(f"Jumped to {node_path}")
            self.app.pop_screen()
        except Exception as e:
            self.app.notify(f"Failed to jump: {e}", severity="error")

refresh_deps()

Fetch dependencies from the server and rebuild the tree.

Source code in src/ectop/widgets/modals/why.py
def refresh_deps(self) -> None:
    """
    Fetch dependencies from the server and rebuild the tree.
    """
    self._refresh_deps_worker()

Zombie Management Dashboard for ectop.

ZombieDashboard

Bases: ModalScreen

A dashboard to view and resolve ecFlow Zombies.

Source code in src/ectop/widgets/modals/zombies.py
class ZombieDashboard(ModalScreen):
    """
    A dashboard to view and resolve ecFlow Zombies.
    """

    BINDINGS = [
        Binding("escape,q", "dismiss", "Close"),
        Binding("r", "refresh", "Refresh"),
        Binding("f", "fob", "Fob"),
        Binding("F", "fail", "Fail"),
        Binding("a", "adopt", "Adopt"),
    ]

    def __init__(self, client: EcflowClient, **kwargs: Any) -> None:
        """
        Initialize the ZombieDashboard.

        Args:
            client: The EcflowClient instance.
        """
        super().__init__(**kwargs)
        self.ecflow_client = client
        self._zombies: list[ecflow.Zombie] = []

    def compose(self) -> ComposeResult:
        """
        Compose the UI.
        """
        yield Container(
            Static("🧟 Zombie Management Dashboard", id="zombie_title"),
            DataTable(id="zombie_table"),
            Horizontal(
                Button("Refresh (r)", variant="primary", id="btn_refresh"),
                Button("Fob (f)", variant="warning", id="btn_fob"),
                Button("Fail (F)", variant="error", id="btn_fail"),
                Button("Adopt (a)", variant="success", id="btn_adopt"),
                classes="modal_actions",
            ),
            id="zombie_container",
        )
        yield Footer()

    def on_mount(self) -> None:
        """
        Handle mount event.
        """
        table = self.query_one(DataTable)
        table.add_columns("Path", "Status", "User", "Host", "RID", "Try", "Created")
        table.cursor_type = "row"
        self.action_refresh()

    @work(exclusive=True)
    async def action_refresh(self) -> None:
        """
        Refresh the zombie list.
        """
        self.app.notify("Refreshing zombies...")
        try:
            self._zombies = await self.ecflow_client.zombie_get()
            self._update_table()
        except RuntimeError as e:
            self.app.notify(f"Failed to fetch zombies: {e}", severity="error")

    def _update_table(self) -> None:
        """
        Update the DataTable with fetched zombies.
        """
        table = self.query_one(DataTable)
        table.clear()
        for i, z in enumerate(self._zombies):
            table.add_row(
                z.path(),
                z.calls(),  # Using calls() as a proxy for status/type if needed, or z.type()
                z.user(),
                z.host(),
                z.rid(),
                str(z.try_no()),
                z.allowed(),  # creation_time might be available in stats or allowed()
                key=str(i),
            )

    def get_selected_zombie(self) -> ecflow.Zombie | None:
        """
        Get the currently selected zombie object.
        """
        table = self.query_one(DataTable)
        if table.cursor_row is not None:
            row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
            if row_key and row_key.value:
                return self._zombies[int(row_key.value)]
        return None

    @work
    async def action_fob(self) -> None:
        """
        Fob the selected zombie.
        """
        z = self.get_selected_zombie()
        if z:
            try:
                await self.ecflow_client.zombie_fob(z)
                self.app.notify(f"Fobbed: {z.path()}")
                self.action_refresh()
            except RuntimeError as e:
                self.app.notify(f"Fob failed: {e}", severity="error")

    @work
    async def action_fail(self) -> None:
        """
        Fail the selected zombie.
        """
        z = self.get_selected_zombie()
        if z:
            try:
                await self.ecflow_client.zombie_fail(z)
                self.app.notify(f"Failed: {z.path()}")
                self.action_refresh()
            except RuntimeError as e:
                self.app.notify(f"Fail failed: {e}", severity="error")

    @work
    async def action_adopt(self) -> None:
        """
        Adopt the selected zombie.
        """
        z = self.get_selected_zombie()
        if z:
            try:
                await self.ecflow_client.zombie_adopt(z)
                self.app.notify(f"Adopted: {z.path()}")
                self.action_refresh()
            except RuntimeError as e:
                self.app.notify(f"Adopt failed: {e}", severity="error")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """
        Handle button presses.
        """
        if event.button.id == "btn_refresh":
            self.action_refresh()
        elif event.button.id == "btn_fob":
            self.action_fob()
        elif event.button.id == "btn_fail":
            self.action_fail()
        elif event.button.id == "btn_adopt":
            self.action_adopt()

__init__(client, **kwargs)

Initialize the ZombieDashboard.

Parameters:

Name Type Description Default
client EcflowClient

The EcflowClient instance.

required
Source code in src/ectop/widgets/modals/zombies.py
def __init__(self, client: EcflowClient, **kwargs: Any) -> None:
    """
    Initialize the ZombieDashboard.

    Args:
        client: The EcflowClient instance.
    """
    super().__init__(**kwargs)
    self.ecflow_client = client
    self._zombies: list[ecflow.Zombie] = []

action_adopt() async

Adopt the selected zombie.

Source code in src/ectop/widgets/modals/zombies.py
@work
async def action_adopt(self) -> None:
    """
    Adopt the selected zombie.
    """
    z = self.get_selected_zombie()
    if z:
        try:
            await self.ecflow_client.zombie_adopt(z)
            self.app.notify(f"Adopted: {z.path()}")
            self.action_refresh()
        except RuntimeError as e:
            self.app.notify(f"Adopt failed: {e}", severity="error")

action_fail() async

Fail the selected zombie.

Source code in src/ectop/widgets/modals/zombies.py
@work
async def action_fail(self) -> None:
    """
    Fail the selected zombie.
    """
    z = self.get_selected_zombie()
    if z:
        try:
            await self.ecflow_client.zombie_fail(z)
            self.app.notify(f"Failed: {z.path()}")
            self.action_refresh()
        except RuntimeError as e:
            self.app.notify(f"Fail failed: {e}", severity="error")

action_fob() async

Fob the selected zombie.

Source code in src/ectop/widgets/modals/zombies.py
@work
async def action_fob(self) -> None:
    """
    Fob the selected zombie.
    """
    z = self.get_selected_zombie()
    if z:
        try:
            await self.ecflow_client.zombie_fob(z)
            self.app.notify(f"Fobbed: {z.path()}")
            self.action_refresh()
        except RuntimeError as e:
            self.app.notify(f"Fob failed: {e}", severity="error")

action_refresh() async

Refresh the zombie list.

Source code in src/ectop/widgets/modals/zombies.py
@work(exclusive=True)
async def action_refresh(self) -> None:
    """
    Refresh the zombie list.
    """
    self.app.notify("Refreshing zombies...")
    try:
        self._zombies = await self.ecflow_client.zombie_get()
        self._update_table()
    except RuntimeError as e:
        self.app.notify(f"Failed to fetch zombies: {e}", severity="error")

compose()

Compose the UI.

Source code in src/ectop/widgets/modals/zombies.py
def compose(self) -> ComposeResult:
    """
    Compose the UI.
    """
    yield Container(
        Static("🧟 Zombie Management Dashboard", id="zombie_title"),
        DataTable(id="zombie_table"),
        Horizontal(
            Button("Refresh (r)", variant="primary", id="btn_refresh"),
            Button("Fob (f)", variant="warning", id="btn_fob"),
            Button("Fail (F)", variant="error", id="btn_fail"),
            Button("Adopt (a)", variant="success", id="btn_adopt"),
            classes="modal_actions",
        ),
        id="zombie_container",
    )
    yield Footer()

get_selected_zombie()

Get the currently selected zombie object.

Source code in src/ectop/widgets/modals/zombies.py
def get_selected_zombie(self) -> ecflow.Zombie | None:
    """
    Get the currently selected zombie object.
    """
    table = self.query_one(DataTable)
    if table.cursor_row is not None:
        row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
        if row_key and row_key.value:
            return self._zombies[int(row_key.value)]
    return None

on_button_pressed(event)

Handle button presses.

Source code in src/ectop/widgets/modals/zombies.py
def on_button_pressed(self, event: Button.Pressed) -> None:
    """
    Handle button presses.
    """
    if event.button.id == "btn_refresh":
        self.action_refresh()
    elif event.button.id == "btn_fob":
        self.action_fob()
    elif event.button.id == "btn_fail":
        self.action_fail()
    elif event.button.id == "btn_adopt":
        self.action_adopt()

on_mount()

Handle mount event.

Source code in src/ectop/widgets/modals/zombies.py
def on_mount(self) -> None:
    """
    Handle mount event.
    """
    table = self.query_one(DataTable)
    table.add_columns("Path", "Status", "User", "Host", "RID", "Try", "Created")
    table.cursor_type = "row"
    self.action_refresh()