edge-containers-cli 3.5.0__tar.gz → 3.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/PKG-INFO +1 -1
  2. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/pyproject.toml +4 -0
  3. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/_version.py +2 -2
  4. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/cli.py +6 -6
  5. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/commands.py +4 -4
  6. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/k8s_commands.py +21 -10
  7. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/local_commands.py +6 -6
  8. edge_containers_cli-3.5.2/src/edge_containers_cli/cmds/monitor.py +483 -0
  9. edge_containers_cli-3.5.2/src/edge_containers_cli/cmds/monitor.tcss +51 -0
  10. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/docker.py +18 -2
  11. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/PKG-INFO +1 -1
  12. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/local.yaml +2 -2
  13. edge_containers_cli-3.5.0/src/edge_containers_cli/cmds/monitor.py +0 -285
  14. edge_containers_cli-3.5.0/src/edge_containers_cli/cmds/monitor.tcss +0 -45
  15. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.copier-answers.yml +0 -0
  16. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.devcontainer/devcontainer.json +0 -0
  17. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/CONTRIBUTING.md +0 -0
  18. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/actions/install_requirements/action.yml +0 -0
  19. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/dependabot.yml +0 -0
  20. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/pages/index.html +0 -0
  21. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/pages/make_switcher.py +0 -0
  22. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_check.yml +0 -0
  23. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_dist.yml +0 -0
  24. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_pypi.yml +0 -0
  25. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_release.yml +0 -0
  26. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_sys_test.yml +0 -0
  27. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_test.yml +0 -0
  28. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/_tox.yml +0 -0
  29. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.github/workflows/ci.yml +0 -0
  30. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.gitignore +0 -0
  31. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.pre-commit-config.yaml +0 -0
  32. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.vscode/extensions.json +0 -0
  33. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.vscode/launch.json +0 -0
  34. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.vscode/settings.json +0 -0
  35. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/.vscode/tasks.json +0 -0
  36. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/Dockerfile +0 -0
  37. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/LICENSE +0 -0
  38. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/README.md +0 -0
  39. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/catalog-info.yaml +0 -0
  40. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/conf.py +0 -0
  41. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/explanations/decisions/0001-record-architecture-decisions.rst +0 -0
  42. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/explanations/decisions/0002-switched-to-pip-skeleton.rst +0 -0
  43. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/explanations/decisions.rst +0 -0
  44. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/build-docs.rst +0 -0
  45. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/contribute.rst +0 -0
  46. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/lint.rst +0 -0
  47. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/make-release.rst +0 -0
  48. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/pin-requirements.rst +0 -0
  49. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/run-tests.rst +0 -0
  50. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/static-analysis.rst +0 -0
  51. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/test-container.rst +0 -0
  52. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/how-to/update-tools.rst +0 -0
  53. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/index.rst +0 -0
  54. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/reference/standards.rst +0 -0
  55. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/developer/tutorials/dev-install.rst +0 -0
  56. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/genindex.rst +0 -0
  57. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/images/dls-favicon.ico +0 -0
  58. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/images/dls-logo.svg +0 -0
  59. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/index.rst +0 -0
  60. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/user/explanations/docs-structure.rst +0 -0
  61. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/user/how-to/run-container.rst +0 -0
  62. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/user/index.rst +0 -0
  63. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/user/reference/api.rst +0 -0
  64. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/docs/user/tutorials/installation.rst +0 -0
  65. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/setup.cfg +0 -0
  66. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/__init__.py +0 -0
  67. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/__main__.py +0 -0
  68. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/autocomplete.py +0 -0
  69. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/__init__.py +0 -0
  70. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/helm.py +0 -0
  71. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/cmds/kubectl.py +0 -0
  72. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/git.py +0 -0
  73. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/globals.py +0 -0
  74. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/logging.py +0 -0
  75. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/shell.py +0 -0
  76. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli/utils.py +0 -0
  77. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/SOURCES.txt +0 -0
  78. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/dependency_links.txt +0 -0
  79. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/entry_points.txt +0 -0
  80. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/requires.txt +0 -0
  81. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/src/edge_containers_cli.egg-info/top_level.txt +0 -0
  82. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/__init__.py +0 -0
  83. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/conftest.py +0 -0
  84. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/autocomplete.yaml +0 -0
  85. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/.copier-answers.yml +0 -0
  86. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/.github/dependabot.yml +0 -0
  87. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/.github/workflows/ci_verify.sh +0 -0
  88. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/.github/workflows/verify.yml +0 -0
  89. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/.gitignore +0 -0
  90. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/LICENSE +0 -0
  91. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/README.md +0 -0
  92. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/environment.sh +0 -0
  93. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/helm/shared/Chart.yaml +0 -0
  94. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/helm/shared/README.md +0 -0
  95. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/helm/shared/values.yaml +0 -0
  96. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/include/iocs/Chart.yaml +0 -0
  97. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/include/iocs/templates/configmap.yaml +0 -0
  98. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/services/bl01t-ea-test-01/Chart.yaml +0 -0
  99. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/services/bl01t-ea-test-01/config/ioc.db +0 -0
  100. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/services/bl01t-ea-test-01/config/ioc.yaml +0 -0
  101. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/services/bl01t-ea-test-01/pretend_helm.tgz +0 -0
  102. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/services/bl01t-ea-test-01/values.yaml +0 -0
  103. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/bl01t/update-helm +0 -0
  104. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/data/ioc.yaml +0 -0
  105. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/test_autocomplete.py +0 -0
  106. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/test_cli.py +0 -0
  107. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/test_ioc.py +0 -0
  108. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/test_local.py +0 -0
  109. {edge_containers_cli-3.5.0 → edge_containers_cli-3.5.2}/tests/test_system.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: edge-containers-cli
