diff --git a/CHANGES.md b/CHANGES.md index 7c8d8ad5110..649f70eb67b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -52,6 +52,10 @@ job status message retries (problems that prevent message transmission are almost never transient, and in practice job polling is the only way to recover). +[#3463](https://github.com/cylc/cylc-flow/pull/3463) - cylc tui: +A new terminal user interface to replace the old `cylc monitor`. +An interactive collapsible tree to match the new web interface. + ### Fixes [#3409](https://github.com/cylc/cylc-flow/pull/3409) - prevent cylc-run from diff --git a/cylc/flow/cfgspec/globalcfg.py b/cylc/flow/cfgspec/globalcfg.py index 52b0228b379..5006e9cc40f 100644 --- a/cylc/flow/cfgspec/globalcfg.py +++ b/cylc/flow/cfgspec/globalcfg.py @@ -94,11 +94,6 @@ 'gui': [VDR.V_STRING, 'gvim -f'], }, - # client - 'monitor': { - 'sort order': [VDR.V_STRING, 'definition', 'alphanumeric'], - }, - # job platforms 'job platforms': { '__MANY__': { diff --git a/cylc/flow/etc/cylc-bash-completion b/cylc/flow/etc/cylc-bash-completion index ea83e2117b1..dc1d672c193 100644 --- a/cylc/flow/etc/cylc-bash-completion +++ b/cylc/flow/etc/cylc-bash-completion @@ -38,7 +38,7 @@ _cylc() { cur="${COMP_WORDS[COMP_CWORD]}" sec="${COMP_WORDS[1]}" opts="$(cylc print -x -y 2>/dev/null)" - suite_cmds="broadcast|bcast|cat-log|log|check-versions|checkpoint|diff|compare|dump|edit|ext-trigger|external-trigger|get-directory|get-suite-config|get-config|get-suite-version|get-cylc-version|graph|graph-diff|hold|insert|jobscript|kill|list|ls|ls-checkpoints|monitor|nudge|ping|poll|print|register|release|unhold|reload|remove|report-timings|reset|restart|run|start|scan|scp-transfer|search|grep|set-verbosity|show|spawn|stop|shutdown|submit|single|suite-state|test-battery|trigger|validate|view|warranty" + suite_cmds="broadcast|bcast|cat-log|log|check-versions|checkpoint|diff|compare|dump|edit|ext-trigger|external-trigger|get-directory|get-suite-config|get-config|get-suite-version|get-cylc-version|graph|graph-diff|hold|insert|jobscript|kill|list|ls|ls-checkpoints|tui|nudge|ping|poll|print|register|release|unhold|reload|remove|report-timings|reset|restart|run|start|scan|scp-transfer|search|grep|set-verbosity|show|spawn|stop|shutdown|submit|single|suite-state|test-battery|trigger|validate|view|warranty" if [[ ${COMP_CWORD} -eq 1 ]]; then diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 2a05dfb85ee..674dc8fabb2 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -113,6 +113,16 @@ def __str__(self): return ret +class SuiteStopped(ClientError): + """Special case of ClientError for a stopped suite.""" + + def __init__(self, suite): + self.suite = suite + + def __str__(self): + return f'{self.suite} is not running' + + class ClientTimeout(CylcError): pass diff --git a/cylc/flow/network/__init__.py b/cylc/flow/network/__init__.py index b01fbdf3fbd..510568d89d2 100644 --- a/cylc/flow/network/__init__.py +++ b/cylc/flow/network/__init__.py @@ -26,7 +26,12 @@ import zmq.asyncio from cylc.flow import LOG -from cylc.flow.exceptions import ClientError, CylcError, SuiteServiceFileError +from cylc.flow.exceptions import ( + ClientError, + CylcError, + SuiteServiceFileError, + SuiteStopped +) from cylc.flow.hostuserutil import get_fqdn_by_host from cylc.flow.suite_files import ( ContactFileFields, @@ -68,11 +73,9 @@ def get_location(suite: str, owner: str, host: str): ClientError: if the suite is not running. """ try: - contact = load_contact_file( - suite, owner, host) + contact = load_contact_file(suite, owner, host) except SuiteServiceFileError: - raise ClientError(f'Contact info not found for suite ' - f'"{suite}", suite not running?') + raise SuiteStopped(suite) if not host: host = contact[ContactFileFields.HOST] diff --git a/cylc/flow/network/client.py b/cylc/flow/network/client.py index 56e57114f1c..3404d16c829 100644 --- a/cylc/flow/network/client.py +++ b/cylc/flow/network/client.py @@ -29,7 +29,8 @@ from cylc.flow.exceptions import ( ClientError, ClientTimeout, - SuiteServiceFileError + SuiteServiceFileError, + SuiteStopped ) from cylc.flow.network import ( encode_, @@ -259,6 +260,6 @@ def _timeout_handler(suite: str, host: str, port: Union[int, str]): return else: # the suite has stopped - raise ClientError('Suite "%s" already stopped' % suite) + raise SuiteStopped(suite) __call__ = serial_request diff --git a/cylc/flow/scripts/cylc_dump.py b/cylc/flow/scripts/cylc_dump.py index 2c3e9ed93ae..952a21d313e 100755 --- a/cylc/flow/scripts/cylc_dump.py +++ b/cylc/flow/scripts/cylc_dump.py @@ -19,8 +19,11 @@ """cylc [info] dump [OPTIONS] ARGS Print state information (e.g. the state of each task) from a running -suite. For small suites 'watch cylc [info] dump SUITE' is an effective -non-GUI real time monitor (but see also 'cylc monitor'). +suite. + +For command line monitoring: +* `cylc tui` +* `watch cylc dump SUITE` works for small simple suites For more information about a specific task, such as the current state of its prerequisites and outputs, see 'cylc [info] show'. diff --git a/cylc/flow/scripts/cylc_help.py b/cylc/flow/scripts/cylc_help.py index 4804f3f7f5a..7b0dfd63f65 100755 --- a/cylc/flow/scripts/cylc_help.py +++ b/cylc/flow/scripts/cylc_help.py @@ -220,7 +220,7 @@ def category_help(category): information_commands['get-suite-version'] = [ 'get-suite-version', 'get-cylc-version'] -information_commands['monitor'] = ['monitor'] +information_commands['tui'] = ['tui'] information_commands['get-suite-config'] = ['get-suite-config', 'get-config'] information_commands['get-site-config'] = [ 'get-site-config', 'get-global-config'] @@ -337,7 +337,7 @@ def category_help(category): comsum['show'] = 'Print task state (prerequisites and outputs etc.)' comsum['cat-log'] = 'Print various suite and task log files' comsum['extract-resources'] = 'Extract cylc.flow library package resources' -comsum['monitor'] = 'An in-terminal suite monitor' +comsum['tui'] = 'A terminal user interface for suites.' comsum['get-suite-config'] = 'Print suite configuration items' comsum['get-site-config'] = 'Print site/user configuration items' comsum['get-host-metrics'] = 'Print localhost metric data' diff --git a/cylc/flow/scripts/cylc_monitor.py b/cylc/flow/scripts/cylc_monitor.py deleted file mode 100755 index 6233c579c1c..00000000000 --- a/cylc/flow/scripts/cylc_monitor.py +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env python3 -# THIS FILE IS PART OF THE CYLC SUITE ENGINE. -# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -""" -Display the state of live task proxies in a running suite. - -For color terminal ASCII escape codes, see -http://ascii-table.com/ansi-escape-sequences.php -""" -import sys - -if "--use-ssh" in sys.argv[1:]: - # requires local terminal - sys.exit("No '--use-ssh': this command requires a local terminal.") - -import os -import re -from time import sleep, time - -from cylc.flow.parsec.OrderedDict import OrderedDict - -from cylc.flow.exceptions import ClientError -from cylc.flow.option_parsers import CylcOptionParser as COP -from cylc.flow.network.client import SuiteRuntimeClient -from cylc.flow.cfgspec.glbl_cfg import glbl_cfg -from cylc.flow.task_state import ( - TASK_STATUS_RUNAHEAD, - TASK_STATUSES_ORDERED, - TASK_STATUSES_RESTRICTED, -) -from cylc.flow.task_state_prop import get_status_prop -from cylc.flow.terminal import cli_function -from cylc.flow.wallclock import get_time_string_from_unix_time - - -SUITE_STATUS_SPLIT_REC = re.compile("^([a-z ]+ at )(.*)$") -# NOTE: would use unicode here but terminal rendering has a lot to be desired -HELD_SYMBOL = "(held)" - - -class SuiteMonitor(object): - def reset(self, suite, owner, host, port, timeout): - self.pclient = SuiteRuntimeClient(suite, owner, host, port, timeout) - - def run(self, options, *args): - suite = args[0] - - if len(args) > 1: - try: - user_at_host, options.port = args[1].split(":") - options.owner, options.host = user_at_host.split("@") - except ValueError: - print( - ("USER_AT_HOST must take the form " '"user@host:port"'), - file=sys.stderr, - ) - sys.exit(1) - - client_name = os.path.basename(sys.argv[0]) - if options.restricted: - client_name += " -r" - - legend = "" - for state in TASK_STATUSES_ORDERED: - legend += get_status_prop(state, "ascii_ctrl") - legend += f"{HELD_SYMBOL}" - legend = legend.rstrip() - - len_header = sum(len(s) for s in TASK_STATUSES_ORDERED) - - self.reset( - suite, - options.owner, - options.host, - options.port, - options.comms_timeout, - ) - - is_cont = False - while True: - if is_cont: - if options.once: - break - else: - sleep(float(options.update_interval)) - is_cont = True - try: - glbl, task_summaries = self.pclient("get_suite_state_summary")[ - 0:2 - ] - except ClientError as exc: - print("\033[1;37;41mERROR\033[0m", str(exc), file=sys.stderr) - self.reset( - suite, - options.owner, - options.host, - options.port, - options.comms_timeout, - ) - else: - if not glbl: - print( - ("\033[1;37;41mWARNING\033[0m suite initialising"), - file=sys.stderr, - ) - continue - states = [ - t["state"] - for t in task_summaries.values() - if ("state" in t) - ] - n_tasks_total = len(states) - if options.restricted: - task_summaries = dict( - (i, j) - for i, j in task_summaries.items() - if (j["state"] in TASK_STATUSES_RESTRICTED) - ) - if not options.display_runahead: - task_summaries = dict( - (i, j) - for i, j in task_summaries.items() - if (j["state"] != TASK_STATUS_RUNAHEAD) - ) - try: - updated_at = get_time_string_from_unix_time( - glbl["last_updated"] - ) - except KeyError: - updated_at = time() - except (TypeError, ValueError): - # Older suite. - updated_at = glbl["last_updated"].isoformat() - - run_mode = glbl["run_mode"] - ns_defn_order = glbl["namespace definition order"] - status_string = glbl["status_string"] - - task_info = {} - name_list = set() - task_ids = list(task_summaries) - for task_id in task_ids: - name = task_summaries[task_id]["name"] - point_string = task_summaries[task_id]["label"] - state = task_summaries[task_id]["state"] - is_held = task_summaries[task_id]["is_held"] - name_list.add(name) - if is_held: - text = f"{HELD_SYMBOL}{name}" - else: - text = name - if point_string not in task_info: - task_info[point_string] = {} - task_info[point_string][name] = get_status_prop( - state, "ascii_ctrl", subst=text - ) - - # Sort the tasks in each cycle point. - if options.sort_order == "alphanumeric": - sorted_name_list = sorted(name_list) - else: - sorted_name_list = ns_defn_order - - sorted_task_info = {} - for point_str, info in task_info.items(): - sorted_task_info[point_str] = OrderedDict() - for name in sorted_name_list: - if name in name_list: - # (Defn order includes family names.). - sorted_task_info[point_str][name] = info.get(name) - - # Construct lines to blit to the screen. - blit = [] - - suite_name = suite - if run_mode != "live": - suite_name += " (%s)" % run_mode - prefix = "%s - %d tasks" % (suite_name, int(n_tasks_total)) - suffix = "%s" % client_name - title_str = " " * len_header - title_str = prefix + title_str[len(prefix):] - title_str = "\033[1;37;44m%s%s\033[0m" % ( - title_str[: -len(suffix)], - suffix, - ) - blit.append(title_str) - blit.append(legend) - - updated_str = "updated: \033[1;38m%s\033[0m" % updated_at - blit.append(updated_str) - summary = "state summary:" - state_totals = glbl["state totals"] - for state, tot in state_totals.items(): - subst = " %d " % tot - summary += get_status_prop(state, "ascii_ctrl", subst) - blit.append(summary) - - # Print a divider line containing the suite status string. - try: - status1, status2 = ( - SUITE_STATUS_SPLIT_REC.match(status_string) - ).groups() - except AttributeError: - status1 = status_string - status2 = "" - suffix = "_".join(list(status1.replace(" ", "_"))) + status2 - divider_str = "_" * len_header - divider_str = "\033[1;31m%s%s\033[0m" % ( - divider_str[: -len(suffix)], - suffix, - ) - blit.append(divider_str) - - blitlines = {} - for point_str, val in sorted_task_info.items(): - indx = point_str - line = "\033[1;34m%s\033[0m" % point_str - for name, info in val.items(): - if info is not None: - line += " %s" % info - elif options.align_columns: - line += " %s" % (" " * len(name)) - blitlines[indx] = line - - if not options.once: - os.system("clear") - print("\n".join(blit)) - indxs = list(blitlines) - try: - int(indxs[1]) - except (ValueError, IndexError): - indxs.sort() - else: - indxs.sort(key=int) - for ix in indxs: - print(blitlines[ix]) - - -def parse_args(): - parser = COP( - """cylc [info] monitor [OPTIONS] ARGS - -A terminal-based live suite monitor. Exit with 'Ctrl-C'. - -The USER_AT_HOST argument allows suite selection by 'cylc scan' output: -cylc monitor $(cylc scan | grep ) -""", - argdoc=[ - ("REG", "Suite name"), - ( - "[USER_AT_HOST]", - "user@host:port, shorthand for --user, " "--host & --port.", - ), - ], - comms=True, - noforce=True, - color=True, - ) - - parser.add_option( - "-a", - "--align", - help="Align task names. Only useful for small suites.", - action="store_true", - default=False, - dest="align_columns", - ) - - parser.add_option( - "-r", - "--restricted", - help="Restrict display to active task states. " - "This may be useful for monitoring very large suites. " - "The state summary line still reflects all task proxies.", - action="store_true", - default=False, - dest="restricted", - ) - - def_sort_order = glbl_cfg().get(["monitor", "sort order"]) - - parser.add_option( - "-s", - "--sort", - metavar="ORDER", - help='Task sort order: "definition" or "alphanumeric".' - "The default is " + def_sort_order + " order, as determined by " - "global config. (Definition order is the order that tasks appear " - "under [runtime] in the suite definition).", - action="store", - default=def_sort_order, - dest="sort_order", - ) - - parser.add_option( - "-o", - "--once", - help="Show a single view then exit.", - action="store_true", - default=False, - dest="once", - ) - - parser.add_option( - "-u", - "--runahead", - help="Display task proxies in the runahead pool (off by default).", - action="store_true", - default=False, - dest="display_runahead", - ) - - parser.add_option( - "-i", - "--interval", - help="Interval between suite state retrievals, " - "in seconds (default 1).", - metavar="SECONDS", - action="store", - default=1, - dest="update_interval", - ) - - return parser - - -@cli_function(parse_args) -def main(_, options, *args): - try: - SuiteMonitor().run(options, *args) - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/cylc/flow/scripts/cylc_tui.py b/cylc/flow/scripts/cylc_tui.py new file mode 100644 index 00000000000..62c91920c9c --- /dev/null +++ b/cylc/flow/scripts/cylc_tui.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""cylc [task] tui REG + +Open the terminal user interface (TUI) for the given suite. +""" +from textwrap import indent + +from urwid import html_fragment + +from cylc.flow.option_parsers import CylcOptionParser as COP +from cylc.flow.terminal import cli_function +from cylc.flow.tui import ( + TUI +) +from cylc.flow.tui.app import ( + TuiApp, + TREE_EXPAND_DEPTH + # ^ a nasty solution +) + + +__doc__ += indent(TUI, ' ') + + +def get_option_parser(): + parser = COP( + __doc__, + argdoc=[ + ('REG', 'Suite name') + ], + # auto_add=False, NOTE: at present auto_add can not be turned off + color=False + ) + + parser.add_option( + '--display', + help=( + 'Specify the display technology to use.' + ' "raw" for interactive in-terminal display.' + ' "html" for non-interactive html output.' + ), + action='store', + choices=['raw', 'html'], + default='raw', + ) + parser.add_option( + '--v-term-size', + help=( + 'The virtual terminal size for non-interactive' + '--display options.' + ), + action='store', + default='80,24' + ) + + return parser + + +@cli_function(get_option_parser) +def main(_, options, reg): + screen = None + if options.display == 'html': + TREE_EXPAND_DEPTH[0] = -1 # expand tree fully + screen = html_fragment.HtmlGenerator() + screen.set_terminal_properties(256) + screen.register_palette(TuiApp.palette) + html_fragment.screenshot_init( + [tuple(map(int, options.v_term_size.split(',')))], + [] + ) + + try: + TuiApp(reg, screen=screen).main() + + if options.display == 'html': + for fragment in html_fragment.screenshot_collect(): + print(fragment) + except KeyboardInterrupt: + pass diff --git a/cylc/flow/task_state.py b/cylc/flow/task_state.py index 76382fcfb73..58de48313be 100644 --- a/cylc/flow/task_state.py +++ b/cylc/flow/task_state.py @@ -80,7 +80,7 @@ 'Job has returned zero exit code.' } -# Tasks statuses ordered according to task runtime progression. +# Task statuses ordered according to task runtime progression. TASK_STATUSES_ORDERED = [ TASK_STATUS_RUNAHEAD, TASK_STATUS_WAITING, @@ -96,6 +96,22 @@ TASK_STATUS_SUCCEEDED ] +# Task statuses ordered according to display importance +TASK_STATUS_DISPLAY_ORDER = [ + TASK_STATUS_SUBMIT_FAILED, + TASK_STATUS_FAILED, + TASK_STATUS_RUNNING, + TASK_STATUS_SUBMITTED, + TASK_STATUS_EXPIRED, + TASK_STATUS_READY, + TASK_STATUS_SUBMIT_RETRYING, + TASK_STATUS_RETRYING, + TASK_STATUS_SUCCEEDED, + TASK_STATUS_QUEUED, + TASK_STATUS_WAITING, + TASK_STATUS_RUNAHEAD +] + TASK_STATUSES_ALL = set(TASK_STATUSES_ORDERED) # Tasks statuses to show in restricted monitoring mode. diff --git a/cylc/flow/tests/tui/test_util.py b/cylc/flow/tests/tui/test_util.py new file mode 100644 index 00000000000..94873d93f73 --- /dev/null +++ b/cylc/flow/tests/tui/test_util.py @@ -0,0 +1,530 @@ +from datetime import ( + datetime, + timedelta +) +from unittest.mock import Mock + +import pytest + +from cylc.flow.tui.util import ( + JOB_ICON, + TASK_ICONS, + render_node, + compute_tree, + get_group_state, + get_task_icon +) +from cylc.flow.wallclock import ( + get_time_string, + get_current_time_string +) + + +def testrender_node__job_info(): + """It renders job information nodes.""" + assert render_node( + None, + {'a': 1, 'b': 2}, + 'job_info' + ) == [ + 'a 1\n', + 'b 2' + ] + + +def testrender_node__job(): + """It renders job nodes.""" + assert render_node( + None, + {'state': 'succeeded', 'submitNum': 1}, + 'job' + ) == [ + '#01 ', + [('job_succeeded', JOB_ICON)] + ] + + +def testrender_node__task__succeeded(): + """It renders tasks.""" + node = Mock() + node.get_child_node = lambda _: None + assert render_node( + node, + { + 'name': 'foo', + 'state': 'succeeded', + 'isHeld': False + }, + 'task' + ) == [ + TASK_ICONS['succeeded'], + ' ', + 'foo' + ] + + +def testrender_node__task__running(): + """It renders running tasks.""" + child = Mock() + child.get_value = lambda: {'data': { + 'startedTime': get_current_time_string(), + 'state': 'running' + }} + node = Mock() + node.get_child_node = lambda _: child + assert render_node( + node, + { + 'name': 'foo', + 'state': 'running', + 'isHeld': False, + 'task': {'meanElapsedTime': 100} + }, + 'task' + ) == [ + TASK_ICONS['running'], + ' ', + ('job_running', JOB_ICON), + ' ', + 'foo' + ] + + +def testrender_node__family(): + """It renders families.""" + assert render_node( + None, + {'state': 'succeeded', 'isHeld': False, 'id': 'myid'}, + 'family' + ) == [ + [TASK_ICONS['succeeded']], + ' ', + 'myid' + ] + + +def testrender_node__cycle_point(): + """It renders cycle points.""" + assert render_node( + None, + {'id': 'myid'}, + 'cycle_point' + ) == 'myid' + + +@pytest.mark.parametrize( + 'status,is_held,start_offset,mean_time,expected', + [ + # task states + ('waiting', False, None, None, ['○']), + ('submitted', False, None, None, ['⊙']), + ('running', False, None, None, ['⊙']), + ('succeeded', False, None, None, ['●']), + ('submit-failed', False, None, None, ['⊗']), + ('failed', False, None, None, ['⊗']), + # progress indicator + ('running', False, 0, 100, ['⊙']), + ('running', False, 25, 100, ['◔']), + ('running', False, 50, 100, ['◑']), + ('running', False, 75, 100, ['◕']), + ('running', False, 100, 100, ['◕']), + # is-held modifier + ('waiting', True, None, None, ['\u030E', '○']) + ] +) +def test_get_task_icon(status, is_held, start_offset, mean_time, expected): + """It renders task icons.""" + start_time = None + if start_offset is not None: + start_time = get_time_string( + datetime.utcnow() - timedelta(seconds=start_offset) + ) + assert ( + get_task_icon(status, is_held, start_time, mean_time) + ) == expected + + +@pytest.mark.parametrize( + 'nodes,expected', + [ + ( + [ + ('waiting', False), + ('running', False) + ], + ('running', False) + ), + ( + [ + ('waiting', False), + ('running', True) + ], + ('running', True) + ) + ] +) +def test_get_group_state(nodes, expected): + + def make_node(data): + node = Mock() + node.get_value = lambda: {'data': data} + return node + + nodes = [ + make_node( + {'state': state, 'isHeld': is_held} + ) + for state, is_held in nodes + ] + assert get_group_state(nodes) == expected + + +def test_compute_tree(): + """It computes a tree in the right structure for urwid. + + This is a pretty rough and ready test, describe cases to trigger all + branches in the method then record the result. + + """ + assert compute_tree({ + 'id': 'workflow id', + 'familyProxies': [ + { # root family node + 'name': 'root', + 'id': 'root.1', + 'cyclePoint': '1', + 'firstParent': None + }, + { # top level family + 'name': 'FOO', + 'id': 'FOO.1', + 'cyclePoint': '1', + 'firstParent': {'name': 'root', 'id': 'root.1'} + }, + { # nested family + 'name': 'FOOT', + 'id': 'FOOT.1', + 'cyclePoint': '1', + 'firstParent': {'name': 'FOO', 'id': 'FOO.1'} + }, + ], + 'taskProxies': [ + { # orphan task (belongs to no family) + 'name': 'baz', + 'id': 'baz.1', + 'parents': [], + 'cyclePoint': '1', + 'jobs': [] + }, + { # top level task + 'name': 'pub', + 'id': 'pub.1', + 'parents': [{'name': 'root', 'id': 'root.1'}], + 'cyclePoint': '1', + 'jobs': [] + }, + { # child task (belongs to family) + 'name': 'fan', + 'id': 'fan.1', + 'parents': [{'name': 'fan', 'id': 'fan.1'}], + 'cyclePoint': '1', + 'jobs': [] + }, + { # nested child task (belongs to incestuous family) + 'name': 'fool', + 'id': 'fool.1', + 'parents': [ + {'name': 'FOO', 'id': 'FOO.1'}, + {'name': 'FOOT', 'id': 'FOOT.1'} + ], + 'cyclePoint': '1', + 'jobs': [] + }, + { # a task which has jobs + 'name': 'worker', + 'id': 'worker.1', + 'parents': [], + 'cyclePoint': '1', + 'jobs': [ + {'id': 'job1', 'submitNum': '1'}, + {'id': 'job2', 'submitNum': '2'}, + {'id': 'job3', 'submitNum': '3'} + ] + } + ] + }) == { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [], + "id_": "job3_info", + "data": { + "id": "job3", + "submitNum": "3" + }, + "type_": "job_info" + } + ], + "id_": "job3", + "data": { + "id": "job3", + "submitNum": "3" + }, + "type_": "job" + }, + { + "children": [ + { + "children": [], + "id_": "job2_info", + "data": { + "id": "job2", + "submitNum": "2" + }, + "type_": "job_info" + } + ], + "id_": "job2", + "data": { + "id": "job2", + "submitNum": "2" + }, + "type_": "job" + }, + { + "children": [ + { + "children": [], + "id_": "job1_info", + "data": { + "id": "job1", + "submitNum": "1" + }, + "type_": "job_info" + } + ], + "id_": "job1", + "data": { + "id": "job1", + "submitNum": "1" + }, + "type_": "job" + } + ], + "id_": "worker.1", + "data": { + "name": "worker", + "id": "worker.1", + "parents": [], + "cyclePoint": "1", + "jobs": [ + { + "id": "job1", + "submitNum": "1" + }, + { + "id": "job2", + "submitNum": "2" + }, + { + "id": "job3", + "submitNum": "3" + } + ] + }, + "type_": "task" + }, + { + "children": [], + "id_": "pub.1", + "data": { + "name": "pub", + "id": "pub.1", + "parents": [ + { + "name": "root", + "id": "root.1" + } + ], + "cyclePoint": "1", + "jobs": [] + }, + "type_": "task" + }, + { + "children": [], + "id_": "baz.1", + "data": { + "name": "baz", + "id": "baz.1", + "parents": [], + "cyclePoint": "1", + "jobs": [] + }, + "type_": "task" + }, + { + "children": [ + { + "children": [], + "id_": "fool.1", + "data": { + "name": "fool", + "id": "fool.1", + "parents": [ + { + "name": "FOO", + "id": "FOO.1" + }, + { + "name": "FOOT", + "id": "FOOT.1" + } + ], + "cyclePoint": "1", + "jobs": [] + }, + "type_": "task" + }, + { + "children": [], + "id_": "FOOT.1", + "data": { + "name": "FOOT", + "id": "FOOT.1", + "cyclePoint": "1", + "firstParent": { + "name": "FOO", + "id": "FOO.1" + } + }, + "type_": "family" + } + ], + "id_": "FOO.1", + "data": { + "name": "FOO", + "id": "FOO.1", + "cyclePoint": "1", + "firstParent": { + "name": "root", + "id": "root.1" + } + }, + "type_": "family" + } + ], + "id_": "1", + "data": { + "name": "1", + "id": "workflow id|1" + }, + "type_": "cycle" + } + ], + "id_": "workflow id", + "data": { + "id": "workflow id", + "familyProxies": [ + { + "name": "root", + "id": "root.1", + "cyclePoint": "1", + "firstParent": None + }, + { + "name": "FOO", + "id": "FOO.1", + "cyclePoint": "1", + "firstParent": { + "name": "root", + "id": "root.1" + } + }, + { + "name": "FOOT", + "id": "FOOT.1", + "cyclePoint": "1", + "firstParent": { + "name": "FOO", + "id": "FOO.1" + } + } + ], + "taskProxies": [ + { + "name": "baz", + "id": "baz.1", + "parents": [], + "cyclePoint": "1", + "jobs": [] + }, + { + "name": "pub", + "id": "pub.1", + "parents": [ + { + "name": "root", + "id": "root.1" + } + ], + "cyclePoint": "1", + "jobs": [] + }, + { + "name": "fan", + "id": "fan.1", + "parents": [ + { + "name": "fan", + "id": "fan.1" + } + ], + "cyclePoint": "1", + "jobs": [] + }, + { + "name": "fool", + "id": "fool.1", + "parents": [ + { + "name": "FOO", + "id": "FOO.1" + }, + { + "name": "FOOT", + "id": "FOOT.1" + } + ], + "cyclePoint": "1", + "jobs": [] + }, + { + "name": "worker", + "id": "worker.1", + "parents": [], + "cyclePoint": "1", + "jobs": [ + { + "id": "job1", + "submitNum": "1" + }, + { + "id": "job2", + "submitNum": "2" + }, + { + "id": "job3", + "submitNum": "3" + } + ] + } + ] + }, + "type_": "workflow" + } diff --git a/cylc/flow/tui/__init__.py b/cylc/flow/tui/__init__.py new file mode 100644 index 00000000000..f5a8f8565fc --- /dev/null +++ b/cylc/flow/tui/__init__.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""The cylc terminal user interface (Tui).""" + +TUI = """ + _,@@@@@@. + <=@@@, `@@@@@. + `-@@@@@@@@@@@' + :@@@@@@@@@@. + (.@@@@@@@@@@@ + ( '@@@@@@@@@@@@. + ;.@@@@@@@@@@@@@@@ + '@@@@@@@@@@@@@@@@@@, + ,@@@@@@@@@@@@@@@@@@@@' + :.@@@@@@@@@@@@@@@@@@@@@. + .@@@@@@@@@@@@@@@@@@@@@@@@. + '@@@@@@@@@@@@@@@@@@@@@@@@@. + ;@@@@@@@@@@@@@@@@@@@@@@@@@@@ + .@@@@@@@@@@@@@@@@@@@@@@@@@@. + .@@@@@@@@@@@@@@@@@@@@@@@@@@, + .@@@@@@@@@@@@@@@@@@@@@@@@@' + .@@@@@@@@@@@@@@@@@@@@@@@@' , + :@@@@@@@@@@@@@@@@@@@@@..''';,,,;::- + '@@@@@@@@@@@@@@@@@@@. `. ` + .@@@@@@.: ,.@@@@@@@. ` + :@@@@@@@, ;.@, + '@@@@@@. `@' + .@@@@@@; ;-, + ;@@@@@@. ..., +,,; ,;; ; ; ; +""" + +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, + TASK_STATUS_DISPLAY_ORDER, + TASK_STATUS_RUNAHEAD, + TASK_STATUS_WAITING, + TASK_STATUS_QUEUED, + TASK_STATUS_EXPIRED, + TASK_STATUS_READY, + TASK_STATUS_SUBMIT_FAILED, + TASK_STATUS_SUBMIT_RETRYING, + TASK_STATUS_SUBMITTED, + TASK_STATUS_RETRYING, + TASK_STATUS_RUNNING, + TASK_STATUS_FAILED, + TASK_STATUS_SUCCEEDED +) + +# default foreground and background colours +# NOTE: set to default to allow user defined terminal theming +FORE = 'default' +BACK = 'default' + +# suite state colour +SUITE_COLOURS = { + 'running': ('light blue', BACK), + 'held': ('brown', BACK), + 'stopping': ('light magenta', BACK), + 'stopped': ('light red', BACK), + 'error': ('light red', BACK, 'bold') +} + +# unicode task icons +TASK_ICONS = { + f'{TASK_STATUS_WAITING}': '\u25cb', + + # TODO: remove with https://github.com/cylc/cylc-admin/pull/47 + f'{TASK_STATUS_READY}': '\u25cb', + f'{TASK_STATUS_QUEUED}': '\u25cb', + f'{TASK_STATUS_RETRYING}': '\u25cb', + f'{TASK_STATUS_SUBMIT_RETRYING}': '\u25cb', + # TODO: remove with https://github.com/cylc/cylc-admin/pull/47 + + f'{TASK_STATUS_SUBMITTED}': '\u2299', + f'{TASK_STATUS_RUNNING}': '\u2299', + f'{TASK_STATUS_RUNNING}:0': '\u2299', + f'{TASK_STATUS_RUNNING}:25': '\u25D4', + f'{TASK_STATUS_RUNNING}:50': '\u25D1', + f'{TASK_STATUS_RUNNING}:75': '\u25D5', + f'{TASK_STATUS_SUCCEEDED}': '\u25CF', + f'{TASK_STATUS_EXPIRED}': '\u25CF', + f'{TASK_STATUS_SUBMIT_FAILED}': '\u2297', + f'{TASK_STATUS_FAILED}': '\u2297' +} + +# unicode modifiers for special task states +TASK_MODIFIERS = { + 'held': '\u030E' +} + +# unicode job icon +JOB_ICON = '\u25A0' + +# job colour coding +JOB_COLOURS = { + 'submitted': 'dark cyan', + 'running': 'light blue', + 'succeeded': 'dark green', + 'failed': 'light red', + 'submit-failed': 'light magenta', + + # TODO: update with https://github.com/cylc/cylc-admin/pull/47 + 'ready': 'brown' + # TODO: update with https://github.com/cylc/cylc-admin/pull/47 +} + + +class Bindings: + + def __init__(self): + self.bindings = [] + self.groups = {} + + def bind(self, keys, group, desc, callback): + if group not in self.groups: + raise ValueError(f'Group {group} not registered.') + binding = { + 'keys': keys, + 'group': group, + 'desc': desc, + 'callback': callback + } + self.bindings.append(binding) + self.groups[group]['bindings'].append(binding) + + def add_group(self, group, desc): + self.groups[group] = { + 'name': group, + 'desc': desc, + 'bindings': [] + } + + def __iter__(self): + return iter(self.bindings) + + def list_groups(self): + for name, group in self.groups.items(): + yield ( + group, + [ + binding + for binding in self.bindings + if binding['group'] == name + ] + ) + + +BINDINGS = Bindings() diff --git a/cylc/flow/tui/app.py b/cylc/flow/tui/app.py new file mode 100644 index 00000000000..06576145d67 --- /dev/null +++ b/cylc/flow/tui/app.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""The application control logic for Tui.""" + +import sys + +import urwid +from urwid import html_fragment +from urwid.wimp import SelectableIcon + +from cylc.flow.network.client import SuiteRuntimeClient +from cylc.flow.exceptions import ( + ClientError, + ClientTimeout, + SuiteStopped +) +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, + TASK_STATUS_RUNAHEAD, + TASK_STATUS_WAITING, + TASK_STATUS_SUBMITTED, + TASK_STATUS_RUNNING, + TASK_STATUS_FAILED, +) +import cylc.flow.tui.overlay as overlay +from cylc.flow.tui import ( + BINDINGS, + FORE, + BACK, + JOB_COLOURS, + SUITE_COLOURS, +) +from cylc.flow.tui.tree import ( + find_closest_focus, + translate_collapsing +) +from cylc.flow.tui.util import ( + compute_tree, + dummy_flow, + # get_group_state, + get_job_icon, + get_task_icon, + get_task_status_summary, + get_workflow_status_str, + render_node +) + + +urwid.set_encoding('utf8') # required for unicode task icons + +TREE_EXPAND_DEPTH = [2] + +QUERY = ''' + query cli($taskStates: [String]){ + workflows { + id + name + status + stateTotals + taskProxies(states: $taskStates) { + id + name + cyclePoint + state + isHeld + parents { + id + name + } + jobs { + id + submitNum + state + host + batchSysName + batchSysJobId + startedTime + } + task { + meanElapsedTime + } + } + familyProxies(states: $taskStates) { + id + name + cyclePoint + state + isHeld + firstParent { + id + name + } + } + } + } +''' + + +class TuiWidget(urwid.TreeWidget): + """Display widget for tree nodes. + + Arguments: + node (TuiNode): + The root tree node. + max_depth (int): + Determines which nodes are unfolded by default. + The maximum tree depth to unfold. + + """ + + # allows leaf nodes to be selectable, otherwise the cursor + # will skip rows when the user naviages + unexpandable_icon = SelectableIcon(' ', 0) + + def __init__(self, node, max_depth=None): + if not max_depth: + max_depth = TREE_EXPAND_DEPTH[0] + self._node = node + self._innerwidget = None + self.is_leaf = not node.get_child_keys() + if max_depth > 0: + self.expanded = node.get_depth() < max_depth + else: + self.expanded = True + widget = self.get_indented_widget() + urwid.WidgetWrap.__init__(self, widget) + + def selectable(self): + """Return True if this node is selectable. + + Allow all nodes to be selectable apart from job information nodes. + + """ + return self.get_node().get_value()['type_'] != 'job_info' + + def _is_leaf(self): + """Return True if this node has no children + + Note: the `is_leaf` attribute doesn't seem to give the right + answer. + + """ + return ( + not hasattr(self, 'git_first_child') + or not self.get_first_child() + ) + + def get_display_text(self): + """Compute the text to display for a given node. + + Returns: + (object) - Text content for the urwid.Text widget, + may be a string, tuple or list, see urwid docs. + + """ + node = self.get_node() + value = node.get_value() + data = value['data'] + type_ = value['type_'] + return render_node(node, data, type_) + + def keypress(self, size, key): + """Handle expand & collapse requests. + + Overridden from urwid.TreeWidget to change the behaviour + of the left arrow key which urwid uses for navigation + but which we think should be used for collapsing. + + """ + ret = self.__super.keypress(size, key) + if ret in ('left',): + self.expanded = False + self.update_expanded_icon() + # return None so that this keypress is not allowed to + # propagate up the tree + return + return key + + def get_indented_widget(self): + if self.is_leaf: + + self._innerwidget = urwid.Columns( + [ + ('fixed', 1, self.unexpandable_icon), + self.get_inner_widget() + ], + dividechars=1 + ) + return self.__super.get_indented_widget() + + +class TuiNode(urwid.TreeNode): + """Data storage object for leaf nodes.""" + + def load_widget(self): + return TuiWidget(self) + + +class TuiParentNode(urwid.ParentNode): + """Data storage object for interior/parent nodes.""" + + def load_widget(self): + return TuiWidget(self) + + def load_child_keys(self): + # Note: keys are really indices. + data = self.get_value() + return range(len(data['children'])) + + def load_child_node(self, key): + """Return either an TuiNode or TuiParentNode""" + childdata = self.get_value()['children'][key] + if 'children' in childdata: + childclass = TuiParentNode + else: + childclass = TuiNode + return childclass( + childdata, + parent=self, + key=key, + depth=self.get_depth() + 1 + ) + + +class TuiApp: + """An application to display a single Cylc workflow. + + This is a single workflow view component (purposefully). + + Multi-suite functionality can be achieved via a GScan-esque + tab/selection panel. + + Arguments: + reg (str): + Suite registration + + """ + + UPDATE_INTERVAL = 1 + CLIENT_TIMEOUT = 1 + + palette = [ + ('head', FORE, BACK), + ('body', FORE, BACK), + ('foot', 'white', 'dark blue'), + ('key', 'light cyan', 'dark blue'), + ('title', FORE, BACK, 'bold'), + ('overlay', 'black', 'light gray'), + ] + [ # job colours + (f'job_{status}', colour, BACK) + for status, colour in JOB_COLOURS.items() + ] + [ # job colours for help screen + (f'overlay_job_{status}', colour, 'light gray') + for status, colour in JOB_COLOURS.items() + ] + [ # suite state colours + (f'suite_{status}',) + spec + for status, spec in SUITE_COLOURS.items() + ] + + def __init__(self, reg, screen=None): + self.reg = reg + self.client = None + self.loop = None + self.screen = None + self.stack = 0 + + # create the template + topnode = TuiParentNode(dummy_flow({'id': 'Loading...'})) + self.listbox = urwid.TreeListBox(urwid.TreeWalker(topnode)) + header = urwid.Text('\n') + footer = urwid.AttrWrap( + # urwid.Text(self.FOOTER_TEXT), + urwid.Text(list_bindings()), + 'foot' + ) + self.view = urwid.Frame( + urwid.AttrWrap(self.listbox, 'body'), + header=urwid.AttrWrap(header, 'head'), + footer=footer + ) + self.filter_states = { + state: True + for state in TASK_STATUSES_ORDERED + if state is not TASK_STATUS_RUNAHEAD + } + if isinstance(screen, html_fragment.HtmlGenerator): + # the HtmlGenerator only captures one frame + # so we need to pre-populate the GUI before + # starting the event loop + self.update() + + def main(self): + """Start the event loop.""" + self.loop = urwid.MainLoop( + self.view, + self.palette, + unhandled_input=self.unhandled_input, + screen=self.screen + ) + # schedule the first update + self.loop.set_alarm_in(0, self._update) + self.loop.run() + + def unhandled_input(self, key): + """Catch key presses, uncaught events are passed down the chain.""" + if key in ('ctrl d',): + raise urwid.ExitMainLoop() + for binding in BINDINGS: + # iterate through key bindings in order + if key in binding['keys'] and binding['callback']: + # if we get a match execute the callback + # NOTE: if there is no callback then this binding is + # for documentation purposes only so we ignore it + meth, *args = binding['callback'] + meth(self, *args) + return + + def get_snapshot(self): + """Contact the workflow, return a tree structure + + In the event of error contacting the suite the + message is written to this Widget's header. + + Returns: + dict if successful, else False + + """ + try: + if not self.client: + self.client = SuiteRuntimeClient( + self.reg, + timeout=self.CLIENT_TIMEOUT + ) + data = self.client( + 'graphql', + { + 'request_string': QUERY, + 'variables': { + # list of task states we want to see + 'taskStates': [ + state + for state, is_on in self.filter_states.items() + if is_on + ] + } + } + ) + except SuiteStopped as exc: + self.client = None + return dummy_flow({ + 'name': self.reg, + 'id': self.reg, + 'status': 'stopped', + 'stateTotals': {} + }) + except (ClientError, ClientTimeout) as exc: + # catch network / client errors + self.set_header(('suite_error', str(exc))) + return False + + if isinstance(data, list): + # catch GraphQL errors + try: + message = data[0]['error']['message'] + except (IndexError, KeyError): + message = str(data) + self.set_header(('suite_error', message)) + return False + + if len(data['workflows']) != 1: + # multiple workflows in returned data - shouldn't happen + raise ValueError() + + return compute_tree(data['workflows'][0]) + + @staticmethod + def get_node_id(node): + """Return a unique identifier for a node. + + Arguments: + node (TuiNode): The node. + + Returns: + str - Unique identifier + + """ + return node.get_value()['id_'] + + def set_header(self, message): + """Set the header message for this widget. + + Arguments: + message (object): + Text content for the urwid.Text widget, + may be a string, tuple or list, see urwid docs. + + """ + # put in a one line gap + if isinstance(message, list): + message.append('\n') + elif isinstance(message, tuple): + message = (message[0], message[1] + '\n') + else: + message += '\n' + self.view.header = urwid.Text(message) + + def _update(self, *_): + try: + self.update() + except Exception as exc: + sys.exit(exc) + + def update(self): + """Refresh the data and redraw this widget. + + Preserves the current focus and collapse/expand state. + + """ + # update the data store + # TODO: this can be done incrementally using deltas + # once this interface is available + snapshot = self.get_snapshot() + if snapshot is False: + return False + data = snapshot['data'] + + # update the suite status message + header = [get_workflow_status_str(data)] + status_summary = get_task_status_summary(snapshot['data']) + if status_summary: + header.extend([' ('] + status_summary + [' )']) + if not all(self.filter_states.values()): + header.extend([' ', '*fitered* "R" to reset', ' ']) + self.set_header(header) + + # global update - the nuclear option - slow but simple + # TODO: this can be done incrementally by adding and + # removing nodes from the existing tree + topnode = TuiParentNode(snapshot) + + # NOTE: because we are nuking the tree we need to manually + # preserve the focus and collapse status of tree nodes + + # record the old focus + _, old_node = self.listbox._body.get_focus() + + # nuke the tree + self.listbox._set_body(urwid.TreeWalker(topnode)) + + # get the new focus + _, new_node = self.listbox._body.get_focus() + + # preserve the focus or walk to the nearest parent + closest_focus = find_closest_focus(self, old_node, new_node) + self.listbox._body.set_focus(closest_focus) + + # preserve the collapse/expand status of all nodes + translate_collapsing(self, old_node, new_node) + + # schedule the next run of this update method + if self.loop: + self.loop.set_alarm_in(self.UPDATE_INTERVAL, self._update) + + return True + + def filter_by_task_state(self, filtered_state=None): + """Filter tasks. + + Args: + filtered_state (str): + A task state to filter by or None. + + """ + self.filter_states = { + state: (state == filtered_state) or not filtered_state + for state in self.filter_states + } + return + + def open_overlay(self, fcn): + self.create_overlay(*fcn(self)) + + def create_overlay(self, widget, kwargs): + """Open an overlay over the monitor. + + Args: + widget (urwid.Widget): + Widget to be placed inside the overlay. + kwargs (dict): + Dictionary of arguments to pass to the `urwid.Overlay` + constructor. + + You will likely need to set `width` and `height` here. + + See `urwid` docs for details. + + """ + kwargs = {'width': 'pack', 'height': 'pack', **kwargs} + overlay = urwid.Overlay( + urwid.LineBox( + urwid.AttrMap( + urwid.Frame( + urwid.Padding( + widget, + left=2, + right=2 + ), + footer=urwid.Text('\n q to close') + ), + 'overlay', + ) + ), + self.loop.widget, + align='center', + valign='middle', + left=self.stack * 5, + top=self.stack * 5, + **kwargs, + ) + self.loop.widget = overlay + self.stack += 1 + + def close_topmost(self): + """Remove the topmost frame or uit the app if none present.""" + if self.stack > 0: + self.loop.widget = self.loop.widget[0] + self.stack -= 1 + else: + raise urwid.ExitMainLoop() + + +BINDINGS.add_group( + '', + 'Application Controls' +) +BINDINGS.bind( + ('q',), + '', + 'Quit', + (TuiApp.close_topmost,) +) +BINDINGS.bind( + ('h',), + '', + 'Help', + (TuiApp.open_overlay, overlay.help_info) +) + +BINDINGS.add_group( + 'tree', + 'Expand/Collapse nodes', +) +BINDINGS.bind( + ('-', '\u2190'), + 'tree', + 'Collapse', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('+', '\u2192'), + 'tree', + 'Expand', + None # this binding is for documentation only - handled by urwid +) + +BINDINGS.add_group( + 'navigation', + 'Move within the tree' +) +BINDINGS.bind( + ('\u2191',), + 'navigation', + 'Up', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('\u2193',), + 'navigation', + 'Down', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('\u21a5',), + 'navigation', + 'PageUp', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('\u21a7',), + 'navigation', + 'PageDown', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('Home',), + 'navigation', + 'Top', + None # this binding is for documentation only - handled by urwid +) +BINDINGS.bind( + ('End',), + 'navigation', + 'Bottom', + None # this binding is for documentation only - handled by urwid +) + +BINDINGS.add_group( + 'filter', + 'Filter by task state' +) +BINDINGS.bind( + ('F',), + 'filter', + 'Select task states to filter by', + (TuiApp.open_overlay, overlay.filter_task_state) +) +BINDINGS.bind( + ('f',), + 'filter', + 'Show only failed tasks', + (TuiApp.filter_by_task_state, TASK_STATUS_FAILED) +) +BINDINGS.bind( + ('s',), + 'filter', + 'Show only submitted tasks', + (TuiApp.filter_by_task_state, TASK_STATUS_SUBMITTED) +) +BINDINGS.bind( + ('r',), + 'filter', + 'Show only running tasks', + (TuiApp.filter_by_task_state, TASK_STATUS_RUNNING) +) +BINDINGS.bind( + ('R',), + 'filter', + 'Reset task state filtering', + (TuiApp.filter_by_task_state,) +) + + +def list_bindings(): + """Write out an in-line list of the key bindings.""" + ret = [] + for group, bindings in BINDINGS.list_groups(): + if group['name']: + ret.append(f' {group["name"]}: ') + for binding in bindings: + for key in binding['keys']: + ret.append(('key', f'{key} ')) + else: + # list each option in the default group individually + for binding in bindings: + ret.append(f'{binding["desc"].lower()}: ') + ret.append(('key', binding["keys"][0])) + ret.append(' ') + ret.append(' ') + ret.pop() # remove surplus space + return ret diff --git a/cylc/flow/tui/overlay.py b/cylc/flow/tui/overlay.py new file mode 100644 index 00000000000..efb2f66ee6d --- /dev/null +++ b/cylc/flow/tui/overlay.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Overlay panels for Tui. + +Panels are functions:: + + function(app) -> (widget, overlay_options) + +Parameters: + + app: + Tui application object. + widget (urwid.Widget): + A widget to place in the overlay. + overlay_optios (dict): + A dictionary of keyword argumnts to provide to the + urwid.Overlay constructor. + + You will likely want to override the `width` and `height` + arguments. + + See the `urwid` documentation for details. + +""" + +from functools import partial + +import urwid + +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, + TASK_STATUS_RUNAHEAD, + TASK_STATUS_WAITING +) +from cylc.flow.tui import ( + BINDINGS, + JOB_COLOURS, + JOB_ICON, + TUI +) +from cylc.flow.tui.util import ( + get_task_icon +) + + +def filter_task_state(app): + """Return a widget for adjusting the task state filter.""" + + def toggle(state, *_): + """Toggle a filter state.""" + app.filter_states[state] = not app.filter_states[state] + + checkboxes = [ + urwid.CheckBox( + get_task_icon(state, False) + + [' ' + state], + state=is_on, + on_state_change=partial(toggle, state) + ) + for state, is_on in app.filter_states.items() + ] + + def invert(*_): + """Invert the state of all filters.""" + for checkbox in checkboxes: + checkbox.set_state(not checkbox.state) + + widget = urwid.ListBox( + urwid.SimpleFocusListWalker([ + urwid.Text('Filter Task States'), + urwid.Divider(), + urwid.Padding( + urwid.Button( + 'Invert', + on_press=invert + ), + right=19 + ) + ] + checkboxes) + ) + + return ( + widget, + {'width': 35, 'height': 23} + ) + + +def help_info(app): + """Return a widget displaying help information.""" + # system title + items = [ + urwid.Text(r''' + _ _ _ + | | | | (_) + ___ _ _| | ___ | |_ _ _ _ + / __| | | | |/ __| | __| | | | | + | (__| |_| | | (__ | |_| |_| | | + \___|\__, |_|\___| \__|\__,_|_| + __/ | + |___/ + + ( scroll using arrow keys ) + + '''), + urwid.Text(TUI) + ] + + # list key bindings + for group, bindings in BINDINGS.list_groups(): + items.append( + urwid.Text([ + f'{group["desc"]}:' + ]) + ) + for binding in bindings: + keystr = ' '.join(binding['keys']) + items.append( + urwid.Text([ + ('key', keystr), + (' ' * (10 - len(keystr))), + binding['desc'] + ]) + ) + items.append( + urwid.Divider() + ) + + # mouse interaction + items.extend([ + urwid.Text('Shift+Click to select text'), + urwid.Divider() + ]) + + # list task states + items.append(urwid.Divider()) + items.append(urwid.Text('Task Icons:')) + for state in TASK_STATUSES_ORDERED: + if state == TASK_STATUS_RUNAHEAD: + continue + items.append( + urwid.Text( + get_task_icon(state, is_held=False) + + [' ', state] + ) + ) + items.append(urwid.Divider()) + items.append(urwid.Text('Special States:')) + items.append( + urwid.Text( + get_task_icon(TASK_STATUS_WAITING, is_held=True) + + [' ', 'held'] + ) + ) + + # list job states + items.append(urwid.Divider()) + items.append(urwid.Text('Job Icons:')) + for state in JOB_COLOURS: + items.append( + urwid.Text( + [ + (f'overlay_job_{state}', JOB_ICON), + ' ', + state + ] + ) + ) + + widget = urwid.ListBox( + urwid.SimpleFocusListWalker(items) + ) + + return ( + widget, + {'width': 60, 'height': 40} + ) diff --git a/cylc/flow/tui/tree.py b/cylc/flow/tui/tree.py new file mode 100644 index 00000000000..e033173a6c4 --- /dev/null +++ b/cylc/flow/tui/tree.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Tree utilities for Tui.""" + + +def find_closest_focus(app, old_node, new_node): + """Return the position of the old node in the new tree. + + 1. Attempts to find the old node in the new tree. + 2. Otherwise it walks up the old tree until it + finds a node which is present in the new tree. + 3. Otherwise it returns the root node of the new tree. + + Arguments: + old_node (MonitiorNode): + The in-focus node from the deceased tree. + new_node (MonitorNode): + The root node from the new tree. + + Returns + MonitorNode - The closest node. + + """ + old_key = app.get_node_id(old_node) + + for node in walk_tree(new_node): + if old_key == app.get_node_id(node): + # (1) + return node + + if not old_node._parent: + # (3) reset focus + return new_node + + # (2) + return find_closest_focus( + app, + old_node._parent, + new_node + ) + + +def translate_collapsing(app, old_node, new_node): + """Transfer the collapse state from one tree to another. + + Arguments: + old_node (MonitorNode): + Any node in the tree you want to copy the + collapse/expand state from. + new_node (MonitorNode): + Any node in the tree you want to copy the + collapse/expand state to. + + """ + old_root = old_node.get_root() + new_root = new_node.get_root() + + old_tree = { + app.get_node_id(node): node.get_widget().expanded + for node in walk_tree(old_root) + } + + for node in walk_tree(new_root): + key = app.get_node_id(node) + if key in old_tree: + expanded = old_tree.get(key) + widget = node.get_widget() + if widget.expanded != expanded: + widget.expanded = expanded + widget.update_expanded_icon() + + +def walk_tree(node): + """Yield nodes in order. + + Arguments: + node (urwid.TreeNode): + Yield this node and all nodes beneath it. + + Yields: + urwid.TreeNode + + """ + stack = [node] + while stack: + node = stack.pop() + yield node + stack.extend([ + node.get_child_node(index) + for index in node.get_child_keys() + ]) diff --git a/cylc/flow/tui/util.py b/cylc/flow/tui/util.py new file mode 100644 index 00000000000..2e3db3baad6 --- /dev/null +++ b/cylc/flow/tui/util.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC SUITE ENGINE. +# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Common utilities for Tui.""" + +from time import time + +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, + TASK_STATUS_DISPLAY_ORDER, + TASK_STATUS_RUNAHEAD, + TASK_STATUS_WAITING, + TASK_STATUS_QUEUED, + TASK_STATUS_EXPIRED, + TASK_STATUS_READY, + TASK_STATUS_SUBMIT_FAILED, + TASK_STATUS_SUBMIT_RETRYING, + TASK_STATUS_SUBMITTED, + TASK_STATUS_RETRYING, + TASK_STATUS_RUNNING, + TASK_STATUS_FAILED, + TASK_STATUS_SUCCEEDED +) +from cylc.flow.tui import ( + JOB_COLOURS, + JOB_ICON, + TASK_ICONS, + TASK_MODIFIERS +) +from cylc.flow.wallclock import get_unix_time_from_time_string + + +def get_task_icon(status, is_held, start_time=None, mean_time=None): + """Return a Unicode string to represent a task. + + Arguments: + status (str): + A Cylc task status string. + is_held (bool): + True if the task is in a held state. + + Returns: + list - Text content for the urwid.Text widget, + may be a string, tuple or list, see urwid docs. + + """ + ret = [] + if is_held: + ret.append(TASK_MODIFIERS['held']) + if ( + status == TASK_STATUS_RUNNING + and start_time + and mean_time + ): + start_time = get_unix_time_from_time_string(start_time) + progress = (time() - start_time) / mean_time + if progress >= 0.75: + status = f'{TASK_STATUS_RUNNING}:75' + elif progress >= 0.5: + status = f'{TASK_STATUS_RUNNING}:50' + elif progress >= 0.25: + status = f'{TASK_STATUS_RUNNING}:25' + else: + status = f'{TASK_STATUS_RUNNING}:0' + ret.append(TASK_ICONS[status]) + return ret + + +def compute_tree(flow): + """Digest GraphQL data to produce a tree. + + Arguments: + flow (dict): + A dictionary representing a single workflow. + + Returns: + dict - A top-level workflow node. + + """ + nodes = {} + flow_node = add_node( + 'workflow', flow['id'], nodes, data=flow) + + # create nodes + for family in flow['familyProxies']: + if family['name'] != 'root': + family_node = add_node( + 'family', family['id'], nodes, data=family) + cycle_data = { + 'name': family['cyclePoint'], + 'id': f"{flow['id']}|{family['cyclePoint']}" + } + cycle_node = add_node( + 'cycle', family['cyclePoint'], nodes, data=cycle_data) + if cycle_node not in flow_node['children']: + flow_node['children'].append(cycle_node) + + # create cycle/family tree + for family in flow['familyProxies']: + if family['name'] != 'root': + family_node = add_node( + 'family', family['id'], nodes) + first_parent = family['firstParent'] + if ( + first_parent + and first_parent['name'] != 'root' + ): + parent_node = add_node( + 'family', first_parent['id'], nodes) + parent_node['children'].append(family_node) + else: + cycle_node = add_node( + 'cycle', family['cyclePoint'], nodes) + cycle_node['children'].append(family_node) + # add leaves + for task in flow['taskProxies']: + parents = task['parents'] + if not parents: + # handle inherit none by defaulting to root + parents = [{'name': 'root'}] + task_node = add_node( + 'task', task['id'], nodes, data=task) + if parents[0]['name'] == 'root': + family_node = add_node( + 'cycle', task['cyclePoint'], nodes) + else: + family_node = add_node( + 'family', parents[0]['id'], nodes) + family_node['children'].append(task_node) + for job in task['jobs']: + job_node = add_node( + 'job', job['id'], nodes, data=job) + job_info_node = add_node( + 'job_info', job['id'] + '_info', nodes, data=job) + job_node['children'] = [job_info_node] + task_node['children'].append(job_node) + + # sort + for (type_, _), node in nodes.items(): + if type_ == 'task': + node['children'].sort( + key=lambda x: x['data']['submitNum'], + reverse=True + ) + else: + node['children'].sort( + key=lambda x: x['id_'], + reverse=True + ) + + return flow_node + + +def dummy_flow(): + """Return a blank workflow node.""" + return add_node( + 'worflow', + '', + {}, + { + 'id': 'Loading...' + } + ) + + +def dummy_flow(data): + return add_node( + 'workflow', + '', + {}, + data + ) + + +def add_node(type_, id_, nodes, data=None): + """Create a node add it to the store and return it. + + Arguments: + type_ (str): + A string to represent the node type. + id_ (str): + A unique identifier for this node. + nodes (dict): + The node store to add the new node to. + data (dict): + An optional dictionary of data to add to this node. + Can be left to None if retrieving a node from the store. + + Returns: + dict - The requested node. + + """ + if (type_, id_) not in nodes: + nodes[(type_, id_)] = { + 'children': [], + 'id_': id_, + 'data': data or {}, + 'type_': type_ + } + return nodes[(type_, id_)] + + +def get_job_icon(status): + """Return a unicode string to represent a job. + + Arguments: + status (str): A Cylc job status string. + + Returns: + list - Text content for the urwid.Text widget, + may be a string, tuple or list, see urwid docs. + + """ + return [ + (f'job_{status}', JOB_ICON) + ] + + +def get_group_state(nodes): + """Return a task state to represent a collection of tasks. + + Arguments: + nodes (list): + List of urwid.TreeNode objects. + + Returns: + tuple - (status, is_held) + + status (str): A Cylc task status. + is_held (bool): True if the task is is a held state. + + Raises: + KeyError: + If any node does not have the key "state" in its + data. E.G. a nested family. + ValueError: + If no matching states are found. E.G. empty nodes + list. + + """ + states = [ + node.get_value()['data']['state'] + for node in nodes + ] + is_held = any(( + node.get_value()['data'].get('isHeld') + for node in nodes + )) + for state in TASK_STATUS_DISPLAY_ORDER: + if state in states: + return state, is_held + raise ValueError() + + +def get_task_status_summary(flow): + """Return a task status summary line for this workflow. + + Arguments: + flow (dict): + GraphQL JSON response for this workflow. + + Returns: + list - Text list for the urwid.Text widget. + + """ + state_totals = flow['stateTotals'] + return [ + [ + ('', ' '), + (f'job_{state}', str(state_totals[state])), + (f'job_{state}', JOB_ICON) + ] + for state, colour in JOB_COLOURS.items() + if state in state_totals + if state_totals[state] + ] + + +def get_workflow_status_str(flow): + """Return a suite status string for the header. + + Arguments: + flow (dict): + GraphQL JSON response for this workflow. + + Returns: + list - Text list for the urwid.Text widget. + + """ + status = flow['status'] + return [ + ( + 'title', + flow['name'], + ), + ' - ', + ( + f'suite_{status}', + status + ) + ] + + +def render_node(node, data, type_): + """Render a tree node as text. + + Args: + node (MonitorNode): + The node to render. + data (dict): + Data associated with that node. + type_ (str): + The node type (e.g. `task`, `job`, `family`). + + """ + if type_ == 'job_info': + key_len = max(len(key) for key in data) + ret = [ + f'{key} {" " * (key_len - len(key))} {value}\n' + for key, value in data.items() + ] + ret[-1] = ret[-1][:-1] # strip trailing newline + return ret + + if type_ == 'job': + return [ + f'#{data["submitNum"]:02d} ', + get_job_icon(data['state']) + ] + + if type_ == 'task': + start_time = None + mean_time = None + try: + # due to sorting this is the most recent job + first_child = node.get_child_node(0) + except IndexError: + first_child = None + + # progress information + if data['state'] == TASK_STATUS_RUNNING: + start_time = first_child.get_value()['data']['startedTime'] + mean_time = data['task']['meanElapsedTime'] + + # the task icon + ret = get_task_icon( + data['state'], + data['isHeld'], + start_time=start_time, + mean_time=mean_time + ) + + # the most recent job status + ret.append(' ') + if first_child: + state = first_child.get_value()['data']['state'] + ret += [(f'job_{state}', f'{JOB_ICON}'), ' '] + + # the task name + ret.append(f'{data["name"]}') + return ret + + if type_ == 'family': + return [ + get_task_icon( + data['state'], + data['isHeld'] + ), + ' ', + data['id'].rsplit('|', 1)[-1] + ] + + return data['id'].rsplit('|', 1)[-1] diff --git a/setup.cfg b/setup.cfg index 1b4eae3ca84..ea1fba85b97 100644 --- a/setup.cfg +++ b/setup.cfg @@ -92,7 +92,6 @@ console_scripts = cylc-list = cylc.flow.scripts.cylc_list:main cylc-ls-checkpoints = cylc.flow.scripts.cylc_ls_checkpoints:main cylc-message = cylc.flow.scripts.cylc_message:main - cylc-monitor = cylc.flow.scripts.cylc_monitor:main cylc-nudge = cylc.flow.scripts.cylc_nudge:main cylc-ping = cylc.flow.scripts.cylc_ping:main cylc-poll = cylc.flow.scripts.cylc_poll:main @@ -116,6 +115,7 @@ console_scripts = cylc-submit = cylc.flow.scripts.cylc_submit:main cylc-subscribe = cylc.flow.scripts.cylc_subscribe:main cylc-suite-state = cylc.flow.scripts.cylc_suite_state:main + cylc-tui = cylc.flow.scripts.cylc_tui:main cylc-trigger = cylc.flow.scripts.cylc_trigger:main cylc-validate = cylc.flow.scripts.cylc_validate:main cylc-view = cylc.flow.scripts.cylc_view:main diff --git a/setup.py b/setup.py index c15314f4194..156bc8e68b6 100644 --- a/setup.py +++ b/setup.py @@ -44,13 +44,14 @@ def find_version(*file_paths): install_requires = [ 'ansimarkup>=1.0.0', 'colorama==0.4.*', + 'click>=7.0', 'graphene>=2.1,<3', - 'metomi-isodatetime==1!2.0.*', 'jinja2==2.11.*', + 'metomi-isodatetime==1!2.0.*', 'markupsafe==1.1.*', 'protobuf==3.11.*', 'pyzmq==18.1.*', - 'click>=7.0' + 'urwid==2.*' ] tests_require = [ 'codecov==2.0.*', diff --git a/tests/cylc-scan/01-scan.t b/tests/cylc-scan/01-scan.t index 2e46dd1f892..6f415824d4b 100644 --- a/tests/cylc-scan/01-scan.t +++ b/tests/cylc-scan/01-scan.t @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . #------------------------------------------------------------------------------- -# Test cylc monitor USER_AT_HOST interface, using cylc scan output. +# Test `cylc scan` output. . "$(dirname "$0")/test_header" #------------------------------------------------------------------------------- set_test_number 8 diff --git a/tests/cylc-scan/03-monitor.t b/tests/cylc-scan/03-monitor.t deleted file mode 100644 index 6e5574c2fca..00000000000 --- a/tests/cylc-scan/03-monitor.t +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -# THIS FILE IS PART OF THE CYLC SUITE ENGINE. -# Copyright (C) 2008-2019 NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#------------------------------------------------------------------------------- -# Test cylc monitor USER_AT_HOST interface, using cylc scan output. -. "$(dirname "$0")/test_header" -#------------------------------------------------------------------------------- -set_test_number 6 -#------------------------------------------------------------------------------- -init_suite "${TEST_NAME_BASE}" <<'__SUITE_RC__' -[scheduling] - [[graph]] - R1 = foo -[runtime] - [[foo]] - script = sleep 60 -__SUITE_RC__ - -run_ok "${TEST_NAME_BASE}-validate" cylc validate "${SUITE_NAME}" -run_ok "${TEST_NAME_BASE}-run" cylc run "${SUITE_NAME}" - -TEST_NAME="${TEST_NAME_BASE}-monitor-1" -# Need fields from "cylc scan", cannot quote -# shellcheck disable=SC2046 -run_ok "${TEST_NAME}" cylc monitor \ - $(cylc scan --color=never -n "${SUITE_NAME}") --once -grep_ok "${SUITE_NAME} - 1 task" "${TEST_NAME}.stdout" - -# Same again, but force a port scan instead of looking under ~/cylc-run. -# (This also tests GitHub #2795 -"cylc scan -a" abort). -TEST_NAME="${TEST_NAME_BASE}-monitor-2" -# Need fields from "cylc scan", cannot quote -# shellcheck disable=SC2046 -run_ok "${TEST_NAME}" cylc monitor \ - $(cylc scan --color=never -n "${SUITE_NAME}") --once -grep_ok "${SUITE_NAME} - 1 task" "${TEST_NAME}.stdout" - -cylc stop --kill --max-polls=20 --interval=1 "${SUITE_NAME}" -purge_suite "${SUITE_NAME}" -exit diff --git a/tests/cylc-subscribe/01-subscribe.t b/tests/cylc-subscribe/01-subscribe.t index a7bb9acdae2..c823cfa74a5 100644 --- a/tests/cylc-subscribe/01-subscribe.t +++ b/tests/cylc-subscribe/01-subscribe.t @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . #------------------------------------------------------------------------------- -# Test cylc monitor USER_AT_HOST interface, using cylc scan output. +# Test `cylc subscribe`. . "$(dirname "$0")/test_header" #------------------------------------------------------------------------------- set_test_number 8 diff --git a/tests/shutdown/10-no-port-file.t b/tests/shutdown/10-no-port-file.t index 4bb543df257..bfcc0c6777e 100755 --- a/tests/shutdown/10-no-port-file.t +++ b/tests/shutdown/10-no-port-file.t @@ -34,8 +34,7 @@ fi rm -f "${SRVD}/contact" run_fail "${TEST_NAME_BASE}-stop-1" cylc stop "${SUITE_NAME}" contains_ok "${TEST_NAME_BASE}-stop-1.stderr" <<__ERR__ -ClientError: Contact info not found for suite \ -"${SUITE_NAME}", suite not running? +SuiteStopped: ${SUITE_NAME} is not running __ERR__ run_ok "${TEST_NAME_BASE}-stop-2" \ cylc stop --host="${HOST}" --port="${PORT}" "${SUITE_NAME}" \ diff --git a/tests/shutdown/18-client-on-dead-suite.t b/tests/shutdown/18-client-on-dead-suite.t index 0aa61423db9..4782876c921 100755 --- a/tests/shutdown/18-client-on-dead-suite.t +++ b/tests/shutdown/18-client-on-dead-suite.t @@ -18,7 +18,7 @@ # Test suite shuts down with error on missing contact file # And correct behaviour with client on the next 2 connection attempts. . "$(dirname "$0")/test_header" -set_test_number 5 +set_test_number 3 init_suite "${TEST_NAME_BASE}" <<'__SUITERC__' [cylc] [[events]] @@ -41,12 +41,7 @@ kill "${MYPID}" # Should leave behind the contact file wait "${MYPID}" 1>'/dev/null' 2>&1 || true run_fail "${TEST_NAME_BASE}-1" cylc ping "${SUITE_NAME}" contains_ok "${TEST_NAME_BASE}-1.stderr" <<__ERR__ -ClientError: Suite "${SUITE_NAME}" already stopped -__ERR__ -run_fail "${TEST_NAME_BASE}-2" cylc ping "${SUITE_NAME}" -contains_ok "${TEST_NAME_BASE}-2.stderr" <<__ERR__ -ClientError: Contact info not found for suite \ -"${SUITE_NAME}", suite not running? +SuiteStopped: ${SUITE_NAME} is not running __ERR__ purge_suite "${SUITE_NAME}" exit