From a7fd93ac17736d2d784345e23f29fa28b8b94b5c Mon Sep 17 00:00:00 2001 From: decryptus Date: Wed, 7 Oct 2026 00:21:55 +0200 Subject: [PATCH 1/4] feat(tui): add optional shared Textual observation interface and reproducible captures --- .github/workflows/tests.yml | 30 ++++ AGENTS.md | 10 ++ MANIFEST.in | 3 + README.md | 6 + auton_client/textual_demo.py | 50 ++++++ auton_client/textual_tui.py | 247 ++++++++++++++++++++++++++++++ auton_client/tui.py | 23 +-- auton_client/tui_model.py | 26 ++++ bin/auton | 13 +- docs/conf.py | 2 +- docs/contributors.rst | 1 + docs/index.md | 1 + docs/textual-review.md | 54 +++++++ docs/textual.md | 35 +++++ scripts/capture_textual.py | 58 +++++++ setup.py | 1 + textual_tests/test_textual_tui.py | 84 ++++++++++ 17 files changed, 618 insertions(+), 26 deletions(-) create mode 100644 auton_client/textual_demo.py create mode 100644 auton_client/textual_tui.py create mode 100644 auton_client/tui_model.py create mode 100644 docs/textual-review.md create mode 100644 docs/textual.md create mode 100644 scripts/capture_textual.py create mode 100644 textual_tests/test_textual_tui.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4f1f99..bab969a 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: 01b802bb33aa02d4dff8a602e27404c5a7e5c21d + 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/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..43b3f14 100644 --- a/README.md +++ b/README.md @@ -1916,3 +1916,9 @@ 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). + +## Optional Textual interface (candidate) + +A shared Textual terminal interface is available on the candidate branch. +See the [installation and compatibility guide](docs/textual.md). The current +release and default curses interface are unchanged. diff --git a/auton_client/textual_demo.py b/auton_client/textual_demo.py new file mode 100644 index 0000000..0cf7ac9 --- /dev/null +++ b/auton_client/textual_demo.py @@ -0,0 +1,50 @@ +"""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 + + +def demo_app(): + return OperatorApp(DemoMonitor(), demo=True) + + +if __name__ == '__main__': + demo_app().run() diff --git a/auton_client/textual_tui.py b/auton_client/textual_tui.py new file mode 100644 index 0000000..160f2c4 --- /dev/null +++ b/auton_client/textual_tui.py @@ -0,0 +1,247 @@ +"""Read-only Textual adapter over FleetMonitor; no execution service or CLI import.""" +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 + [ + ('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): + super().__init__(product='Auton', heading='Operations overview', columns=COLUMNS, + navigation=NAVIGATION, mode='read_only', + subtitle='SYNTHETIC DEMO' if demo else 'DAEMON OBSERVATION') + self.monitor, self.refresh, self.clock, self.demo = monitor, refresh, clock, demo + 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): + 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 + 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('READ ONLY | %s entries\nExecution preparation remains available in the curses interface.' % 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 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()} + monitor = FleetMonitor(clients) + try: + OperatorApp(monitor, refresh).run() + finally: + monitor.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..4722c98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ Installation, configuration, public APIs and troubleshooting. :caption: User guide and reference configuration-validation + textual release-1.2.0 release-1.2.1 release-1.0 diff --git a/docs/textual-review.md b/docs/textual-review.md new file mode 100644 index 0000000..c9ad108 --- /dev/null +++ b/docs/textual-review.md @@ -0,0 +1,54 @@ +# Textual candidate: validation and media + +## Architecture and rollout + +The optional presentation imports the shared DWho dashboard. The product supplies +rows and handles navigation. Execution preparation, scenario editing, report export and reconciliation remain in curses/CLI. No POST, cancellation or execution service is wired into the Textual adapter. + +The default interface remains curses. This branch depends on the separate DWho +candidate `01b802bb33aa02d4dff8a602e27404c5a7e5c21d`; it must be reconciled with +the concurrent DWho/HTTPdis audit before merge. No release/version bump or +production website deployment is included. + +## Local validation (Python 3.12) + +The discovery roots are `tests`, `textual_tests` and `.github/tests`. +They contain 254 unittest cases: 234 base regressions, +6 optional Textual cases and 14 helper cases. Intentional skips: +2 Redis integration cases and one pytest-only helper case. Redis/Docker prerequisites remain separate CI gates. + +Optional tests exercise the real Textual app with headless key/click input, +existing service boundaries and offline fixtures. Packages are built and the +optional suite is also run against the installed wheel outside the checkout. +This is not production Docker acceptance or a complete SSH-terminal matrix. + +```sh +python .github/scripts/check-test-collection.py --runner unittest tests textual_tests .github/tests +python -m unittest discover -s tests -v +python -m unittest discover -s textual_tests -v +python -m unittest discover -s .github/tests -v +``` + +## Reproducible captures and demonstration + +```sh +PYTHONPATH=. python scripts/capture_textual.py --output /tmp/auton-textual-captures +``` + +This drives the actual interface and exports three SVG screens with synthetic +fixtures. The accompanying manifest records source hashes, renderer version, +Git revision when available and whether the checkout has modifications. No live +credentials or infrastructure are captured. The optional CI job publishes these +as review artifacts, without replacing release screenshots. + +The demonstration modules run interactively from the user guide. Existing +curses captures and production website source pins are preserved. After release +acceptance, update the website's source pin and regenerate shared manual +screenshots from this capture script; review scenario legends and source hashes. +Auton's README must continue to use the shared `auton.run/manual-captures/` +assets, not committed copies. Keep the same principle for monit-docker. + +For the video, replace the terminal passages only once the new interaction is +accepted. Existing narration and unrelated scenes can be retained where they +still match. These candidate captures do not imply that the complete published +video has been regenerated. diff --git a/docs/textual.md b/docs/textual.md new file mode 100644 index 0000000..1f21d29 --- /dev/null +++ b/docs/textual.md @@ -0,0 +1,35 @@ +# Textual terminal candidate + +The optional Textual interface requires Python 3.9+ and the unreleased shared +DWho Textual candidate. Existing commands retain curses as their default. +This feature is available on a review branch, not in the published release. + +From this candidate checkout: + +```sh +python -m pip install 'git+https://github.com/decryptus/dwho.git@01b802bb33aa02d4dff8a602e27404c5a7e5c21d' +AUTON_PACKAGE=auton python -m pip install '.[textual]' +auton --tui --tui-ui textual --uri https://daemon.example.org +``` + +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. + +This candidate is **read-only**. Execution preparation, scenario editing, result export and reconciliation still use the existing curses interface or CLI. It does not claim feature parity with that operator workflow. Authentication, TLS settings, explicitly selected targets and separate monitoring of failover origins use the existing client services. Displayed command output is bounded by the daemon response and the shared detail panel (65,536 characters). + +`/` 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 performs no operation. Screenshots generated from it are +candidate demonstrations, not production observations or release evidence. + +See the [contributor validation and media guide](textual-review.md) for tests +and capture generation. The published manual and existing screenshots still +represent the current release until migration is accepted. diff --git a/scripts/capture_textual.py b/scripts/capture_textual.py new file mode 100644 index 0000000..921c115 --- /dev/null +++ b/scripts/capture_textual.py @@ -0,0 +1,58 @@ +"""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 + +os.environ.pop('NO_COLOR', None) +from auton_client.textual_demo import demo_app + + +async def capture(output): + output.mkdir(parents=True, exist_ok=True) + app = demo_app() + 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)] + 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())) + 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'))} + (output / 'manifest.json').write_text(json.dumps(dict(product='auton', synthetic=True, + renderer='Textual', textual_version=importlib.metadata.version('textual'), + 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() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + asyncio.run(capture(args.output)) diff --git a/setup.py b/setup.py index 1916dda..cb87033 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>=0.3.63', "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/textual_tests/test_textual_tui.py b/textual_tests/test_textual_tui.py new file mode 100644 index 0000000..568119d --- /dev/null +++ b/textual_tests/test_textual_tui.py @@ -0,0 +1,84 @@ +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() From 8ead6de7856a5ef824580499436038f72e59fbbc Mon Sep 17 00:00:00 2001 From: decryptus Date: Wed, 7 Oct 2026 02:00:38 +0200 Subject: [PATCH 2/4] Prepare coordinated Textual release with audited DWho and reproducible media --- .github/workflows/tests.yml | 2 +- CHANGELOG | 8 ++++++++ RELEASE | 2 +- VERSION | 2 +- auton_client/__init__.py | 2 +- docs/index.md | 1 + docs/release-1.3.0.md | 18 ++++++++++++++++++ docs/textual-review.md | 11 ++++++++++- docs/textual.md | 2 +- scripts/capture_textual.py | 5 +++++ setup.py | 2 +- setup.yml | 4 ++-- 12 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 docs/release-1.3.0.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bab969a..e896162 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 with: repository: decryptus/dwho - ref: 01b802bb33aa02d4dff8a602e27404c5a7e5c21d + ref: fdc3a12b1547c9f78ebec28e1e53770444f34caf path: candidate-dwho persist-credentials: false - uses: actions/setup-python@v5 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/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/docs/index.md b/docs/index.md index 4722c98..86989fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ Installation, configuration, public APIs and troubleshooting. 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 index c9ad108..c033eea 100644 --- a/docs/textual-review.md +++ b/docs/textual-review.md @@ -6,7 +6,7 @@ The optional presentation imports the shared DWho dashboard. The product supplie rows and handles navigation. Execution preparation, scenario editing, report export and reconciliation remain in curses/CLI. No POST, cancellation or execution service is wired into the Textual adapter. The default interface remains curses. This branch depends on the separate DWho -candidate `01b802bb33aa02d4dff8a602e27404c5a7e5c21d`; it must be reconciled with +candidate `fdc3a12b1547c9f78ebec28e1e53770444f34caf`; it must be reconciled with the concurrent DWho/HTTPdis audit before merge. No release/version bump or production website deployment is included. @@ -52,3 +52,12 @@ For the video, replace the terminal passages only once the new interaction is accepted. Existing narration and unrelated scenes can be retained where they still match. These candidate captures do not imply that the complete published video has been regenerated. + +## Post-audit integration — 2026-10-07 + +This release candidate targets auton 1.3.0 with the optional shared +DWho 0.3.65 foundation, including published DWho 0.3.64 audit fixes. +HTTPdis 0.6.34 and Sonicprobe 0.3.58 are the current published dependencies. +The historical validation counts above are retained; the final pull request +records the coordinated CI checks and generated captures. Publication and +replacement of shared website media await the agreed visual acceptance. diff --git a/docs/textual.md b/docs/textual.md index 1f21d29..52c1a68 100644 --- a/docs/textual.md +++ b/docs/textual.md @@ -7,7 +7,7 @@ This feature is available on a review branch, not in the published release. From this candidate checkout: ```sh -python -m pip install 'git+https://github.com/decryptus/dwho.git@01b802bb33aa02d4dff8a602e27404c5a7e5c21d' +python -m pip install 'git+https://github.com/decryptus/dwho.git@fdc3a12b1547c9f78ebec28e1e53770444f34caf' AUTON_PACKAGE=auton python -m pip install '.[textual]' auton --tui --tui-ui textual --uri https://daemon.example.org ``` diff --git a/scripts/capture_textual.py b/scripts/capture_textual.py index 921c115..a48bfaa 100644 --- a/scripts/capture_textual.py +++ b/scripts/capture_textual.py @@ -7,6 +7,7 @@ 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 @@ -34,8 +35,12 @@ async def capture(output): 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') diff --git a/setup.py b/setup.py index cb87033..46675d3 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ packages = ['auton_client'], options = {'build': {'build_base': 'build/auton'}}, install_requires = requirements_auton, - extras_require = {'textual': ['dwho>=0.3.63', "textual>=8.2.8,<9; python_version >= '3.9'"]}, + 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: From 6c28d6414be24c76e22995ce9359a3e81ff2ec58 Mon Sep 17 00:00:00 2001 From: decryptus Date: Wed, 7 Oct 2026 02:04:24 +0200 Subject: [PATCH 3/4] Stop Textual observation callbacks after application shutdown --- auton_client/textual_tui.py | 2 ++ textual_tests/test_textual_tui.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/auton_client/textual_tui.py b/auton_client/textual_tui.py index 160f2c4..0aece07 100644 --- a/auton_client/textual_tui.py +++ b/auton_client/textual_tui.py @@ -51,6 +51,8 @@ def requested_job(self): return self.output_job if self.daemon is None else self.output_job[1:] def tick(self): + if not self.is_running: + return result = self.monitor.poll() if result is not None: daemon, job, data = result diff --git a/textual_tests/test_textual_tui.py b/textual_tests/test_textual_tui.py index 568119d..5f5ace3 100644 --- a/textual_tests/test_textual_tui.py +++ b/textual_tests/test_textual_tui.py @@ -82,3 +82,12 @@ def test_read_only_launcher_preserves_transport_and_closes_monitor(self): 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() From b5934d5882619aa0d707f7f2d60adc1e40c9f2af Mon Sep 17 00:00:00 2001 From: decryptus Date: Wed, 7 Oct 2026 02:48:42 +0200 Subject: [PATCH 4/4] Complete Textual workflows and refresh interface documentation --- .github/workflows/tests.yml | 2 +- README.md | 25 ++- auton_client/preparation.py | 103 +-------- auton_client/preparation_model.py | 126 ++++++++++++ auton_client/textual_demo.py | 31 ++- auton_client/textual_operations.py | 252 +++++++++++++++++++++++ auton_client/textual_tui.py | 45 +++- docs/textual-review.md | 73 ++----- docs/textual.md | 39 ++-- scripts/capture_textual.py | 42 +++- tests/test_preparation_model.py | 22 ++ textual_tests/test_textual_operations.py | 52 +++++ 12 files changed, 617 insertions(+), 195 deletions(-) create mode 100644 auton_client/preparation_model.py create mode 100644 auton_client/textual_operations.py create mode 100644 tests/test_preparation_model.py create mode 100644 textual_tests/test_textual_operations.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e896162..84b31cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 with: repository: decryptus/dwho - ref: fdc3a12b1547c9f78ebec28e1e53770444f34caf + ref: 50b50eaa9696628e9521a31ae92305e01966e6fa path: candidate-dwho persist-credentials: false - uses: actions/setup-python@v5 diff --git a/README.md b/README.md index 43b3f14..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: @@ -1917,8 +1917,15 @@ 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). -## Optional Textual interface (candidate) -A shared Textual terminal interface is available on the candidate branch. -See the [installation and compatibility guide](docs/textual.md). The current -release and default curses interface are unchanged. +## 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/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 index 0cf7ac9..4d0a380 100644 --- a/auton_client/textual_demo.py +++ b/auton_client/textual_demo.py @@ -42,9 +42,34 @@ def close(self): self.closed = True -def demo_app(): - return OperatorApp(DemoMonitor(), demo=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__': - demo_app().run() + 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 index 0aece07..26894df 100644 --- a/auton_client/textual_tui.py +++ b/auton_client/textual_tui.py @@ -1,4 +1,4 @@ -"""Read-only Textual adapter over FleetMonitor; no execution service or CLI import.""" +"""Textual adapter over observation and explicit operation services.""" import hashlib import json import math @@ -23,16 +23,17 @@ def identity(parts): class OperatorApp(DashboardApp): BINDINGS = DashboardApp.BINDINGS + [ - ('r', 'refresh', 'Refresh'), ('p', 'pause', 'Pause'), + ('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): + 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='read_only', + navigation=NAVIGATION, mode='interactive' if preparation is not None else 'read_only', subtitle='SYNTHETIC DEMO' if demo else 'DAEMON OBSERVATION') - self.monitor, self.refresh, self.clock, self.demo = monitor, refresh, clock, demo + 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 @@ -53,13 +54,15 @@ def requested_job(self): 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 + 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: @@ -129,7 +132,7 @@ def render_snapshot(self): self.display_rows(rows) if self.view == 'output': self.show_output() - self.set_activity('READ ONLY | %s entries\nExecution preparation remains available in the curses interface.' % len(rows)) + 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': @@ -221,6 +224,26 @@ def action_clear_filters(self): 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': @@ -241,9 +264,15 @@ def run(specs, uris, auth=None, http_timeout=30, refresh=DEFAULT_REFRESH, config 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).run() + OperatorApp(monitor, refresh, preparation=preparation).run() finally: monitor.close() + session.close() return 0 diff --git a/docs/textual-review.md b/docs/textual-review.md index c033eea..e54d0dd 100644 --- a/docs/textual-review.md +++ b/docs/textual-review.md @@ -1,63 +1,24 @@ -# Textual candidate: validation and media +# Textual validation and media -## Architecture and rollout - -The optional presentation imports the shared DWho dashboard. The product supplies -rows and handles navigation. Execution preparation, scenario editing, report export and reconciliation remain in curses/CLI. No POST, cancellation or execution service is wired into the Textual adapter. - -The default interface remains curses. This branch depends on the separate DWho -candidate `fdc3a12b1547c9f78ebec28e1e53770444f34caf`; it must be reconciled with -the concurrent DWho/HTTPdis audit before merge. No release/version bump or -production website deployment is included. - -## Local validation (Python 3.12) - -The discovery roots are `tests`, `textual_tests` and `.github/tests`. -They contain 254 unittest cases: 234 base regressions, -6 optional Textual cases and 14 helper cases. Intentional skips: -2 Redis integration cases and one pytest-only helper case. Redis/Docker prerequisites remain separate CI gates. - -Optional tests exercise the real Textual app with headless key/click input, -existing service boundaries and offline fixtures. Packages are built and the -optional suite is also run against the installed wheel outside the checkout. -This is not production Docker acceptance or a complete SSH-terminal matrix. +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 .github/tests +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 -m unittest discover -s .github/tests -v +python scripts/capture_textual.py --output /tmp/textual-captures --png ``` -## Reproducible captures and demonstration - -```sh -PYTHONPATH=. python scripts/capture_textual.py --output /tmp/auton-textual-captures -``` - -This drives the actual interface and exports three SVG screens with synthetic -fixtures. The accompanying manifest records source hashes, renderer version, -Git revision when available and whether the checkout has modifications. No live -credentials or infrastructure are captured. The optional CI job publishes these -as review artifacts, without replacing release screenshots. - -The demonstration modules run interactively from the user guide. Existing -curses captures and production website source pins are preserved. After release -acceptance, update the website's source pin and regenerate shared manual -screenshots from this capture script; review scenario legends and source hashes. -Auton's README must continue to use the shared `auton.run/manual-captures/` -assets, not committed copies. Keep the same principle for monit-docker. - -For the video, replace the terminal passages only once the new interaction is -accepted. Existing narration and unrelated scenes can be retained where they -still match. These candidate captures do not imply that the complete published -video has been regenerated. - -## Post-audit integration — 2026-10-07 - -This release candidate targets auton 1.3.0 with the optional shared -DWho 0.3.65 foundation, including published DWho 0.3.64 audit fixes. -HTTPdis 0.6.34 and Sonicprobe 0.3.58 are the current published dependencies. -The historical validation counts above are retained; the final pull request -records the coordinated CI checks and generated captures. Publication and -replacement of shared website media await the agreed visual acceptance. +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 index 52c1a68..942a62b 100644 --- a/docs/textual.md +++ b/docs/textual.md @@ -1,20 +1,31 @@ -# Textual terminal candidate +# Textual terminal interface -The optional Textual interface requires Python 3.9+ and the unreleased shared -DWho Textual candidate. Existing commands retain curses as their default. -This feature is available on a review branch, not in the published release. - -From this candidate checkout: +Install the optional terminal interface and select it explicitly. The existing curses interface remains available. ```sh -python -m pip install 'git+https://github.com/decryptus/dwho.git@fdc3a12b1547c9f78ebec28e1e53770444f34caf' -AUTON_PACKAGE=auton python -m pip install '.[textual]' -auton --tui --tui-ui textual --uri https://daemon.example.org +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. -This candidate is **read-only**. Execution preparation, scenario editing, result export and reconciliation still use the existing curses interface or CLI. It does not claim feature parity with that operator workflow. Authentication, TLS settings, explicitly selected targets and separate monitoring of failover origins use the existing client services. Displayed command output is bounded by the daemon response and the shared detail panel (65,536 characters). +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 @@ -27,9 +38,7 @@ python -m auton_client.textual_demo ``` This opens the actual interface with labelled synthetic fixtures. It connects -to no service and performs no operation. Screenshots generated from it are -candidate demonstrations, not production observations or release evidence. +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. The published manual and existing screenshots still -represent the current release until migration is accepted. +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 index a48bfaa..a611ebb 100644 --- a/scripts/capture_textual.py +++ b/scripts/capture_textual.py @@ -13,14 +13,15 @@ from auton_client.textual_demo import demo_app -async def capture(output): +async def capture(output, png=False): output.mkdir(parents=True, exist_ok=True) - app = demo_app() + 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)] + 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) @@ -28,6 +29,12 @@ async def capture(output): 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)) @@ -56,8 +63,35 @@ async def output_view(pilot, app): 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)) + asyncio.run(capture(args.output, args.png)) 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)