3
- Version: 3.5.0
3
+ Version: 3.5.2
4
4
  Summary: CLI for deploying and managing epics containers IOCs and services
5
5
  Author-email: Giles Knap <giles.knap@gmail.com>
6
6
  License: Apache License
@@ -75,11 +75,15 @@ testpaths = "src tests"
75
75
 
76
76
  [tool.coverage.run]
77
77
  data_file = "/tmp/edge_containers_cli.coverage"
78
+ omit = ["src/edge_containers_cli/cmds/monitor.py"]
78
79
 
79
80
  [tool.coverage.paths]
80
81
  # Tests are run from installed location, map back to the src directory
81
82
  source = ["src", "**/site-packages/"]
82
83
 
84
+ [tool.coverage.report]
85
+ omit = ["src/edge_containers_cli/cmds/monitor.py"]
86
+
83
87
  # tox must currently be configured via an embedded ini string
84
88
  # See: https://github.com/tox-dev/tox/issues/999
85
89
  [tool.tox]
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '3.5.0'
16
- __version_tuple__ = version_tuple = (3, 5, 0)
15
+ __version__ = version = '3.5.2'
16
+ __version_tuple__ = version_tuple = (3, 5, 2)
@@ -48,27 +48,27 @@ def commands(ctx):
48
48
  @cli.command()
