Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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 <codex@openai.com> 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.
Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion RELEASE
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.1
1.3.0
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.1
1.3.0
2 changes: 1 addition & 1 deletion auton_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
103 changes: 4 additions & 99 deletions auton_client/preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
126 changes: 126 additions & 0 deletions auton_client/preparation_model.py
Original file line number Diff line number Diff line change
@@ -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


Loading
Loading