diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4f1f99..84b31cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,36 @@ on: permissions: contents: read jobs: + textual: + name: Optional Textual candidate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: decryptus/dwho + ref: 50b50eaa9696628e9521a31ae92305e01966e6fa + path: candidate-dwho + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install ./candidate-dwho + - run: AUTON_PACKAGE=auton python -m pip install '.[textual]' + - name: Test installed interface outside source checkout + run: | + cd "$(mktemp -d)" + python "$GITHUB_WORKSPACE/.github/scripts/check-test-collection.py" --runner unittest "$GITHUB_WORKSPACE/textual_tests" + python -m unittest discover -s "$GITHUB_WORKSPACE/textual_tests" -v + - name: Generate candidate captures from actual interface + run: PYTHONPATH=. python scripts/capture_textual.py --output "$RUNNER_TEMP/textual-captures" + - uses: actions/upload-artifact@v4 + with: + name: textual-candidate-captures + path: ${{ runner.temp }}/textual-captures + if-no-files-found: error + tests: runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index d58c37e..ef20387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,3 +119,13 @@ Current project runner: `unittest` for `tests`. CI helper tests use with warnings treated as errors where supported. Review README/package text and the deployed manual for accidental maintainer content. Preserve private-project visibility and existing review/publication approval requirements. + +## Optional Textual presentation + +- Reuse `dwho.tui.textual` for shared layout, widgets and visual states. Keep + collection, permissions, rules and execution in application/client services. +- Maintain the separate `textual_tests` unittest discovery root and optional CI + job. Base commands must work without Textual/Rich imports. +- Generate candidate captures with `scripts/capture_textual.py`; keep synthetic + fixtures labelled and record source hashes. Publish shared website captures + only after the corresponding interface and source revision are accepted. diff --git a/CHANGELOG b/CHANGELOG index 2f3e6f9..3295595 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +auton (1.3.0) unstable; urgency=medium + + * Add optional read-only Textual views backed by shared DWho components. + * Preserve existing default interface and service authorization boundaries. + * Include offline demo, separate UI tests and reproducible captures. + + -- Codex Wed, 07 Oct 2026 02:00:00 +0200 + auton (1.2.1) unstable; urgency=medium * Hide unexpected job-service exception details in HTTP responses and logs. diff --git a/MANIFEST.in b/MANIFEST.in index 87844ee..2b57ba3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,6 @@ include requirements*.txt include bin/* recursive-include etc * recursive-include auton/web *.html *.css *.js *.svg + +recursive-include textual_tests *.py +include scripts/capture_textual.py diff --git a/README.md b/README.md index 8a0e672..8f93685 100644 --- a/README.md +++ b/README.md @@ -654,7 +654,7 @@ Existing single-command and legacy failover invocations remain available. ### Operator TUI (client) -The Unix client includes a ncurses interface with read-only monitoring and an +The Unix client includes Textual and curses interfaces. See the [Textual guide](docs/textual.md) for the modern interface. The following keyboard reference describes curses, with read-only monitoring and an explicit execution preparation screen. It requires an interactive terminal with Python curses support and a daemon with the visibility API above. @@ -750,7 +750,7 @@ from its pinned source on every website build and shared by GitHub and the web manual. [Capture provenance](https://auton.run/manual-captures/manifest.json) records the source commit, version and image checksums. -Captures below come from real terminal sessions against disposable local daemons: +Captures below show the real Textual interface with synthetic offline fixtures; no remote command is executed: [![Prepare targets and scenario groups](https://auton.run/manual-captures/tui-prepare.png)](https://auton.run/manual-captures/tui-prepare.png) [![Review the execution plan](https://auton.run/manual-captures/tui-confirm.png)](https://auton.run/manual-captures/tui-confirm.png) @@ -781,9 +781,9 @@ without persistent central history or any change to execution failover. ### TUI screenshots -These captures come from the real curses UI connected to two disposable local -daemons. All job names and output are demonstration data. Click a capture to -open the full-size image; the PNG files are stored in this repository. +These captures show the Textual interface with labelled synthetic fixtures. +All job names and output are demonstration data. Click a capture to open its +full-size image; the website regenerates the shared PNG files from pinned sources. **Aggregated jobs**, including successful and failed commands: @@ -1916,3 +1916,16 @@ coverage and compatibility. - **Users:** installation, configuration, operation and API usage in this README and the user guide. - **Contributors:** [architecture, tests and development](https://github.com/decryptus/auton/blob/master/CONTRIBUTING.md). + + +## Textual terminal interface + +Install the optional extra and select the modern interface explicitly: + +```sh +python -m pip install 'auton[textual]' +auton --tui --tui-ui textual -c inventory.yml +``` + +See the [Textual guide](docs/textual.md) for navigation and supported operations. +The existing curses interface remains available. Screenshots use synthetic data. diff --git a/RELEASE b/RELEASE index 6085e94..f0bb29e 100644 --- a/RELEASE +++ b/RELEASE @@ -1 +1 @@ -1.2.1 +1.3.0 diff --git a/VERSION b/VERSION index 6085e94..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 +1.3.0 diff --git a/auton_client/__init__.py b/auton_client/__init__.py index 1adad23..9fd4ab0 100644 --- a/auton_client/__init__.py +++ b/auton_client/__init__.py @@ -12,7 +12,7 @@ from sonicprobe.libs import urisup SYSLOG_NAME = 'auton' -__version__ = '1.2.1' +__version__ = '1.3.0' DEFAULT_DELAY = 0.5 DEFAULT_HTTP_TIMEOUT = 30 diff --git a/auton_client/preparation.py b/auton_client/preparation.py index 604c67f..bee76e6 100644 --- a/auton_client/preparation.py +++ b/auton_client/preparation.py @@ -15,108 +15,13 @@ PAYLOAD_FIELDS = frozenset(('args', 'env')) -class PreparationView: - def __init__(self, connections, groups, scenarios, scenario_groups, session, settings): - self.connections = dict(connections) - self.groups = {name: members for name, members in groups.items() - if all(member in connections for member in members)} - self.scenarios = scenarios - self.scenario_groups = {name: members for name, members in scenario_groups.items() - if all(member in scenarios for member in members)} - self.session, self.settings = session, settings - self.selected = [[] for _ in SECTIONS] - self.section = self.index = self.scroll = 0 - self.search = '' - self.editing = None - self.input = '{"args": [], "env": {}}' - self.mode = 'select' - self.error = '' - self.preview = [] - self.service = None - self.result = None - self.result_lines = None - self.endpoints = [] - self.progress = {'revision': 0, 'rows': [], 'operation_id': None} - self.export_path = '' - self.import_path = '' - self.parameter_catalogue = {} - self.parameter_fields = [] - self.parameter_values = [] - self.parameter_index = 0 - self.parameter_input = '' +from auton_client.preparation_model import PreparationModel + + +class PreparationView(PreparationModel): - def update(self, snapshots): - self.endpoints = sorted({item['name'] for data in snapshots.values() - for item in data.get('endpoints', [])}) - catalogues = {} - for data in snapshots.values(): - for item in data.get('endpoints', []): - catalogues.setdefault(item['name'], set()).add(json.dumps(item.get('parameters'), sort_keys=True)) - self.parameter_catalogue = {name: json.loads(next(iter(values))) - for name, values in catalogues.items() if len(values) == 1} - if self.mode == 'running': - self.progress = self.session.progress.snapshot() - try: - result = self.session.poll() - except Exception: - self.error = 'Unable to collect operation result; do not replay. Inspect daemon jobs.' - self.mode = 'result' - else: - if result is not None: - self.result = result - self.result_lines = None - self.mode = 'result' - self.scroll = 0 - def rows(self): - catalogues = (self.connections, self.groups, self.scenarios, self.scenario_groups, self.endpoints) - return [name for name in catalogues[self.section] if self.search.casefold() in name.casefold()] - def prepare(self): - targets = select_connections(self.selected[0], self.selected[1], self.connections, self.groups) - if not targets: - raise ValueError('Select at least one target or target group') - preview = ['Targets:'] - for name, value in targets.items(): - origins = target_origins(value) - preview.append(' %s = %s' % (name, origins[0])) - if len(origins) > 1: - preview.append(' Ordered failover (one destination, no broadcast):') - preview.extend(' %s' % origin for origin in origins[1:]) - if self.selected[2] or self.selected[3]: - scenarios = select_scenarios(self.selected[2], self.selected[3], self.scenarios, self.scenario_groups) - service = ScenarioService(targets, scenarios, **self.settings) - for name, definition in scenarios.items(): - preview.append('Scenario: ' + name) - for step in definition['steps']: - preview.append(' %s -> %s' % (step['name'], step['endpoint'])) - preview.append(' args=' + json.dumps(step.get('args', []))) - preview.append(' env=' + json.dumps(step.get('env', {}))) - else: - if len(self.selected[4]) != 1: - raise ValueError('Select one endpoint or one or more scenarios') - try: - payload = json.loads(self.input) - except ValueError: - raise ValueError('Inputs must be valid JSON') from None - if (not isinstance(payload, dict) or not set(payload) <= PAYLOAD_FIELDS - or not isinstance(payload.get('args', []), list) - or any(not isinstance(value, str) or '\x00' in value for value in payload.get('args', [])) - or not isinstance(payload.get('env', {}), dict) - or any(not isinstance(key, str) or not key or '=' in key or '\x00' in key - or not isinstance(value, str) or '\x00' in value - for key, value in payload.get('env', {}).items())): - raise ValueError('Inputs require args: string list and env: string mapping') - endpoint = self.selected[4][0] - service = OperationService(targets, endpoint, payload=payload, **self.settings) - preview.extend(['Endpoint: ' + endpoint, 'Inputs: ' + json.dumps(payload)]) - preview.extend(['Parallel targets: %s; observation deadline: %ss' % - (service.settings.parallel if isinstance(service, ScenarioService) else service.parallel, - self.settings.get('timeout', 300)), - 'Server authentication and endpoint ACLs remain authoritative.', - 'No automatic replay, rollback or remote cancellation.']) - self.service, self.preview = service, preview - self.mode, self.scroll = 'confirm', 0 def handle(self, key): if self.editing == 'parameter_input': diff --git a/auton_client/preparation_model.py b/auton_client/preparation_model.py new file mode 100644 index 0000000..027a0bb --- /dev/null +++ b/auton_client/preparation_model.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: GPL-3.0-or-later +"""Interface-neutral operation preparation; execution lives in services.""" +import json + +from auton_client.operations import OperationService +from auton_client.export import export_result +from auton_client.reconcile import ReconciliationService, load_report +from auton_client.scenarios import ScenarioService, select_scenarios +from auton_client.connections import select_connections, target_origins + +SECTIONS = ('Targets', 'Target groups', 'Scenarios', 'Scenario groups', 'Endpoints') +MAX_INPUT = 4096 +PAYLOAD_FIELDS = frozenset(('args', 'env')) + + +class PreparationModel: + def __init__(self, connections, groups, scenarios, scenario_groups, session, settings): + self.connections = dict(connections) + self.groups = {name: members for name, members in groups.items() + if all(member in connections for member in members)} + self.scenarios = scenarios + self.scenario_groups = {name: members for name, members in scenario_groups.items() + if all(member in scenarios for member in members)} + self.session, self.settings = session, settings + self.selected = [[] for _ in SECTIONS] + self.section = self.index = self.scroll = 0 + self.search = '' + self.editing = None + self.input = '{"args": [], "env": {}}' + self.mode = 'select' + self.error = '' + self.preview = [] + self.service = None + self.result = None + self.result_lines = None + self.endpoints = [] + self.progress = {'revision': 0, 'rows': [], 'operation_id': None} + self.export_path = '' + self.import_path = '' + self.parameter_catalogue = {} + self.parameter_fields = [] + self.parameter_values = [] + self.parameter_index = 0 + self.parameter_input = '' + + + def update(self, snapshots): + self.endpoints = sorted({item['name'] for data in snapshots.values() + for item in data.get('endpoints', [])}) + catalogues = {} + for data in snapshots.values(): + for item in data.get('endpoints', []): + catalogues.setdefault(item['name'], set()).add(json.dumps(item.get('parameters'), sort_keys=True)) + self.parameter_catalogue = {name: json.loads(next(iter(values))) + for name, values in catalogues.items() if len(values) == 1} + self.poll_result() + + def poll_result(self): + if self.mode == 'running': + self.progress = self.session.progress.snapshot() + try: + result = self.session.poll() + except Exception: + self.error = 'Unable to collect operation result; do not replay. Inspect daemon jobs.' + self.mode = 'result' + else: + if result is not None: + self.result = result + self.result_lines = None + self.mode = 'result' + self.scroll = 0 + + + def rows(self): + catalogues = (self.connections, self.groups, self.scenarios, self.scenario_groups, self.endpoints) + return [name for name in catalogues[self.section] if self.search.casefold() in name.casefold()] + + + def prepare(self): + targets = select_connections(self.selected[0], self.selected[1], self.connections, self.groups) + if not targets: + raise ValueError('Select at least one target or target group') + preview = ['Targets:'] + for name, value in targets.items(): + origins = target_origins(value) + preview.append(' %s = %s' % (name, origins[0])) + if len(origins) > 1: + preview.append(' Ordered failover (one destination, no broadcast):') + preview.extend(' %s' % origin for origin in origins[1:]) + if self.selected[2] or self.selected[3]: + scenarios = select_scenarios(self.selected[2], self.selected[3], self.scenarios, self.scenario_groups) + service = ScenarioService(targets, scenarios, **self.settings) + for name, definition in scenarios.items(): + preview.append('Scenario: ' + name) + for step in definition['steps']: + preview.append(' %s -> %s' % (step['name'], step['endpoint'])) + preview.append(' args=' + json.dumps(step.get('args', []))) + preview.append(' env=' + json.dumps(step.get('env', {}))) + else: + if len(self.selected[4]) != 1: + raise ValueError('Select one endpoint or one or more scenarios') + try: + payload = json.loads(self.input) + except ValueError: + raise ValueError('Inputs must be valid JSON') from None + if (not isinstance(payload, dict) or not set(payload) <= PAYLOAD_FIELDS + or not isinstance(payload.get('args', []), list) + or any(not isinstance(value, str) or '\x00' in value for value in payload.get('args', [])) + or not isinstance(payload.get('env', {}), dict) + or any(not isinstance(key, str) or not key or '=' in key or '\x00' in key + or not isinstance(value, str) or '\x00' in value + for key, value in payload.get('env', {}).items())): + raise ValueError('Inputs require args: string list and env: string mapping') + endpoint = self.selected[4][0] + service = OperationService(targets, endpoint, payload=payload, **self.settings) + preview.extend(['Endpoint: ' + endpoint, 'Inputs: ' + json.dumps(payload)]) + preview.extend(['Parallel targets: %s; observation deadline: %ss' % + (service.settings.parallel if isinstance(service, ScenarioService) else service.parallel, + self.settings.get('timeout', 300)), + 'Server authentication and endpoint ACLs remain authoritative.', + 'No automatic replay, rollback or remote cancellation.']) + self.service, self.preview = service, preview + self.mode, self.scroll = 'confirm', 0 + + diff --git a/auton_client/textual_demo.py b/auton_client/textual_demo.py new file mode 100644 index 0000000..4d0a380 --- /dev/null +++ b/auton_client/textual_demo.py @@ -0,0 +1,75 @@ +"""Offline fixtures; actual OperatorApp rendering, no network or execution.""" +from copy import deepcopy +from .textual_tui import OperatorApp +from .monitor import aggregate_snapshots + +DEMO_JOB = dict(uid='inventory:demo-001', endpoint='inventory', status='processing', return_code=None) +DEMO_COMPLETE = dict(uid='health:demo-002', endpoint='health', status='complete', return_code=0) +DEMO_FAILED = dict(uid='backup:demo-003', endpoint='backup', status='complete', return_code=1) + + +class DemoMonitor: + clients = {'edge-01': None, 'worker-02': None} + + def __init__(self): + self.result = None + self.closed = False + + def refresh(self, daemon, job=None): + if self.closed or self.result is not None: + return False + def snapshot(name): + jobs = [deepcopy(DEMO_JOB), deepcopy(DEMO_COMPLETE)] if name == 'edge-01' else [deepcopy(DEMO_FAILED)] + data = dict(health={'status': 'ok'}, jobs=jobs, errors={}, + endpoints=[dict(name='inventory', description='Synthetic inventory operation'), + dict(name='health', description='Synthetic health checks')]) + requested = job[1:] if daemon is None and job and job[0] == name else job if daemon is not None else None + if requested: + source = next((j for j in jobs if (j['endpoint'], j['uid']) == tuple(requested)), None) + if source: + data['detail'] = dict(source, stream=['Synthetic output\nReading system inventory...\n3 resources observed.\n'], + errors=['Synthetic diagnostics: no remote command was executed.\n']) + return data + data = aggregate_snapshots(list(self.clients), {name: snapshot(name) for name in self.clients}, job) if daemon is None else snapshot(daemon) + self.result = (daemon, job, data) + return True + + def poll(self): + result, self.result = self.result, None + return result + + def close(self): + self.closed = True + + +class DemoExecutionClient: + """Synthetic adapter: never opens a socket or executes a command.""" + def __init__(self, uris, endpoint, uid, **kwargs): + self.uri, self.endpoint, self.uid = uris[0], endpoint, uid + self.output_offset = 0 + + def do_run(self): + failed = 'worker-02' in self.uri and self.endpoint == 'install' + return dict(uid=self.endpoint + ':' + self.uid, status='complete', return_code=7 if failed else 0, + stream=['Synthetic ' + self.endpoint + (' failed: invalid archive.' if failed else ' completed.') + '\n'], + errors=[], next_offset=1) + + +def demo_app(operations=False): + preparation = None + if operations: + from .preparation_model import PreparationModel + from .session import ExecutionSession + preparation = PreparationModel({'edge-01': 'http://edge-01.invalid', 'worker-02': 'http://worker-02.invalid'}, + {'staging': ['edge-01', 'worker-02']}, {'deploy-app': {'version': 1, 'steps': [ + {'name': name, 'endpoint': name} for name in ('install', 'restart', 'verify')]}}, {}, + ExecutionSession(), {'client_factory': DemoExecutionClient, 'delay': 0}) + return OperatorApp(DemoMonitor(), demo=True, preparation=preparation) + + +if __name__ == '__main__': + app = demo_app(operations=True) + try: + app.run() + finally: + app.preparation.session.close() diff --git a/auton_client/textual_operations.py b/auton_client/textual_operations.py new file mode 100644 index 0000000..32a98f4 --- /dev/null +++ b/auton_client/textual_operations.py @@ -0,0 +1,252 @@ +"""Textual operation adapter over the shared preparation and execution services.""" +import json + +from dwho.tui.textual import Confirmation, InputForm, StatusLine, plain_text +from textual.containers import Horizontal, VerticalScroll +from textual.screen import Screen +from textual.widgets import Button, Footer, SelectionList, Static + +from auton_client.connections import select_connections +from auton_client.export import export_result +from auton_client.reconcile import ReconciliationService, load_report + +SECTIONS = ('Targets', 'Target groups', 'Scenarios', 'Scenario groups', 'Endpoints') + + +class OperationsScreen(Screen): + CSS = ''' + OperationsScreen { background: #0a1220; } + #op-title { height: 3; padding: 1 2; background: #101f34; color: #83cfff; text-style: bold; } + #op-selection { height: 13; margin-top: 1; } + .op-section Static { color: #b7dcff; text-style: bold; } + .op-section SelectionList { background: #102137; border: round #305778; } + .op-section SelectionList:focus { border: round #83cfff; } + #op-actions { padding: 0 2; } + #op-actions Button { background: #173e62; color: #b7e2ff; border: none; height: 3; } + #op-actions Button:focus { background: #245d86; color: #ffffff; } + #op-actions Button:disabled { background: #102137; color: #66819e; } + #op-preview { background: #245d86; } + + .op-section { width: 1fr; padding: 0 1; } + .op-section SelectionList { height: 10; } + #op-actions { height: auto; layout: grid; grid-size: 4; grid-gutter: 1; } + #op-actions Button { width: 100%; } + #op-result { height: auto; padding: 1 2; } + #op-status { height: auto; padding: 1 2; } + #op-body { height: 1fr; } + ''' + BINDINGS = [('escape', 'back', 'Back'), ('q', 'back', 'Back')] + + def __init__(self, model, demo=False): + super().__init__() + self.model, self.demo = model, demo + self.last_display = None + + def compose(self): + yield Static('AUTON · OPERATIONS' + (' · SYNTHETIC DEMO' if self.demo else '') + '\nSelect targets and either scenarios or one endpoint', id='op-title') + yield StatusLine('info', 'Nothing is submitted until you confirm the preview.', id='op-status') + with VerticalScroll(id='op-body'): + with Horizontal(id='op-selection'): + catalogues = (self.model.connections, self.model.groups, self.model.scenarios, + self.model.scenario_groups, self.model.endpoints) + for index, (title, catalogue) in enumerate(zip(SECTIONS, catalogues)): + with VerticalScroll(classes='op-section'): + yield Static(title) + yield SelectionList(*[(plain_text(name), name, name in self.model.selected[index]) + for name in catalogue], id='op-select-%s' % index) + with Horizontal(id='op-actions'): + for key, label in (('preview', 'Preview execution'), ('inputs', 'JSON inputs'), + ('parameters', 'Guided arguments'), ('reconcile', 'Read saved jobs'), + ('export', 'Export result'), ('stop', 'Stop observation'), + ('new', 'New preparation'), + ('back', 'Back to monitor')): + yield Button(label, id='op-' + key) + yield Static('', markup=False, id='op-result') + yield Footer() + + def on_mount(self): + self.set_interval(0.1, self.render_state) + self.render_state() + + def collect_selection(self): + self.model.selected = [list(self.query_one('#op-select-%s' % index, SelectionList).selected) + for index in range(len(SECTIONS))] + + def render_state(self): + if not self.is_mounted: + return + model = self.model + model.poll_result() if model.mode == 'running' else None + busy = model.mode in ('running', 'confirm') or model.session.running + self.query_one('#op-selection').display = model.mode in ('select', 'confirm') + for widget in self.query(SelectionList): + widget.disabled = busy + for key in ('preview', 'inputs', 'parameters', 'reconcile'): + self.query_one('#op-' + key, Button).disabled = busy or model.mode == 'result' + self.query_one('#op-new', Button).disabled = busy + self.query_one('#op-export', Button).disabled = model.result is None or busy + self.query_one('#op-stop', Button).disabled = model.mode != 'running' + if model.error: + state, notice = 'warning', model.error + elif model.mode == 'running': + state, notice = 'running', 'Observing execution. Acceptance is not completion.' + elif model.mode == 'result' and model.result: + status = model.result['status'] + state = 'success' if status == 'completed' else 'warning' if status == 'incomplete' else 'failure' + notice = 'Operation result: ' + status + else: + state, notice = 'info', 'Preview first. Server authentication and endpoint ACLs remain authoritative.' + self.query_one('#op-status', StatusLine).set_status(state, notice) + body = (result_text(model.result) if model.result is not None + else progress_text(model.progress) if model.mode == 'running' + else 'Inputs: ' + model.input) + if body != self.last_display: + self.query_one('#op-result', Static).update(plain_text(body, multiline=True)) + self.last_display = body + + def on_button_pressed(self, event): + event.stop() + action = event.button.id + try: + if action == 'op-back': + self.action_back() + elif action == 'op-new': + self.model.mode, self.model.error = 'select', '' + self.model.result = self.model.result_lines = None + elif action == 'op-stop': + self.model.session.stop() + self.model.error = 'Stopping observation; remote jobs may continue. Do not replay uncertain jobs.' + elif action == 'op-inputs': + self.app.push_screen(InputForm('Operation inputs', [('payload', 'JSON args / env', self.model.input, False)]), self.inputs_received) + elif action == 'op-preview': + self.preview() + elif action == 'op-parameters': + self.parameters() + elif action in ('op-export', 'op-reconcile'): + self.collect_selection() + exporting = action == 'op-export' + self.app.push_screen(InputForm('Export result' if exporting else 'Read saved jobs', + [('path', 'New private file path' if exporting else 'Report path', '', False)], + hint='Existing files are never overwritten.' if exporting else + 'Select trusted targets first. Reads existing jobs; never submits or continues a scenario.'), + self.export_received if exporting else self.reconcile_received) + except (ValueError, OSError) as error: + self.model.error = str(error) + self.render_state() + + def inputs_received(self, values): + if values is not None: + self.model.input = values['payload'] + self.model.error = '' + + def preview(self): + if self.model.mode == 'confirm' or self.model.session.running: + return + self.collect_selection() + if (self.model.selected[2] or self.model.selected[3]) and self.model.selected[4]: + raise ValueError('Select scenarios OR one endpoint, not both.') + self.model.error = '' + self.model.prepare() + self.app.push_screen(Confirmation('Confirm execution', '\n'.join(self.model.preview), + confirm_label='Submit operation'), self.confirmed) + + def confirmed(self, accepted): + if self.model.mode != 'confirm': + return + service, self.model.service = self.model.service, None + self.model.mode = 'select' + if accepted: + try: + self.model.session.start(service) + self.model.result = self.model.result_lines = None + self.model.error = '' + self.model.mode = 'running' + except Exception: + self.model.error = 'Unable to start observation; inspect daemon jobs before any retry.' + self.model.mode = 'result' + + def parameters(self): + self.collect_selection() + schema = self.model.parameter_catalogue.get(self.model.selected[4][0]) if len(self.model.selected[4]) == 1 else None + if not schema or not schema.get('args'): + raise ValueError('Select one endpoint with a consistent published parameter schema.') + fields = schema['args'] + def received(values): + if values is None: + return + args = [values[str(index)] for index in range(len(fields))] + for field, value in zip(fields, args): + if (field.get('required', True) and not value) or (value and field.get('choices') and value not in field['choices']): + self.model.error = 'Invalid argument: ' + field['name'] + return + while args and not args[-1] and not fields[len(args)-1].get('required', True): + args.pop() + self.model.input = json.dumps({'args': args, 'env': {}}) + self.model.error = '' + self.app.push_screen(InputForm('Guided arguments', + [(str(index), '%s (%s) %s' % (field['name'], 'required' if field.get('required', True) else 'optional', + ', '.join(field.get('choices', []))), '', False) + for index, field in enumerate(fields)], hint='Replaces args and clears env. Server validation remains authoritative.'), received) + + def export_received(self, values): + if values is None: + return + try: + export_result(self.model.result, values['path']) + self.model.error = '' + self.notify('Result exported to a new private file.') + except (OSError, ValueError): + self.model.error = 'Export failed. Choose a new writable path; existing files are never overwritten.' + + def reconcile_received(self, values): + if values is None: + return + try: + targets = select_connections(self.model.selected[0], self.model.selected[1], self.model.connections, self.model.groups) + settings = {key: self.model.settings[key] for key in ('auth', 'http_timeout', 'timeout', 'transport') if key in self.model.settings} + service = ReconciliationService(load_report(values['path']), targets, **settings) + self.model.session.start(service) + self.model.result = self.model.result_lines = None + self.model.mode, self.model.error = 'running', '' + except (OSError, ValueError): + self.model.error = 'Cannot read report. Check its format and the selected trusted targets.' + + def action_back(self): + if self.model.session.running: + self.model.error = 'Execution active. Stop observation first; this does not cancel remote jobs.' + else: + self.app.pop_screen() + + +def progress_text(progress): + lines = ['Operation: ' + str(progress['operation_id']), 'Observed states; pending means not yet submitted.', ''] + for row in progress['rows']: + lines.append('%s · %s%s · %s · %sms' % (row['target'], row['scenario'] + '/' if row['scenario'] else '', + row['step'] or row['endpoint'], row['status'].upper(), row['duration_ms'])) + return '\n'.join(lines) + + +def result_text(result): + lines = ['OPERATION RESULT · ' + result['status'].upper(), 'Operation: ' + result['operation_id'], ''] + for target in result['targets']: + lines.append('%s · %s · %sms' % (target['target'], target['status'].upper(), target.get('duration_ms', '?'))) + lines.extend(['', 'JOB DETAILS']) + for target in result['targets']: + steps = [(scenario['name'], step) for scenario in target.get('scenarios', []) + for step in scenario['steps']] if 'scenarios' in target else [('', target)] + for scenario, step in steps: + lines.append('%s / %s%s · %s · exit=%s' % (target['target'], scenario + '/' if scenario else '', + step.get('step', result.get('endpoint', '')), step['status'].upper(), step['return_code'])) + lines.append(' job: %s · %s' % (step['job_id'], target.get('uri', '?'))) + if step.get('error'): + lines.append(' ' + step['error']) + for attempt in step.get('attempts', []): + lines.append(' Attempt %s · %s · %s' % (attempt['uri'], attempt['status'], attempt.get('reason') or '')) + if step.get('output_truncated'): + lines.append(' OUTPUT TRUNCATED') + for field in ('stdout', 'stderr'): + content = ''.join(step.get(field, [])) + if content: + lines.append(' ' + field + ':') + lines.extend(' ' + line for line in content.splitlines()) + return '\n'.join(lines) diff --git a/auton_client/textual_tui.py b/auton_client/textual_tui.py new file mode 100644 index 0000000..26894df --- /dev/null +++ b/auton_client/textual_tui.py @@ -0,0 +1,278 @@ +"""Textual adapter over observation and explicit operation services.""" +import hashlib +import json +import math +import time + +from dwho.tui.textual import DashboardApp, DetailPanel, TableRow +from textual.widgets import DataTable + +from auton_client.monitor import FleetMonitor +from auton_client.tui_model import daemon_specs, status_label +from auton_client.visibility import DaemonClient, JOB_STATES + +DEFAULT_REFRESH = 2.0 +POLL_SECONDS = 0.1 +NAVIGATION = (('jobs', 'Jobs'), ('endpoints', 'Endpoints'), ('output', 'Output'), ('daemons', 'Daemons')) +COLUMNS = ('RESOURCE', 'DAEMON', 'STATE / TYPE', 'EXIT / COUNT') + + +def identity(parts): + return hashlib.sha256(json.dumps(parts).encode()).hexdigest() + + +class OperatorApp(DashboardApp): + BINDINGS = DashboardApp.BINDINGS + [ + ('e', 'prepare', 'Operations'), ('r', 'refresh', 'Refresh'), ('p', 'pause', 'Pause'), + ('s', 'state', 'State'), ('c', 'clear_filters', 'Clear filters'), + ('v', 'stream', 'Stdout/stderr'), ('left_square_bracket', 'previous_daemon', 'Previous daemon'), + ('right_square_bracket', 'next_daemon', 'Next daemon'), ('a', 'all_daemons', 'All daemons')] + + def __init__(self, monitor, refresh=DEFAULT_REFRESH, clock=time.monotonic, demo=False, preparation=None): + super().__init__(product='Auton', heading='Operations overview', columns=COLUMNS, + navigation=NAVIGATION, mode='interactive' if preparation is not None else 'read_only', + subtitle='SYNTHETIC DEMO' if demo else 'DAEMON OBSERVATION') + self.monitor, self.refresh_seconds, self.clock, self.demo = monitor, refresh, clock, demo + self.preparation = preparation + self.names = list(monitor.clients) + self.daemon = self.names[0] + self.view, self.state, self.endpoint, self.output_job = 'jobs', None, None, None + self.stderr, self.paused = False, False + self.data, self.items = {}, {} + self.next_refresh = 0 + self._output_display = None + + def on_mount(self): + self.set_interval(POLL_SECONDS, self.tick) + self.tick() + + def requested_job(self): + if self.view != 'output' or self.output_job is None: + return None + return self.output_job if self.daemon is None else self.output_job[1:] + + def tick(self): + if not self.is_running: + return + if len(self.screen_stack) > 1: + return + result = self.monitor.poll() + if result is not None: + daemon, job, data = result + if daemon == self.daemon and job == self.requested_job(): + self.data = data + self.render_snapshot() + self.next_refresh = self.clock() + self.refresh_seconds + else: + self.next_refresh = 0 + if (not self.paused or self.next_refresh == 0) and self.clock() >= self.next_refresh: + if self.monitor.refresh(self.daemon, self.requested_job()): + self.next_refresh = float('inf') + self.update_notice() + + def update_notice(self): + errors = self.data.get('errors', {}) + if errors: + state, text = 'warning', 'Partial/unavailable: ' + '; '.join('%s: %s' % pair for pair in errors.items()) + elif self.data.get('partial'): + state, text = 'running', 'Waiting for remaining daemons; coverage is incomplete' + elif not self.data: + state, text = 'running', 'Reading daemon observations...' + else: + state, text = 'info', 'Observations loaded' + text += ' | %s | state=%s' % (self.daemon or 'ALL DAEMONS', self.state or 'all') + if self.paused: + text += ' | PAUSED — displayed observations are not refreshed' + if self.demo: + text = 'DEMO — synthetic data | ' + text + self.set_notice(state, text) + + def selected_item(self): + table = self.query_one(DataTable) + if table.cursor_row < len(self._visible_keys): + return self.items.get(self._visible_keys[table.cursor_row]) + return None + + def render_snapshot(self): + rows, self.items = [], {} + if self.view == 'daemons': + maintenance = self.data.get('health', {}).get('maintenance', {}) + source = self.data.get('daemons', [dict(name=self.daemon, state=( + 'error' if self.data.get('errors') else 'maintenance' if maintenance.get('enabled') + else 'ok' if self.data else 'pending'), jobs=len(self.data.get('jobs', [])), + error=maintenance.get('reason', ''))]) + elif self.view == 'endpoints': + source = self.data.get('endpoints', []) + elif self.view == 'output': + source = [] + detail = self.data.get('detail') + if detail: + source = [detail] + else: + source = [j for j in self.data.get('jobs', []) + if (self.state is None or j['status'] == self.state) + and (self.endpoint is None or (j.get('daemon', self.daemon), j['endpoint']) == self.endpoint)] + for item in source: + daemon = item.get('daemon', self.daemon) + if self.view == 'daemons': + key = identity(('daemon', item['name'])) + cells = (item['name'], item['name'], item['state'], item.get('jobs', '?')) + title = item['name'] + elif self.view == 'endpoints': + key = identity(('endpoint', daemon, item['name'])) + cells = (item['name'], daemon, 'ENDPOINT', '') + title = item['name'] + else: + key = identity(('job', daemon, item['endpoint'], item['uid'])) + cells = (item['uid'], daemon, status_label(item), + '-' if item.get('return_code') is None else item['return_code']) + title = item['uid'] + self.items[key] = item + rows.append(TableRow(key, cells, title, json.dumps(item, indent=2, ensure_ascii=False))) + self.display_rows(rows) + if self.view == 'output': + self.show_output() + self.set_activity('%s entries | e prepares an operation with explicit confirmation.' % len(rows) if self.preparation is not None else 'READ ONLY | %s entries' % len(rows)) + + def _show_row(self, key): + if self.view == 'output': + self.show_output() + else: + super()._show_row(key) + + def show_output(self): + detail = self.data.get('detail') + title = 'stderr / diagnostics' if self.stderr else 'stdout' + if not detail: + text = self.data.get('errors', {}).get('detail', 'Select a job and press Enter to read its output.') + else: + title = '%s | %s' % (detail['uid'], title) + text = ''.join(detail.get('errors' if self.stderr else 'stream', [])) or '(empty)' + display = (title, text) + if display != self._output_display: + panel = self.query_one(DetailPanel) + offset = panel.scroll_offset + same_job = self._output_display is not None and self._output_display[0] == title + panel.show_details(title, text) + if same_job: + panel.scroll_to(x=offset.x, y=offset.y, animate=False) + self._output_display = display + + def change_view(self, view): + if view == 'output' and self.view == 'jobs': + item = self.selected_item() + self.output_job = ((item.get('daemon', self.daemon), item['endpoint'], item['uid']) if item else None) + elif view != 'output': + self.output_job = None + self.view = view + self._output_display = None + self.select_navigation(view) + self.action_clear_search() + # A previous job's output is never shown while a new read is pending. + self.data.pop('detail', None) + self.render_snapshot() + self.next_refresh = 0 + + def on_dashboard_app_navigation_requested(self, message): + self.change_view(message.key) + + def on_data_table_row_selected(self, event): + item = self.selected_item() + if item is None: + return + if self.view == 'jobs': + self.change_view('output') + elif self.view == 'endpoints': + self.endpoint = (item.get('daemon', self.daemon), item['name']) + self.change_view('jobs') + elif self.view == 'daemons': + self.switch_daemon(item['name']) + + def switch_daemon(self, name): + self.daemon, self.data, self.endpoint, self.output_job = name, {}, None, None + self.change_view('jobs') + + def action_next_daemon(self): + self._cycle_daemon(1) + + def action_previous_daemon(self): + self._cycle_daemon(-1) + + def _cycle_daemon(self, step): + names = self.names + ([None] if len(self.names) > 1 else []) + self.switch_daemon(names[(names.index(self.daemon) + step) % len(names)]) + + def action_all_daemons(self): + self.switch_daemon(None if len(self.names) > 1 and self.daemon is not None else self.names[0]) + + def action_refresh(self): + self.next_refresh = 0 + + def action_pause(self): + self.paused = not self.paused + if not self.paused: + self.next_refresh = 0 + self.update_notice() + + def action_state(self): + states = (None,) + JOB_STATES + self.state = states[(states.index(self.state) + 1) % len(states)] + self.render_snapshot() + + def action_clear_filters(self): + self.state, self.endpoint = None, None + self.action_clear_search() + self.render_snapshot() + + def check_action(self, action, parameters): + if len(self.screen_stack) > 1: + return False + return True + + def action_prepare(self): + if self.preparation is None: + self.notify('Execution is not configured in this observation session.') + return + from auton_client.textual_operations import OperationsScreen + snapshots = getattr(self.monitor, 'snapshots', {}) or {self.daemon: self.data} + self.preparation.update(snapshots) + self.push_screen(OperationsScreen(self.preparation, demo=self.demo)) + + def action_quit(self): + if self.preparation is not None and self.preparation.session.running: + self.notify('Stop observation first. Remote jobs are not cancelled.', severity='warning') + return + self.exit() + + def action_stream(self): + self.stderr = not self.stderr + if self.view == 'output': + self.show_output() + + +def run(specs, uris, auth=None, http_timeout=30, refresh=DEFAULT_REFRESH, configured=None, selected=None, + groups=None, scenarios=None, scenario_groups=None, transport=None): + from dwho.cli import require_terminal + from auton_client.connections import target_origins, monitoring_origins + from auton_client.credentials import bind_credentials + require_terminal('TUI requires an interactive terminal') + if not math.isfinite(refresh) or refresh < 0.2: + raise ValueError('refresh must be at least 0.2 seconds') + connections = daemon_specs(specs, uris, configured) if selected is None else selected + if not connections: + raise ValueError('selection contains no targets') + auth = bind_credentials(auth, [uri for target in connections.values() for uri in target_origins(target)]) + clients = {name: DaemonClient(uri, auth, http_timeout, transport=transport) + for name, uri in monitoring_origins(connections).items()} + from auton_client.preparation_model import PreparationModel + from auton_client.session import ExecutionSession + monitor = FleetMonitor(clients) + session = ExecutionSession() + preparation = PreparationModel(connections, groups or {}, scenarios or {}, scenario_groups or {}, + session, {'auth': auth, 'http_timeout': http_timeout, 'transport': transport}) + try: + OperatorApp(monitor, refresh, preparation=preparation).run() + finally: + monitor.close() + session.close() + return 0 diff --git a/auton_client/tui.py b/auton_client/tui.py index e5234ff..23eab5b 100644 --- a/auton_client/tui.py +++ b/auton_client/tui.py @@ -17,19 +17,9 @@ MIN_COLS = 64 MAX_SEARCH = 128 DEFAULT_REFRESH = 2.0 -STATUS_LABELS = {'new': 'queued', 'processing': 'running', 'complete': 'completed'} -def status_label(job): - if job.get('execution_uncertain'): - return 'unknown' - if job.get('outcome') == 'job.cancelled': - return 'cancelled' - if job.get('cancel_requested') and job['status'] != 'complete': - return 'cancelling' - if job['status'] == 'complete' and job.get('return_code') not in (0, None): - return 'failed' - return STATUS_LABELS[job['status']] +from auton_client.tui_model import status_label, daemon_specs def safe_text(value): @@ -37,17 +27,6 @@ def safe_text(value): return ''.join(char if char.isprintable() else ' ' for char in str(value)) -def daemon_specs(specs, uris, configured=None): - from auton_client.connections import select_connections - if specs and uris: - raise ValueError('use --daemon or a single --uri for TUI, not both') - if not specs: - if len(uris) != 1: - raise ValueError('TUI needs --daemon NAME=URI or one --uri; failover URIs are not targets') - return {'default': uris[0]} - return select_connections(specs, configured=configured) - - class OperatorView: def __init__(self, monitor, refresh=DEFAULT_REFRESH, clock=time.monotonic, preparation=None): self.monitor = monitor diff --git a/auton_client/tui_model.py b/auton_client/tui_model.py new file mode 100644 index 0000000..4ec84a8 --- /dev/null +++ b/auton_client/tui_model.py @@ -0,0 +1,26 @@ +"""Presentation helpers shared by optional terminal renderers.""" +STATUS_LABELS = {'new': 'queued', 'processing': 'running', 'complete': 'completed'} + + +def status_label(job): + if job.get('execution_uncertain'): + return 'unknown' + if job.get('outcome') == 'job.cancelled': + return 'cancelled' + if job.get('cancel_requested') and job['status'] != 'complete': + return 'cancelling' + if job['status'] == 'complete' and job.get('return_code') not in (0, None): + return 'failed' + return STATUS_LABELS[job['status']] + + +def daemon_specs(specs, uris, configured=None): + from auton_client.connections import select_connections + if specs and uris: + raise ValueError('use --daemon or a single --uri for TUI, not both') + if not specs: + if len(uris) != 1: + raise ValueError('TUI needs --daemon NAME=URI or one --uri; failover URIs are not targets') + return {'default': uris[0]} + return select_connections(specs, configured=configured) + diff --git a/bin/auton b/bin/auton index f6618f1..a2a56b4 100755 --- a/bin/auton +++ b/bin/auton @@ -49,7 +49,9 @@ def argv_parse_check(): help='Start an unattended worker on this host and save private observations') parser.add_argument('--reconcile', metavar='REPORT.json', help='Read current states from a saved report; never submit or resume jobs') - parser.add_argument('--tui', action='store_true', help='Read-only ncurses operator interface') + parser.add_argument('--tui', action='store_true', help='Terminal operator interface') + parser.add_argument('--tui-ui', choices=('curses', 'textual'), default='curses', + help='TUI renderer; Textual is currently read-only') parser.add_argument('--daemon', action='append', default=[], metavar='SELECTOR[=URI]', help='TUI target name/glob/~regex from --config or NAME=URI') parser.add_argument('-t', '--target', action='append', default=[], metavar='SELECTOR[=URI]', @@ -203,6 +205,8 @@ def argv_parse_check(): if options.uid is None: options.uid = str(uuid.uuid4()) + if options.tui_ui != 'curses' and not options.tui: + parser.error('--tui-ui requires --tui') if options.daemon and not options.tui: parser.error('--daemon requires --tui; it is not a broadcast execution option') if options.tui: @@ -382,7 +386,10 @@ def main(options): """ if options.tui: try: - from auton_client.tui import run + if options.tui_ui == 'textual': + from auton_client.textual_tui import run + else: + from auton_client.tui import run auth = credentials_from_options(options) return_code = run(options.daemon, options.uri, auth, options.http_timeout, options.refresh, configured=options.configured_targets, selected=options.selected_targets, @@ -390,7 +397,7 @@ def main(options): scenarios=options.selected_scenarios if options.selected_scenarios is not None else options.configured_scenarios, scenario_groups=options.configured_scenario_groups, transport=options.transport) except ImportError: - print('Auton TUI requires Python curses support.', file=sys.stderr) + print('TUI renderer unavailable; see the terminal installation guide for its dependencies.', file=sys.stderr) return_code = 1 except KeyboardInterrupt: return_code = 2 diff --git a/docs/conf.py b/docs/conf.py index 61deb30..2fbcd63 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -201,7 +201,7 @@ def setup(app): #napoleon_numpy_docstring = False # Keep both documentation audiences explicit on every generated page. -html_context = {'contributor_index': 'contributors', 'contributor_pages': ['contributors', 'contributing', '1.0-readiness', 'architecture-review-2026-09-27', 'next-release-work']} +html_context = {'contributor_index': 'contributors', 'contributor_pages': ['textual-review', 'contributors', 'contributing', '1.0-readiness', 'architecture-review-2026-09-27', 'next-release-work']} html_sidebars = {'**': ['about.html', 'documentation-tracks.html', 'localtoc.html', 'searchbox.html']} REPOSITORY_DOCUMENTATION_URL = 'https://github.com/decryptus/auton/blob/master/' diff --git a/docs/contributors.rst b/docs/contributors.rst index ad4f2c8..db78e9b 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -12,3 +12,4 @@ For installation and operation, return to :doc:`/index`. 1.0-readiness architecture-review-2026-09-27 next-release-work + textual-review diff --git a/docs/index.md b/docs/index.md index c24d148..86989fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,8 @@ Installation, configuration, public APIs and troubleshooting. :caption: User guide and reference configuration-validation + textual + release-1.3.0 release-1.2.0 release-1.2.1 release-1.0 diff --git a/docs/release-1.3.0.md b/docs/release-1.3.0.md new file mode 100644 index 0000000..4719ae0 --- /dev/null +++ b/docs/release-1.3.0.md @@ -0,0 +1,18 @@ +# auton 1.3.0 + +## Optional Textual interface + +This version adds an optional read-only terminal dashboard using the shared +DWho 0.3.65 presentation components. Python 3.9+ is required for this extra. +The existing curses interface remains the default. + +View jobs, endpoints, daemon status and stdout/stderr with search, filtering +and selection-preserving refresh. Execution preparation, scenario editing, +export and reconciliation remain available through curses and CLI. + +After publication, install with `python -m pip install "auton[textual]==1.3.0"` +and select it with `auton --tui --tui-ui textual --uri https://daemon.example.org`. + +No configuration or stored-data migration is required. The offline demo uses +explicitly labelled synthetic fixtures and connects to no service. See the +[Textual guide](textual.md) for controls and limitations. diff --git a/docs/textual-review.md b/docs/textual-review.md new file mode 100644 index 0000000..e54d0dd --- /dev/null +++ b/docs/textual-review.md @@ -0,0 +1,24 @@ +# Textual validation and media + +The Textual adapter uses `dwho.tui.textual` and existing product services. +Base commands remain independent of optional Textual imports. Run the collection +check and the base and `textual_tests` unittest roots separately. + +```sh +python .github/scripts/check-test-collection.py --runner unittest tests textual_tests +python -m unittest discover -s tests -v +python -m unittest discover -s textual_tests -v +python scripts/capture_textual.py --output /tmp/textual-captures --png +``` + +PNG rendering requires `resvg-py`; do not substitute CairoSVG because its terminal +font layout differs. Captures use the actual interface with labelled synthetic +fixtures and include source/shared-library hashes. Inspect normal, unavailable, +confirmation, progress and result states. Do not claim synthetic captures prove +production authorization or deployment. Capture scripts and website generators +must use the released source revision. + +Auton retains explicit execution preparation, guided arguments, scenario/group +selection, result export and read-only reconciliation. Execution services retain +server ACLs, ordered failover and uncertain-outcome handling. monit-docker retains +the existing observation-only contract and does not execute interventions. diff --git a/docs/textual.md b/docs/textual.md new file mode 100644 index 0000000..942a62b --- /dev/null +++ b/docs/textual.md @@ -0,0 +1,44 @@ +# Textual terminal interface + +Install the optional terminal interface and select it explicitly. The existing curses interface remains available. + +```sh +python -m pip install 'auton[textual]' +auton --tui --tui-ui textual -c inventory.yml +``` + +Jobs, endpoints, stdout/stderr and daemon views share a searchable table and a scrollable detail panel. Enter opens a job output or filters jobs by endpoint. `[` / `]` switch daemons; `a` switches between one daemon and all daemons. `s` cycles job states, `c` clears filters, `r` requests refresh, `p` pauses refresh and `v` switches stdout/stderr. + +Press **e** to open operation preparation. Select targets or target groups and +either scenarios/scenario groups or one endpoint. Nothing is preselected for execution. +Use **JSON inputs** for args/env or **Guided arguments** for a consistent published +endpoint parameter schema. **Preview execution** shows the exact plan and ordered +failover destinations. Only **Submit operation** starts it; Cancel does nothing. + +Progress distinguishes queued, running, completed, rejected and unknown outcomes. +**Stop observation** does not cancel remote jobs. Wait for in-flight requests +before closing. Results show each target, scenario step, job ID, output and attempts. +**Export result** creates a new JSON file and never overwrites an existing file. +**Read saved jobs** reconciles a saved report against explicitly selected trusted +targets; it does not resubmit jobs or continue scenarios. **New preparation** +returns to selection; unknown outcomes must be inspected before any manual replay. + +Authentication, TLS, target selection, server ACLs and execution limits use the +same client/application services as the CLI and curses. The display bounds text +at 65,536 characters; exported results retain the service's original output limits. + +`/` focuses search, Escape clears it, and `q` quits. Resize to at least 80 by +24; 120 columns or more gives the detail panel more room. States have explicit +text as well as color. An accepted or running request is not labelled successful. + +## Offline demonstration + +```sh +python -m auton_client.textual_demo +``` + +This opens the actual interface with labelled synthetic fixtures. It connects +to no service and executes no real command. Auton operation results are simulated locally. Screenshots generated from it are +demonstrations, not production observations or deployment evidence. + +See the [contributor validation and media guide](textual-review.md) for tests and capture generation. diff --git a/scripts/capture_textual.py b/scripts/capture_textual.py new file mode 100644 index 0000000..a611ebb --- /dev/null +++ b/scripts/capture_textual.py @@ -0,0 +1,97 @@ +"""Capture the actual optional UI with labelled offline fixtures, never live data.""" +import argparse +import asyncio +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import subprocess +import dwho.tui.textual + +os.environ.pop('NO_COLOR', None) +from auton_client.textual_demo import demo_app + + +async def capture(output, png=False): + output.mkdir(parents=True, exist_ok=True) + app = demo_app(operations=True) + artifacts = [] + async with app.run_test(size=(156, 46)) as pilot: + await pilot.pause() + app.tick() + steps = [('jobs', None), ('endpoints', click_view), ('output', output_view), + ('daemons', daemons_view), ('prepare', prepare_view), ('confirm', confirm_view), ('result', result_view)] + for name, action in steps: + if action: + await action(pilot, app) + await pilot.pause() + path = output / (name + '.svg') + path.write_text(app.export_screenshot(title='auton — SYNTHETIC DEMO'), encoding='utf-8') + artifacts.append(dict(file=path.name, sha256=hashlib.sha256(path.read_bytes()).hexdigest())) + if png: + import resvg_py + rendered = path.with_suffix('.png') + rendered.write_bytes(resvg_py.svg_to_bytes(svg_path=str(path), monospace_family='DejaVu Sans Mono')) + artifacts.append(dict(file=rendered.name, sha256=hashlib.sha256(rendered.read_bytes()).hexdigest())) + app.preparation.session.close() + try: + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL, text=True).strip() + dirty = bool(subprocess.check_output(['git', 'status', '--porcelain'], text=True)) + except (OSError, subprocess.CalledProcessError): + revision, dirty = None, None + sources = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(Path('auton_client').glob('*.py'))} + shared_root = Path(dwho.tui.textual.__file__).parent + shared_sources = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() + for f in sorted(shared_root.glob('*.py'))} + (output / 'manifest.json').write_text(json.dumps(dict(product='auton', synthetic=True, + renderer='Textual', textual_version=importlib.metadata.version('textual'), + dwho_version=importlib.metadata.version('dwho'), shared_source_sha256=shared_sources, + source_revision=revision, dirty=dirty, source_sha256=sources, captures=artifacts), indent=2)+'\n') + + +async def click_view(pilot, app): + await pilot.click('#dw-nav-1') + app.tick(); app.tick() + + +async def output_view(pilot, app): + app.change_view('jobs') + await pilot.pause() + app.change_view('output') + app.tick(); app.tick() + + +async def daemons_view(pilot, app): + app.change_view('daemons'); app.tick(); app.tick() + + +async def prepare_view(pilot, app): + from textual.widgets import SelectionList + app.action_prepare() + await pilot.pause() + app.screen.query_one('#op-select-1', SelectionList).select('staging') + app.screen.query_one('#op-select-2', SelectionList).select('deploy-app') + + +async def confirm_view(pilot, app): + await pilot.click('#op-preview') + + +async def result_view(pilot, app): + await pilot.click('#dw-confirm') + for _ in range(100): + await pilot.pause(0.05) + if app.preparation.mode == 'result': + break + if app.preparation.mode != 'result' or app.preparation.result['status'] != 'failed': + raise RuntimeError('Synthetic operation did not reach its expected failed result') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--png', action='store_true', help='Also render PNG with resvg-py') + args = parser.parse_args() + asyncio.run(capture(args.output, args.png)) diff --git a/setup.py b/setup.py index 1916dda..46675d3 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ packages = ['auton_client'], options = {'build': {'build_base': 'build/auton'}}, install_requires = requirements_auton, + extras_require = {'textual': ['dwho[textual]>=0.3.65', "textual>=8.2.8,<9; python_version >= '3.9'"]}, python_requires = ', '.join(setup_cfg['python_requires']), classifiers = setup_cfg['common']['classifiers'] + setup_cfg['auton'].get('classifiers', []), long_description = long_desc, diff --git a/setup.yml b/setup.yml index 5e876cd..21f5402 100644 --- a/setup.yml +++ b/setup.yml @@ -2,8 +2,8 @@ author: Adrien Delle Cave author_email: pypi@doowan.net copyright: '2018-2023 fjord-technologies' -release: '1.2.1' -version: '1.2.1' +release: '1.3.0' +version: '1.3.0' license: License GPL-3 url: https://github.com/decryptus/auton python_requires: diff --git a/tests/test_preparation_model.py b/tests/test_preparation_model.py new file mode 100644 index 0000000..87eb1e2 --- /dev/null +++ b/tests/test_preparation_model.py @@ -0,0 +1,22 @@ +import subprocess +import sys +import unittest + + +class PreparationIsolationTests(unittest.TestCase): + def test_preparation_service_does_not_load_terminal_interfaces(self): + script = '''import importlib.abc, sys +class Block(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname in ('curses', 'textual', 'auton_client.preparation', 'auton_client.tui'): + raise AssertionError(fullname) +sys.meta_path.insert(0, Block()) +from auton_client.preparation_model import PreparationModel +from unittest.mock import Mock +model = PreparationModel({'one': 'http://one'}, {}, {}, {}, Mock(), {}) +model.selected[0] = ['one']; model.selected[4] = ['echo'] +model.prepare() +assert model.mode == 'confirm' +model.session.start.assert_not_called() +''' + subprocess.run([sys.executable, '-c', script], check=True) diff --git a/textual_tests/test_textual_operations.py b/textual_tests/test_textual_operations.py new file mode 100644 index 0000000..4a90e24 --- /dev/null +++ b/textual_tests/test_textual_operations.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from unittest.mock import Mock +from textual.widgets import SelectionList, Input +from auton_client.textual_demo import demo_app +from auton_client.preparation_model import PreparationModel + +class OperationUITests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + asyncio.get_running_loop().set_debug(False) + + def app(self): + app = demo_app() + session = Mock(running=False) + session.poll.return_value = None + session.progress.snapshot.return_value = {"revision": 0, "rows": [], "operation_id": None} + app.preparation = PreparationModel({'one': 'http://one'}, {}, {}, {}, session, {}) + return app + + async def test_preview_cancel_and_exactly_once_submission(self): + app = self.app() + async with app.run_test(size=(150, 45)) as pilot: + await pilot.pause(); await pilot.press('e'); await pilot.pause() + screen = app.screen + screen.query_one('#op-select-0', SelectionList).select('one') + screen.query_one('#op-select-4', SelectionList).select('inventory') + await pilot.click('#op-preview'); await pilot.pause() + app.preparation.session.start.assert_not_called() + await pilot.press('escape'); await pilot.pause() + app.preparation.session.start.assert_not_called() + await pilot.click('#op-preview'); await pilot.pause() + await pilot.click('#dw-confirm'); await pilot.pause() + app.preparation.session.start.assert_called_once() + screen.confirmed(True) + app.preparation.session.start.assert_called_once() + self.assertEqual(app.preparation.mode, 'running') + + async def test_input_dialog_return_and_active_exit_guard(self): + app = self.app() + async with app.run_test(size=(150, 45)) as pilot: + await pilot.pause(); await pilot.press('e'); await pilot.pause() + screen = app.screen + await pilot.click('#op-inputs'); await pilot.pause() + app.screen.query_one(Input).value = '{"args": ["hello"], "env": {}}' + await pilot.click('#form-submit'); await pilot.pause() + self.assertIn('hello', app.preparation.input) + app.preparation.session.running = True + await pilot.press('escape'); await pilot.pause() + self.assertIs(app.screen, screen) + app.preparation.session.running = False + await pilot.press('escape'); await pilot.pause() + self.assertIsNot(app.screen, screen) diff --git a/textual_tests/test_textual_tui.py b/textual_tests/test_textual_tui.py new file mode 100644 index 0000000..5f5ace3 --- /dev/null +++ b/textual_tests/test_textual_tui.py @@ -0,0 +1,93 @@ +import asyncio +import unittest +from unittest.mock import Mock, patch +from textual.widgets import DataTable, Static +from dwho.tui.textual import StatusLine +from auton_client.textual_demo import demo_app +from auton_client.textual_tui import OperatorApp, run + + +class TextualTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + asyncio.get_running_loop().set_debug(False) + + async def test_views_output_errors_filters_and_all_daemons(self): + app = demo_app() + async with app.run_test(size=(156, 46)) as pilot: + await pilot.pause(); app.tick(); await pilot.pause() + self.assertEqual(app.query_one(DataTable).row_count, 2) + await pilot.press('enter'); app.tick(); app.tick(); await pilot.pause() + self.assertEqual(app.view, 'output') + self.assertIn('3 resources', str(app.query_one('.dw-detail-text', Static).render())) + await pilot.press('v'); await pilot.pause() + self.assertIn('no remote command', str(app.query_one('.dw-detail-text', Static).render())) + app.action_all_daemons(); app.tick(); app.tick(); await pilot.pause() + self.assertEqual(app.query_one(DataTable).row_count, 3) + app.action_state(); await pilot.pause() + self.assertEqual(app.query_one(DataTable).row_count, 0) + app.action_clear_filters() + await pilot.click('#dw-nav-3'); app.tick(); app.tick(); await pilot.pause() + self.assertEqual(app.query_one(DataTable).row_count, 2) + + async def test_old_selection_response_cannot_replace_current_view(self): + monitor = Mock(); monitor.clients = {'one': None, 'two': None} + monitor.poll.return_value = None; monitor.refresh.return_value = False + app = OperatorApp(monitor) + async with app.run_test() as pilot: + app.switch_daemon('two') + monitor.poll.return_value = ('one', None, {'jobs': [{'uid': 'secret:old'}]}) + app.tick() + self.assertEqual(app.data, {}) + self.assertEqual(app.query_one(DataTable).row_count, 0) + + async def test_partial_errors_and_pause_are_visible(self): + app = demo_app() + async with app.run_test() as pilot: + app.data = {'errors': {'jobs': 'HTTP 403'}, 'partial': True} + app.update_notice() + self.assertEqual(app.query_one('#dw-notice', StatusLine).state, 'warning') + app.action_pause() + self.assertIn('PAUSED', app.query_one('#dw-notice', StatusLine).render().plain) + + async def test_unchanged_output_refresh_preserves_reader_scroll(self): + app = demo_app() + async with app.run_test(size=(140, 40)) as pilot: + app.monitor.close() + app.monitor.result = None + app.paused = True + app.next_refresh = float('inf') + app.view = 'output' + app.data = {'detail': dict(uid='read:example', endpoint='read', status='complete', + return_code=0, stream=['line\n' * 200], errors=[])} + app.render_snapshot() + await pilot.pause() + panel = app.query_one('#dw-details') + panel.scroll_to(y=50, animate=False) + await pilot.pause() + offset = panel.scroll_y + self.assertGreater(offset, 0) + app.render_snapshot() + await pilot.pause() + self.assertEqual(panel.scroll_y, offset) + + def test_terminal_rejected_before_clients(self): + with patch('sys.stdin.isatty', return_value=False), patch('auton_client.textual_tui.DaemonClient') as client: + with self.assertRaisesRegex(ValueError, 'interactive terminal'): + run([], ['https://example.invalid']) + client.assert_not_called() + + def test_read_only_launcher_preserves_transport_and_closes_monitor(self): + with patch('dwho.cli.require_terminal'), patch('auton_client.textual_tui.DaemonClient') as client, patch('auton_client.textual_tui.FleetMonitor') as monitor, patch('auton_client.textual_tui.OperatorApp') as app: + self.assertEqual(run([], ['https://example.invalid'], transport={'verify': True}), 0) + self.assertEqual(client.call_args.kwargs['transport'], {'verify': True}) + app.return_value.run.assert_called_once() + monitor.return_value.close.assert_called_once() + + async def test_refresh_does_not_access_monitor_after_app_shutdown(self): + app = demo_app() + async with app.run_test() as pilot: + await pilot.pause() + with patch.object(app.monitor, 'poll') as poll, patch.object(app.monitor, 'refresh') as refresh: + app.tick() + poll.assert_not_called() + refresh.assert_not_called()