49
49
  def ps(
50
50
  ctx: typer.Context,
51
- all: bool = typer.Option(
52
- False, "-a", "--all", help="list stopped IOCs/services as well as running ones"
51
+ running_only: bool = typer.Option(
52
+ False, "-r", "--running-only", help="list only IOCs/services that are running"
53
53
  ),
54
54
  wide: bool = typer.Option(
55
55
  False, "--wide", "-w", help="use a wide format with additional fields"
56
56
  ),
57
57
  ):
58
58
  """List the IOCs/services running in the current namespace"""
59
- commands(ctx).ps(all, wide)
59
+ commands(ctx).ps(running_only, wide)
60
60
 
61
61
 
62
62
  @cli.command()
63
63
  def monitor(
64
64
  ctx: typer.Context,
65
- all: bool = typer.Option(
66
- False, "-a", "--all", help="list stopped IOCs/services as well as running ones"
65
+ running_only: bool = typer.Option(
66
+ False, "-r", "--running-only", help="list only IOCs/services that are running"
67
67
  ),
68
68
  ):
69
69
  """Open IOC monitor TUI."""
70
70
  cmds = commands(ctx)
71
- app = MonitorApp(EC_K8S_NAMESPACE, cmds, all)
71
+ app = MonitorApp(EC_K8S_NAMESPACE, cmds, running_only)
72
72
  app.run()
73
73
 
74
74
 
@@ -42,7 +42,7 @@ class Commands:
42
42
  def exec(self, service_name: str):
43
43
  raise NotImplementedError
44
44
 
45
- def logs(self, service_name: str, prev: bool, follow: bool):
45
+ def logs(self, service_name: str, prev: bool, follow: bool, stdout: bool):
46
46
  raise NotImplementedError
47
47
 
48
48
  def restart(self, service_name: str):
@@ -54,11 +54,11 @@ class Commands:
54
54
  def stop(self, service_name: str):
55
55
  raise NotImplementedError
56
56
 
57
- def get_services(self, all: bool) -> polars.DataFrame:
57
+ def get_services(self, running_only: bool) -> polars.DataFrame:
58
58
  raise NotImplementedError
59
59
 
60
- def ps(self, all: bool, wide: bool):
61
- select_data = self.get_services(all)
60
+ def ps(self, running_only: bool, wide: bool):
61
+ select_data = self.get_services(running_only)
62
62
  if not wide:
63
63
  select_data.drop_in_place("image")
64
64
  print(select_data)
@@ -7,7 +7,7 @@ Relies on the Helm class for deployment aspects.
7
7
  from datetime import datetime
8
8
  from io import StringIO
9
9
  from pathlib import Path
10
- from typing import Optional
10
+ from typing import Optional, Union
11
11
 
12
12
  import polars
13
13
  import typer
@@ -123,14 +123,25 @@ class K8sCommands(Commands):
123
123
  fullname = check_service(service_name, self.namespace)
124
124
  shell.run_command(f"kubectl -it -n {self.namespace} exec {fullname} -- bash")
125
125
 
126
- def logs(self, service_name: str, prev: bool, follow: bool):
126
+ def logs(
127
+ self, service_name: str, prev: bool, follow: bool, stdout: bool = False
128
+ ) -> Optional[Union[str, bool]]:
127
129
  fullname = check_service(service_name, self.namespace)
128
130
  previous = "-p" if prev else ""
129
131
  fol = "-f" if follow else ""
130
132
 
131
- shell.run_command(
132
- f"kubectl -n {self.namespace} logs {fullname} {previous} {fol}"
133
- )
133
+ if stdout:
134
+ a = shell.run_command(
135
+ f"kubectl -n {self.namespace} logs {fullname} {previous} {fol}",
136
+ interactive=False,
137
+ show=False,
138
+ error_OK=True,
139
+ )
140
+ return a
141
+ else:
142
+ shell.run_command(
143
+ f"kubectl -n {self.namespace} logs {fullname} {previous} {fol}",
144
+ )
134
145
 
135
146
  def restart(self, service_name):
136
147
  check_service(service_name, self.namespace)
@@ -149,7 +160,7 @@ class K8sCommands(Commands):
149
160
  """Stop an IOC"""
150
161
  shell.run_command(f"kubectl scale -n {self.namespace} {fullname} --replicas=0 ")
151
162
 
152
- def get_services(self, all: bool) -> polars.DataFrame:
163
+ def get_services(self, running_only: bool) -> polars.DataFrame:
153
164
  services_df = polars.DataFrame()
154
165
 
155
166
  # Gives all services (running & not running) and their image
@@ -188,7 +199,7 @@ class K8sCommands(Commands):
188
199
  polars.col("running").replace({"Running": True}, default=False),
189
200
  polars.col("restarts").fill_null(0),
190
201
  )
191
- elif all:
202
+ elif not running_only:
192
203
  services_df = services_df.with_columns(
193
204
  running=polars.lit(False), restarts=polars.lit(0)
194
205
  )
@@ -210,14 +221,14 @@ class K8sCommands(Commands):
210
221
  services_df = services_df.select(
211
222
  ["name", "version", "running", "restarts", "deployed", "image"]
212
223
  )
213
- if not all:
224
+ if running_only:
214
225
  services_df = services_df.filter(polars.col("running").eq(True))
215
226
  log.debug(services_df)
216
227
  return services_df
217
228
 
218
- def ps(self, all: bool, wide: bool):
229
+ def ps(self, running_only: bool, wide: bool):
219
230
  """List all IOCs and Services in the current namespace"""
220
- services_df = self.get_services(all)
231
+ services_df = self.get_services(running_only)
221
232
  if not wide:
222
233
  services_df.drop_in_place("image")
223
234
  log.debug(services_df)
@@ -141,8 +141,8 @@ class LocalCommands(Commands):
141
141
  def exec(self, service_name):
142
142
  self.docker.exec(service_name, "bash", args="-it")
143
143
 
144
- def logs(self, service_name: str, prev: bool, follow: bool):
145
- self.docker.logs(service_name, prev, follow)
144
+ def logs(self, service_name: str, prev: bool, follow: bool, stdout: bool = False):
145
+ self.docker.logs(service_name, prev, follow, stdout)
146
146
 
147
147
  def restart(self, service_name: str):
148
148
  shell.run_command(f"{self.docker.docker} restart {service_name}")
@@ -153,8 +153,8 @@ class LocalCommands(Commands):
153
153
  def stop(self, service_name: str):
154
154
  shell.run_command(f"{self.docker.docker} stop {service_name}")
155
155
 
156
- def get_services(self, all: bool) -> polars.DataFrame:
157
- all_arg = " --all" if all else ""
156
+ def get_services(self, running_only: bool) -> polars.DataFrame:
157
+ all_arg = " --all" if not running_only else ""
158
158
 
159
159
  # List services
160
160
  avail_services = shell.run_command(
@@ -201,8 +201,8 @@ class LocalCommands(Commands):
201
201
  log.debug(select_data)
202
202
  return polars.DataFrame(select_data)
203
203
 
204
- def ps(self, all: bool, wide: bool):
205
- select_data = self.get_services(all)
204
+ def ps(self, running_only: bool, wide: bool):
205
+ select_data = self.get_services(running_only)
206
206
  services_df = polars.DataFrame(select_data)
207
207
  if not wide:
208
208
  services_df.drop_in_place("image")
@@ -0,0 +1,483 @@
1
+ """TUI monitor for containerised IOCs."""
2
+
3
+ from functools import total_ordering
4
+ from threading import Thread
5
+ from time import sleep
6
+ from typing import Any, Callable, Union, cast
7
+
8
+ import polars
9
+ from rich.style import Style
10
+ from rich.syntax import Syntax
11
+ from rich.text import Text
12
+
13
+ # from textual import on
14
+ from textual import work
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.color import Color
18
+ from textual.containers import Grid
19
+ from textual.reactive import reactive
20
+ from textual.screen import ModalScreen
21
+ from textual.widget import Widget
22
+ from textual.widgets import Button, DataTable, Footer, Header, Label, RichLog
23
+ from textual.widgets.data_table import RowKey
24
+
25
+ from edge_containers_cli.cmds.commands import Commands
26
+
27
+
28
+ class OptionScreen(ModalScreen[bool], inherit_bindings=False):
29
+ BINDINGS = [
30
+ Binding("y,enter", "option_yes", "Yes"),
31
+ Binding("n,c,escape", "option_cancel", "Cancel"),
32
+ ]
33
+
34
+ def __init__(self, service_name: str) -> None:
35
+ super().__init__()
36
+
37
+ self.service_name = service_name
38
+ self.type_action = "stop"
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield Grid(
42
+ Label(
43
+ f"Are you sure you want to {self.type_action} {self.service_name}?",
44
+ id="question",
45
+ ),
46
+ Button("Yes", variant="error", id="yes"),
47
+ Button("No", variant="primary", id="cancel"),
48
+ id="dialog",
49
+ )
50
+ yield Footer()
51
+
52
+ def on_button_pressed(self, event: Button.Pressed) -> None:
53
+ if event.button.id == "yes":
54
+ self.action_option_yes()
55
+ else:
56
+ self.action_option_cancel()
57
+
58
+ def action_option_yes(self) -> None:
59
+ self.dismiss(True)
60
+
61
+ def action_option_cancel(self) -> None:
62
+ self.dismiss(False)
63
+
64
+
65
+ class StartScreen(OptionScreen):
66
+ """Screen with dialog to start service."""
67
+
68
+ def __init__(self, service_name: str) -> None:
69
+ super().__init__(service_name)
70
+
71
+ self.type_action = "start"
72
+
73
+
74
+ class StopScreen(OptionScreen):
75
+ """Screen with dialog to stop service."""
76
+
77
+ def __init__(self, service_name: str) -> None:
78
+ super().__init__(service_name)
79
+
80
+ self.type_action = "stop"
81
+
82
+
83
+ class RestartScreen(OptionScreen):
84
+ """Screen with dialog to restart service."""
85
+
86
+ def __init__(self, service_name: str) -> None:
87
+ super().__init__(service_name)
88
+
89
+ self.type_action = "restart"
90
+
91
+
92
+ class LogsScreen(ModalScreen, inherit_bindings=False):
93
+ """Screen to display IOC logs."""
94
+
95
+ BINDINGS = [
96
+ Binding("q", "close_screen", "Close"),
97
+ Binding("up,w,k", "scroll_up", "Scroll Up", show=False),
98
+ Binding("down,s,j", "scroll_down", "Scroll Down", show=False),
99
+ Binding("left,h", "scroll_left", "Scroll Left", show=False),
100
+ Binding("right,l", "scroll_right", "Scroll Right", show=False),
101
+ Binding("home,G", "scroll_home", "Scroll Home", show=False),
102
+ Binding("end,g", "scroll_end", "Scroll End", show=False),
103
+ Binding("pageup,b", "page_up", "Page Up", show=False),
104
+ Binding("pagedown,space", "page_down", "Page Down", show=False),
105
+ ]
106
+
107
+ def __init__(self, fetch_log: Callable, service_name) -> None:
108
+ super().__init__()
109
+
110
+ self.fetch_log = fetch_log
111
+ self.service_name = service_name
112
+ self.log_text = ""
113
+
114
+ def compose(self) -> ComposeResult:
115
+ yield RichLog(highlight=True, id="log")
116
+ yield Footer()
117
+
118
+ def on_mount(self) -> None:
119
+ log = self.query_one(RichLog)
120
+ log.loading = True
121
+ self.load_logs(log)
122
+
123
+ @work
124
+ async def load_logs(self, log: RichLog) -> None:
125
+ self.log_text: str = self.fetch_log(
126
+ self.service_name, prev=False, follow=False, stdout=True
127
+ )
128
+ log.loading = False
129
+ width = max(len(line) for line in self.log_text.split("\n"))
130
+ log.write(
131
+ Syntax(self.log_text, "bash", line_numbers=True),
132
+ width=width + 10,
133
+ expand=True,
134
+ shrink=False,
135
+ scroll_end=True,
136
+ )
137
+ log.focus()
138
+
139
+ def action_close_screen(self) -> None:
140
+ self.app.pop_screen()
141
+
142
+ def action_scroll_up(self) -> None:
143
+ log = self.query_one(RichLog)
144
+ log.action_scroll_up()
145
+
146
+ def action_scroll_down(self) -> None:
147
+ log = self.query_one(RichLog)
148
+ log.action_scroll_down()
149
+
150
+ def action_scroll_home(self) -> None:
151
+ log = self.query_one(RichLog)
152
+ log.action_scroll_home()
153
+
154
+ def action_scroll_end(self) -> None:
155
+ log = self.query_one(RichLog)
156
+ log.action_scroll_end()
157
+
158
+ def action_page_down(self) -> None:
159
+ log = self.query_one(RichLog)
160
+ log.action_page_down()
161
+
162
+ def action_page_up(self) -> None:
163
+ log = self.query_one(RichLog)
164
+ log.action_page_up()
165
+
166
+
167
+ @total_ordering
168
+ class SortableText(Text):
169
+ __slots__ = ("value",)
170
+
171
+ def __init__(
172
+ self,
173
+ value: Any,
174
+ text: str,
175
+ color: Color,
176
+ justify: Any = "left", # "Any" is a hack: justify should be Literal
177
+ ) -> None:
178
+ self.value = value
179
+ super().__init__(
180
+ str(text),
181
+ Style(color=color.rich_color),
182
+ justify=justify,
183
+ )
184
+
185
+ def __lt__(self, other: Any) -> bool:
186
+ if type(other) != SortableText:
187
+ return NotImplemented
188
+ return cast(bool, self.value < other.value)
189
+
190
+ def __gt__(self, other: Any) -> bool:
191
+ if type(other) != SortableText:
192
+ return NotImplemented
193
+ return cast(bool, self.value > other.value)
194
+
195
+ def __eq__(self, other: Any) -> bool:
196
+ if type(other) != SortableText:
197
+ return NotImplemented
198
+ return cast(bool, self.value == other.value)
199
+
200
+
201
+ class IocTable(Widget):
202
+ """Widget to display the IOC table."""
203
+
204
+ default_sort_column_id = "name"
205
+ # init=False otherwise triggers table query before yielded in compose
206
+ sort_column_id = reactive(default_sort_column_id, init=False)
207
+
208
+ def __init__(self, commands, running_only: bool) -> None:
209
+ super().__init__()
210
+
211
+ self.commands = commands
212
+ self.running_only = running_only
213
+ self.iocs_df = self.commands.get_services(self.running_only)
214
+
215
+ self._polling = True
216
+ self._poll_thread = Thread(target=self._poll_services)
217
+ self._poll_thread.start()
218
+ self._get_iocs()
219
+
220
+ def _poll_services(self):
221
+ while self._polling:
222
+ # ioc list data table update loop
223
+ print()
224
+ self.iocs_df = self.commands.get_services(self.running_only)
225
+ sleep(2.0)
226
+
227
+ def stop(self):
228
+ self._polling = False
229
+ self._poll_thread.join()
230
+
231
+ def _get_iocs(self) -> None:
232
+ iocs = self._convert_df_to_list(self.iocs_df)
233
+ # give up the GIL to other threads
234
+ sleep(0)
235
+ self.iocs = sorted(iocs, key=lambda d: d["name"])
236
+ exclude = ["deployed", "image"]
237
+
238
+ for i, ioc in enumerate(self.iocs):
239
+ ioc = {key: value for key, value in ioc.items() if key not in exclude}
240
+ self.iocs[i] = ioc
241
+ self.columns = [key for key in self.iocs[0].keys() if key not in exclude]
242
+
243
+ def _convert_df_to_list(self, iocs_df: Union[polars.DataFrame, list]) -> list[dict]:
244
+ if isinstance(iocs_df, polars.DataFrame):
245
+ iocs = iocs_df.to_dicts()
246
+ else:
247
+ iocs = iocs_df
248
+
249
+ return iocs
250
+
251
+ def on_mount(self) -> None:
252
+ """Provides a loop after generating the app for updating the data."""
253
+ self.set_interval(1.0, self.update_iocs)
254
+
255
+ async def update_iocs(self) -> None:
256
+ """Updates the IOC stats data."""
257
+ # Fetch services dataframe
258
+ self._get_iocs()
259
+
260
+ await self.populate_table()
261
+
262
+ def _get_heading(self, column_id: str):
263
+ sorted_style = Style(bold=True, underline=True)
264
+
265
+ if column_id == self.sort_column_id:
266
+ heading = Text(column_id, justify="center", style=sorted_style)
267
+ else:
268
+ # screen.sort() is referring to the screen action function
269
+ heading = Text(column_id, justify="center").on(
270
+ click=f"app.sort('{column_id}')"
271
+ )
272
+
273
+ return heading
274
+
275
+ def compose(self) -> ComposeResult:
276
+ table: DataTable[Text] = DataTable(
277
+ id="body_table", header_height=1, show_cursor=False, zebra_stripes=True
278
+ )
279
+ table.focus()
280
+
281
+ for column_id in self.columns:
282
+ heading = self._get_heading(column_id)
283
+ table.add_column(heading, key=str(column_id))
284
+
285
+ # Set a size for the left column
286
+ # table.ordered_columns[0].content_width = 50
287
+
288
+ table.show_cursor = True
289
+ table.cursor_type = "row"
290
+
291
+ yield table
292
+
293
+ def watch_sort_column_id(self, sort_column_id: str) -> None:
294
+ """Called when the sort_column_id attribute changes."""
295
+ table = self.query_one("#body_table", DataTable)
296
+
297
+ # Reformat headings based on new sorted column
298
+ for i, _column in enumerate(self.columns):
299
+ table.ordered_columns[i].label = self._get_heading(_column)
300
+
301
+ sorted_col = self.columns.index(sort_column_id)
302
+
303
+ table.sort(table.ordered_columns[sorted_col].key, reverse=False)
304
+
305
+ def _get_color(self, value: str) -> Color:
306
+ if value == "True":
307
+ return Color.parse("lime")
308
+ elif value == "False":
309
+ return Color.parse("red")
310
+ else:
311
+ return Color.parse("white")
312
+
313
+ async def populate_table(self) -> None:
314
+ """Method to render the TUI table."""
315
+ table = self.query_one("#body_table", DataTable)
316
+
317
+ if not table.columns:
318
+ return
319
+
320
+ iocs = set(table.rows)
321
+ new_iocs = set()
322
+
323
+ # For each IOC row
324
+ for ioc in self.iocs:
325
+ row_key = str(ioc["name"])
326
+ new_iocs.add(RowKey(row_key))
327
+
328
+ cells = [
329
+ {
330
+ "col_key": key,
331
+ "contents": SortableText(
332
+ ioc[key], str(ioc[key]), self._get_color(str(ioc[key]))
333
+ ),
334
+ }
335
+ for key in self.columns
336
+ ]
337
+
338
+ if row_key not in table.rows:
339
+ table.add_row(
340
+ *[cell["contents"] for cell in cells],
341
+ key=row_key,
342
+ )
343
+ else:
344
+ for cell in cells:
345
+ table.update_cell(row_key, cell["col_key"], cell["contents"])
346
+
347
+ # If any IOC has been removed, remove it from the table
348
+ for old_row_key in iocs - new_iocs:
349
+ table.remove_row(old_row_key)
350
+
351
+ # Sort by column
352
+ table.sort(self.sort_column_id, reverse=False)
353
+
354
+
355
+ class MonitorApp(App):
356
+ def __init__(
357
+ self,
358
+ beamline: str,
359
+ commands: Commands,
360
+ running_only: bool,
361
+ ) -> None:
362
+ super().__init__()
363
+
364
+ self.commands = commands
365
+ self.running_only = running_only
366
+ self.beamline = beamline
367
+
368
+ CSS_PATH = "monitor.tcss"
369
+
370
+ BINDINGS = [
371
+ Binding("escape", "close_application", "Exit"),
372
+ Binding("s", "start_ioc", "Start IOC"),
373
+ Binding("t", "stop_ioc", "Stop IOC"),
374
+ Binding("r", "restart_ioc", "Restart IOC"),
375
+ Binding("l", "ioc_logs", "IOC Logs"),
376
+ Binding("o", "sort", "Sort"),
377
+ # Binding("d", "toggle_dark", "Toggle dark mode"),
378
+ ]
379
+
380
+ def compose(self) -> ComposeResult:
381
+ """Create child widgets for the app."""
382
+ yield Header(show_clock=True)
383
+ self.table = IocTable(self.commands, self.running_only)
384
+ yield self.table
385
+ yield Footer()
386
+
387
+ def on_mount(self) -> None:
388
+ self.title = f"{self.beamline} IOC Monitor"
389
+
390
+ def on_unmount(self) -> None:
391
+ """Executes when the app is closed."""
392
+ # Makes sure the thread is stopped even if the App crashes
393
+ self.table.stop()
394
+
395
+ # def action_toggle_dark(self) -> None:
396
+ # """An action to toggle dark mode."""
397
+ # self.dark = not self.dark
398
+
399
+ def action_close_application(self) -> None:
400
+ """Provide another way of exiting the app along with CTRL+C."""
401
+ self.exit()
402
+
403
+ def _get_highlighted_cell(self, col_key: str) -> str:
404
+ table = self.get_widget_by_id("body_table")
405
+ assert isinstance(table, DataTable)
406
+ # Fetches hightlighted row ID (integer)
407
+ row = table.cursor_row
408
+ ioc_row = table.ordered_rows[row]
409
+ col_keys = [ord_col.key.value for ord_col in table.ordered_columns]
410
+ col_i = col_keys.index(col_key)
411
+ ioc_col = table.ordered_columns[col_i]
412
+ cell: Union[str, SortableText] = table.get_cell(ioc_row.key, ioc_col.key)
413
+ # SortableText inherits __str__() from Text
414
+ return str(cell)
415
+
416
+ def _get_service_name(self) -> str:
417
+ service_name = self._get_highlighted_cell("name")
418
+ return service_name
419
+
420
+ def action_start_ioc(self) -> None:
421
+ """Start the IOC that is currently highlighted."""
422
+ service_name = self._get_service_name()
423
+
424
+ def check_start(restart: bool) -> None:
425
+ """Called when StartScreen is dismissed."""
426
+ if restart:
427
+ self.commands.start(service_name)
428
+
429
+ self.push_screen(StartScreen(service_name), check_start)
430
+
431
+ def action_stop_ioc(self) -> None:
432
+ """Stop the IOC that is currently highlighted."""
433
+ service_name = self._get_service_name()
434
+
435
+ def check_stop(restart: bool) -> None:
436
+ """Called when StopScreen is dismissed."""
437
+ if restart:
438
+ self.commands.stop(service_name)
439
+
440
+ self.push_screen(StopScreen(service_name), check_stop)
441
+
442
+ def action_restart_ioc(self) -> None:
443
+ """Restart the IOC that is currently highlighted."""
444
+ service_name = self._get_service_name()
445
+
446
+ def check_restart(restart: bool) -> None:
447
+ """Called when RestartScreen is dismissed."""
448
+ if restart:
449
+ self.commands.restart(service_name)
450
+
451
+ self.push_screen(RestartScreen(service_name), check_restart)
452
+
453
+ def action_ioc_logs(self) -> None:
454
+ """Display the logs of the IOC that is currently highlighted."""
455
+ service_name = self._get_service_name()
456
+
457
+ # Convert to corresponding bool
458
+ running = self._get_highlighted_cell("running") == "True"
459
+
460
+ if running:
461
+ command = self.commands.logs
462
+ self.push_screen(LogsScreen(command, service_name))
463
+
464
+ def action_sort(self, col_name: str = "") -> None:
465
+ """An action to sort the table rows by column heading."""
466
+ if col_name != "":
467
+ # If col_name is provided, sort by that column
468
+ # e.g. if a column heading is clicked
469
+ new_col = col_name
470
+ else:
471
+ # If no column name is provided (e.g. by pressing the key bind),
472
+ # then just cycle to the next column
473
+ table = self.query_one(IocTable)
474
+ col_name = table.sort_column_id
475
+ cols = table.columns
476
+ col_index = cols.index(col_name)
477
+ new_col = cols[0 if col_index + 1 > 3 else col_index + 1]
478
+ self.update_sort_key(new_col)
479
+
480
+ def update_sort_key(self, col_name: str) -> None:
481
+ """Method called to update the table sort key attribute."""
482
+ table = self.query_one(IocTable)
483
+ table.sort_column_id = col_name