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