From 16d137dcf6181188d887e4189650f25682906898 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 06:19:49 +0100 Subject: [PATCH 1/5] Improve connection setup, command guidance and workflow recovery --- .env.example | 4 + README.md | 2 + app/__init__.py | 2 + app/automation_notifications.py | 136 + app/automation_routes.py | 62 +- app/automation_schedule.py | 33 +- app/automation_service.py | 49 +- app/automation_store.py | 22 + app/backup_manager.py | 5 +- app/customer_workflows.py | 148 + app/resources/commands/system_commands.json | 2550 ++++++++++++++--- app/user_settings.py | 12 + docker-compose.yml | 3 + docs/automation.md | 13 + docs/credentials.md | 4 + docs/webssh-3.md | 8 + .../Backup-Restore-and-Secret-Rotation.md | 4 + .../Terminal-and-Persistent-tmux-Sessions.md | 6 + static/css/automation.css | 4 + static/css/customer-workflows.css | 25 + static/css/management-ui.css | 9 + static/css/overview-workspace.css | 28 + static/i18n/commands/de.json | 10 +- static/i18n/commands/es.json | 10 +- static/i18n/commands/fr.json | 10 +- static/i18n/commands/vi.json | 10 +- static/i18n/commands/zh.json | 10 +- static/js/app.js | 2 +- static/js/automation-i18n.js | 68 +- static/js/automation.js | 37 +- static/js/command-library.js | 60 +- static/js/command-safety-i18n.js | 84 + static/js/customer-workflows-i18n.js | 355 +++ static/js/customer-workflows.js | 177 ++ static/js/i18n-auth.js | 8 + static/js/i18n.js | 32 +- static/js/overview-model.js | 34 +- static/js/overview-workspace.js | 98 +- static/js/session-command-launcher.js | 18 + static/js/sftp-file-manager.js | 1 + static/js/work-first-i18n.js | 205 ++ templates/_automation_workspace.html | 14 +- templates/_overview_workspace.html | 15 +- templates/admin.html | 4 + templates/index.html | 15 + tests/e2e/automation-followthrough.spec.js | 48 + tests/e2e/commands-experience.spec.js | 75 +- tests/e2e/customer-workflows.spec.js | 57 + tests/e2e/recovery-review-notice.spec.js | 77 + tests/e2e/work-first-setup.spec.js | 103 + tests/js/automation-tools.test.js | 2 +- tests/js/command-library-validation.test.js | 40 + tests/js/command-safety.test.js | 64 + tests/js/customer-floating-layout.test.js | 8 + tests/js/customer-workflows.test.js | 23 + tests/js/i18n-completeness.test.js | 33 +- tests/js/management-inspector.test.js | 10 +- tests/js/overview-work-first.test.js | 52 + tests/test_automation_followthrough.py | 228 ++ tests/test_backup_credential_exclusion.py | 10 +- tests/test_backup_manager.py | 9 +- tests/test_customer_workflows.py | 74 + tests/test_i18n_parity.py | 31 +- tests/test_recovery_marker_cli.py | 16 + tests/test_recovery_review.py | 69 + 65 files changed, 4988 insertions(+), 447 deletions(-) create mode 100644 app/automation_notifications.py create mode 100644 app/customer_workflows.py create mode 100644 static/css/customer-workflows.css create mode 100644 static/js/command-safety-i18n.js create mode 100644 static/js/customer-workflows-i18n.js create mode 100644 static/js/customer-workflows.js create mode 100644 static/js/work-first-i18n.js create mode 100644 tests/e2e/automation-followthrough.spec.js create mode 100644 tests/e2e/customer-workflows.spec.js create mode 100644 tests/e2e/recovery-review-notice.spec.js create mode 100644 tests/e2e/work-first-setup.spec.js create mode 100644 tests/js/command-safety.test.js create mode 100644 tests/js/customer-floating-layout.test.js create mode 100644 tests/js/customer-workflows.test.js create mode 100644 tests/js/overview-work-first.test.js create mode 100644 tests/test_automation_followthrough.py create mode 100644 tests/test_customer_workflows.py create mode 100644 tests/test_recovery_marker_cli.py create mode 100644 tests/test_recovery_review.py diff --git a/.env.example b/.env.example index 30b8347b..cccd4ea1 100644 --- a/.env.example +++ b/.env.example @@ -355,3 +355,7 @@ RATELIMIT_BACKUP_CREATE=3 per hour RATELIMIT_BACKUP_UPLOAD=5 per hour RATELIMIT_BACKUP_DOWNLOAD=10 per hour RATELIMIT_BACKUP_RESTORE=3 per hour + +# Optional administrator-owned HTTPS endpoint for opted-in workflow attention signals. +# Payload contains only event/status, no host names, commands or output. No redirects. +AUTOMATION_NOTIFICATION_WEBHOOK_URL= diff --git a/README.md b/README.md index 49d40fcf..90cf4055 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ See the [automation guide](docs/automation.md) and - Broadcast input, terminal search, transcripts, reconnect controls, and configurable scrollback. - Session-aware Files, Commands, Diagnostics, and Notes contexts. +- Named, account-owned workspace presets for pane layouts and saved hosts, + with explicit connections and visible session-continuity guidance. - **Active Session Monitoring** for supported Linux resource and runtime data. - **Expanded Diagnostics** for resource history, processes, systemd services, and Docker containers. diff --git a/app/__init__.py b/app/__init__.py index af980156..d4cdfd8b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -849,6 +849,8 @@ def enforce_security_feature_gate(): from .credential_service import CredentialService from .onboarding import onboarding_blueprint app.register_blueprint(onboarding_blueprint) + from .customer_workflows import customer_workflows_blueprint + app.register_blueprint(customer_workflows_blueprint) from .overview_routes import overview_blueprint app.register_blueprint(overview_blueprint) from .discovery_routes import cancel_all as cancel_discovery, discovery_available, discovery_blueprint diff --git a/app/automation_notifications.py b/app/automation_notifications.py new file mode 100644 index 00000000..719a54af --- /dev/null +++ b/app/automation_notifications.py @@ -0,0 +1,136 @@ +"""Opt-in, one-shot attention signals to an administrator-owned HTTPS endpoint.""" +import http.client +import json +import os +import ssl +import threading +from urllib.parse import urlsplit + +from flask import current_app +from .automation_store import notification_preferences, incarnation + + +_job_slot = threading.BoundedSemaphore(1) +_network_slot = threading.BoundedSemaphore(1) +_pending = set() + + +def endpoint(): + value = current_app.config.get('AUTOMATION_NOTIFICATION_WEBHOOK_URL', os.environ.get('AUTOMATION_NOTIFICATION_WEBHOOK_URL', '')) + try: + parsed = urlsplit(value) + if (not isinstance(value, str) or len(value) > 2048 or any(ord(c) < 33 for c in value) + or parsed.scheme != 'https' or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.fragment or (parsed.port is not None and not 1 <= parsed.port <= 65535)): + return None + return value + except (ValueError, TypeError): + return None + + +def status(user_id): + preferences = notification_preferences(user_id) + delivery = preferences['delivery'] + if delivery == 'pending' and (id(current_app._get_current_object()), int(user_id)) not in _pending: + delivery = 'failed' # A restart cannot prove the last delivery completed. + return dict(enabled=preferences['enabled'], available=endpoint() is not None, delivery=delivery) + + +def _post(url, payload): + # Direct standard-library connection: no proxy environment, redirects or retry. + parsed = urlsplit(url) + connection = http.client.HTTPSConnection(parsed.hostname, parsed.port or 443, timeout=3, context=ssl.create_default_context()) + try: + connection.request('POST', (parsed.path or '/') + ('?' + parsed.query if parsed.query else ''), + body=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json', 'User-Agent': 'WebSSH-Workflow'}) + response = connection.getresponse() + if not 200 <= response.status < 300: + raise ValueError('delivery_failed') + # No response body is needed or retained. + finally: + connection.close() + + +def _deliver(url, payload): + # A wall-clock bound also covers DNS and a peer sending headers very slowly. + # At most one daemon request may outlive the deadline; no queue or replay. + if not _network_slot.acquire(blocking=False): + raise TimeoutError('delivery_busy') + completed = threading.Event() + outcome = [] + def request_once(): + try: + _post(url, payload) + outcome.append(True) + except Exception: + pass + finally: + _network_slot.release() + completed.set() + worker = threading.Thread(target=request_once, name='workflow-notification-http', daemon=True) + try: + worker.start() + except Exception: + _network_slot.release() + raise + if not completed.wait(3) or not outcome: + raise TimeoutError('delivery_failed') + + +def notify(user_id, event): + """Queue at most one best-effort delivery without delaying workflow work.""" + if event not in {'failed', 'unknown', 'needs_review'}: + return + acquired = False + pending_key = None + try: + preferences = notification_preferences(user_id) + if not preferences['enabled']: + return + url = endpoint() + if url is None: + notification_preferences(user_id, delivery='unavailable') + return + acquired = _job_slot.acquire(blocking=False) + if not acquired: + notification_preferences(user_id, delivery='failed') + return + app = current_app._get_current_object() + from .automation_schedule import _account, _policy + account = _account(user_id) + policy = _policy() + pending_key = (id(app), int(user_id)) + _pending.add(pending_key) + notification_preferences(user_id, delivery='pending') + def deliver(cancelled): + try: + with app.app_context(): + if incarnation(user_id) != account[0]: + return + delivery = 'failed' + try: + if (not cancelled.is_set() and endpoint() == url + and _account(user_id) == account and _policy() == policy + and notification_preferences(user_id)['enabled']): + _deliver(url, {'event': 'workflow_attention', 'status': event}) + delivery = 'sent' + except Exception: + pass + if incarnation(user_id) == account[0]: + notification_preferences(user_id, delivery=delivery) + except Exception: + pass + finally: + _pending.discard(pending_key) + _job_slot.release() + app.extensions['runtime_lifecycle'].start_job('workflow-notification', deliver, owner_id=user_id) + acquired = False # Job now owns the admission slot. + except Exception: + if acquired: + _pending.discard(pending_key) + _job_slot.release() + try: + notification_preferences(user_id, delivery='failed') + except Exception: + pass diff --git a/app/automation_routes.py b/app/automation_routes.py index 101750c1..50790364 100644 --- a/app/automation_routes.py +++ b/app/automation_routes.py @@ -1,6 +1,9 @@ """Authenticated HTTP control plane for native automation and write-only vault.""" import ipaddress -from flask import Blueprint, current_app, jsonify, request, redirect, url_for +import csv +import io +import json +from flask import Blueprint, current_app, jsonify, request, redirect, url_for, Response from flask_login import current_user, login_required import config from .automation_store import AutomationError, runbooks, save_runbook, delete_runbook @@ -11,6 +14,9 @@ from .storage_errors import StorageCorruptionError from .quota_manager import QuotaExceeded +from .automation_notifications import status as notification_status +from .automation_store import notification_preferences + automation_blueprint = Blueprint('automation', __name__) def service(): @@ -124,7 +130,8 @@ def target(profile): return jsonify(enabled=True, credentials_enabled=is_credentials_enabled(), runbooks=runbooks(current_user.id), profiles=[target(item) for item in resources if item['kind'] == 'ssh'], - vault=credentials.vault_status(current_user.id, auth.id)) + vault=credentials.vault_status(current_user.id, auth.id), + notifications=notification_status(current_user.id)) @automation_blueprint.post('/api/automation/runbooks') @login_required @@ -272,3 +279,54 @@ def schedule_delete(schedule_id): _secure() current_app.extensions['automation_schedules'].pause(current_user.id, schedule_id, delete=True) return jsonify(deleted=True) + + +@automation_blueprint.post('/api/automation/notifications') +@login_required +def notifications_update(): + _auth() + _enabled() + data = _data() + if set(data) != {'enabled'} or type(data['enabled']) is not bool: + raise AutomationError('invalid_request') + notification_preferences(current_user.id, enabled=data['enabled']) + return jsonify(notifications=notification_status(current_user.id)) + + +@automation_blueprint.get('/api/automation/runs//export') +@login_required +def export_run(run_id): + _auth() + _enabled() + format_name = request.args.get('format', 'json') + if format_name not in {'json', 'csv'}: + raise AutomationError('invalid_request') + # Same validated, size-bounded, owner-fenced source as the run history UI. + run = next((item for item in service().history(current_user.id) if item['id'] == run_id), None) + if run is None: + raise AutomationError('not_found') + if format_name == 'json': + body = json.dumps(run, ensure_ascii=True) + mimetype = 'application/json' + else: + output = io.StringIO(newline='') + writer = csv.writer(output) + writer.writerow(['run_id', 'workflow', 'created_at', 'finished_at', 'run_status', + 'target_id', 'target', 'host', 'port', 'username', 'group', 'target_status', 'target_reason', + 'step', 'status', 'reason', 'exit_code', 'duration', 'output_truncated', 'stdout', 'stderr']) + def safe(value): + value = '' if value is None else str(value) + return "'" + value if value.lstrip('\ufeff \t\r\n\v\f').startswith(('=', '+', '-', '@')) or value.startswith(('\t', '\r', '\n')) else value + for target in run['targets']: + for step in target['steps']: + values = [run['id'], run['name'], run['created_at'], run.get('finished_at'), run['status'], + target['id'], target['name'], target['host'], target['port'], target['username'], target['group'], + target['status'], target['reason'], step['name'], step['status'], step.get('reason'), + step['exit_code'], step['duration'], step.get('output_truncated'), + step.get('stdout', ''), step.get('stderr', '')] + writer.writerow([safe(value) for value in values]) + body, mimetype = output.getvalue(), 'text/csv' + response = Response(body, mimetype=mimetype) + response.headers['Content-Disposition'] = f'attachment; filename="workflow-run.{format_name}"' + response.headers['X-Content-Type-Options'] = 'nosniff' + return response diff --git a/app/automation_schedule.py b/app/automation_schedule.py index 71372896..83419b2c 100644 --- a/app/automation_schedule.py +++ b/app/automation_schedule.py @@ -239,13 +239,22 @@ def tick(self, *, now=None): # Recover ambiguous claims independently of the next cron occurrence. # Revoking the revision fences surviving workers; only fresh approval # can resume scheduling and the unknown outcome remains visible. - db.session.execute(update(AutomationSchedule).where( - AutomationSchedule.claim_id.is_not(None), - or_(AutomationSchedule.claimed_at.is_(None), - AutomationSchedule.claimed_at < now.timestamp() - 1860) - ).values(enabled=False, last_status='unknown', claim_id=None, - claimed_at=None, revision=AutomationSchedule.revision + 1)) + interrupted = [(row.id, row.user_id, row.revision, row.claim_id, row.last_status) + for row in AutomationSchedule.query.filter(AutomationSchedule.claim_id.is_not(None), + or_(AutomationSchedule.claimed_at.is_(None), AutomationSchedule.claimed_at < now.timestamp() - 1860)).all()] + recovered = [] + for sid, uid, rev, claim, previous_status in interrupted: + changed = db.session.execute(update(AutomationSchedule).where( + AutomationSchedule.id == sid, AutomationSchedule.revision == rev, + AutomationSchedule.claim_id == claim + ).values(enabled=False, last_status='unknown', claim_id=None, + claimed_at=None, revision=rev + 1)) + if changed.rowcount and previous_status != 'unknown': + recovered.append(uid) db.session.commit() + from .automation_notifications import notify + for uid in recovered: + notify(uid, 'unknown') from .app_settings import is_automation_enabled if not is_automation_enabled() or not self.app.extensions['runtime_lifecycle'].accepting_work(): return @@ -288,19 +297,25 @@ def tick(self, *, now=None): last_status='skipped' if transient else 'needs_review') if not transient: values.update(enabled=False, revision=rev + 2) - db.session.execute(update(AutomationSchedule).where( + changed = db.session.execute(update(AutomationSchedule).where( AutomationSchedule.id == sid, AutomationSchedule.claim_id == claim ).values(**values)) db.session.commit() + if changed.rowcount and not transient: + notify(uid, 'needs_review') except Exception: # Never retry an ambiguous dispatch automatically. - db.session.execute(update(AutomationSchedule).where( + changed = db.session.execute(update(AutomationSchedule).where( AutomationSchedule.id == sid, AutomationSchedule.claim_id == claim ).values(enabled=False, last_status='unknown')) db.session.commit() + if changed.rowcount: + notify(uid, 'unknown') except Exception: db.session.rollback() - db.session.execute(update(AutomationSchedule).where( + changed = db.session.execute(update(AutomationSchedule).where( AutomationSchedule.id == sid, AutomationSchedule.revision == rev ).values(enabled=False, last_status='needs_review', revision=rev + 1)) db.session.commit() + if changed.rowcount: + notify(uid, 'needs_review') diff --git a/app/automation_service.py b/app/automation_service.py index 5044ada7..65a51580 100644 --- a/app/automation_service.py +++ b/app/automation_service.py @@ -305,25 +305,36 @@ def valid(doc): return read_document(path, dict(version=1, runs=[]), valid) def history(self, user_id): - with user_data_access(user_id), storage_lock(f'automation-history:{user_id}'): - document = self._read_history(user_path(user_id, 'automation-runs.json')) - with self._lock: + recovered = False + manual_attention = False + # Match start()'s lock ordering. A new dispatch cannot appear between the + # active snapshot and persisting an orphan's one-time unknown transition. + with self._lock, user_data_access(user_id), storage_lock(f'automation-history:{user_id}'): + path = user_path(user_id, 'automation-runs.json') + document = self._read_history(path) active = {v['record']['id']: deepcopy(v['record']) for uid, v in self._active.items() if uid == int(user_id)} - result = [] - for record in document['runs']: - if record['id'] in active: - record = active[record['id']] - elif record['status'] not in TERMINAL: - record['status'] = 'unknown' - for target in record['targets']: - if target['status'] not in TERMINAL: - target['status'] = 'unknown' - for step in target['steps']: - if step['status'] not in TERMINAL: - step['status'] = 'unknown' - if 'reason' in step: - step['reason'] = 'run_interrupted' - result.append(record) + result = [] + for record in document['runs']: + if record['id'] in active: + record = active[record['id']] + elif record['status'] not in TERMINAL: + recovered = True + manual_attention = manual_attention or not record.get('schedule_id') + record['status'] = 'unknown' + for target in record['targets']: + if target['status'] not in TERMINAL: + target['status'] = 'unknown' + for step in target['steps']: + if step['status'] not in TERMINAL: + step['status'] = 'unknown' + if 'reason' in step: + step['reason'] = 'run_interrupted' + result.append(record) + if recovered: + write_document(path, document) + if manual_attention: + from .automation_notifications import notify + notify(user_id, 'unknown') return result def delete_runs(self, user_id, run_ids=None): @@ -580,6 +591,8 @@ def secret(resource): except Exception: pass log_security_event('AUTOMATION_RUN_FINISHED', user_id=uid, outcome=record['status']) + from .automation_notifications import notify + notify(uid, record['status']) if intent.get('kind') == 'scheduled': try: self.app.extensions['automation_schedules'].finish(intent, record['status']) diff --git a/app/automation_store.py b/app/automation_store.py index cc1c0bab..6595b519 100644 --- a/app/automation_store.py +++ b/app/automation_store.py @@ -167,3 +167,25 @@ def delete_runbook(user_id, book_id): raise AutomationError('not_found') document['runbooks'] = [book for book in document['runbooks'] if book['id'] != book_id] write_document(path, document) + +@user_operation +def notification_preferences(user_id, enabled=None, delivery=None): + """Separate additive document: existing workflow/history formats stay unchanged.""" + def valid(doc): + return (isinstance(doc, dict) and set(doc) == {'version', 'enabled', 'delivery'} + and doc['version'] == 1 and type(doc['enabled']) is bool + and doc['delivery'] in {'never', 'pending', 'sent', 'failed', 'unavailable'}) + with storage_lock(f'automation-notifications:{user_id}'): + path = user_path(user_id, 'automation-notifications.json') + document = read_document(path, dict(version=1, enabled=False, delivery='never'), valid) + if enabled is not None: + if type(enabled) is not bool: + raise AutomationError('invalid_request') + document['enabled'] = enabled + if delivery is not None: + if delivery not in {'pending', 'sent', 'failed', 'unavailable'}: + raise AutomationError('invalid_request') + document['delivery'] = delivery + if enabled is not None or delivery is not None: + write_document(path, document) + return document diff --git a/app/backup_manager.py b/app/backup_manager.py index bc202420..126f3c53 100644 --- a/app/backup_manager.py +++ b/app/backup_manager.py @@ -858,7 +858,10 @@ def _verify_staged_files(stage, manifest): def restore_backup(archive, data_dir): """Restore an export; discard remote credentials even from older archives.""" - return _restore_archive(archive, data_dir, include_remote_credentials=False) + result = _restore_archive(archive, data_dir, include_remote_credentials=False) + from .customer_workflows import mark_restore_recovery + mark_restore_recovery(data_dir) + return result def _restore_recovery_snapshot(archive, data_dir): diff --git a/app/customer_workflows.py b/app/customer_workflows.py new file mode 100644 index 00000000..1350cf74 --- /dev/null +++ b/app/customer_workflows.py @@ -0,0 +1,148 @@ +"""Account-owned workspace presets and post-restore recovery guidance.""" +import json +import re +from datetime import datetime, timezone +from pathlib import Path + +from flask import Blueprint, current_app, jsonify, request +from flask_login import current_user, login_required + +import config +from .data_access import user_data_access +from .storage_utils import atomic_write_json, storage_lock +from .user_settings import get_user_settings, save_user_settings + +customer_workflows_blueprint = Blueprint('customer_workflows', __name__) +_IDENTIFIER = re.compile(r'^[A-Za-z0-9_-]{1,128}$') + + +def valid_presets(value): + if not isinstance(value, list) or len(value) > 12: + return False + ids = set() + for preset in value: + if not isinstance(preset, dict) or set(preset) != {'id', 'name', 'layout', 'profile_ids'}: + return False + identifier, name = preset['id'], preset['name'] + hosts, layout = preset['profile_ids'], preset['layout'] + if (not isinstance(identifier, str) or not _IDENTIFIER.fullmatch(identifier) + or identifier in ids or not isinstance(name, str) or not name.strip() + or len(name) > 80 or any(not char.isprintable() for char in name) + or type(layout) is not int or layout not in (1, 2, 4) + or not isinstance(hosts, list) or len(hosts) > layout + or any(not isinstance(host, str) or not _IDENTIFIER.fullmatch(host) for host in hosts) + or len(set(hosts)) != len(hosts)): + return False + ids.add(identifier) + return True + + +@customer_workflows_blueprint.route('/api/account/workspace-presets', methods=['GET', 'PUT']) +@login_required +def workspace_presets(): + from .profile_manager import load_profiles + with user_data_access(current_user.id): + if request.method == 'PUT': + body = request.get_json(silent=True) + if not isinstance(body, dict) or set(body) != {'presets'} or not valid_presets(body['presets']): + return jsonify(error='Invalid workspace presets'), 400 + owned = {item['id'] for item in load_profiles(current_user.id)} + previous = {item['id']: set(item['profile_ids']) for item in + get_user_settings(current_user.id).get('workspace_presets', [])} + if any(host not in owned and host not in previous.get(preset['id'], set()) + for preset in body['presets'] for host in preset['profile_ids']): + return jsonify(error='Choose existing hosts from your account'), 400 + if not save_user_settings(current_user.id, {'workspace_presets': body['presets']}): + return jsonify(error='Could not save workspace presets'), 500 + response = jsonify(presets=get_user_settings(current_user.id).get('workspace_presets', [])) + response.headers['Cache-Control'] = 'no-store' + return response + + +def valid_recovery_timestamp(value): + if not isinstance(value, str) or not 1 <= len(value) <= 64: + return False + try: + timestamp = datetime.fromisoformat(value) + return timestamp.tzinfo is not None and timestamp.utcoffset() is not None + except (ValueError, TypeError, OverflowError): + return False + + +def _restore_marker(): + # A corrupt or missing reminder is never proof of restored credentials. + try: + with (Path(config.DATA_DIR) / 'recovery-check.json').open(encoding='utf-8') as stream: + content = stream.read(513) + if len(content) > 512: + return None + marker = json.loads(content) + candidate = marker.get('restored_at') if isinstance(marker, dict) else None + return candidate if valid_recovery_timestamp(candidate) else None + except (OSError, ValueError, TypeError): + return None + + +def mark_restore_recovery(data_dir): + try: + with storage_lock('restore-recovery-check'): + atomic_write_json(Path(data_dir) / 'recovery-check.json', { + 'restored_at': datetime.now(timezone.utc).isoformat(), + 'remote_credentials_excluded': True, + }) + except OSError: + from .audit_logger import log_warning + log_warning('Restore completed but optional recovery reminder could not be saved') + + +@customer_workflows_blueprint.route('/api/account/recovery-check', methods=['GET', 'POST']) +@login_required +def recovery_check(): + from .profile_manager import load_profiles + from .key_manager import load_keys + from .jump_host_manager import load_jump_hosts + from .host_key_store import HostKeyStore + with user_data_access(current_user.id): + if request.method == 'POST': + body = request.get_json(silent=True) + if (not isinstance(body, dict) or set(body) != {'restored_at'} + or not valid_recovery_timestamp(body.get('restored_at'))): + return jsonify(error='Invalid recovery review'), 400 + # Serialize marker comparison and account acknowledgement against a + # concurrent restore. An older checklist cannot dismiss a new restore. + with storage_lock('restore-recovery-check'): + current = _restore_marker() + if current is None or body['restored_at'] != current: + return jsonify(error='The restore marker changed. Review the current checklist.', code='restore_changed'), 409 + if not save_user_settings(current_user.id, {'recovery_reviewed_at': current}): + return jsonify(error='Could not save recovery review'), 500 + response = jsonify(restored_at=current, needs_review=False) + response.headers['Cache-Control'] = 'no-store' + return response + profiles = load_profiles(current_user.id) + keys = {key['id'] for key in load_keys(current_user.id)} + jumps = {jump['id'] for jump in load_jump_hosts(current_user.id)} + credentials = current_app.extensions['credentials'] + resources = {item['id']: item for item in credentials.resources(current_user.id) if item.get('kind') == 'ssh'} + trust = HostKeyStore(current_user.id, config.KNOWN_HOSTS_FILE, config.USERS_DIR).trust_checker() + hosts = [] + for host in profiles: + if host['auth_type'] == 'key': + credential = 'ready' if host.get('key_id') in keys else 'key_required' + elif host['auth_type'] == 'password': + resource = resources.get(host['id']) + credential = 'ready' if resource and credentials.credential_status(current_user.id, resource) == 'ready' else 'password_required' + else: + credential = 'interactive' + hosts.append(dict(id=host['id'], name=host['name'], credential=credential, + route='missing_jump' if host.get('jump_host_id') and host['jump_host_id'] not in jumps else 'configured', + host_key='trusted' if trust(host['host'], host.get('port', 22)) else 'review_required')) + schedules = current_app.extensions['automation_schedules'].list(current_user.id) + with storage_lock('restore-recovery-check'): + restored_at = _restore_marker() + needs_review = bool(restored_at and get_user_settings(current_user.id).get('recovery_reviewed_at') != restored_at) + response = jsonify(restored_at=restored_at, needs_review=needs_review, + coverage={'configuration': 'included', 'account_authentication': 'included', 'remote_credentials': 'excluded', 'live_sessions': 'excluded'}, + hosts=hosts, schedules=[{'id': row['id'], 'name': row['name'], 'enabled': row['enabled'], 'status': row['last_status']} for row in schedules]) + response.headers['Cache-Control'] = 'no-store' + return response diff --git a/app/resources/commands/system_commands.json b/app/resources/commands/system_commands.json index 582ae650..d5d3be36 100644 --- a/app/resources/commands/system_commands.json +++ b/app/resources/commands/system_commands.json @@ -9,7 +9,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "apt", + "command": "apt update", + "parameters": "" + } }, { "id": "sys-002", @@ -21,7 +27,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "apt", + "command": "apt upgrade", + "parameters": "-y" + } }, { "id": "sys-003", @@ -33,7 +45,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "apt", + "command": "apt install", + "parameters": "package-name" + } }, { "id": "sys-004", @@ -45,7 +63,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "apt", + "command": "apt remove", + "parameters": "package-name" + } }, { "id": "sys-005", @@ -57,7 +81,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "apt", + "command": "apt search", + "parameters": "keyword" + } }, { "id": "sys-006", @@ -70,7 +100,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "reboot", + "command": "reboot", + "parameters": "" + } }, { "id": "sys-007", @@ -83,7 +119,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "shutdown", + "command": "shutdown", + "parameters": "-h now" + } }, { "id": "sys-008", @@ -95,7 +137,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "systemctl", + "command": "systemctl list-units", + "parameters": "--type=service" + } }, { "id": "sys-009", @@ -107,7 +155,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemctl", + "command": "systemctl start", + "parameters": "service-name" + } }, { "id": "sys-010", @@ -119,7 +173,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemctl", + "command": "systemctl stop", + "parameters": "service-name" + } }, { "id": "sys-011", @@ -131,7 +191,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemctl", + "command": "systemctl restart", + "parameters": "service-name" + } }, { "id": "sys-012", @@ -143,7 +209,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemctl", + "command": "systemctl enable", + "parameters": "service-name" + } }, { "id": "sys-013", @@ -155,7 +227,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemctl", + "command": "systemctl disable", + "parameters": "service-name" + } }, { "id": "sys-014", @@ -167,7 +245,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "systemctl", + "command": "systemctl status", + "parameters": "service-name" + } }, { "id": "sys-015", @@ -179,7 +263,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "journalctl", + "command": "journalctl -u", + "parameters": "service-name -f" + } }, { "id": "sys-016", @@ -193,7 +283,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "df", + "command": "df", + "parameters": "-h" + } }, { "id": "sys-017", @@ -207,7 +303,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "du", + "command": "du", + "parameters": "-sh *" + } }, { "id": "sys-018", @@ -219,7 +321,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "free", + "command": "free", + "parameters": "-h" + } }, { "id": "sys-019", @@ -233,7 +341,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "top", + "command": "top", + "parameters": "" + } }, { "id": "sys-020", @@ -247,7 +361,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ps", + "command": "ps aux", + "parameters": "" + } }, { "id": "sys-021", @@ -261,7 +381,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "kill", + "command": "kill", + "parameters": "-9 PID" + } }, { "id": "sys-022", @@ -275,7 +401,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "pgrep", + "command": "pgrep", + "parameters": "process-name" + } }, { "id": "sys-023", @@ -289,7 +421,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "uptime", + "command": "uptime", + "parameters": "" + } }, { "id": "sys-024", @@ -303,7 +441,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "uname", + "command": "uname", + "parameters": "-r" + } }, { "id": "sys-025", @@ -317,7 +461,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "uname", + "command": "uname", + "parameters": "-a" + } }, { "id": "sys-026", @@ -329,7 +479,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lscpu", + "command": "lscpu", + "parameters": "" + } }, { "id": "sys-027", @@ -341,7 +497,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /proc/meminfo", + "parameters": "" + } }, { "id": "sys-028", @@ -353,7 +515,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsblk", + "command": "lsblk", + "parameters": "" + } }, { "id": "sys-029", @@ -365,7 +533,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lspci", + "command": "lspci", + "parameters": "" + } }, { "id": "sys-030", @@ -377,7 +551,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsusb", + "command": "lsusb", + "parameters": "" + } }, { "id": "net-001", @@ -389,7 +569,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip addr show", + "parameters": "" + } }, { "id": "net-002", @@ -403,7 +589,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ifconfig", + "command": "ifconfig", + "parameters": "" + } }, { "id": "net-003", @@ -412,10 +604,18 @@ "parameters": "-c 4 hostname", "description": "Test connectivity", "os": [ - "all" + "linux", + "macos", + "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ping", + "command": "ping", + "parameters": "-c 4 hostname" + } }, { "id": "net-004", @@ -427,7 +627,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "traceroute", + "command": "traceroute", + "parameters": "hostname" + } }, { "id": "net-005", @@ -439,7 +645,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nslookup", + "command": "nslookup", + "parameters": "hostname" + } }, { "id": "net-006", @@ -453,7 +665,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "dig", + "command": "dig", + "parameters": "hostname" + } }, { "id": "net-007", @@ -462,10 +680,16 @@ "parameters": "-tuln", "description": "Show listening ports", "os": [ - "all" + "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "netstat", + "command": "netstat", + "parameters": "-tuln" + } }, { "id": "net-008", @@ -477,7 +701,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ss", + "command": "ss", + "parameters": "-tuln" + } }, { "id": "net-009", @@ -491,7 +721,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "wget", + "command": "wget", + "parameters": "URL" + } }, { "id": "net-010", @@ -503,7 +739,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "curl", + "command": "curl", + "parameters": "-I URL" + } }, { "id": "net-011", @@ -517,7 +759,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nc", + "command": "nc", + "parameters": "-zv hostname port" + } }, { "id": "net-012", @@ -529,7 +777,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ufw", + "command": "ufw status", + "parameters": "" + } }, { "id": "net-013", @@ -541,7 +795,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ufw", + "command": "ufw enable", + "parameters": "" + } }, { "id": "net-014", @@ -553,7 +813,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ufw", + "command": "ufw allow", + "parameters": "port" + } }, { "id": "net-015", @@ -565,7 +831,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ufw", + "command": "ufw deny", + "parameters": "port" + } }, { "id": "net-016", @@ -577,7 +849,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "iptables", + "command": "iptables", + "parameters": "-L -n -v" + } }, { "id": "net-017", @@ -589,7 +867,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip route show", + "parameters": "" + } }, { "id": "net-018", @@ -603,7 +887,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "arp", + "command": "arp", + "parameters": "-a" + } }, { "id": "net-019", @@ -617,7 +907,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nmap", + "command": "nmap", + "parameters": "-sn 192.168.1.0/24" + } }, { "id": "net-020", @@ -631,7 +927,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nmap", + "command": "nmap", + "parameters": "-p- hostname" + } }, { "id": "net-021", @@ -643,7 +945,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip link show", + "parameters": "" + } }, { "id": "net-022", @@ -655,7 +963,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip addr add", + "parameters": "192.168.1.10/24 dev eth0" + } }, { "id": "net-023", @@ -667,7 +981,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip link set", + "parameters": "eth0 up" + } }, { "id": "net-024", @@ -679,7 +999,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip link set", + "parameters": "eth0 down" + } }, { "id": "net-025", @@ -691,7 +1017,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip link set", + "parameters": "eth0 mtu 1500" + } }, { "id": "net-026", @@ -703,7 +1035,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip addr flush", + "parameters": "dev eth0" + } }, { "id": "net-027", @@ -715,7 +1053,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip route add", + "parameters": "10.0.0.0/8 via 192.168.1.1" + } }, { "id": "net-028", @@ -727,7 +1071,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip route del", + "parameters": "10.0.0.0/8" + } }, { "id": "net-029", @@ -741,7 +1091,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tcpdump", + "command": "tcpdump", + "parameters": "-i eth0 -n" + } }, { "id": "net-030", @@ -755,7 +1111,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsof", + "command": "lsof", + "parameters": "-i" + } }, { "id": "net-031", @@ -769,7 +1131,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "whois", + "command": "whois", + "parameters": "domain.com" + } }, { "id": "net-032", @@ -783,7 +1151,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "iftop", + "command": "iftop", + "parameters": "" + } }, { "id": "net-033", @@ -797,7 +1171,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "speedtest-cli", + "command": "speedtest-cli", + "parameters": "" + } }, { "id": "net-034", @@ -811,7 +1191,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /etc/hosts", + "parameters": "" + } }, { "id": "net-035", @@ -825,7 +1211,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /etc/resolv.conf", + "parameters": "" + } }, { "id": "net-036", @@ -837,7 +1229,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip link show", + "parameters": "eth0 | grep link/ether" + } }, { "id": "net-037", @@ -849,7 +1247,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ip", + "command": "ip link set", + "parameters": "eth0 address 00:11:22:33:44:55" + } }, { "id": "net-038", @@ -861,7 +1265,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ssh", + "command": "ssh", + "parameters": "-L 8080:localhost:80 user@host" + } }, { "id": "net-039", @@ -873,7 +1283,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ssh", + "command": "ssh", + "parameters": "-R 8080:localhost:80 user@host" + } }, { "id": "net-040", @@ -885,7 +1301,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "scp", + "command": "scp", + "parameters": "file user@host:/path" + } }, { "id": "net-041", @@ -897,7 +1319,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "scp", + "command": "scp", + "parameters": "user@host:/path/file ." + } }, { "id": "net-042", @@ -911,7 +1339,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "rsync", + "command": "rsync", + "parameters": "-avz -e ssh source/ user@host:/dest/" + } }, { "id": "net-043", @@ -923,7 +1357,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "python3", + "command": "python3 -m http.server", + "parameters": "8000" + } }, { "id": "net-044", @@ -937,7 +1377,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nc", + "command": "nc", + "parameters": "-l -p 1234" + } }, { "id": "net-045", @@ -951,7 +1397,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "nc", + "command": "nc", + "parameters": "hostname 1234 < file" + } }, { "id": "net-046", @@ -965,7 +1417,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "telnet", + "command": "telnet", + "parameters": "hostname port" + } }, { "id": "net-047", @@ -977,7 +1435,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip route", + "parameters": "| grep default" + } }, { "id": "net-048", @@ -989,7 +1453,13 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "systemd-resolve", + "command": "systemd-resolve --flush-caches", + "parameters": "" + } }, { "id": "net-049", @@ -1001,19 +1471,69 @@ "linux" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ip", + "command": "ip -s link", + "parameters": "" + } }, { "id": "net-050", "name": "Ping Flood", "command": "ping", "parameters": "-f hostname", - "description": "Flood ping (testing)", + "description": "Send continuous flood-ping traffic until interrupted. Can overload networks and targets; elevated privileges may be required.", "os": [ - "all" + "linux", + "macos", + "bsd" + ], + "category": "network", + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ping", + "command": "ping", + "parameters": "-f hostname" + } + }, + { + "id": "net-051", + "name": "Ping Host (Windows)", + "command": "ping", + "parameters": "-n 4 hostname", + "description": "Send four ICMP echo requests using Windows ping; replace hostname with the intended target.", + "os": [ + "windows" + ], + "category": "network", + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ping", + "command": "ping", + "parameters": "-n 4 hostname" + } + }, + { + "id": "net-052", + "name": "Connections and Listening Ports (Windows)", + "command": "netstat", + "parameters": "-ano", + "description": "Show connections and listening TCP/UDP ports with numeric addresses and owning process IDs using native Windows netstat.", + "os": [ + "windows" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "netstat", + "command": "netstat", + "parameters": "-ano" + } }, { "id": "file-001", @@ -1027,7 +1547,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ls", + "command": "ls", + "parameters": "-lah" + } }, { "id": "file-002", @@ -1041,7 +1567,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "cd", + "command": "cd", + "parameters": "/path" + } }, { "id": "file-003", @@ -1055,7 +1587,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "pwd", + "command": "pwd", + "parameters": "" + } }, { "id": "file-004", @@ -1069,7 +1607,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mkdir", + "command": "mkdir", + "parameters": "-p path/to/dir" + } }, { "id": "file-005", @@ -1083,7 +1627,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "rmdir", + "command": "rmdir", + "parameters": "directory" + } }, { "id": "file-006", @@ -1097,7 +1647,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "rm", + "command": "rm", + "parameters": "-rf path" + } }, { "id": "file-007", @@ -1111,7 +1667,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "cp", + "command": "cp", + "parameters": "-r source dest" + } }, { "id": "file-008", @@ -1125,7 +1687,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mv", + "command": "mv", + "parameters": "source dest" + } }, { "id": "file-009", @@ -1139,7 +1707,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat", + "parameters": "filename" + } }, { "id": "file-010", @@ -1153,7 +1727,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "less", + "command": "less", + "parameters": "filename" + } }, { "id": "file-011", @@ -1167,7 +1747,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "head", + "command": "head", + "parameters": "-n 20 filename" + } }, { "id": "file-012", @@ -1181,7 +1767,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "tail", + "command": "tail", + "parameters": "-n 20 filename" + } }, { "id": "file-013", @@ -1195,7 +1787,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tail", + "command": "tail", + "parameters": "-f filename" + } }, { "id": "file-014", @@ -1209,7 +1807,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "grep", + "command": "grep", + "parameters": "-r \"pattern\" ." + } }, { "id": "file-015", @@ -1223,7 +1827,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -name \"*.txt\"" + } }, { "id": "file-016", @@ -1237,7 +1847,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -size +100M" + } }, { "id": "file-017", @@ -1251,7 +1867,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -mtime -7" + } }, { "id": "file-018", @@ -1265,7 +1887,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chmod", + "command": "chmod", + "parameters": "755 filename" + } }, { "id": "file-019", @@ -1279,7 +1907,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chown", + "command": "chown", + "parameters": "user:group file" + } }, { "id": "file-020", @@ -1293,7 +1927,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "file", + "command": "file", + "parameters": "filename" + } }, { "id": "file-021", @@ -1307,7 +1947,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "wc", + "command": "wc", + "parameters": "-l filename" + } }, { "id": "file-022", @@ -1321,7 +1967,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "touch", + "command": "touch", + "parameters": "filename" + } }, { "id": "file-023", @@ -1335,7 +1987,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "diff", + "command": "diff", + "parameters": "file1 file2" + } }, { "id": "file-024", @@ -1349,7 +2007,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ln", + "command": "ln", + "parameters": "-s /path/to/file link" + } }, { "id": "file-025", @@ -1363,7 +2027,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tar", + "command": "tar", + "parameters": "-czf archive.tar.gz dir/" + } }, { "id": "file-026", @@ -1377,7 +2047,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tar", + "command": "tar", + "parameters": "-xzf archive.tar.gz" + } }, { "id": "file-027", @@ -1391,7 +2067,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "zip", + "command": "zip", + "parameters": "-r archive.zip dir/" + } }, { "id": "file-028", @@ -1405,7 +2087,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "unzip", + "command": "unzip", + "parameters": "archive.zip" + } }, { "id": "file-029", @@ -1419,7 +2107,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "rsync", + "command": "rsync", + "parameters": "-avz source/ dest/" + } }, { "id": "file-030", @@ -1433,7 +2127,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "df", + "command": "df", + "parameters": "-h ." + } }, { "id": "file-031", @@ -1447,7 +2147,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "du", + "command": "du", + "parameters": "-sh" + } }, { "id": "file-032", @@ -1461,7 +2167,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "du", + "command": "du", + "parameters": "-h | sort -rh | head -20" + } }, { "id": "file-033", @@ -1475,7 +2187,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -type f -empty" + } }, { "id": "file-034", @@ -1489,7 +2207,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -type d -empty" + } }, { "id": "file-035", @@ -1503,7 +2227,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": ". -type f | wc -l" + } }, { "id": "file-036", @@ -1517,7 +2247,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "tree", + "command": "tree", + "parameters": "-L 2" + } }, { "id": "file-037", @@ -1531,7 +2267,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "stat", + "command": "stat", + "parameters": "filename" + } }, { "id": "file-038", @@ -1540,12 +2282,16 @@ "parameters": "filename", "description": "Calculate MD5 hash", "os": [ - "linux", - "macos", - "bsd" + "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "md5sum", + "command": "md5sum", + "parameters": "filename" + } }, { "id": "file-039", @@ -1554,12 +2300,16 @@ "parameters": "filename", "description": "Calculate SHA256 hash", "os": [ - "linux", - "macos", - "bsd" + "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sha256sum", + "command": "sha256sum", + "parameters": "filename" + } }, { "id": "file-040", @@ -1568,12 +2318,16 @@ "parameters": "-c checksums.txt", "description": "Verify file integrity", "os": [ - "linux", - "macos", - "bsd" + "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "md5sum", + "command": "md5sum", + "parameters": "-c checksums.txt" + } }, { "id": "file-041", @@ -1587,7 +2341,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "split", + "command": "split", + "parameters": "-b 100M file" + } }, { "id": "file-042", @@ -1601,7 +2361,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "cat", + "command": "cat", + "parameters": "file.part* > file" + } }, { "id": "file-043", @@ -1615,7 +2381,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "rename", + "command": "rename", + "parameters": "'s/\\.txt$/\\.md/' *.txt" + } }, { "id": "file-044", @@ -1629,7 +2401,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "iconv", + "command": "iconv", + "parameters": "-f ISO-8859-1 -t UTF-8 file" + } }, { "id": "file-045", @@ -1643,7 +2421,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sort", + "command": "sort file | uniq", + "parameters": "" + } }, { "id": "file-046", @@ -1657,7 +2441,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sort", + "command": "sort file | uniq -c", + "parameters": "" + } }, { "id": "file-047", @@ -1671,7 +2461,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "tac", + "command": "tac", + "parameters": "filename" + } }, { "id": "file-048", @@ -1685,7 +2481,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "truncate", + "command": "truncate", + "parameters": "-s 0 filename" + } }, { "id": "file-049", @@ -1699,7 +2501,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "watch", + "command": "watch", + "parameters": "-n 1 cat filename" + } }, { "id": "file-050", @@ -1713,7 +2521,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "hexdump", + "command": "hexdump", + "parameters": "-C filename" + } }, { "id": "file-051", @@ -1727,7 +2541,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "base64", + "command": "base64", + "parameters": "filename" + } }, { "id": "file-052", @@ -1741,7 +2561,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "base64", + "command": "base64", + "parameters": "-d filename" + } }, { "id": "file-053", @@ -1753,7 +2579,13 @@ "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "getfacl", + "command": "getfacl", + "parameters": "filename" + } }, { "id": "file-054", @@ -1765,7 +2597,13 @@ "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "setfacl", + "command": "setfacl", + "parameters": "-m u:user:rw filename" + } }, { "id": "file-055", @@ -1777,7 +2615,13 @@ "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsattr", + "command": "lsattr", + "parameters": "filename" + } }, { "id": "file-056", @@ -1789,7 +2633,13 @@ "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chattr", + "command": "chattr", + "parameters": "+i filename" + } }, { "id": "file-057", @@ -1801,7 +2651,13 @@ "linux" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chattr", + "command": "chattr", + "parameters": "-i filename" + } }, { "id": "file-058", @@ -1815,7 +2671,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": "/ -perm -4000" + } }, { "id": "file-059", @@ -1829,7 +2691,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "find", + "command": "find", + "parameters": "/ -perm -002" + } }, { "id": "file-060", @@ -1843,7 +2711,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "shred", + "command": "shred", + "parameters": "-u filename" + } }, { "id": "docker-001", @@ -1855,7 +2729,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker", + "parameters": "--version" + } }, { "id": "docker-002", @@ -1867,7 +2747,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker ps", + "parameters": "-a" + } }, { "id": "docker-003", @@ -1879,7 +2765,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker images", + "parameters": "" + } }, { "id": "docker-004", @@ -1891,7 +2783,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker pull", + "parameters": "image:tag" + } }, { "id": "docker-005", @@ -1903,7 +2801,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker run", + "parameters": "-d --name name image" + } }, { "id": "docker-006", @@ -1915,7 +2819,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker start", + "parameters": "container" + } }, { "id": "docker-007", @@ -1927,7 +2837,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker stop", + "parameters": "container" + } }, { "id": "docker-008", @@ -1939,7 +2855,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "docker", + "command": "docker rm", + "parameters": "container" + } }, { "id": "docker-009", @@ -1951,7 +2873,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "docker", + "command": "docker rmi", + "parameters": "image" + } }, { "id": "docker-010", @@ -1963,7 +2891,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker logs", + "parameters": "-f container" + } }, { "id": "docker-011", @@ -1975,7 +2909,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker exec", + "parameters": "-it container /bin/bash" + } }, { "id": "docker-012", @@ -1987,7 +2927,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker stats", + "parameters": "" + } }, { "id": "docker-013", @@ -1999,7 +2945,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker inspect", + "parameters": "container" + } }, { "id": "docker-014", @@ -2011,7 +2963,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker network ls", + "parameters": "" + } }, { "id": "docker-015", @@ -2023,7 +2981,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker volume ls", + "parameters": "" + } }, { "id": "docker-016", @@ -2035,7 +2999,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker-compose", + "command": "docker-compose up", + "parameters": "-d" + } }, { "id": "docker-017", @@ -2047,7 +3017,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker-compose", + "command": "docker-compose down", + "parameters": "" + } }, { "id": "docker-018", @@ -2059,7 +3035,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "docker", + "command": "docker container prune", + "parameters": "-f" + } }, { "id": "docker-019", @@ -2071,7 +3053,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "docker", + "command": "docker image prune", + "parameters": "-a" + } }, { "id": "docker-020", @@ -2083,7 +3071,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "docker", + "command": "docker system prune", + "parameters": "-a --volumes" + } }, { "id": "docker-021", @@ -2095,7 +3089,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker build", + "parameters": "-t myimage ." + } }, { "id": "docker-022", @@ -2107,7 +3107,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker tag", + "parameters": "image newtag" + } }, { "id": "docker-023", @@ -2119,7 +3125,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker push", + "parameters": "image:tag" + } }, { "id": "docker-024", @@ -2131,7 +3143,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker cp", + "parameters": "container:/path ./local" + } }, { "id": "docker-025", @@ -2143,7 +3161,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker cp", + "parameters": "./local container:/path" + } }, { "id": "docker-026", @@ -2155,7 +3179,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker export", + "parameters": "container > backup.tar" + } }, { "id": "docker-027", @@ -2167,7 +3197,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker import", + "parameters": "backup.tar" + } }, { "id": "docker-028", @@ -2179,7 +3215,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker save", + "parameters": "-o image.tar image" + } }, { "id": "docker-029", @@ -2191,7 +3233,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "docker", + "command": "docker load", + "parameters": "-i image.tar" + } }, { "id": "docker-030", @@ -2203,7 +3251,13 @@ "all" ], "category": "docker", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "docker", + "command": "docker top", + "parameters": "container" + } }, { "id": "git-001", @@ -2215,7 +3269,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git clone", + "parameters": "repo-url" + } }, { "id": "git-002", @@ -2227,7 +3287,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git status", + "parameters": "" + } }, { "id": "git-003", @@ -2239,7 +3305,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git add", + "parameters": "." + } }, { "id": "git-004", @@ -2251,7 +3323,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git commit", + "parameters": "-m \"message\"" + } }, { "id": "git-005", @@ -2263,7 +3341,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git push", + "parameters": "origin main" + } }, { "id": "git-006", @@ -2275,7 +3359,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git pull", + "parameters": "" + } }, { "id": "git-007", @@ -2287,7 +3377,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git branch", + "parameters": "-a" + } }, { "id": "git-008", @@ -2299,7 +3395,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git checkout", + "parameters": "branch" + } }, { "id": "git-009", @@ -2311,7 +3413,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git checkout", + "parameters": "-b newbranch" + } }, { "id": "git-010", @@ -2323,7 +3431,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git merge", + "parameters": "branch" + } }, { "id": "git-011", @@ -2335,7 +3449,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git log", + "parameters": "--oneline --graph --all" + } }, { "id": "git-012", @@ -2347,7 +3467,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git diff", + "parameters": "" + } }, { "id": "git-013", @@ -2359,7 +3485,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git stash", + "parameters": "" + } }, { "id": "git-014", @@ -2371,7 +3503,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git stash pop", + "parameters": "" + } }, { "id": "git-015", @@ -2383,7 +3521,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "git", + "command": "git reset", + "parameters": "--hard HEAD" + } }, { "id": "git-016", @@ -2395,7 +3539,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git remote", + "parameters": "-v" + } }, { "id": "git-017", @@ -2407,7 +3557,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git fetch", + "parameters": "--all" + } }, { "id": "git-018", @@ -2419,7 +3575,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git rebase", + "parameters": "main" + } }, { "id": "git-019", @@ -2431,7 +3593,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git tag", + "parameters": "v1.0.0" + } }, { "id": "git-020", @@ -2443,7 +3611,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git config", + "parameters": "--global user.name \"Name\"" + } }, { "id": "git-021", @@ -2455,7 +3629,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git config", + "parameters": "--global user.email \"email\"" + } }, { "id": "git-022", @@ -2467,7 +3647,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "git", + "command": "git branch", + "parameters": "-d branchname" + } }, { "id": "git-023", @@ -2479,7 +3665,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "git", + "command": "git push origin", + "parameters": "--delete branchname" + } }, { "id": "git-024", @@ -2491,7 +3683,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git show", + "parameters": "commit-hash" + } }, { "id": "git-025", @@ -2503,7 +3701,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git blame", + "parameters": "filename" + } }, { "id": "git-026", @@ -2515,7 +3719,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "git", + "command": "git reflog", + "parameters": "" + } }, { "id": "git-027", @@ -2527,7 +3737,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git cherry-pick", + "parameters": "commit-hash" + } }, { "id": "git-028", @@ -2539,7 +3755,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "git", + "command": "git clean", + "parameters": "-fd" + } }, { "id": "git-029", @@ -2551,7 +3773,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git submodule", + "parameters": "init" + } }, { "id": "git-030", @@ -2563,7 +3791,13 @@ "all" ], "category": "git", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "git", + "command": "git submodule", + "parameters": "update --remote" + } }, { "id": "db-001", @@ -2575,7 +3809,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mysql", + "command": "mysql", + "parameters": "-u user -p" + } }, { "id": "db-002", @@ -2587,7 +3827,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "psql", + "command": "psql", + "parameters": "-U user -d database" + } }, { "id": "db-003", @@ -2599,7 +3845,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mysqldump", + "command": "mysqldump", + "parameters": "-u user -p db > backup.sql" + } }, { "id": "db-004", @@ -2611,7 +3863,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "mysql", + "command": "mysql", + "parameters": "-u user -p db < backup.sql" + } }, { "id": "db-005", @@ -2623,7 +3881,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "pg_dump", + "command": "pg_dump", + "parameters": "-U user db > backup.sql" + } }, { "id": "db-006", @@ -2635,7 +3899,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "psql", + "command": "psql", + "parameters": "-U user db < backup.sql" + } }, { "id": "db-007", @@ -2647,7 +3917,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "redis-cli", + "command": "redis-cli", + "parameters": "" + } }, { "id": "db-008", @@ -2659,7 +3935,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mongosh", + "command": "mongosh", + "parameters": "" + } }, { "id": "db-009", @@ -2671,7 +3953,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "sqlite3", + "command": "sqlite3", + "parameters": "database.db" + } }, { "id": "db-010", @@ -2683,7 +3971,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "redis-cli", + "command": "redis-cli", + "parameters": "ping" + } }, { "id": "db-011", @@ -2695,7 +3989,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "mysql", + "command": "mysql", + "parameters": "-u user -p -e 'SHOW DATABASES;'" + } }, { "id": "db-012", @@ -2707,7 +4007,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "psql", + "command": "psql", + "parameters": "-U user -l" + } }, { "id": "db-013", @@ -2719,7 +4025,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "redis-cli", + "command": "redis-cli", + "parameters": "KEYS '*'" + } }, { "id": "db-014", @@ -2731,7 +4043,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "mongosh", + "command": "mongosh", + "parameters": "--eval 'show dbs'" + } }, { "id": "db-015", @@ -2743,7 +4061,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mysql", + "command": "mysql", + "parameters": "-e \"GRANT ALL ON db.* TO 'user'@'%'\"" + } }, { "id": "db-016", @@ -2755,7 +4079,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "createuser", + "command": "createuser", + "parameters": "-U postgres username" + } }, { "id": "db-017", @@ -2767,7 +4097,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "redis-cli", + "command": "redis-cli", + "parameters": "FLUSHALL" + } }, { "id": "db-018", @@ -2779,7 +4115,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "mysqlcheck", + "command": "mysqlcheck", + "parameters": "-o database" + } }, { "id": "db-019", @@ -2791,7 +4133,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "vacuumdb", + "command": "vacuumdb", + "parameters": "-U user -d database" + } }, { "id": "db-020", @@ -2803,7 +4151,13 @@ "all" ], "category": "database", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "sqlite3", + "command": "sqlite3", + "parameters": "db.sqlite \".backup backup.db\"" + } }, { "id": "mac-001", @@ -2815,7 +4169,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "brew", + "command": "brew install", + "parameters": "package" + } }, { "id": "mac-002", @@ -2827,7 +4187,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "brew", + "command": "brew update", + "parameters": "" + } }, { "id": "mac-003", @@ -2839,7 +4205,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "brew", + "command": "brew upgrade", + "parameters": "" + } }, { "id": "mac-004", @@ -2851,7 +4223,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "brew", + "command": "brew search", + "parameters": "keyword" + } }, { "id": "mac-005", @@ -2863,7 +4241,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sw_vers", + "command": "sw_vers", + "parameters": "" + } }, { "id": "mac-006", @@ -2875,7 +4259,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "brew", + "command": "brew list", + "parameters": "" + } }, { "id": "mac-007", @@ -2887,7 +4277,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "brew", + "command": "brew info", + "parameters": "package" + } }, { "id": "mac-008", @@ -2899,7 +4295,13 @@ "macos" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "dscacheutil", + "command": "sudo dscacheutil", + "parameters": "-flushcache" + } }, { "id": "mac-009", @@ -2911,7 +4313,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "defaults", + "command": "defaults write com.apple.finder", + "parameters": "AppleShowAllFiles YES" + } }, { "id": "mac-010", @@ -2923,7 +4331,13 @@ "macos" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "killall", + "command": "killall", + "parameters": "Finder" + } }, { "id": "user-001", @@ -2936,7 +4350,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /etc/passwd", + "parameters": "" + } }, { "id": "user-002", @@ -2948,7 +4368,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "useradd", + "command": "useradd", + "parameters": "-m username" + } }, { "id": "user-003", @@ -2960,7 +4386,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "userdel", + "command": "userdel", + "parameters": "-r username" + } }, { "id": "user-004", @@ -2974,7 +4406,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "passwd", + "command": "passwd", + "parameters": "username" + } }, { "id": "user-005", @@ -2988,7 +4426,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "who", + "command": "who", + "parameters": "" + } }, { "id": "user-006", @@ -3002,7 +4446,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "last", + "command": "last", + "parameters": "-n 10" + } }, { "id": "user-007", @@ -3016,7 +4466,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "whoami", + "command": "whoami", + "parameters": "" + } }, { "id": "user-008", @@ -3030,7 +4486,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "groups", + "command": "groups", + "parameters": "username" + } }, { "id": "user-009", @@ -3042,7 +4504,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "usermod", + "command": "usermod", + "parameters": "-aG group username" + } }, { "id": "user-010", @@ -3056,7 +4524,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "su", + "command": "su", + "parameters": "- username" + } }, { "id": "user-011", @@ -3070,7 +4544,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "sudo", + "command": "sudo", + "parameters": "command" + } }, { "id": "user-012", @@ -3084,7 +4564,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "visudo", + "command": "visudo", + "parameters": "" + } }, { "id": "user-013", @@ -3098,7 +4584,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sudo", + "command": "sudo", + "parameters": "-l" + } }, { "id": "user-014", @@ -3112,7 +4604,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chsh", + "command": "chsh", + "parameters": "-s /bin/bash username" + } }, { "id": "user-015", @@ -3124,7 +4622,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "passwd", + "command": "passwd", + "parameters": "-l username" + } }, { "id": "ssh-001", @@ -3138,7 +4642,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ssh-keygen", + "command": "ssh-keygen", + "parameters": "-t ed25519 -C \"email\"" + } }, { "id": "ssh-002", @@ -3152,7 +4662,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ssh-copy-id", + "command": "ssh-copy-id", + "parameters": "user@host" + } }, { "id": "ssh-003", @@ -3166,7 +4682,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat ~/.ssh/config", + "parameters": "" + } }, { "id": "ssh-004", @@ -3180,7 +4702,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ssh-add", + "command": "ssh-add", + "parameters": "~/.ssh/id_ed25519" + } }, { "id": "ssh-005", @@ -3194,7 +4722,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ssh-add", + "command": "ssh-add", + "parameters": "-l" + } }, { "id": "ssh-006", @@ -3208,7 +4742,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chmod", + "command": "chmod", + "parameters": "600 ~/.ssh/id_ed25519" + } }, { "id": "ssh-007", @@ -3222,7 +4762,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "chmod", + "command": "chmod", + "parameters": "700 ~/.ssh" + } }, { "id": "ssh-008", @@ -3236,7 +4782,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat ~/.ssh/known_hosts", + "parameters": "" + } }, { "id": "ssh-009", @@ -3250,7 +4802,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "ssh-keygen", + "command": "ssh-keygen", + "parameters": "-R hostname" + } }, { "id": "ssh-010", @@ -3262,7 +4820,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "eval", + "command": "eval $(ssh-agent)", + "parameters": "" + } }, { "id": "ssh-011", @@ -3274,7 +4838,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "openssl", + "command": "openssl x509", + "parameters": "-in cert.pem -text -noout" + } }, { "id": "ssh-012", @@ -3286,7 +4856,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "openssl", + "command": "openssl req", + "parameters": "-x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365" + } }, { "id": "ssh-013", @@ -3298,7 +4874,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "openssl", + "command": "openssl s_client", + "parameters": "-connect host:443" + } }, { "id": "ssh-014", @@ -3312,7 +4894,13 @@ "bsd" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ssh-keygen", + "command": "ssh-keygen", + "parameters": "-lf ~/.ssh/id_ed25519.pub" + } }, { "id": "ssh-015", @@ -3324,7 +4912,13 @@ "all" ], "category": "network", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "network-load", + "tool": "ssh", + "command": "ssh", + "parameters": "-vvv user@host" + } }, { "id": "perf-001", @@ -3336,7 +4930,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "iostat", + "command": "iostat", + "parameters": "-x 1" + } }, { "id": "perf-002", @@ -3348,7 +4948,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "vmstat", + "command": "vmstat", + "parameters": "1" + } }, { "id": "perf-003", @@ -3360,7 +4966,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "iotop", + "command": "iotop", + "parameters": "" + } }, { "id": "perf-004", @@ -3374,7 +4986,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "htop", + "command": "htop", + "parameters": "" + } }, { "id": "perf-005", @@ -3386,7 +5004,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /proc/loadavg", + "parameters": "" + } }, { "id": "perf-006", @@ -3398,7 +5022,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "mpstat", + "command": "mpstat", + "parameters": "1" + } }, { "id": "perf-007", @@ -3410,7 +5040,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "vmstat", + "command": "vmstat", + "parameters": "-s" + } }, { "id": "perf-008", @@ -3422,7 +5058,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "swapon", + "command": "swapon", + "parameters": "--show" + } }, { "id": "perf-009", @@ -3434,7 +5076,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "nstat", + "command": "nstat", + "parameters": "" + } }, { "id": "perf-010", @@ -3446,7 +5094,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "iotop", + "command": "iotop", + "parameters": "-o" + } }, { "id": "perf-011", @@ -3455,12 +5109,16 @@ "parameters": "--sort=-%cpu | head -10", "description": "Top CPU processes", "os": [ - "linux", - "macos", - "bsd" + "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ps", + "command": "ps aux", + "parameters": "--sort=-%cpu | head -10" + } }, { "id": "perf-012", @@ -3469,12 +5127,16 @@ "parameters": "--sort=-%mem | head -10", "description": "Top memory processes", "os": [ - "linux", - "macos", - "bsd" + "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ps", + "command": "ps aux", + "parameters": "--sort=-%mem | head -10" + } }, { "id": "perf-013", @@ -3488,7 +5150,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsof", + "command": "lsof", + "parameters": "" + } }, { "id": "perf-014", @@ -3502,7 +5170,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "ps", + "command": "ps", + "parameters": "-eLf" + } }, { "id": "perf-015", @@ -3514,7 +5188,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sar", + "command": "sar", + "parameters": "-u 1 5" + } }, { "id": "text-001", @@ -3528,7 +5208,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sed", + "command": "sed", + "parameters": "'s/old/new/g' file" + } }, { "id": "text-002", @@ -3542,7 +5228,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "awk", + "command": "awk", + "parameters": "'{print $1, $3}' file" + } }, { "id": "text-003", @@ -3556,7 +5248,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "sort", + "command": "sort", + "parameters": "file" + } }, { "id": "text-004", @@ -3570,7 +5268,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "uniq", + "command": "uniq", + "parameters": "file" + } }, { "id": "text-005", @@ -3584,7 +5288,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cut", + "command": "cut", + "parameters": "-d',' -f1,3 file.csv" + } }, { "id": "text-006", @@ -3598,7 +5308,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "tr", + "command": "tr", + "parameters": "'a-z' 'A-Z' < file" + } }, { "id": "text-007", @@ -3612,7 +5328,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "column", + "command": "column", + "parameters": "-t file" + } }, { "id": "text-008", @@ -3626,7 +5348,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "paste", + "command": "paste", + "parameters": "file1 file2" + } }, { "id": "text-009", @@ -3640,7 +5368,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "join", + "command": "join", + "parameters": "file1 file2" + } }, { "id": "text-010", @@ -3654,7 +5388,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "expand", + "command": "expand", + "parameters": "file" + } }, { "id": "text-011", @@ -3668,7 +5408,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "unexpand", + "command": "unexpand", + "parameters": "file" + } }, { "id": "text-012", @@ -3682,7 +5428,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "fold", + "command": "fold", + "parameters": "-w 80 file" + } }, { "id": "text-013", @@ -3696,7 +5448,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "nl", + "command": "nl", + "parameters": "file" + } }, { "id": "text-014", @@ -3710,7 +5468,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "rev", + "command": "rev", + "parameters": "file" + } }, { "id": "text-015", @@ -3724,7 +5488,13 @@ "bsd" ], "category": "files", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "grep", + "command": "grep", + "parameters": "-v '^$' file" + } }, { "id": "cron-001", @@ -3738,7 +5508,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "crontab", + "command": "crontab", + "parameters": "-l" + } }, { "id": "cron-002", @@ -3752,7 +5528,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "crontab", + "command": "crontab", + "parameters": "-e" + } }, { "id": "cron-003", @@ -3764,7 +5546,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "cat", + "command": "cat /etc/crontab", + "parameters": "" + } }, { "id": "cron-004", @@ -3778,7 +5566,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "deletes-data", + "tool": "crontab", + "command": "crontab", + "parameters": "-r" + } }, { "id": "cron-005", @@ -3790,7 +5584,13 @@ "all" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "at", + "command": "at", + "parameters": "now + 1 hour" + } }, { "id": "web-001", @@ -3804,7 +5604,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "nginx", + "command": "nginx", + "parameters": "-t" + } }, { "id": "web-002", @@ -3818,7 +5624,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "nginx", + "command": "nginx", + "parameters": "-s reload" + } }, { "id": "web-003", @@ -3832,7 +5644,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "apachectl", + "command": "apachectl", + "parameters": "configtest" + } }, { "id": "web-004", @@ -3846,7 +5664,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "apachectl", + "command": "apachectl", + "parameters": "restart" + } }, { "id": "web-005", @@ -3858,7 +5682,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "systemctl", + "command": "systemctl status", + "parameters": "nginx" + } }, { "id": "web-006", @@ -3870,7 +5700,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "systemctl", + "command": "systemctl status", + "parameters": "apache2" + } }, { "id": "web-007", @@ -3884,7 +5720,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tail", + "command": "tail", + "parameters": "-f /var/log/nginx/access.log" + } }, { "id": "web-008", @@ -3898,7 +5740,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tail", + "command": "tail", + "parameters": "-f /var/log/nginx/error.log" + } }, { "id": "web-009", @@ -3912,7 +5760,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tail", + "command": "tail", + "parameters": "-f /var/log/apache2/access.log" + } }, { "id": "web-010", @@ -3926,7 +5780,13 @@ "bsd" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "tail", + "command": "tail", + "parameters": "-f /var/log/apache2/error.log" + } }, { "id": "kernel-001", @@ -3938,7 +5798,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "modprobe", + "command": "modprobe", + "parameters": "module-name" + } }, { "id": "kernel-002", @@ -3950,7 +5816,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "lsmod", + "command": "lsmod", + "parameters": "" + } }, { "id": "kernel-003", @@ -3962,7 +5834,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "dmesg", + "command": "dmesg", + "parameters": "| tail -50" + } }, { "id": "kernel-004", @@ -3974,7 +5852,13 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "changes-system", + "tool": "modprobe", + "command": "modprobe", + "parameters": "-r module-name" + } }, { "id": "kernel-005", @@ -3986,6 +5870,12 @@ "linux" ], "category": "system", - "isSystem": true + "isSystem": true, + "safety": { + "impact": "read-only", + "tool": "modinfo", + "command": "modinfo", + "parameters": "module-name" + } } -] \ No newline at end of file +] diff --git a/app/user_settings.py b/app/user_settings.py index 82fda955..da388641 100644 --- a/app/user_settings.py +++ b/app/user_settings.py @@ -85,6 +85,12 @@ def _defaults_for_user(user): def _valid_settings(value): + from .customer_workflows import valid_presets, valid_recovery_timestamp + if isinstance(value, dict) and 'recovery_reviewed_at' in value and not ( + value['recovery_reviewed_at'] == '' or valid_recovery_timestamp(value['recovery_reviewed_at'])): + return False + if isinstance(value, dict) and "workspace_presets" in value and not valid_presets(value["workspace_presets"]): + return False if not isinstance(value, dict): return False if value.get('schema_version') != CURRENT_STORAGE_VERSIONS['settings']: @@ -143,6 +149,12 @@ def _valid_settings(value): def _valid_settings_update(value): + from .customer_workflows import valid_presets, valid_recovery_timestamp + if isinstance(value, dict) and 'recovery_reviewed_at' in value and not ( + value['recovery_reviewed_at'] == '' or valid_recovery_timestamp(value['recovery_reviewed_at'])): + return False + if isinstance(value, dict) and "workspace_presets" in value and not valid_presets(value["workspace_presets"]): + return False return ( isinstance(value, dict) and ('start_page' not in value or (isinstance(value['start_page'], str) diff --git a/docker-compose.yml b/docker-compose.yml index a2e09050..94943263 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,9 @@ services: - OIDC_STEP_UP_ACR_VALUES=${OIDC_STEP_UP_ACR_VALUES:-} - STEP_UP_MAX_AGE_SECONDS=${STEP_UP_MAX_AGE_SECONDS:-300} + # Optional administrator-owned HTTPS notification endpoint; accounts opt in separately. + - AUTOMATION_NOTIFICATION_WEBHOOK_URL=${AUTOMATION_NOTIFICATION_WEBHOOK_URL:-} + # Optional configuration # Optional authoritative override for Unix permissions (default: disabled). diff --git a/docs/automation.md b/docs/automation.md index 5fc0ac89..012220e1 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -74,3 +74,16 @@ Most examples collect inventory or diagnostics. The Debian/Ubuntu and Fedora/RHE Saved command sets are available in a separate group in the template selector. Importing creates a copy as one shell step, preserving the resolved sequence and its shell context. Parameterized sets request fixed values and use the existing server-side parameter resolver. Missing references or oversized commands must be repaired before import. Later changes to the original set do not update a workflow copy. Overview displays up to five recent runs, prioritizing queued and running jobs, with confirmed step counts and a direct link to the expanded run log. Status refreshes while Overview is visible; a failed refresh is marked as stale rather than shown as a successful result. The workflow action bar stays reachable while the editor and destination list scroll. Motion follows the reduced-motion preference. + + +## External attention notifications + +Each account can opt in under **Automation → Runs**. Notifications are disabled by default. An administrator configures a fixed HTTPS webhook using `AUTOMATION_NOTIFICATION_WEBHOOK_URL`; leave it empty to disable external delivery. Pass that environment variable into the container when using Compose and restart after changing it. The URL is never accepted from the browser or returned in API responses. Use a service compatible with a JSON POST containing `{"event":"workflow_attention","status":"failed"}`; status is `failed`, `unknown` or `needs_review`. A valid HTTPS certificate is required, including for administrator-controlled internal endpoints. + +No account identifiers, workflow names, hostnames, commands, credentials or output are sent. Pending alerts are discarded if account security, the restored session epoch, the automation policy or the opt-in changes before delivery. An interrupted manual run becomes a persisted unknown result when its owner next reads history; that transition alerts once, without replay on later reads. The endpoint is responsible for routing the generic alert. URLs with embedded credentials or fragments are rejected. Requests connect directly, ignoring ambient proxy variables, with a three-second delivery deadline and socket timeout, no redirects and no retry. Delivery runs as a bounded background job; there is no notification backlog. If its single slot is occupied, a new alert is marked unconfirmed rather than queued. A timed-out transport may still finish on the remote service; it is never replayed. Notification failure never changes the workflow outcome. Runs shows whether the service is unavailable and the last delivery status; it does not expose endpoint diagnostics. A failed alert is not replayed automatically. Check the retained run results for details. + +## Export and retention + +Each retained run has **Export JSON** and **Export CSV** actions. Exports require the same authenticated owner and transport protections as run history. They contain the currently retained metadata and bounded stdout/stderr; they do not recover deleted or truncated output. CSV cells that could be interpreted as spreadsheet formulas are prefixed with an apostrophe. Exported output can still contain sensitive remote data; store it appropriately. + +The account history retains at most 50 runs within a 1 MiB document limit, so older results can be removed sooner when the size bound is reached. Per-step output becomes visible after that step finishes, including long-running steps. A silent running step does not imply that it failed or is safe to repeat. Unknown outcomes require checking the remote system before starting again. diff --git a/docs/credentials.md b/docs/credentials.md index 5e315ab3..8bea5450 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -39,3 +39,7 @@ Never put secrets in commands, exported profiles, URLs, browser storage or logs. Existing automation-vault.json files remain intact. **Import existing vault** asks for the old passphrase once and imports named entries and valid assignments. The old ciphertext is not deleted. Failed import leaves it recoverable; repeating the same source revision does not duplicate records. Changed old destination bindings require explicit correction. New installations need no vault passphrase. A lost old passphrase cannot be repaired by the new store: import the existing vault using the original passphrase or enter remote passwords again. Native backup restore does not restore the legacy vault. Older versions do not understand the named store; downgrade requires a compatible backup, not overwriting new data. + +## Recovery check + +After a portable restore, open **Account → Recovery check** (administrators can also open it from **Backup & Restore**). The checklist shows only your own saved hosts, missing keys/password assignments, host-trust decisions and schedule state. It inspects configuration without opening remote connections, testing credentials or granting approvals. Re-provision credentials from your separate trusted source, review each host, and explicitly approve schedules again. The last-restore timestamp is informational and does not certify recovery. diff --git a/docs/webssh-3.md b/docs/webssh-3.md index 14d59a67..9cc54961 100644 --- a/docs/webssh-3.md +++ b/docs/webssh-3.md @@ -253,3 +253,11 @@ A rollback to 2.x ignores the stored Admin values and follows its environment. Authentication capabilities retain separate readiness and activation checks. TOTP capability is available by default; Admin activation remains required. + +## Task-first access and recovery guidance + +The dashboard offers active sessions, favorites and recent hosts before its detailed panels, falling back to saved hosts for new accounts. The first-connection checklist observes host configuration, authentication, host trust, terminal and Files progress without connecting automatically. Configuration readiness and TCP reachability remain separate; neither certifies remote login or permissions. Existing classic and customized dashboard layouts remain available. + +The command library distinguishes a fixed system template from its known effect. Inspection browsing keeps advanced commands available through search or All tasks. Exact-template impact hints do not validate custom arguments or guarantee a safe remote result. + +Workspace presets retain account-owned host references and pane choices without storing credentials or replaying commands. After a portable restore, an account-specific reminder opens Recovery check; marking the steps reviewed dismisses only that reminder and does not verify recovery or authorize any connection or schedule. See the terminal, credentials and backup guides for details. diff --git a/docs/wiki/Backup-Restore-and-Secret-Rotation.md b/docs/wiki/Backup-Restore-and-Secret-Rotation.md index 7156cb76..0c767bbf 100644 --- a/docs/wiki/Backup-Restore-and-Secret-Rotation.md +++ b/docs/wiki/Backup-Restore-and-Secret-Rotation.md @@ -127,3 +127,7 @@ Test the full sequence on an isolated instance: 9. Record the observed recovery time and required secret material. An untested archive is not a verified recovery capability. + +## Recovery checklist in the application + +The backup page lists included configuration/account data and excluded remote credentials/live sessions separately. Normal export restores write a small informational `recovery-check.json` timestamp; private rollback restore does not create a new successful-restore marker. **Recovery check** uses the signed-in account’s current host/key/password and schedule metadata to guide re-provisioning. It does not import excluded credentials, approve host keys, test connectivity or enable schedules. The timestamp is not a restore-drill result. diff --git a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md index 95c0c642..d1d6dd1c 100644 --- a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md +++ b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md @@ -274,3 +274,9 @@ Open quotes, continuations, here-documents, running commands, copy mode and unfi ### Repeating the real tmux checks Run `node tests/integration/tmux_sync.cjs` after installing the project Node dependencies and Playwright Chromium. On Linux it uses Python 3 and tmux; on Windows it uses Ubuntu under WSL. The test creates its own tmux server and synthetic shell configuration, checks actual xterm/controller behavior, and removes only those test resources. It verifies continuation blocking, repeated folder changes, Unicode/quoted paths, checkbox recovery, prompt/exit-status preservation, configuration hashes, pane identity and detach/reattach. + +## Named workspace presets + +Open **Workspace presets** in the desktop terminal toolbar or account menu. Save a name, one/two/four panes and up to that many of your saved hosts. Presets belong to your account and retain only layout and host references, never passwords, commands or terminal output. Applying prepares the layout and offers an explicit Connect button for each host; it never logs in or executes commands by itself. Existing assigned sessions stay in place, and a smaller preset does not shrink their layout. More than four open floating windows must be docked or reduced before applying a preset. Deleted hosts are shown as unavailable; remove the affected preset and save updated choices. + +The session continuity message distinguishes browser transport loss from remote SSH disconnection and explains the different normal-SSH and tmux guarantees. Reconnection never proves that a previous command succeeded; inspect the remote state before repeating work. diff --git a/static/css/automation.css b/static/css/automation.css index 842bbd8e..03f5eaae 100644 --- a/static/css/automation.css +++ b/static/css/automation.css @@ -578,3 +578,7 @@ body[data-theme="paper"] [data-native-automation] { color-scheme: light; } .automation-compare-filter { margin: 10px 0 0; font-size: 13px; } .automation-compare-filter[hidden] { display: none; } .automation-run-compare[data-filter-active="true"][data-only-differing="true"] .automation-compare-step[data-uniform="true"] { display: none; } + +.automation-source-controls input[type="search"] { width: 100%; min-width: 0; margin-bottom: 8px; } + +.automation-notifications .automation-check { display: flex; align-items: flex-start; gap: 8px; } diff --git a/static/css/customer-workflows.css b/static/css/customer-workflows.css new file mode 100644 index 00000000..0af45a9d --- /dev/null +++ b/static/css/customer-workflows.css @@ -0,0 +1,25 @@ +.customer-dialog { margin: auto; inset: 0; width: min(42rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem); box-sizing: border-box; padding: 1.5rem; overflow: auto; color: var(--ws2-text, var(--text-primary)); background: var(--ws2-panel, var(--bg-secondary)); border: 1px solid var(--ws2-border, var(--border-color)); border-radius: 12px; } +.customer-dialog::backdrop { background: rgb(0 0 0 / 65%); } +.customer-dialog h2 { margin-block: 0 1rem; } +.customer-dialog h3 { margin-block: 1.25rem .5rem; } +.customer-dialog p, .customer-dialog li { line-height: 1.6; overflow-wrap: anywhere; } +.customer-dialog form { display: grid; gap: .75rem; margin-block-start: 1.5rem; } +.customer-dialog input:not([type=checkbox]), .customer-dialog select { width: 100%; min-height: 2.75rem; padding: .5rem; font: inherit; font-size: 1rem; color: inherit; background: var(--ws2-surface, var(--bg-primary)); border: 1px solid var(--ws2-border, var(--border-color)); border-radius: 4px; box-sizing: border-box; } +.customer-dialog button, .customer-dialog a.btn { white-space: normal; min-height: 2.75rem; margin: .25rem; } +.customer-dialog fieldset { max-height: 16rem; overflow: auto; border: 1px solid var(--ws2-border, var(--border-color)); } +.customer-choice { display: flex; gap: .75rem; align-items: center; padding: .5rem; min-height: 2rem; overflow-wrap: anywhere; } +.customer-row { padding-block: 1rem; border-bottom: 1px solid var(--ws2-border, var(--border-color)); } +.workspace-continuity { margin: .25rem .75rem; max-width: 75ch; color: var(--ws2-text-secondary, var(--text-secondary)); font-size: .875rem; line-height: 1.5; } +.fm-recovery-guidance { margin-block: .5rem; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; } +.backup-coverage { margin-block: 1rem; line-height: 1.6; } + +.customer-dialog a { color: var(--ws2-accent, var(--accent-color, #58a6ff)); text-underline-offset: .2em; } +.customer-dialog :is(button,a,input,select):focus-visible { outline: 2px solid var(--ws2-accent, var(--accent-color, #58a6ff)); outline-offset: 3px; } + +.customer-dialog ol { padding-inline-start: 1.5rem; } +.restore-recovery-notice { position: fixed; inset-inline-end: 1rem; bottom: 1rem; z-index: 900; width: min(30rem, calc(100vw - 2rem)); box-sizing: border-box; padding: 1rem; border: 1px solid var(--ws2-accent, #58a6ff); border-radius: 12px; color: var(--ws2-text, var(--text-primary)); background: var(--ws2-panel, var(--bg-secondary)); box-shadow: 0 8px 32px rgb(0 0 0 / 25%); } +.restore-recovery-notice[hidden] { display: none; } +.restore-recovery-notice p { font-size: .875rem; line-height: 1.5; margin: .5rem 0; } +.restore-recovery-notice button { min-height: 44px; white-space: normal; } +.restore-recovery-notice button:focus-visible { outline: 2px solid var(--ws2-accent, #58a6ff); outline-offset: 3px; } +@media (max-width: 767px) { .restore-recovery-notice { bottom: 5rem; } } diff --git a/static/css/management-ui.css b/static/css/management-ui.css index 5a2a12fc..16d94c2b 100644 --- a/static/css/management-ui.css +++ b/static/css/management-ui.css @@ -301,3 +301,12 @@ .webssh-ui #keyManagementModal .key-item-actions .btn { min-height: 44px; } } .command-template-hint { color: var(--ws2-text-secondary); font-size: 13px; line-height: 1.5; margin-block: 8px; overflow-wrap: anywhere; } + +/* Keep the return action reachable while reading long command templates. */ +.webssh-ui .command-impact-label { display: block; color: var(--ws2-text-secondary); font-size: 13px; line-height: 1.5; } +@media (max-width: 1120px) { + .webssh-ui #commandWorkspaceModal .command-library-details > .management-inspector-heading { position: sticky; top: 0; z-index: 1; background: var(--ws2-surface, var(--bg-primary)); padding-block: 8px; } + .webssh-ui #commandWorkspaceModal .command-library-details .management-inspector-heading > h3 { flex: 1 1 auto; min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; } + .webssh-ui #commandWorkspaceModal .command-library-details .inspector-return { display: inline-flex; flex: 0 0 auto; width: auto; min-inline-size: max-content; min-height: 44px; white-space: nowrap; text-align: start; max-width: none; } + .webssh-ui #commandWorkspaceModal .command-library-details .inspector-return-label { flex: 0 0 auto; white-space: nowrap; } +} diff --git a/static/css/overview-workspace.css b/static/css/overview-workspace.css index bfeae93b..eb2db4c3 100644 --- a/static/css/overview-workspace.css +++ b/static/css/overview-workspace.css @@ -540,3 +540,31 @@ body.webssh-ui[data-theme="glass"]:not([data-custom-background]) .od-scene { bac .od-host { transition: none; } .od-host:hover, .od-host:focus-within { transform: none; } } + +/* Task shortcuts precede the customizable board without changing saved layouts. */ +.od-work-first { margin: 0 0 1.5rem; padding: 1.1rem; border: 1px solid var(--ws2-border); border-radius: 12px; background: var(--ws2-panel); color: var(--ws2-text); } +.od-work-first .od-panel-head { flex-wrap: wrap; gap: .75rem; padding: 0 0 .9rem; } +.od-work-first h2 { margin: 0; font-size: 1.05rem; } +.od-shortcuts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 1rem; } +.od-shortcut-group { min-width: 0; } +.od-shortcut-group h3 { font-size: .85rem; margin: 0 0 .5rem; color: var(--ws2-text-secondary); } +.od-shortcut { display: flex; flex-direction: column; align-items: flex-start; width: 100%; min-height: 44px; padding: .55rem .75rem; margin: .3rem 0; text-align: left; border: 1px solid var(--ws2-border); border-radius: 8px; background: var(--ws2-surface); color: var(--ws2-text); overflow-wrap: anywhere; cursor: pointer; } +.od-shortcut small { color: var(--ws2-text-secondary); } +.od-shortcut:focus-visible, .od-first-connection summary:focus-visible { outline: 2px solid var(--ws2-accent); outline-offset: 3px; } +.od-first-connection { margin-top: 1rem; border-top: 1px solid var(--ws2-border); padding-top: .75rem; } +.od-first-connection summary { cursor: pointer; padding: .4rem 0; font-weight: 600; } +.od-first-connection p { color: var(--ws2-text-secondary); font-size: .85rem; line-height: 1.5; } +.od-first-connection select { max-width: 100%; margin: .5rem 0; } +.od-first-connection ol { padding: 0; list-style: none; display: grid; gap: .5rem; } +.od-setup-step { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; min-height: 44px; } +.od-setup-step > span:not(.material-icons) { flex: 1 1 160px; } +.od-setup-step small { color: var(--ws2-text-secondary); } +.od-setup-step[data-complete="true"] > .material-icons { color: var(--ws2-success); } +.od-host-context { margin: .2rem .9rem .75rem; font-size: .72rem; color: var(--ws2-text-secondary); line-height: 1.45; } +.od-host-context small { display: block; font-size: inherit; } + +.od-work-first a { color: var(--ws2-accent); text-decoration: underline; text-underline-offset: .2em; border-radius: 3px; } +.od-work-first a:hover { color: var(--ws2-text); text-decoration-thickness: 2px; } +.od-work-first a:focus-visible { color: var(--ws2-text); background: var(--ws2-surface); outline: 2px solid var(--ws2-focus, var(--ws2-accent)); outline-offset: 4px; } +.od-shortcut:hover { border-color: var(--ws2-accent); background: color-mix(in srgb, var(--ws2-accent) 10%, var(--ws2-surface)); } +.od-first-connection select { border-color: var(--ws2-border); background: var(--ws2-surface); color: var(--ws2-text); } diff --git a/static/i18n/commands/de.json b/static/i18n/commands/de.json index 5f0b621d..940f52d3 100644 --- a/static/i18n/commands/de.json +++ b/static/i18n/commands/de.json @@ -317,7 +317,15 @@ }, "net-050": { "name": "Ping-Flood", - "description": "Ping-Flood (zu Testzwecken)" + "description": "Sendet bis zum Abbruch kontinuierlich Flood-Ping-Verkehr. Kann Netzwerke und Ziele überlasten; erhöhte Rechte können erforderlich sein." + }, + "net-051": { + "name": "Host anpingen (Windows)", + "description": "Vier ICMP-Echo-Anfragen mit Windows-Ping senden; hostname durch das gewünschte Ziel ersetzen." + }, + "net-052": { + "name": "Verbindungen und lauschende Ports (Windows)", + "description": "Zeigt Verbindungen und lauschende TCP-/UDP-Ports mit numerischen Adressen und Prozess-IDs über das native Windows-netstat." }, "file-001": { "name": "Dateien auflisten", diff --git a/static/i18n/commands/es.json b/static/i18n/commands/es.json index b937e411..4b66a6e6 100644 --- a/static/i18n/commands/es.json +++ b/static/i18n/commands/es.json @@ -317,7 +317,15 @@ }, "net-050": { "name": "Ping flood", - "description": "Ping de inundación (pruebas)" + "description": "Envía tráfico ping continuo hasta interrumpirlo. Puede saturar redes y destinos; puede requerir privilegios elevados." + }, + "net-051": { + "name": "Ping a un host (Windows)", + "description": "Enviar cuatro solicitudes ICMP con ping de Windows; sustituir hostname por el destino previsto." + }, + "net-052": { + "name": "Conexiones y puertos en escucha (Windows)", + "description": "Muestra conexiones y puertos TCP/UDP en escucha con direcciones numéricas e identificadores de proceso mediante netstat nativo de Windows." }, "file-001": { "name": "Listar archivos", diff --git a/static/i18n/commands/fr.json b/static/i18n/commands/fr.json index 99e0440b..03cdfeeb 100644 --- a/static/i18n/commands/fr.json +++ b/static/i18n/commands/fr.json @@ -317,7 +317,15 @@ }, "net-050": { "name": "Ping flood", - "description": "Ping en inondation (test)" + "description": "Émet un flux continu de ping jusqu’à interruption. Peut saturer les réseaux et les cibles ; des droits élevés peuvent être requis." + }, + "net-051": { + "name": "Tester un hôte (Windows)", + "description": "Envoyer quatre requêtes ICMP avec ping sous Windows ; remplacer hostname par la cible voulue." + }, + "net-052": { + "name": "Connexions et ports en écoute (Windows)", + "description": "Affiche les connexions et les ports TCP/UDP en écoute avec adresses numériques et identifiants de processus via netstat natif de Windows." }, "file-001": { "name": "Lister les fichiers", diff --git a/static/i18n/commands/vi.json b/static/i18n/commands/vi.json index 5b226af3..82f0c956 100644 --- a/static/i18n/commands/vi.json +++ b/static/i18n/commands/vi.json @@ -317,7 +317,15 @@ }, "net-050": { "name": "Ping flood", - "description": "Ping dồn dập (để kiểm thử)" + "description": "Gửi lưu lượng ping dồn dập liên tục cho đến khi ngắt. Có thể gây quá tải mạng và máy đích; có thể cần quyền nâng cao." + }, + "net-051": { + "name": "Ping máy chủ (Windows)", + "description": "Gửi bốn yêu cầu ICMP bằng ping của Windows; thay hostname bằng đích mong muốn." + }, + "net-052": { + "name": "Kết nối và cổng đang lắng nghe (Windows)", + "description": "Hiển thị kết nối và cổng TCP/UDP đang lắng nghe cùng địa chỉ dạng số và mã tiến trình bằng netstat có sẵn trên Windows." }, "file-001": { "name": "Liệt kê tệp", diff --git a/static/i18n/commands/zh.json b/static/i18n/commands/zh.json index 791479fd..f607bdc1 100644 --- a/static/i18n/commands/zh.json +++ b/static/i18n/commands/zh.json @@ -317,7 +317,15 @@ }, "net-050": { "name": "Ping 洪泛", - "description": "洪泛 Ping(测试用)" + "description": "持续发送洪泛 ping 流量,直到中断。可能使网络和目标过载;可能需要提升权限。" + }, + "net-051": { + "name": "Ping 主机(Windows)", + "description": "使用 Windows ping 发送四次 ICMP 回显请求;将 hostname 替换为目标主机。" + }, + "net-052": { + "name": "连接和侦听端口(Windows)", + "description": "使用 Windows 自带的 netstat 显示连接和侦听的 TCP/UDP 端口,包括数字地址和所属进程 ID。" }, "file-001": { "name": "列出文件", diff --git a/static/js/app.js b/static/js/app.js index 2cfa8e26..00bcd03a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1252,7 +1252,7 @@ } for (const id of ['editorDraftBtn', 'editorReloadBtn']) { const button = document.getElementById(id); - if (button) button.hidden = !unknown; + if (button) button.hidden = id === 'editorDraftBtn' ? false : !unknown; } if (unknown) { const message = window.i18n ? i18n.t('editor.saveOutcomeUnknown') diff --git a/static/js/automation-i18n.js b/static/js/automation-i18n.js index 15662fd8..edf4bd63 100644 --- a/static/js/automation-i18n.js +++ b/static/js/automation-i18n.js @@ -173,14 +173,14 @@ "credentials": "Credentials", "new": "New", "inventoryTemplate": "Start with an inventory check", - "newRunbook": "New runbook", + "newRunbook": "New workflow", "delete": "Delete", - "runbookName": "Runbook name", + "runbookName": "Workflow name", "steps": "Steps", "addStep": "Add step", "stepHint": "Each step uses a separate shell. Use complete commands; shell state is not shared. Never put passwords or tokens in commands.", "stopOnError": "Stop the run after the first host with an error", - "save": "Save runbook", + "save": "Save workflow", "targets": "Choose targets", "targetHint": "Use saved SSH hosts or complete groups. Direct password and SSH-key connections are supported. Jump hosts, gateways and Tailscale SSH cannot run here.", "searchHosts": "Search hosts", @@ -206,14 +206,14 @@ "timeout": "Timeout (seconds)", "sudo": "Use noninteractive sudo (sudo -n)", "unsaved": "Unsaved changes", - "editRunbook": "Edit runbook", + "editRunbook": "Edit workflow", "stepCount": "{count} steps", "oneStep": "1 step", "hostCount": "{count} hosts", "unsavedTitle": "Discard unsaved changes?", "discardHint": "Your edits have not been saved.", "saved": "Saved", - "noRunbooks": "Create your first runbook, or start with a small inventory check.", + "noRunbooks": "Create your first workflow, or start with a small inventory check.", "unsupported": "Unsupported route", "sshKey": "SSH key", "password": "Password", @@ -227,10 +227,10 @@ "typeCount": "Type {count} to confirm the exact number of hosts", "cancelRun": "Stop remaining work", "cancellationRequested": "Cancellation requested. Already dispatched commands may continue remotely.", - "noRuns": "No runs yet. Choose a runbook and review your targets to get started.", - "deleteRunbook": "Delete runbook", - "deleteRunbookHint": "This removes the saved runbook. Previous run results stay available.", - "deleted": "Runbook deleted.", + "noRuns": "No runs yet. Choose a workflow and review your targets to get started.", + "deleteRunbook": "Delete workflow", + "deleteRunbookHint": "This removes the saved workflow. Previous run results stay available.", + "deleted": "Workflow deleted.", "inventoryName": "System inventory", "inventoryStep": "Check system health", "runStarted": "Run started. You can follow each host below.", @@ -272,11 +272,11 @@ "error.invalid_targets": "Choose 1–50 supported hosts. Selected groups must exist and contain only supported hosts.", "error.unsupported_target": "A selected host uses a jump host, gateway or Tailscale route. Direct SSH hosts are supported here.", "error.key_unavailable": "A selected SSH key is unavailable or requires an unsupported unlock. Check it in Hosts.", - "error.preview_changed": "A host, key, credential or runbook changed. Close this preview and review the run again.", + "error.preview_changed": "A host, key, credential or workflow changed. Close this preview and review the run again.", "error.invalid_intent": "This authorization expired or was already used. Review the action again.", "error.confirmation_required": "Type the exact confirmation shown.", "error.step_up_required": "Reauthenticate your WebSSH account to authorize this exact action.", - "error.storage_limit": "Automation storage limit reached. Remove unused runbooks or credentials.", + "error.storage_limit": "Automation storage limit reached. Remove unused workflows or credentials.", "error.storage_unavailable": "Stored automation data could not be validated. Ask your administrator to inspect or restore it.", "error.not_found": "This item no longer exists. Refresh the page.", "error.acknowledgement_required": "Read and acknowledge the vault security boundary.", @@ -341,7 +341,7 @@ "scheduleSummaryTitle":"Dein Zeitplan", "sectionHint.library":"Deine gespeicherten Abläufe. Starte einen Ablauf, bearbeite seine Schritte oder erstelle einen neuen.", "editWorkflow":"Bearbeiten", -"backToWorkflows":"Alle Abläufe", +"backToWorkflows":"Alle Workflows", "sectionHint.runbooks":"Wiederverwendbaren Ablauf erstellen, Hosts wählen und anschließend einen einmaligen Lauf oder einen optionalen Zeitplan prüfen.", "sectionHint.schedules":"Wiederkehrende Läufe verwalten: nächste Ausführung prüfen, Zeitplan pausieren oder Änderungen freigeben.", "sectionHint.runs":"Nachvollziehen, was auf welchen Hosts lief und warum ein Schritt erfolgreich war oder scheiterte.", @@ -433,7 +433,7 @@ "adminEnable": "Automation in Administration unter Integrationen einschalten.", "credentialChanged": "Verbindung geändert · Kennwort erneut speichern", "replacePassword": "Kennwort ersetzen", - "searchRunbooks": "Abläufe suchen", + "searchRunbooks": "Workflows suchen", "supportedTargets": "Unterstützte Ziele", "editStep": "Schritt bearbeiten", "storedUnverified": "Kennwort gespeichert · Vertrauen bei Prüfung", @@ -445,7 +445,7 @@ "intro": "Geprüfte Aufgaben, geordnete Schritte und die Geräte deiner Wahl.", "workspaces": "Arbeitsbereiche", "settings": "Einstellungen", - "runbooks": "Abläufe", + "runbooks": "Workflows", "runs": "Läufe", "credentials": "Zugangsdaten", "enableTitle": "Automatisierung für diese Installation aktivieren", @@ -454,14 +454,14 @@ "httpsHint": "WebSSH über HTTPS bereitstellen. Nur für lokale Entwicklung: DEBUG=true und AUTOMATION_ALLOW_INSECURE_LOOPBACK=true.", "new": "Neu", "inventoryTemplate": "Mit einer Systemprüfung beginnen", - "newRunbook": "Neues Runbook", + "newRunbook": "Neuer Workflow", "delete": "Löschen", - "runbookName": "Runbook-Name", + "runbookName": "Workflow-Name", "steps": "Schritte", "addStep": "Schritt hinzufügen", "stepHint": "Jeder Schritt verwendet eine eigene Shell. Vollständige Befehle verwenden; Shell-Zustand wird nicht geteilt. Keine Kennwörter oder Tokens in Befehlen speichern.", "stopOnError": "Nach dem ersten Gerät mit einem Fehler den Lauf stoppen", - "save": "Runbook speichern", + "save": "Workflow speichern", "targets": "Ziele auswählen", "targetHint": "Gespeicherte SSH-Hosts oder vollständige Gruppen verwenden. Unterstützt werden direkte Kennwort- und SSH-Key-Verbindungen. Jump Hosts, Gateways und Tailscale SSH können hier nicht ausgeführt werden.", "searchHosts": "Hosts durchsuchen", @@ -487,14 +487,14 @@ "timeout": "Zeitlimit (Sekunden)", "sudo": "Nichtinteraktives sudo verwenden (sudo -n)", "unsaved": "Ungespeicherte Änderungen", - "editRunbook": "Runbook bearbeiten", + "editRunbook": "Workflow bearbeiten", "stepCount": "{count} Schritte", "oneStep": "1 Schritt", "hostCount": "{count} Geräte", "unsavedTitle": "Ungespeicherte Änderungen verwerfen?", "discardHint": "Deine Änderungen wurden noch nicht gespeichert.", "saved": "Gespeichert", - "noRunbooks": "Erstelle dein erstes Runbook oder beginne mit einer kleinen Systemprüfung.", + "noRunbooks": "Erstelle deinen ersten Workflow oder beginne mit einer kleinen Systemprüfung.", "unsupported": "Route nicht unterstützt", "sshKey": "SSH-Key", "password": "Kennwort", @@ -508,10 +508,10 @@ "typeCount": "Zur Bestätigung der genauen Geräteanzahl {count} eingeben", "cancelRun": "Verbleibende Arbeit stoppen", "cancellationRequested": "Abbruch angefordert. Bereits gestartete Befehle können auf den Geräten weiterlaufen.", - "noRuns": "Noch keine Läufe. Wähle ein Runbook und prüfe die gewünschten Ziele.", - "deleteRunbook": "Runbook löschen", - "deleteRunbookHint": "Das gespeicherte Runbook wird entfernt. Bisherige Ergebnisse bleiben verfügbar.", - "deleted": "Runbook gelöscht.", + "noRuns": "Noch keine Läufe. Wähle einen Workflow und prüfe die gewünschten Ziele.", + "deleteRunbook": "Workflow löschen", + "deleteRunbookHint": "Der gespeicherte Workflow wird entfernt. Bisherige Ergebnisse bleiben verfügbar.", + "deleted": "Workflow gelöscht.", "inventoryName": "Systeminventar", "inventoryStep": "Systemzustand prüfen", "runStarted": "Lauf gestartet. Hier kannst du jedes Gerät verfolgen.", @@ -553,11 +553,11 @@ "error.invalid_targets": "1–50 unterstützte Hosts auswählen. Ausgewählte Gruppen müssen existieren und vollständig unterstützt sein.", "error.unsupported_target": "Ein Host nutzt Jump Host, Gateway oder Tailscale. Hier sind direkte SSH-Hosts unterstützt.", "error.key_unavailable": "Ein SSH-Key ist nicht verfügbar oder benötigt eine nicht unterstützte Entsperrung. Unter Hosts prüfen.", - "error.preview_changed": "Host, Key, Zugangsdaten oder Runbook wurden geändert. Vorschau schließen und erneut prüfen.", + "error.preview_changed": "Host, Key, Zugangsdaten oder Workflow wurden geändert. Vorschau schließen und erneut prüfen.", "error.invalid_intent": "Diese Freigabe ist abgelaufen oder wurde bereits verwendet. Aktion erneut prüfen.", "error.confirmation_required": "Genau die angezeigte Bestätigung eingeben.", "error.step_up_required": "Dein WebSSH-Konto erneut authentifizieren, um genau diese Aktion freizugeben.", - "error.storage_limit": "Speicherlimit erreicht. Ungenutzte Runbooks oder Zugangsdaten entfernen.", + "error.storage_limit": "Speicherlimit erreicht. Ungenutzte Workflows oder Zugangsdaten entfernen.", "error.storage_unavailable": "Gespeicherte Automatisierungsdaten konnten nicht geprüft werden. Deinen Administrator um Prüfung oder Wiederherstellung bitten.", "error.not_found": "Dieser Eintrag existiert nicht mehr. Seite aktualisieren.", "error.acknowledgement_required": "Die Sicherheitsgrenze des Tresors lesen und bestätigen.", @@ -672,6 +672,24 @@ "compareGroupOne": "1 Host · {state} · Exit {exit}: {hosts}", "templateOpenedFor": "Vorlage als Entwurf mit {count} ausgewählten Hosts geöffnet. Befehle vor dem Ausführen prüfen." }); + Object.assign(strings.en, { + sourceSearch:'Search commands, command sets and templates', exportRun:'Export {format}', + retentionExplanation:'Only the latest 50 runs are retained, within a 1 MiB account history limit. Older runs may be removed sooner when the limit is reached. Exports include only currently retained results and bounded output.', + stepOutputExplanation:'Step output appears after the step completes. A long-running step can stay silent until its timeout. Unknown results require checking the host before another run; they are never replayed automatically.', + notificationOptIn:'Send external alerts for failed, unknown or reapproval-required workflows', + notificationPrivacy:'Optional for this account. Alerts go to the administrator-configured HTTPS service and contain only an event type and status, without account, host, command or output content. Delivery errors do not change the run result. No retry or replay.', + notificationUnavailable:'External alerts unavailable: an administrator must configure a valid HTTPS endpoint. Your preference is retained.', + 'notificationDelivery.pending':'Alert waiting for one delivery attempt.', 'notificationDelivery.never':'No alert delivery attempted.', 'notificationDelivery.sent':'Last alert delivered.', 'notificationDelivery.failed':'Last alert delivery could not be confirmed. Check the external service with your administrator; no automatic retry.', 'notificationDelivery.unavailable':'Last alert could not be sent because the service was unavailable.' + }); + Object.assign(strings.de, { + sourceSearch:'Befehle, Command Sets und Vorlagen suchen', exportRun:'{format} exportieren', + retentionExplanation:'Gespeichert werden maximal die letzten 50 Läufe innerhalb von 1 MiB Verlauf pro Konto. Bei Erreichen des Limits werden ältere Läufe früher entfernt. Exporte enthalten nur noch gespeicherte Ergebnisse und begrenzte Ausgabe.', + stepOutputExplanation:'Die Ausgabe erscheint nach Abschluss eines Schritts. Lange Schritte können bis zum Timeout ohne Ausgabe bleiben. Bei unklarem Ergebnis vor einem weiteren Lauf den Host prüfen; es erfolgt keine automatische Wiederholung.', + notificationOptIn:'Externe Hinweise bei Fehlern, unklarem Ergebnis oder nötiger neuer Workflow-Freigabe senden', + notificationPrivacy:'Optional für dieses Konto. Hinweise gehen an den administrativ konfigurierten HTTPS-Dienst und enthalten nur Ereignistyp und Status, keine Konto-, Host-, Befehls- oder Ausgabedaten. Zustellfehler ändern kein Laufergebnis. Keine automatische Wiederholung.', + notificationUnavailable:'Externe Hinweise nicht verfügbar: Ein Administrator muss einen gültigen HTTPS-Endpunkt konfigurieren. Die Einstellung bleibt gespeichert.', + 'notificationDelivery.pending':'Hinweis wartet auf einen Zustellversuch.', 'notificationDelivery.never':'Noch kein Zustellversuch.', 'notificationDelivery.sent':'Letzter Hinweis zugestellt.', 'notificationDelivery.failed':'Letzte Zustellung nicht bestätigt. Den externen Dienst mit dem Administrator prüfen; keine automatische Wiederholung.', 'notificationDelivery.unavailable':'Letzter Hinweis konnte wegen fehlendem Dienst nicht gesendet werden.' + }); window.AutomationText = Object.freeze({t(key, values = {}) { const lang = window.i18n?.getLanguage() === 'de' ? 'de' : 'en'; let result = strings[lang][key] || strings.en[key] || key; diff --git a/static/js/automation.js b/static/js/automation.js index 626218f5..939bfd90 100644 --- a/static/js/automation.js +++ b/static/js/automation.js @@ -145,7 +145,7 @@ busy = true; notify('', true); notify(''); - const controls = [...document.querySelectorAll('#automationApp button[type="submit"], .automation-dialog button[type="submit"], #previewRun, #reviewWorkspace, #scheduleList button, #openSchedule, #runbookList button, #newRunbook, #inventoryTemplate, #useWorkflowTemplate')]; + const controls = [...document.querySelectorAll('#automationApp button[type="submit"], .automation-dialog button[type="submit"], #previewRun, #reviewWorkspace, #scheduleList button, #openSchedule, #runbookList button, #newRunbook, #inventoryTemplate, #useWorkflowTemplate, #workflowNotifications')]; controls.forEach(button => { button.disabled = true; }); document.body.setAttribute('aria-busy', 'true'); try { await task(); } catch (error) { @@ -163,6 +163,7 @@ } function translate() { document.querySelectorAll('[data-auto-i18n]').forEach(element => { element.textContent = tr(element.dataset.autoI18n); }); + document.querySelectorAll('[data-auto-label]').forEach(element => { element.setAttribute('aria-label', tr(element.dataset.autoLabel)); }); document.querySelectorAll('[data-auto-placeholder]').forEach(element => { element.placeholder = tr(element.dataset.autoPlaceholder); }); const language = window.i18n?.getLanguage() === 'de' ? 'de' : 'en'; document.querySelectorAll('main[data-native-automation], .automation-dialog').forEach(element => { @@ -263,6 +264,7 @@ } function renderEditorSources() { const select=$('editorSource');if(!select)return; + const selectedSource=editorSource(); const previous=select.value, language=window.i18n?.getLanguage()==='de'?'de':'en'; const placeholder=node('option',tr('editorSourceChoose'));placeholder.value='';select.replaceChildren(placeholder); const groups=[ @@ -270,7 +272,12 @@ [tr('ownCommandSets'),(window.CommandSetManager?.commandSets || []).map(c=>({id:'set:'+c.id,name:c.name}))], [tr('editorSourceTemplates'),(window.AutomationTemplates?.list() || []).map(c=>({id:c.id,name:c.title[language]}))] ]; - groups.forEach(([label,items])=>{if(!items.length)return;const group=node('optgroup');group.label=label;items.forEach(item=>{const option=node('option',item.name);option.value=item.id;group.append(option);});select.append(group);}); + const search = ($('editorSourceSearch')?.value || '').trim().toLocaleLowerCase(); + groups.forEach(([label,items])=>{items=items.filter(item=>!search || item.name.toLocaleLowerCase().includes(search));if(!items.length)return;const group=node('optgroup');group.label=label;items.forEach(item=>{const option=node('option',item.name);option.value=item.id;group.append(option);});select.append(group);}); + if (previous && ![...select.options].some(option=>option.value===previous)) { + const source=selectedSource; + if(source) { const option=node('option', source.libraryCommand?.name || source.title?.[language] || source.steps[0].name); option.value=previous; select.append(option); } + } if([...select.options].some(option=>option.value===previous))select.value=previous; previewEditorSource(); } @@ -611,6 +618,13 @@ filterTargets(); updateTargetCount(); } + function renderNotificationStatus() { + const preferences=model.notifications || {enabled:false,available:false,delivery:'never'}; + const toggle=$('workflowNotifications'); + if (!toggle) return; + toggle.checked=preferences.enabled; + $('workflowNotificationStatus').textContent=tr(preferences.available ? 'notificationDelivery.'+preferences.delivery : 'notificationUnavailable'); + } async function loadState({initial = false} = {}) { const generation = authorizationGeneration; const sequence = ++stateRequestSequence; @@ -622,6 +636,7 @@ model = {...result, runbooks: result.runbooks || [], vault:result.vault || {credentials:[],unlocked:false}}; lastStateRefresh = Date.now(); if (initial || targetsChanged) renderTargets(); + renderNotificationStatus(); if (initial) edit(model.runbooks[0]); else if (booksChanged) renderBooks(); } @@ -818,6 +833,13 @@ } remove.title = tr('deleteRun'); remove.setAttribute('aria-label', tr('deleteRunLabel', {name: run.name})); remove.hidden = active; + const exports=runPart(details,'exports','div','automation-run-actions'); + ['json','csv'].forEach(format=>{ + const link=runPart(exports,format,'a','btn btn-secondary'); + link.textContent=tr('exportRun',{format:format.toUpperCase()}); + link.href=(document.querySelector('meta[name="app-root"]')?.content || '').replace(/\/$/,'')+'/api/automation/runs/'+encodeURIComponent(run.id)+'/export?format='+format; + link.setAttribute('download','workflow-run.'+format); + }); const cancel = runPart(details, 'cancel', 'button', 'btn btn-secondary'); cancel.type = 'button'; cancel.textContent = tr('cancelRun'); cancel.hidden = !active; if (!exists) cancel.addEventListener('click', () => action(async () => { @@ -1133,6 +1155,15 @@ document.querySelector('.automation-library').classList.toggle('is-open', open); if (open) $('runbookSearch').focus(); }); + $('editorSourceSearch')?.addEventListener('input', renderEditorSources); + $('workflowNotifications')?.addEventListener('change', () => action(async()=>{ + const generation=authorizationGeneration; + stateRequestSequence += 1; + try { + const result=await api('/api/automation/notifications',{method:'POST',body:{enabled:$('workflowNotifications').checked}}); + if (generation === authorizationGeneration) model.notifications=result.notifications; + } finally { renderNotificationStatus(); } + })); $('runbookSearch').addEventListener('input', renderBooks); document.querySelectorAll('[data-workspace-view]').forEach(button => button.addEventListener('click', () => { setStage(button.dataset.workspaceView, {focus:true}); @@ -1174,7 +1205,7 @@ window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } }); window.addEventListener('languageChanged', () => { const currentDraft = draft(); const wasDirty = dirty; const currentStage = $('auto-runbooks').dataset.view; - translate(); if ($('scheduleFrequency')) updateScheduleBuilder({preserveCron:true}); edit(currentDraft, {preserveSchedule:true}); dirty = wasDirty; renderTargets(); setStage(currentStage, {animate:false}); + translate(); renderNotificationStatus(); if ($('scheduleFrequency')) updateScheduleBuilder({preserveCron:true}); edit(currentDraft, {preserveSchedule:true}); dirty = wasDirty; renderTargets(); setStage(currentStage, {animate:false}); historySignature = ''; if (selectedTab === 'runs') action(loadRuns); }); // Open a built-in template as an unsaved draft with the given hosts selected. diff --git a/static/js/command-library.js b/static/js/command-library.js index 776938b8..26687ee0 100644 --- a/static/js/command-library.js +++ b/static/js/command-library.js @@ -3,6 +3,7 @@ const CommandLibrary = { filteredCommands: [], currentOs: 'all', currentSource: 'all', + currentImpact: 'recommended', searchQuery: '', editingCommandId: null, selectedCommandId: null, @@ -177,6 +178,30 @@ const CommandLibrary = { label.append(text, select); toolbar.insertBefore(label, toolbar.querySelector('.management-panel-actions')); } + if (!document.getElementById('commandImpactFilter')) { + const label = document.createElement('label'); + label.className = 'command-source-filter'; + const text = document.createElement('span'); + text.textContent = window.i18n?.t('commandSafety.browse') || 'Browse'; + text.setAttribute('data-i18n', 'commandSafety.browse'); + const select = document.createElement('select'); + select.id = 'commandImpactFilter'; + select.className = 'form-control'; + for (const value of ['recommended', 'all']) { + const option = document.createElement('option'); + option.value = value; + option.textContent = window.i18n?.t(`commandSafety.${value}`) || value; + option.setAttribute('data-i18n', `commandSafety.${value}`); + select.append(option); + } + select.value = this.currentImpact; + select.addEventListener('change', () => { + this.currentImpact = select.value; + this.searchCommands(this.searchQuery); + }); + label.append(text, select); + toolbar.insertBefore(label, toolbar.querySelector('.management-panel-actions')); + } const header = document.querySelector('#commandLibraryPanel .commands-table-header'); if (header) { header.replaceChildren(); @@ -282,13 +307,14 @@ const CommandLibrary = { || (command.os || ['all']).some(os => os.toLowerCase() === 'all' || os.toLowerCase() === this.currentOs.toLowerCase())) && (this.currentSource === 'all' || (command.isSystem ? 'system' : 'personal') === this.currentSource)); if (!query) { - this.filteredCommands = commands; + this.filteredCommands = commands.filter(command => this.currentImpact === 'all' + || !command.isSystem || this.commandSafety(command)?.impact === 'read-only'); } else { const lowerQuery = query.toLowerCase(); this.filteredCommands = commands.filter(cmd => { const matchesEnglish = ( cmd.name.toLowerCase().includes(lowerQuery) || - cmd.command.toLowerCase().includes(lowerQuery) || + `${cmd.command} ${cmd.parameters || ''}`.toLowerCase().includes(lowerQuery) || cmd.parameters.toLowerCase().includes(lowerQuery) || cmd.description.toLowerCase().includes(lowerQuery) || String(cmd.sourceName || '').toLowerCase().includes(lowerQuery) || @@ -380,6 +406,7 @@ const CommandLibrary = {
${this.escapeHtml(cmd.description)}
+ ${this.escapeHtml(this.impactLabel(cmd))}
${this.escapeHtml(cmd.command + (cmd.parameters ? ` ${cmd.parameters}` : ''))} @@ -455,9 +482,22 @@ const CommandLibrary = { }; }, + commandSafety(command) { + const safety = command.safety; + return command.isSystem && safety && safety.command === command.command + && safety.parameters === (command.parameters || '') + && ['read-only', 'changes-system', 'deletes-data', 'network-load'].includes(safety.impact) + ? safety : null; + }, + + impactLabel(command) { + const impact = this.commandSafety(command)?.impact || 'unknown'; + return window.i18n?.t(`commandSafety.${impact}`) || impact; + }, + commandSourceLabel(command) { const key = command.isSystem ? 'commands.systemSource' : 'commands.personalSource'; - return window.i18n?.t(key) || (command.isSystem ? 'System · read-only' : 'Personal'); + return window.i18n?.t(key) || (command.isSystem ? 'System · fixed template' : 'Personal'); }, selectCommand(commandId) { @@ -526,6 +566,20 @@ const CommandLibrary = { source.className = 'command-source-label'; source.textContent = this.commandSourceLabel(command); details.append(header, source); + const safety = this.commandSafety(command); + const impact = document.createElement('p'); + impact.className = 'command-impact-label'; + impact.textContent = this.impactLabel(command); + details.append(impact); + if (safety) { + const prerequisites = document.createElement('p'); + prerequisites.className = 'command-template-hint'; + prerequisites.textContent = `${window.i18n?.t('commandSafety.requires') || 'Required on target'}: ${safety.tool} · ${(command.os || []).join(', ')}. ${window.i18n?.t('commandSafety.prerequisites') || 'Check the installed tool, shell, permissions and arguments before use.'}`; + const scope = document.createElement('p'); + scope.className = 'command-template-hint'; + scope.textContent = window.i18n?.t('commandSafety.scope') || 'Impact describes this exact template, not a safety guarantee. Changed arguments, interactive actions and sudo can change the effect.'; + details.append(prerequisites, scope); + } const fields = [ ['commands.command', 'Command', command.command, true], ...(command.parameters ? [ diff --git a/static/js/command-safety-i18n.js b/static/js/command-safety-i18n.js new file mode 100644 index 00000000..c5fd97d3 --- /dev/null +++ b/static/js/command-safety-i18n.js @@ -0,0 +1,84 @@ +// Command impact guidance is independent of catalog template mutability. +(function () { + const bundles = { + "en": { + "commandSafety.browse": "Browse", + "commandSafety.recommended": "Inspection templates + personal", + "commandSafety.all": "All tasks (includes advanced)", + "commandSafety.read-only": "Inspects data", + "commandSafety.changes-system": "Changes system or data", + "commandSafety.deletes-data": "Deletes or overwrites data", + "commandSafety.network-load": "Sends network traffic", + "commandSafety.unknown": "Impact not classified", + "commandSafety.requires": "Required on target", + "commandSafety.prerequisites": "Check the installed tool, shell, permissions and arguments before use.", + "commandSafety.scope": "Impact describes this exact template, not a safety guarantee. Changed arguments, interactive actions and sudo can change the effect." + }, + "de": { + "commandSafety.browse": "Ansicht", + "commandSafety.recommended": "Prüfvorlagen + eigene Befehle", + "commandSafety.all": "Alle Aufgaben (auch erweiterte)", + "commandSafety.read-only": "Liest Daten", + "commandSafety.changes-system": "Ändert System oder Daten", + "commandSafety.deletes-data": "Löscht oder überschreibt Daten", + "commandSafety.network-load": "Sendet Netzwerkverkehr", + "commandSafety.unknown": "Wirkung nicht eingestuft", + "commandSafety.requires": "Auf dem Ziel erforderlich", + "commandSafety.prerequisites": "Installiertes Werkzeug, Shell, Berechtigungen und Argumente vor Verwendung prüfen.", + "commandSafety.scope": "Die Wirkung bezieht sich auf diese genaue Vorlage und ist keine Sicherheitsgarantie. Andere Argumente, interaktive Aktionen und sudo können die Wirkung ändern." + }, + "fr": { + "commandSafety.browse": "Afficher", + "commandSafety.recommended": "Modèles de contrôle + personnels", + "commandSafety.all": "Toutes les tâches (avancées incluses)", + "commandSafety.read-only": "Consulte les données", + "commandSafety.changes-system": "Modifie le système ou les données", + "commandSafety.deletes-data": "Supprime ou écrase des données", + "commandSafety.network-load": "Émet du trafic réseau", + "commandSafety.unknown": "Effet non classé", + "commandSafety.requires": "Requis sur la cible", + "commandSafety.prerequisites": "Vérifiez l’outil installé, le shell, les droits et les arguments avant utilisation.", + "commandSafety.scope": "L’effet décrit ce modèle exact, sans garantie de sécurité. D’autres arguments, les actions interactives et sudo peuvent le modifier." + }, + "es": { + "commandSafety.browse": "Ver", + "commandSafety.recommended": "Plantillas de consulta + personales", + "commandSafety.all": "Todas las tareas (incluidas avanzadas)", + "commandSafety.read-only": "Consulta datos", + "commandSafety.changes-system": "Cambia el sistema o los datos", + "commandSafety.deletes-data": "Borra o sobrescribe datos", + "commandSafety.network-load": "Envía tráfico de red", + "commandSafety.unknown": "Efecto sin clasificar", + "commandSafety.requires": "Requerido en el destino", + "commandSafety.prerequisites": "Compruebe la herramienta instalada, el shell, los permisos y los argumentos antes de usar.", + "commandSafety.scope": "El efecto describe esta plantilla exacta, sin garantizar su seguridad. Otros argumentos, acciones interactivas y sudo pueden cambiarlo." + }, + "vi": { + "commandSafety.browse": "Hiển thị", + "commandSafety.recommended": "Mẫu kiểm tra + lệnh cá nhân", + "commandSafety.all": "Tất cả tác vụ (gồm nâng cao)", + "commandSafety.read-only": "Đọc dữ liệu", + "commandSafety.changes-system": "Thay đổi hệ thống hoặc dữ liệu", + "commandSafety.deletes-data": "Xóa hoặc ghi đè dữ liệu", + "commandSafety.network-load": "Gửi lưu lượng mạng", + "commandSafety.unknown": "Chưa phân loại tác động", + "commandSafety.requires": "Cần có trên máy đích", + "commandSafety.prerequisites": "Kiểm tra công cụ đã cài, shell, quyền và tham số trước khi dùng.", + "commandSafety.scope": "Tác động mô tả đúng mẫu này, không bảo đảm an toàn. Tham số khác, thao tác tương tác và sudo có thể thay đổi tác động." + }, + "zh": { + "commandSafety.browse": "显示", + "commandSafety.recommended": "检查模板和个人命令", + "commandSafety.all": "所有任务(包括高级任务)", + "commandSafety.read-only": "查看数据", + "commandSafety.changes-system": "修改系统或数据", + "commandSafety.deletes-data": "删除或覆盖数据", + "commandSafety.network-load": "发送网络流量", + "commandSafety.unknown": "影响未分类", + "commandSafety.requires": "目标上需要", + "commandSafety.prerequisites": "使用前请检查已安装的工具、Shell、权限和参数。", + "commandSafety.scope": "影响说明仅适用于此确切模板,并非安全保证。更改参数、交互操作和 sudo 可能改变其影响。" + } +}; + for (const [language, values] of Object.entries(bundles)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/customer-workflows-i18n.js b/static/js/customer-workflows-i18n.js new file mode 100644 index 00000000..d7097167 --- /dev/null +++ b/static/js/customer-workflows-i18n.js @@ -0,0 +1,355 @@ +// Account recovery and deliberate workspace setup guidance. +(function () { + 'use strict'; + const dictionaries = { + "en": { + "customer.recoveryTitle": "Recovery check", + "customer.recoveryHint": "Checks your account configuration without connecting to hosts or proving reachability.", + "customer.coverageTitle": "Backup coverage", + "customer.included": "Includes configuration, account sign-in data, profiles, commands, workflows and user settings.", + "customer.excluded": "Portable backups exclude saved remote passwords, SSH private keys and live sessions. Re-provision remote credentials after restore.", + "customer.restoredAt": "Last restore:", + "customer.recoveryKeys": "For each account, securely re-add missing SSH keys and saved remote passwords. Never paste secrets into logs or commands.", + "customer.recoveryHosts": "Review each host, authentication method and jump host. Verify host-key fingerprints through a trusted channel before accepting new trust.", + "customer.recoverySchedules": "Review your schedules, target hosts and credentials. Complete any required approval before deliberately enabling a schedule.", + "customer.recoveryTest": "Explicitly connect to a host and review authentication and host trust. Start with a harmless inspection before running changes.", + "customer.editHost": "Edit host", + "customer.noHosts": "No saved hosts in this account.", + "customer.openSchedules": "Review schedules", + "customer.scheduleEnabled": "Schedule enabled; review its configuration and approval.", + "customer.scheduleReview": "Schedule disabled; review before enabling.", + "customer.key_required": "SSH key required", + "customer.password_required": "Remote password required", + "customer.interactive": "Interactive authentication required when connecting", + "customer.missingJump": "Referenced jump host is missing", + "customer.trustReview": "Host-key trust needs review", + "customer.prepared": "Configuration present; connection not tested.", + "customer.presetsTitle": "Workspace presets", + "customer.presetsHint": "Presets save layout and host choices for your account. Applying a layout will never connect automatically or run commands. Connect each host explicitly; existing sessions remain.", + "customer.applyLayout": "Apply layout and choose connections", + "customer.connect": "Connect", + "customer.missingHost": "Host no longer available in this account", + "customer.removePreset": "Remove preset", + "customer.presetName": "Preset name", + "customer.layout": "Panes", + "customer.chooseHosts": "Choose hosts from your account", + "customer.savePreset": "Save preset", + "customer.tooManyHosts": "Choose no more hosts than the number of panes.", + "customer.presetLimit": "You can save up to 12 presets per account. Remove one before adding another.", + "customer.extraWindowLayout": "This layout cannot be applied while more than four floating windows are open. Close or dock extra windows first.", + "customer.loading": "Checking…", + "customer.close": "Close", + "customer.requestFailed": "The request failed. Review the current state before trying again.", + "customer.nextSteps": "What can I do?", + "customer.permission": "Check source and destination permissions and ownership. Use the existing permissions tool only if authorized; do not repeat with elevated privileges blindly.", + "customer.unknown": "The outcome is unknown. Inspect the destination and compare files before any retry to avoid duplicates or overwriting completed work.", + "customer.conflict": "The source or destination changed. Refresh both locations and compare their current contents before deciding whether to repeat the operation.", + "customer.source": "The source or share is unavailable. Check its connection and access, select the source again, then inspect the destination before starting a new transfer.", + "customer.failure": "Review the error, connection and available storage. Inspect source and destination before repeating; partial results may already exist.", + "customer.continuity.none": "Select a session to review its continuity.", + "customer.continuity.transport": "The browser connection is interrupted. The remote outcome is uncertain; reconnect and inspect before repeating commands.", + "customer.continuity.tmux": "tmux session: remote processes can continue after an SSH disconnect while tmux remains running. Reconnect to the same host and session to inspect progress.", + "customer.continuity.tmuxDisconnected": "SSH disconnected. tmux may still be running remotely; reconnect to the same session and inspect its state. Commands are not replayed automatically.", + "customer.continuity.normal": "Normal SSH session: no durable remote-session guarantee. Losing the SSH connection may end foreground commands; inspect results before retrying.", + "customer.continuity.disconnected": "SSH disconnected. Foreground commands may have stopped or partially completed. Reconnect explicitly and inspect results; do not assume success or repeat blindly.", + "customer.recoveryNoticeTitle": "Review recovery after restore", + "customer.recoveryNoticeBody": "This instance was restored. Review your missing remote credentials, host trust and schedules before resuming work.", + "customer.openRecoveryChecklist": "Open recovery checklist", + "customer.recoveryReviewHint": "Marking these steps reviewed only dismisses this reminder for your account. It does not verify recovery, test credentials, trust host keys or enable schedules.", + "customer.reviewRecovery": "Mark recovery steps reviewed", + "customer.recoveryChanged": "Another restore occurred. Reload the checklist and review the current recovery steps before dismissing this reminder.", + "customer.reloadRecovery": "Reload recovery checklist" + }, + "de": { + "customer.recoveryTitle": "Wiederherstellung prüfen", + "customer.recoveryHint": "Prüft die Konfiguration deines Kontos, ohne Hosts zu verbinden oder deren Erreichbarkeit nachzuweisen.", + "customer.coverageTitle": "Umfang des Backups", + "customer.included": "Enthält Konfiguration, Konto-Anmeldedaten, Profile, Befehle, Workflows und Benutzereinstellungen.", + "customer.excluded": "Portable Backups enthalten keine gespeicherten Remote-Passwörter, privaten SSH-Schlüssel oder laufenden Sitzungen. Remote-Zugangsdaten nach dem Restore neu bereitstellen.", + "customer.restoredAt": "Letzte Wiederherstellung:", + "customer.recoveryKeys": "Fehlende SSH-Schlüssel und gespeicherte Remote-Passwörter für jedes Konto sicher neu hinterlegen. Geheimnisse nie in Logs oder Befehle kopieren.", + "customer.recoveryHosts": "Hosts, Anmeldeverfahren und Jump Hosts prüfen. Host-Key-Fingerprints vor neuer Vertrauensfreigabe über einen vertrauenswürdigen Kanal vergleichen.", + "customer.recoverySchedules": "Eigene Zeitpläne, Zielhosts und Zugangsdaten prüfen. Erforderliche Freigaben vor dem bewussten Aktivieren eines Zeitplans erneuern.", + "customer.recoveryTest": "Einen Host ausdrücklich verbinden und Anmeldung sowie Host-Vertrauen prüfen. Vor Änderungen zunächst einen harmlosen Prüfbefehl verwenden.", + "customer.editHost": "Host bearbeiten", + "customer.noHosts": "Keine gespeicherten Hosts in diesem Konto.", + "customer.openSchedules": "Zeitpläne prüfen", + "customer.scheduleEnabled": "Zeitplan aktiv; Konfiguration und Freigabe prüfen.", + "customer.scheduleReview": "Zeitplan deaktiviert; vor Aktivierung prüfen.", + "customer.key_required": "SSH-Schlüssel erforderlich", + "customer.password_required": "Remote-Passwort erforderlich", + "customer.interactive": "Interaktive Anmeldung beim Verbinden erforderlich", + "customer.missingJump": "Referenzierter Jump Host fehlt", + "customer.trustReview": "Host-Key-Vertrauen prüfen", + "customer.prepared": "Konfiguration vorhanden; Verbindung nicht getestet.", + "customer.presetsTitle": "Arbeitsbereich-Vorlagen", + "customer.presetsHint": "Vorlagen speichern Layout und Host-Auswahl für dein Konto. Das Anwenden verbindet nie automatisch und führt keine Befehle aus. Hosts einzeln ausdrücklich verbinden; vorhandene Sitzungen bleiben bestehen.", + "customer.applyLayout": "Layout anwenden und Verbindungen wählen", + "customer.connect": "Verbinden", + "customer.missingHost": "Host in diesem Konto nicht mehr verfügbar", + "customer.removePreset": "Vorlage entfernen", + "customer.presetName": "Name der Vorlage", + "customer.layout": "Bereiche", + "customer.chooseHosts": "Hosts deines Kontos wählen", + "customer.savePreset": "Vorlage speichern", + "customer.tooManyHosts": "Höchstens so viele Hosts wie Bereiche wählen.", + "customer.presetLimit": "Pro Konto sind bis zu 12 Vorlagen möglich. Vor einer weiteren eine entfernen.", + "customer.extraWindowLayout": "Dieses Layout lässt sich bei mehr als vier schwebenden Fenstern nicht anwenden. Zusätzliche Fenster zuerst schließen oder andocken.", + "customer.loading": "Wird geprüft…", + "customer.close": "Schließen", + "customer.requestFailed": "Die Anfrage ist fehlgeschlagen. Vor einem neuen Versuch den aktuellen Zustand prüfen.", + "customer.nextSteps": "Was kann ich tun?", + "customer.permission": "Berechtigungen und Eigentümer von Quelle und Ziel prüfen. Das vorhandene Berechtigungswerkzeug nur mit Freigabe nutzen; nicht blind mit erhöhten Rechten wiederholen.", + "customer.unknown": "Das Ergebnis ist unklar. Vor jedem neuen Versuch das Ziel prüfen und Dateien vergleichen, damit keine Duplikate entstehen oder fertige Arbeit überschrieben wird.", + "customer.conflict": "Quelle oder Ziel hat sich geändert. Beide Orte aktualisieren und aktuelle Inhalte vergleichen, bevor du über eine Wiederholung entscheidest.", + "customer.source": "Quelle oder Freigabe nicht verfügbar. Verbindung und Zugriff prüfen, Quelle erneut auswählen und vor einem neuen Transfer das Ziel prüfen.", + "customer.failure": "Fehler, Verbindung und freien Speicher prüfen. Quelle und Ziel vor einer Wiederholung kontrollieren; Teilergebnisse können bereits vorhanden sein.", + "customer.continuity.none": "Sitzung wählen, um ihre Fortsetzung zu prüfen.", + "customer.continuity.transport": "Die Browser-Verbindung ist unterbrochen. Das Remote-Ergebnis ist unklar; erneut verbinden und vor dem Wiederholen von Befehlen prüfen.", + "customer.continuity.tmux": "tmux-Sitzung: Remote-Prozesse können nach einem SSH-Abbruch weiterlaufen, solange tmux läuft. Mit demselben Host und derselben Sitzung verbinden, um den Fortschritt zu prüfen.", + "customer.continuity.tmuxDisconnected": "SSH getrennt. tmux kann auf dem Ziel weiterlaufen; dieselbe Sitzung erneut verbinden und Zustand prüfen. Befehle werden nicht automatisch wiederholt.", + "customer.continuity.normal": "Normale SSH-Sitzung: keine garantierte dauerhafte Remote-Sitzung. Ein SSH-Abbruch kann Vordergrundbefehle beenden; Ergebnisse vor einem neuen Versuch prüfen.", + "customer.continuity.disconnected": "SSH getrennt. Vordergrundbefehle können beendet oder teilweise abgeschlossen sein. Ausdrücklich erneut verbinden und Ergebnisse prüfen; weder Erfolg annehmen noch blind wiederholen.", + "customer.recoveryNoticeTitle": "Wiederherstellung prüfen", + "customer.recoveryNoticeBody": "Diese Instanz wurde wiederhergestellt. Prüfe fehlende Remote-Zugangsdaten, Hostvertrauen und Zeitpläne, bevor du weiterarbeitest.", + "customer.openRecoveryChecklist": "Wiederherstellungscheckliste öffnen", + "customer.recoveryReviewHint": "Die Bestätigung blendet nur diese Erinnerung für dein Konto aus. Sie bestätigt keine erfolgreiche Wiederherstellung, prüft keine Zugangsdaten, vertraut keinen Hostschlüsseln und aktiviert keine Zeitpläne.", + "customer.reviewRecovery": "Wiederherstellungsschritte als geprüft markieren", + "customer.recoveryChanged": "Es gab eine weitere Wiederherstellung. Lade die Checkliste neu und prüfe den aktuellen Stand, bevor du die Erinnerung ausblendest.", + "customer.reloadRecovery": "Checkliste neu laden" + }, + "fr": { + "customer.recoveryTitle": "Vérifier la restauration", + "customer.recoveryHint": "Vérifie la configuration de votre compte sans connexion aux hôtes ni vérification de leur accessibilité.", + "customer.coverageTitle": "Contenu de la sauvegarde", + "customer.included": "Inclut la configuration, les données de connexion aux comptes, les profils, les commandes, les workflows et les préférences.", + "customer.excluded": "Les sauvegardes portables excluent les mots de passe distants enregistrés, les clés privées SSH et les sessions actives. Rétablissez les identifiants distants après restauration.", + "customer.restoredAt": "Dernière restauration :", + "customer.recoveryKeys": "Pour chaque compte, réajoutez de façon sûre les clés SSH et mots de passe distants manquants. Ne placez jamais de secrets dans les journaux ou commandes.", + "customer.recoveryHosts": "Vérifiez les hôtes, méthodes d’authentification et bastions. Contrôlez les empreintes des clés d’hôte par un canal fiable avant de les accepter.", + "customer.recoverySchedules": "Vérifiez vos planifications, cibles et identifiants. Effectuez toute approbation requise avant d’activer volontairement une planification.", + "customer.recoveryTest": "Connectez-vous explicitement à un hôte et vérifiez l’authentification et sa clé. Commencez par une inspection sans modification.", + "customer.editHost": "Modifier l’hôte", + "customer.noHosts": "Aucun hôte enregistré dans ce compte.", + "customer.openSchedules": "Vérifier les planifications", + "customer.scheduleEnabled": "Planification activée ; vérifiez sa configuration et son approbation.", + "customer.scheduleReview": "Planification désactivée ; vérifiez-la avant activation.", + "customer.key_required": "Clé SSH requise", + "customer.password_required": "Mot de passe distant requis", + "customer.interactive": "Authentification interactive requise à la connexion", + "customer.missingJump": "Le bastion référencé est absent", + "customer.trustReview": "Confiance dans la clé d’hôte à vérifier", + "customer.prepared": "Configuration présente ; connexion non testée.", + "customer.presetsTitle": "Espaces de travail prédéfinis", + "customer.presetsHint": "Les préréglages enregistrent disposition et choix d’hôtes pour votre compte. Appliquer une disposition ne connecte jamais automatiquement et n’exécute aucune commande. Connectez chaque hôte explicitement ; les sessions existantes restent ouvertes.", + "customer.applyLayout": "Appliquer la disposition et choisir les connexions", + "customer.connect": "Se connecter", + "customer.missingHost": "Hôte indisponible dans ce compte", + "customer.removePreset": "Supprimer le préréglage", + "customer.presetName": "Nom du préréglage", + "customer.layout": "Volets", + "customer.chooseHosts": "Choisir les hôtes de votre compte", + "customer.savePreset": "Enregistrer le préréglage", + "customer.tooManyHosts": "Ne choisissez pas plus d’hôtes que de volets.", + "customer.presetLimit": "Vous pouvez enregistrer 12 préréglages par compte. Supprimez-en un avant d’en ajouter un autre.", + "customer.extraWindowLayout": "Cette disposition ne peut pas être appliquée avec plus de quatre fenêtres flottantes. Fermez ou ancrez d’abord les fenêtres supplémentaires.", + "customer.loading": "Vérification…", + "customer.close": "Fermer", + "customer.requestFailed": "La demande a échoué. Vérifiez l’état actuel avant de réessayer.", + "customer.nextSteps": "Que puis-je faire ?", + "customer.permission": "Vérifiez les droits et propriétaires de la source et de la destination. Utilisez l’outil de permissions uniquement avec autorisation ; ne recommencez pas aveuglément avec des droits élevés.", + "customer.unknown": "Le résultat est inconnu. Vérifiez la destination et comparez les fichiers avant toute nouvelle tentative pour éviter doublons ou écrasement d’un travail terminé.", + "customer.conflict": "La source ou la destination a changé. Actualisez les deux emplacements et comparez leur contenu avant de décider de répéter l’opération.", + "customer.source": "La source ou le partage est indisponible. Vérifiez connexion et accès, sélectionnez à nouveau la source puis inspectez la destination avant un nouveau transfert.", + "customer.failure": "Vérifiez l’erreur, la connexion et l’espace disponible. Inspectez source et destination avant de recommencer ; des résultats partiels peuvent déjà exister.", + "customer.continuity.none": "Sélectionnez une session pour vérifier sa continuité.", + "customer.continuity.transport": "La connexion du navigateur est interrompue. Le résultat distant est incertain ; reconnectez-vous et vérifiez avant de répéter des commandes.", + "customer.continuity.tmux": "Session tmux : les processus distants peuvent continuer après une déconnexion SSH tant que tmux fonctionne. Reconnectez-vous au même hôte et à la même session pour vérifier leur progression.", + "customer.continuity.tmuxDisconnected": "SSH déconnecté. tmux peut encore fonctionner à distance ; reconnectez-vous à la même session et vérifiez son état. Les commandes ne sont pas rejouées automatiquement.", + "customer.continuity.normal": "Session SSH classique : aucune garantie de session distante persistante. Une perte de connexion peut terminer les commandes au premier plan ; vérifiez les résultats avant de réessayer.", + "customer.continuity.disconnected": "SSH déconnecté. Les commandes au premier plan peuvent être arrêtées ou partiellement terminées. Reconnectez-vous explicitement et vérifiez les résultats ; ne présumez pas le succès et ne répétez pas aveuglément.", + "customer.recoveryNoticeTitle": "Vérifier après restauration", + "customer.recoveryNoticeBody": "Cette instance a été restaurée. Vérifiez les identifiants distants manquants, la confiance des hôtes et les planifications avant de reprendre.", + "customer.openRecoveryChecklist": "Ouvrir la liste de récupération", + "customer.recoveryReviewHint": "Marquer ces étapes comme examinées masque uniquement ce rappel pour votre compte. Cela ne vérifie pas la récupération, ne teste pas les identifiants, n’approuve pas les clés et n’active pas les planifications.", + "customer.reviewRecovery": "Marquer les étapes comme examinées", + "customer.recoveryChanged": "Une autre restauration a eu lieu. Rechargez la liste et examinez les étapes actuelles avant de masquer ce rappel.", + "customer.reloadRecovery": "Recharger la liste" + }, + "es": { + "customer.recoveryTitle": "Comprobar recuperación", + "customer.recoveryHint": "Comprueba la configuración de su cuenta sin conectar con hosts ni verificar su accesibilidad.", + "customer.coverageTitle": "Contenido de la copia", + "customer.included": "Incluye configuración, datos de acceso a las cuentas, perfiles, comandos, workflows y preferencias.", + "customer.excluded": "Las copias portátiles excluyen contraseñas remotas guardadas, claves privadas SSH y sesiones activas. Vuelva a configurar las credenciales remotas tras restaurar.", + "customer.restoredAt": "Última restauración:", + "customer.recoveryKeys": "En cada cuenta, vuelva a añadir de forma segura las claves SSH y contraseñas remotas que falten. Nunca copie secretos en registros o comandos.", + "customer.recoveryHosts": "Revise los hosts, métodos de autenticación y servidores de salto. Verifique las huellas de las claves mediante un canal fiable antes de aceptarlas.", + "customer.recoverySchedules": "Revise sus programaciones, destinos y credenciales. Complete las aprobaciones necesarias antes de activar una programación de forma deliberada.", + "customer.recoveryTest": "Conecte explícitamente a un host y compruebe la autenticación y su clave. Empiece por una consulta inocua antes de realizar cambios.", + "customer.editHost": "Editar host", + "customer.noHosts": "No hay hosts guardados en esta cuenta.", + "customer.openSchedules": "Revisar programaciones", + "customer.scheduleEnabled": "Programación activa; revise su configuración y aprobación.", + "customer.scheduleReview": "Programación desactivada; revísela antes de activarla.", + "customer.key_required": "Se requiere clave SSH", + "customer.password_required": "Se requiere contraseña remota", + "customer.interactive": "Se requiere autenticación interactiva al conectar", + "customer.missingJump": "Falta el servidor de salto indicado", + "customer.trustReview": "Revise la confianza en la clave del host", + "customer.prepared": "Configuración disponible; conexión sin probar.", + "customer.presetsTitle": "Preajustes del espacio de trabajo", + "customer.presetsHint": "Los preajustes guardan distribución y hosts para su cuenta. Aplicar una distribución nunca conecta automáticamente ni ejecuta comandos. Conecte cada host explícitamente; las sesiones existentes permanecen.", + "customer.applyLayout": "Aplicar distribución y elegir conexiones", + "customer.connect": "Conectar", + "customer.missingHost": "El host ya no está disponible en esta cuenta", + "customer.removePreset": "Eliminar preajuste", + "customer.presetName": "Nombre del preajuste", + "customer.layout": "Paneles", + "customer.chooseHosts": "Elegir hosts de su cuenta", + "customer.savePreset": "Guardar preajuste", + "customer.tooManyHosts": "No elija más hosts que paneles.", + "customer.presetLimit": "Puede guardar hasta 12 preajustes por cuenta. Elimine uno antes de añadir otro.", + "customer.extraWindowLayout": "No se puede aplicar esta distribución con más de cuatro ventanas flotantes abiertas. Cierre o acople las ventanas adicionales primero.", + "customer.loading": "Comprobando…", + "customer.close": "Cerrar", + "customer.requestFailed": "La solicitud falló. Revise el estado actual antes de intentarlo de nuevo.", + "customer.nextSteps": "¿Qué puedo hacer?", + "customer.permission": "Compruebe permisos y propietarios de origen y destino. Use la herramienta de permisos solo con autorización; no repita a ciegas con privilegios elevados.", + "customer.unknown": "El resultado es desconocido. Inspeccione el destino y compare archivos antes de reintentar para evitar duplicados o sobrescribir trabajo completado.", + "customer.conflict": "El origen o el destino cambió. Actualice ambas ubicaciones y compare su contenido antes de decidir si repite la operación.", + "customer.source": "El origen o recurso compartido no está disponible. Compruebe conexión y acceso, seleccione de nuevo el origen e inspeccione el destino antes de iniciar otra transferencia.", + "customer.failure": "Revise el error, la conexión y el espacio disponible. Inspeccione origen y destino antes de repetir; pueden existir resultados parciales.", + "customer.continuity.none": "Seleccione una sesión para revisar su continuidad.", + "customer.continuity.transport": "Se interrumpió la conexión del navegador. El resultado remoto es incierto; reconecte e inspeccione antes de repetir comandos.", + "customer.continuity.tmux": "Sesión tmux: los procesos remotos pueden continuar tras una desconexión SSH mientras tmux siga activo. Reconecte al mismo host y sesión para comprobar el progreso.", + "customer.continuity.tmuxDisconnected": "SSH desconectado. tmux puede seguir activo en el destino; reconecte a la misma sesión y revise su estado. Los comandos no se repiten automáticamente.", + "customer.continuity.normal": "Sesión SSH normal: no garantiza una sesión remota persistente. Perder la conexión puede terminar los comandos en primer plano; compruebe los resultados antes de reintentar.", + "customer.continuity.disconnected": "SSH desconectado. Los comandos en primer plano pueden haberse detenido o completado parcialmente. Reconecte explícitamente y compruebe resultados; no suponga éxito ni repita a ciegas.", + "customer.recoveryNoticeTitle": "Revisar tras restaurar", + "customer.recoveryNoticeBody": "Esta instancia se ha restaurado. Revisa credenciales remotas ausentes, confianza de hosts y programaciones antes de continuar.", + "customer.openRecoveryChecklist": "Abrir lista de recuperación", + "customer.recoveryReviewHint": "Marcar los pasos revisados solo oculta este recordatorio para tu cuenta. No verifica la recuperación, prueba credenciales, confía en claves ni activa programaciones.", + "customer.reviewRecovery": "Marcar pasos de recuperación revisados", + "customer.recoveryChanged": "Hubo otra restauración. Recarga la lista y revisa los pasos actuales antes de ocultar este recordatorio.", + "customer.reloadRecovery": "Recargar lista de recuperación" + }, + "vi": { + "customer.recoveryTitle": "Kiểm tra khôi phục", + "customer.recoveryHint": "Kiểm tra cấu hình tài khoản của bạn mà không kết nối hoặc xác minh khả năng truy cập máy chủ.", + "customer.coverageTitle": "Phạm vi sao lưu", + "customer.included": "Bao gồm cấu hình, dữ liệu đăng nhập tài khoản, hồ sơ, lệnh, quy trình và cài đặt người dùng.", + "customer.excluded": "Bản sao lưu di động không gồm mật khẩu máy từ xa đã lưu, khóa riêng SSH hoặc phiên đang chạy. Cung cấp lại thông tin xác thực từ xa sau khi khôi phục.", + "customer.restoredAt": "Lần khôi phục gần nhất:", + "customer.recoveryKeys": "Với từng tài khoản, thêm lại an toàn các khóa SSH và mật khẩu từ xa còn thiếu. Không dán bí mật vào nhật ký hoặc lệnh.", + "customer.recoveryHosts": "Kiểm tra máy chủ, phương thức xác thực và máy chủ trung chuyển. Xác minh dấu vân tay khóa máy chủ qua kênh tin cậy trước khi chấp nhận.", + "customer.recoverySchedules": "Kiểm tra lịch chạy, máy đích và thông tin xác thực của bạn. Hoàn tất phê duyệt cần thiết trước khi chủ động bật lịch chạy.", + "customer.recoveryTest": "Chủ động kết nối tới máy chủ và kiểm tra xác thực, độ tin cậy khóa. Bắt đầu bằng kiểm tra không gây thay đổi trước khi sửa hệ thống.", + "customer.editHost": "Sửa máy chủ", + "customer.noHosts": "Tài khoản này chưa có máy chủ đã lưu.", + "customer.openSchedules": "Kiểm tra lịch chạy", + "customer.scheduleEnabled": "Lịch chạy đang bật; hãy kiểm tra cấu hình và phê duyệt.", + "customer.scheduleReview": "Lịch chạy đang tắt; hãy kiểm tra trước khi bật.", + "customer.key_required": "Cần khóa SSH", + "customer.password_required": "Cần mật khẩu máy từ xa", + "customer.interactive": "Cần xác thực tương tác khi kết nối", + "customer.missingJump": "Thiếu máy chủ trung chuyển được tham chiếu", + "customer.trustReview": "Cần kiểm tra độ tin cậy khóa máy chủ", + "customer.prepared": "Đã có cấu hình; chưa thử kết nối.", + "customer.presetsTitle": "Mẫu không gian làm việc", + "customer.presetsHint": "Mẫu lưu bố cục và lựa chọn máy chủ cho tài khoản của bạn. Áp dụng bố cục không tự kết nối hoặc chạy lệnh. Chủ động kết nối từng máy; các phiên hiện có vẫn giữ nguyên.", + "customer.applyLayout": "Áp dụng bố cục và chọn kết nối", + "customer.connect": "Kết nối", + "customer.missingHost": "Máy chủ không còn trong tài khoản này", + "customer.removePreset": "Xóa mẫu", + "customer.presetName": "Tên mẫu", + "customer.layout": "Ô làm việc", + "customer.chooseHosts": "Chọn máy chủ trong tài khoản của bạn", + "customer.savePreset": "Lưu mẫu", + "customer.tooManyHosts": "Không chọn số máy chủ vượt quá số ô.", + "customer.presetLimit": "Mỗi tài khoản lưu tối đa 12 mẫu. Xóa một mẫu trước khi thêm mẫu mới.", + "customer.extraWindowLayout": "Không thể áp dụng bố cục khi có hơn bốn cửa sổ nổi. Hãy đóng hoặc ghim các cửa sổ dư trước.", + "customer.loading": "Đang kiểm tra…", + "customer.close": "Đóng", + "customer.requestFailed": "Yêu cầu thất bại. Kiểm tra trạng thái hiện tại trước khi thử lại.", + "customer.nextSteps": "Tôi có thể làm gì?", + "customer.permission": "Kiểm tra quyền và chủ sở hữu của nguồn, đích. Chỉ dùng công cụ phân quyền khi được phép; không lặp lại mù quáng với quyền cao hơn.", + "customer.unknown": "Chưa rõ kết quả. Kiểm tra đích và so sánh tệp trước khi thử lại để tránh tạo bản trùng hoặc ghi đè công việc đã hoàn tất.", + "customer.conflict": "Nguồn hoặc đích đã thay đổi. Làm mới cả hai nơi và so sánh nội dung hiện tại trước khi quyết định lặp lại thao tác.", + "customer.source": "Nguồn hoặc thư mục chia sẻ không khả dụng. Kiểm tra kết nối và quyền truy cập, chọn lại nguồn, rồi kiểm tra đích trước khi bắt đầu truyền mới.", + "customer.failure": "Kiểm tra lỗi, kết nối và dung lượng trống. Kiểm tra nguồn và đích trước khi lặp lại; có thể đã có kết quả một phần.", + "customer.continuity.none": "Chọn phiên để kiểm tra khả năng tiếp tục.", + "customer.continuity.transport": "Kết nối trình duyệt bị gián đoạn. Chưa rõ kết quả từ xa; kết nối lại và kiểm tra trước khi lặp lại lệnh.", + "customer.continuity.tmux": "Phiên tmux: tiến trình từ xa có thể tiếp tục sau khi SSH ngắt nếu tmux còn chạy. Kết nối lại đúng máy chủ và phiên để kiểm tra tiến độ.", + "customer.continuity.tmuxDisconnected": "SSH đã ngắt. tmux có thể vẫn chạy từ xa; kết nối lại cùng phiên và kiểm tra trạng thái. Lệnh không được tự động chạy lại.", + "customer.continuity.normal": "Phiên SSH thông thường: không bảo đảm duy trì phiên từ xa. Mất kết nối có thể kết thúc lệnh tiền cảnh; kiểm tra kết quả trước khi thử lại.", + "customer.continuity.disconnected": "SSH đã ngắt. Lệnh tiền cảnh có thể đã dừng hoặc hoàn tất một phần. Chủ động kết nối lại và kiểm tra kết quả; không mặc định thành công hoặc lặp lại mù quáng.", + "customer.recoveryNoticeTitle": "Kiểm tra sau khôi phục", + "customer.recoveryNoticeBody": "Phiên bản này đã được khôi phục. Kiểm tra thông tin đăng nhập từ xa còn thiếu, độ tin cậy máy chủ và lịch chạy trước khi tiếp tục.", + "customer.openRecoveryChecklist": "Mở danh sách kiểm tra khôi phục", + "customer.recoveryReviewHint": "Đánh dấu đã xem chỉ ẩn lời nhắc cho tài khoản của bạn. Thao tác này không xác minh khôi phục, kiểm tra thông tin đăng nhập, tin cậy khóa hoặc bật lịch chạy.", + "customer.reviewRecovery": "Đánh dấu đã xem các bước khôi phục", + "customer.recoveryChanged": "Đã có lần khôi phục khác. Tải lại danh sách và xem các bước hiện tại trước khi ẩn lời nhắc.", + "customer.reloadRecovery": "Tải lại danh sách khôi phục" + }, + "zh": { + "customer.recoveryTitle": "恢复检查", + "customer.recoveryHint": "检查您账户的配置,不连接主机,也不验证主机是否可达。", + "customer.coverageTitle": "备份范围", + "customer.included": "包含配置、账户登录数据、连接配置、命令、工作流和用户设置。", + "customer.excluded": "可移植备份不包含已保存的远程密码、SSH 私钥和活动会话。恢复后请重新配置远程凭据。", + "customer.restoredAt": "上次恢复:", + "customer.recoveryKeys": "请为每个账户安全地重新添加缺失的 SSH 密钥和已保存的远程密码。切勿将秘密粘贴到日志或命令中。", + "customer.recoveryHosts": "检查各主机、认证方式和跳板机。接受新的主机密钥前,请通过可信渠道核实指纹。", + "customer.recoverySchedules": "检查您的计划、目标主机和凭据。请先完成所需审批,再明确启用计划。", + "customer.recoveryTest": "请主动连接主机并检查认证和主机信任。执行更改前,先进行无害的检查。", + "customer.editHost": "编辑主机", + "customer.noHosts": "此账户没有已保存的主机。", + "customer.openSchedules": "检查计划", + "customer.scheduleEnabled": "计划已启用;请检查其配置和审批。", + "customer.scheduleReview": "计划已停用;启用前请检查。", + "customer.key_required": "需要 SSH 密钥", + "customer.password_required": "需要远程密码", + "customer.interactive": "连接时需要交互式认证", + "customer.missingJump": "引用的跳板机不存在", + "customer.trustReview": "需要检查主机密钥信任", + "customer.prepared": "配置已存在;尚未测试连接。", + "customer.presetsTitle": "工作区预设", + "customer.presetsHint": "预设保存您账户的布局和主机选择。应用布局绝不会自动连接或执行命令。请明确连接各主机;现有会话会保留。", + "customer.applyLayout": "应用布局并选择连接", + "customer.connect": "连接", + "customer.missingHost": "此账户中已无此主机", + "customer.removePreset": "删除预设", + "customer.presetName": "预设名称", + "customer.layout": "窗格", + "customer.chooseHosts": "选择您账户中的主机", + "customer.savePreset": "保存预设", + "customer.tooManyHosts": "选择的主机数量不能超过窗格数量。", + "customer.presetLimit": "每个账户最多保存 12 个预设。添加新预设前请先删除一个。", + "customer.extraWindowLayout": "打开的浮动窗口超过四个时无法应用此布局。请先关闭或停靠多余窗口。", + "customer.loading": "正在检查…", + "customer.close": "关闭", + "customer.requestFailed": "请求失败。重试前请检查当前状态。", + "customer.nextSteps": "我该怎么做?", + "customer.permission": "检查源和目标的权限及所有者。仅在获得授权时使用现有权限工具;不要盲目提升权限重试。", + "customer.unknown": "结果未知。再次尝试前,请检查目标并比较文件,以免产生副本或覆盖已完成的内容。", + "customer.conflict": "源或目标已更改。请刷新两处并比较当前内容,再决定是否重复操作。", + "customer.source": "源或共享不可用。请检查连接和访问权限,重新选择源,然后在开始新传输前检查目标。", + "customer.failure": "检查错误、连接和可用存储空间。重复操作前请检查源和目标;可能已存在部分结果。", + "customer.continuity.none": "选择会话以查看其持续性。", + "customer.continuity.transport": "浏览器连接已中断。远程结果不确定;请重新连接并检查,再决定是否重复命令。", + "customer.continuity.tmux": "tmux 会话:只要 tmux 仍在运行,SSH 断开后远程进程可能继续。请重新连接同一主机和会话以检查进度。", + "customer.continuity.tmuxDisconnected": "SSH 已断开。远程 tmux 可能仍在运行;请重新连接同一会话并检查状态。命令不会自动重放。", + "customer.continuity.normal": "普通 SSH 会话:不保证远程会话持久存在。SSH 连接中断可能终止前台命令;重试前请检查结果。", + "customer.continuity.disconnected": "SSH 已断开。前台命令可能已停止或部分完成。请主动重新连接并检查结果;不要假定成功或盲目重复。", + "customer.recoveryNoticeTitle": "恢复后请检查", + "customer.recoveryNoticeBody": "此实例已恢复。继续工作前,请检查缺失的远程凭据、主机信任和计划。", + "customer.openRecoveryChecklist": "打开恢复检查清单", + "customer.recoveryReviewHint": "标记已检查仅为您的账户隐藏此提醒,不会验证恢复、测试凭据、信任主机密钥或启用计划。", + "customer.reviewRecovery": "标记恢复步骤已检查", + "customer.recoveryChanged": "发生了另一次恢复。请重新加载清单并检查当前恢复步骤,然后再关闭提醒。", + "customer.reloadRecovery": "重新加载恢复清单" + } +}; + for (const [language, values] of Object.entries(dictionaries)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/customer-workflows.js b/static/js/customer-workflows.js new file mode 100644 index 00000000..ce1af2f3 --- /dev/null +++ b/static/js/customer-workflows.js @@ -0,0 +1,177 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) { root.CustomerWorkflows = api; root.addEventListener('load', () => api.init(root), {once: true}); } +}(typeof window === 'undefined' ? null : window, function() { + 'use strict'; + function layoutAssignments(requested, existing, current) { + const assigned = [...new Set(existing.filter(Boolean))]; + if (assigned.length > 4) throw new RangeError('Too many assigned sessions'); + const needed = [1, 2, 4].find(count => count >= assigned.length); + const layout = Math.max([1, 2, 4].includes(requested) ? requested : 1, + [1, 2, 4].includes(current) ? current : needed, needed); + const slots = current === 'windows' || existing.slice(layout).some(Boolean) ? assigned : existing; + return {layout, assignments: Array.from({length: layout}, (_, index) => slots[index] || null)}; + } + function presetId(crypto) { + if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); + // Metadata identifiers carry no authority; HTTP homelabs lack randomUUID. + return `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; + } + function continuityKey(session, transport) { + if (!session) return 'none'; + if (session.connected && !transport) return 'transport'; + if (session.useTmux) return session.connected ? 'tmux' : 'tmuxDisconnected'; + return session.connected ? 'normal' : 'disconnected'; + } + function adviceKey(code) { + if (code === 'PERMISSION_DENIED') return 'permission'; + if (code === 'OUTCOME_UNKNOWN' || code === 'TIMEOUT') return 'unknown'; + if (code === 'CONFLICT' || code === 'SOURCE_CHANGED') return 'conflict'; + if (code === 'SOURCE_UNAVAILABLE' || code === 'SHARE_UNAVAILABLE') return 'source'; + return 'failure'; + } + function init(root) { + const sessions = root.SessionManager || (typeof SessionManager === 'undefined' ? null : SessionManager); + const document = root.document; + const t = key => root.i18n?.t('customer.' + key) || key; + const el = (tag, text, className) => { const item = document.createElement(tag); if (text) item.textContent = text; if (className) item.className = className; return item; }; + const button = (text, action) => { const item = el('button', text, 'btn btn-secondary'); item.type='button'; item.addEventListener('click', action); return item; }; + const api = async (path, body, method = 'PUT') => { + const prefix = document.querySelector('meta[name="app-root"]')?.content || ''; + const response = await root.fetch(prefix + '/api/account/' + path, { + method: body ? method : 'GET', credentials:'same-origin', + headers:{'Content-Type':'application/json', 'X-CSRFToken':document.querySelector('meta[name="csrf-token"]')?.content || ''}, + ...(body ? {body:JSON.stringify(body)} : {}) + }); + if (!response.ok) { const error=Error(t('requestFailed')); error.status=response.status; throw error; } + return response.json(); + }; + function dialog(title) { + const previous = document.activeElement; + const modal = el('dialog', '', 'customer-dialog'); + const heading = el('h2', title); heading.id='customerDialogTitle'; modal.setAttribute('aria-labelledby', heading.id); + modal.append(heading, button(t('close'), () => modal.close())); + modal.addEventListener('close', () => { modal.remove(); previous?.focus(); }, {once:true}); + document.body.append(modal); modal.showModal(); return modal; + } + function report(modal, error) { + let status=modal.querySelector('[role="alert"]'); if (!status) { status=el('p');status.role='alert';modal.append(status); } + status.textContent=error.message || t('requestFailed'); + } + const notice = document.getElementById('restoreRecoveryNotice'); + async function refreshRecoveryNotice() { + if (!notice) return; + try { const data=await api('recovery-check'); notice.hidden=data.needs_review !== true; } + catch { /* Optional guidance must not block normal work. */ } + } + async function openRecovery() { + const modal=dialog(t('recoveryTitle')); modal.append(el('p',t('recoveryHint'))); + const loading=el('p',t('loading'));modal.append(loading); + try { + const data=await api('recovery-check'); if (!modal.isConnected) return; loading.remove(); + modal.append(el('h3',t('coverageTitle')),el('p',t('included')),el('p',t('excluded'))); + if(data.restored_at) modal.append(el('p',t('restoredAt')+' '+new Date(data.restored_at).toLocaleString(root.i18n?.getLanguage?.()))); + const list=el('ol'); + for(const key of ['recoveryKeys','recoveryHosts','recoverySchedules','recoveryTest']) list.append(el('li',t(key))); + modal.append(list); + for(const host of data.hosts) { + const row=el('div','', 'customer-row'); + const problems=[host.credential==='ready' ? null : t(host.credential),host.route==='missing_jump'?t('missingJump'):null,host.host_key==='review_required'?t('trustReview'):null].filter(Boolean); + row.append(el('strong',host.name),el('p',problems.join(' · ') || t('prepared'))); + if(root.ProfileManager) row.append(button(t('editHost'),()=>{modal.close(); root.document.getElementById('manageProfilesBtn')?.click();root.ProfileManager.openEditor(host.id);})); + modal.append(row); + } + if(!data.hosts.length) modal.append(el('p',t('noHosts'))); + for(const schedule of data.schedules) modal.append(el('p',schedule.name+' · '+t(schedule.enabled?'scheduleEnabled':'scheduleReview'))); + const schedules=el('a',t('openSchedules'),'btn btn-secondary');schedules.href=(document.querySelector('meta[name="app-root"]')?.content || '')+'/automation#schedules';modal.append(schedules); + if (data.needs_review && data.restored_at) { + const acknowledgement=el('p',t('recoveryReviewHint'));modal.append(acknowledgement); + const review=button(t('reviewRecovery'),async()=>{ + review.disabled=true; + try { + await api('recovery-check',{restored_at:data.restored_at},'POST'); + await refreshRecoveryNotice(); + modal.close(); + } catch(error) { + if (error.status===409) { + await refreshRecoveryNotice(); + report(modal,Error(t('recoveryChanged'))); + modal.append(button(t('reloadRecovery'),()=>{modal.close();openRecovery();})); + } else {review.disabled=false;report(modal,error);} + } + }); + review.dataset.reviewRecovery='';modal.append(review); + } + + }catch(error){loading.remove();report(modal,error);} + } + async function openPresets() { + const modal=dialog(t('presetsTitle'));modal.append(el('p',t('presetsHint'))); + let presets; + try {presets=(await api('workspace-presets')).presets;}catch(error){report(modal,error);return;} + if(!modal.isConnected) return; + const saved=el('div');modal.append(saved); + async function save(next) { const data=await api('workspace-presets',{presets:next}); presets=data.presets; render(); } + function render() { + saved.replaceChildren(); + for(const preset of presets) { + const row=el('section','', 'customer-row');row.append(el('h3',preset.name)); + const choices=el('div'); choices.hidden=true; + row.append(button(t('applyLayout'),()=>{ + const manager=sessions; if(!manager)return; + if(manager.windowMode && manager.paneAssignments.filter(Boolean).length>4){report(modal,Error(t('extraWindowLayout')));return;} + let next; + try { next=layoutAssignments(preset.layout,manager.paneAssignments,manager.layout); } + catch { report(modal,Error(t('extraWindowLayout')));return; } + manager.applyPaneAssignments(next.layout,next.assignments); + document.getElementById('workspaceNavBtn')?.click(); choices.hidden=false; + })); + for(const id of preset.profile_ids) { + const profile=root.ProfileManager?.profiles.find(item=>item.id===id); + const connect=button(profile ? t('connect')+' '+profile.name : t('missingHost'),()=>{ + modal.close(); const manager=sessions; + const free=manager?.paneAssignments?.findIndex(item=>!item); + root.launchProfileForPane?.(id,free>=0?free:undefined); + });connect.disabled=!profile;choices.append(connect); + } + row.append(choices,button(t('removePreset'),async()=>{try{await save(presets.filter(item=>item.id!==preset.id));}catch(error){report(modal,error);}})); + saved.append(row); + } + } + render(); + const form=el('form');const name=el('input');name.required=true;name.maxLength=80;name.id='workspacePresetName'; + const label=el('label',t('presetName'));label.htmlFor=name.id; + const layout=el('select');layout.id='workspacePresetLayout';const layoutLabel=el('label',t('layout'));layoutLabel.htmlFor=layout.id; + for(const count of [1,2,4]){const option=el('option',String(count));option.value=String(count);layout.append(option);}layout.value=String([1,2,4].includes(sessions?.layout) ? sessions.layout : [1,2,4].find(count=>count>=(sessions?.paneAssignments?.filter(Boolean).length || 1)) || 4); + form.append(label,name,layoutLabel,layout); + const hosts=el('fieldset');hosts.append(el('legend',t('chooseHosts'))); + const current=new Set(sessions?.getAllSessions?.().map(item=>item.profileId)); + for(const profile of root.ProfileManager?.profiles || []) { const label=el('label','', 'customer-choice');const check=el('input');check.type='checkbox';check.value=profile.id;check.checked=current.has(profile.id);label.append(check,document.createTextNode(profile.name));hosts.append(label); } + form.append(hosts);const submit=el('button',t('savePreset'),'btn btn-primary');submit.type='submit';form.append(submit);modal.append(form); + form.addEventListener('submit',async event=>{ + event.preventDefault();submit.disabled=true; + try { + const ids=[...hosts.querySelectorAll('input:checked')].map(item=>item.value); + if(ids.length>Number(layout.value))throw Error(t('tooManyHosts')); + if(presets.length>=12)throw Error(t('presetLimit')); + await save([...presets,{id:presetId(root.crypto),name:name.value.trim(),layout:Number(layout.value),profile_ids:ids}]);name.value=''; + }catch(error){report(modal,error);}finally{submit.disabled=false;} + }); + } + document.querySelectorAll('[data-workspace-presets]').forEach(item=>item.addEventListener('click',openPresets)); + document.querySelectorAll('[data-recovery-check]').forEach(item=>item.addEventListener('click',openRecovery)); + const status=document.getElementById('workspaceContinuity'); + function renderContinuity() { + if(!status)return; + const manager=sessions;const session=manager?.getSession(manager.getWorkspaceSession?.() || manager.getActiveSession?.()); + status.textContent=t('continuity.'+continuityKey(session,root.socket?.connected!==false)); + status.hidden=!session; + } + root.addEventListener('session-workspace-change',renderContinuity);root.addEventListener('languageChanged',renderContinuity); + for(const event of ['connect','disconnect','ssh_disconnected','ssh_session_restored']) root.socket?.on(event,()=>root.setTimeout(renderContinuity,0)); + renderContinuity(); + refreshRecoveryNotice(); + } + return {layoutAssignments, presetId, continuityKey, adviceKey, init}; +})); diff --git a/static/js/i18n-auth.js b/static/js/i18n-auth.js index 6917ca6e..f9dbfb92 100644 --- a/static/js/i18n-auth.js +++ b/static/js/i18n-auth.js @@ -1107,6 +1107,14 @@ const storedLanguage = BrowserPreferences.get('language', 'en'); const i18n = { currentLang: translations[storedLanguage] ? storedLanguage : 'en', + addTranslations(lang, values) { + if (!Object.hasOwn(translations, lang) || !values || typeof values !== 'object' || Array.isArray(values)) return false; + for (const [key, value] of Object.entries(values)) { + if (typeof value === 'string' && !['__proto__', 'constructor', 'prototype'].includes(key)) translations[lang][key] = value; + } + return true; + }, + t(key) { const activeTranslations = translations[this.currentLang] || translations.en; return activeTranslations[key] || translations.en[key] || key; diff --git a/static/js/i18n.js b/static/js/i18n.js index 0538db4f..5ff7cf05 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -324,7 +324,7 @@ const translations = { "fm.new": "New", "fm.more": "More…", "fm.openFileManager": "Open File Manager", - "commands.systemSource": "System · read-only", + "commands.systemSource": "System · fixed template", "commands.personalSource": "Personal", "commands.details": "Command details", "commands.selectDetails": "Select a command to view its details.", @@ -2373,7 +2373,7 @@ const translations = { 'commands.currentOs': 'Current OS', 'commands.detecting': 'Detecting...', 'commands.helpTip': "Press F1 to open this library anytime. Choose “Insert into terminal”, review the command, and press Enter when ready to run it.", - 'commands.helpSystem': 'System commands (gray background) are read-only. User commands can be edited or deleted.', + 'commands.helpSystem': 'System templates cannot be edited in the catalog. They can still change or delete data when run. Copy one to customize it. Personal commands can be edited or deleted.', 'commands.namePlaceholder': 'e.g., Update System', 'commands.commandPlaceholder': 'e.g., apt update', 'commands.parametersPlaceholder': 'e.g., -y', @@ -2756,7 +2756,7 @@ const translations = { "fm.new": "Mới", "fm.more": "Thêm…", "fm.openFileManager": "Mở trình quản lý tệp", - "commands.systemSource": "Hệ thống · chỉ đọc", + "commands.systemSource": "Hệ thống · mẫu cố định", "commands.personalSource": "Cá nhân", "commands.details": "Chi tiết lệnh", "commands.selectDetails": "Chọn một lệnh để xem chi tiết.", @@ -4805,7 +4805,7 @@ const translations = { 'commands.currentOs': 'OS hiện tại', 'commands.detecting': 'Đang phát hiện...', 'commands.helpTip': "Nhấn F1 để mở thư viện này bất kỳ lúc nào. Chọn “Chèn vào terminal”, kiểm tra lệnh rồi nhấn Enter khi sẵn sàng chạy.", - 'commands.helpSystem': 'Các lệnh hệ thống (nền xám) là chỉ đọc. Các lệnh của người dùng có thể được chỉnh sửa hoặc xóa.', + 'commands.helpSystem': 'Không thể sửa mẫu hệ thống trong danh mục. Khi chạy, lệnh vẫn có thể thay đổi hoặc xóa dữ liệu. Sao chép để tùy chỉnh. Có thể sửa hoặc xóa lệnh cá nhân.', 'commands.namePlaceholder': 'Ví dụ: Cập nhật hệ thống', 'commands.commandPlaceholder': 'Ví dụ: apt update', 'commands.parametersPlaceholder': 'Ví dụ: -y', @@ -5192,7 +5192,7 @@ const translations = { "fm.new": "Neu", "fm.more": "Mehr…", "fm.openFileManager": "Dateimanager öffnen", - "commands.systemSource": "System · schreibgeschützt", + "commands.systemSource": "System · feste Vorlage", "commands.personalSource": "Eigener Befehl", "commands.details": "Befehlsdetails", "commands.selectDetails": "Wähle einen Befehl, um seine Details zu sehen.", @@ -7240,7 +7240,7 @@ const translations = { 'commands.currentOs': 'Aktuelles OS', 'commands.detecting': 'Erkennung läuft...', 'commands.helpTip': "Drücke F1, um diese Bibliothek jederzeit zu öffnen. Wähle „Ins Terminal einfügen“, prüfe den Befehl und drücke zum Ausführen die Eingabetaste.", - 'commands.helpSystem': 'Systembefehle (grauer Hintergrund) sind schreibgeschützt. Benutzerbefehle können bearbeitet oder gelöscht werden.', + 'commands.helpSystem': 'Systemvorlagen sind im Katalog nicht bearbeitbar. Ihre Ausführung kann trotzdem Daten ändern oder löschen. Zum Anpassen kopieren. Persönliche Befehle lassen sich bearbeiten oder löschen.', 'commands.namePlaceholder': 'z.B. System aktualisieren', 'commands.commandPlaceholder': 'z.B. apt update', 'commands.parametersPlaceholder': 'z.B. -y', @@ -7623,7 +7623,7 @@ const translations = { "fm.new": "Nouveau", "fm.more": "Plus…", "fm.openFileManager": "Ouvrir le gestionnaire de fichiers", - "commands.systemSource": "Système · lecture seule", + "commands.systemSource": "Système · modèle fixe", "commands.personalSource": "Personnel", "commands.details": "Détails de la commande", "commands.selectDetails": "Sélectionnez une commande pour afficher ses détails.", @@ -9671,7 +9671,7 @@ const translations = { 'commands.currentOs': 'OS actuel', 'commands.detecting': 'Détection...', 'commands.helpTip': "Appuyez sur F1 pour ouvrir cette bibliothèque à tout moment. Choisissez « Insérer dans le terminal », vérifiez la commande, puis appuyez sur Entrée pour l’exécuter.", - 'commands.helpSystem': 'Les commandes système (fond gris) sont en lecture seule. Les commandes utilisateur peuvent être modifiées ou supprimées.', + 'commands.helpSystem': 'Les modèles système ne sont pas modifiables dans le catalogue. Leur exécution peut modifier ou supprimer des données. Copiez-les pour les adapter. Les commandes personnelles restent modifiables.', 'commands.namePlaceholder': 'par ex., Mettre à jour le système', 'commands.commandPlaceholder': 'par ex., apt update', 'commands.parametersPlaceholder': 'par ex., -y', @@ -10054,7 +10054,7 @@ const translations = { "fm.new": "Nuevo", "fm.more": "Más…", "fm.openFileManager": "Abrir el gestor de archivos", - "commands.systemSource": "Sistema · solo lectura", + "commands.systemSource": "Sistema · plantilla fija", "commands.personalSource": "Personal", "commands.details": "Detalles del comando", "commands.selectDetails": "Selecciona un comando para ver sus detalles.", @@ -12102,7 +12102,7 @@ const translations = { 'commands.currentOs': 'OS actual', 'commands.detecting': 'Detectando...', 'commands.helpTip': "Pulsa F1 para abrir esta biblioteca en cualquier momento. Elige «Insertar en el terminal», revisa el comando y pulsa Intro cuando quieras ejecutarlo.", - 'commands.helpSystem': 'Los comandos del sistema (fondo gris) son de solo lectura. Los comandos de usuario se pueden editar o eliminar.', + 'commands.helpSystem': 'Las plantillas del sistema no se editan en el catálogo. Su ejecución puede cambiar o borrar datos. Cópielas para adaptarlas. Los comandos personales se pueden editar o eliminar.', 'commands.namePlaceholder': 'p. ej., Actualizar sistema', 'commands.commandPlaceholder': 'p. ej., apt update', 'commands.parametersPlaceholder': 'p. ej., -y', @@ -12484,7 +12484,7 @@ const translations = { "fm.new": "新建", "fm.more": "更多…", "fm.openFileManager": "打开文件管理器", - "commands.systemSource": "系统 · 只读", + "commands.systemSource": "系统 · 固定模板", "commands.personalSource": "个人", "commands.details": "命令详情", "commands.selectDetails": "选择一个命令以查看详情。", @@ -14532,7 +14532,7 @@ const translations = { 'commands.currentOs': '当前系统', 'commands.detecting': '检测中…', 'commands.helpTip': "随时按 F1 打开此命令库。选择“插入终端”,检查命令,然后在准备执行时按回车键。", - 'commands.helpSystem': '系统命令(灰色背景)为只读。用户命令可以编辑或删除。', + 'commands.helpSystem': '系统模板无法在目录中编辑,但执行时仍可能修改或删除数据。请复制后自定义。个人命令可以编辑或删除。', 'commands.namePlaceholder': '例如:更新系统', 'commands.commandPlaceholder': '例如:apt update', 'commands.parametersPlaceholder': '例如:-y', @@ -14621,6 +14621,14 @@ const storedLanguage = BrowserPreferences.get('language', 'en'); const i18n = { currentLang: translations[storedLanguage] ? storedLanguage : 'en', + addTranslations(lang, values) { + if (!Object.hasOwn(translations, lang) || !values || typeof values !== 'object' || Array.isArray(values)) return false; + for (const [key, value] of Object.entries(values)) { + if (typeof value === 'string' && !['__proto__', 'constructor', 'prototype'].includes(key)) translations[lang][key] = value; + } + return true; + }, + t(key) { const activeTranslations = translations[this.currentLang] || translations.en; return activeTranslations[key] || translations.en[key] || key; diff --git a/static/js/overview-model.js b/static/js/overview-model.js index a08b1649..14102610 100644 --- a/static/js/overview-model.js +++ b/static/js/overview-model.js @@ -86,8 +86,11 @@ function metrics({hostList = [], sessions = [], runs = [], schedules = [], reachability = null, now = Date.now()} = {}) { const total = hostList.length; - const known = hostList.filter(host => host.state !== 'unknown'); - const readyCount = hostList.filter(host => ['ready', 'live'].includes(host.state)).length; + // TCP reachability is a separate observation, not configuration readiness. + const configurationState = host => host.sessionId ? 'ready' + : host.route?.kind === 'missing' ? 'broken' : host.status?.state || 'unknown'; + const known = hostList.filter(host => configurationState(host) !== 'unknown'); + const readyCount = hostList.filter(host => configurationState(host) === 'ready').length; const bins = automationBins(runs, now); const finished = bins.reduce((sum, bin) => sum + bin.succeeded + bin.failed + bin.other, 0); const succeeded = bins.reduce((sum, bin) => sum + bin.succeeded, 0); @@ -270,9 +273,34 @@ return {center, hubs, nodes, width, height}; } + /** Saved-host shortcuts only: stale history can never resurrect a deleted host. */ + function workFirst({hostList = [], history = [], sessions = [], limit = 6} = {}) { + const used = new Set(); + const take = list => list.filter(host => !used.has(host.id) && used.add(host.id)).slice(0, limit); + const liveHosts = hostList.filter(host => host.sessionId); + const adHoc = sessions.filter(session => session.connected === true && !liveHosts.some(host => host.sessionId === session.id)) + .map(session => ({id: `session:${session.id}`, sessionId: session.id, + name: String(session.displayName || session.host || ''), destination: destination(session)})); + const resume = take([...liveHosts, ...adHoc]); + const favorites = take(hostList.filter(host => host.favorite)); + const recent = take([...history].filter(entry => Number.isFinite(Number(entry.timestamp))) + .sort((a, b) => Number(b.timestamp) - Number(a.timestamp)) + .map(entry => hostList.find(host => sameEndpoint(entry, host.profile))).filter(Boolean)); + const saved = resume.length || favorites.length || recent.length ? [] : hostList.slice(0, limit); + return {resume, favorites, recent, saved}; + } + + /** These are observations, never acknowledgements or predictions of login success. */ + function setupProgress(host, fileSessions = new Set()) { + const live = Boolean(host?.sessionId); + return {host: Boolean(host?.profile?.host && host?.profile?.username), auth: live, + trust: host?.status?.host_key === 'trusted' || live, + terminal: live, files: live && fileSessions.has(host.sessionId)}; + } + function greetingKey(hour) { return hour < 11 ? 'greetingMorning' : hour < 18 ? 'greetingDay' : 'greetingEvening'; } - return {destination, hosts, filter, automationBins, metrics, headline, attention, attentionKey, maintenance, fingerprint, activity, platforms, topology, greetingKey}; + return {workFirst, setupProgress, destination, hosts, filter, automationBins, metrics, headline, attention, attentionKey, maintenance, fingerprint, activity, platforms, topology, greetingKey}; })); diff --git a/static/js/overview-workspace.js b/static/js/overview-workspace.js index 8affd1e9..21ab03ce 100644 --- a/static/js/overview-workspace.js +++ b/static/js/overview-workspace.js @@ -44,10 +44,12 @@ return element; }; + const wf = (key, fallback, values) => t(`workFirst.${key}`, fallback, values); + const fileSessions = new Set(); const search = $('overviewSearch'); const hostList = $('overviewHostList'); const mode = () => view.dataset.reachability || 'manual'; - const state = {readiness: null, reachability: null, checkedAt: null, checking: false, reachError: null, + const state = {readiness: null, reachability: null, checkedAt: null, readinessAt: null, checking: false, reachError: null, shown: new Map(), scheduled: false, readinessTimer: 0, automaticTimer: 0, routeMap: null, firstPaint: true, returnKey: null, topologyNarrow: false, stateFilter: null, focusId: null, idFilter: null, dismissed: [], dismissSequence: 0, attention: [], board: null}; @@ -65,7 +67,7 @@ state.returnKey = `connect:${host.id}`; showWorkspaces(); if (host.sessionId && sessionManager?.getSession?.(host.sessionId)) sessionManager.switchSession(host.sessionId); - else win.launchProfileForPane?.(host.id); + else if (host.profile) win.launchProfileForPane?.(host.id); } function edit(hostId) { const manager = win.ProfileManager; @@ -146,6 +148,74 @@ countUp(number, value, suffix); } + function renderWorkFirst(hosts) { + const holder = $('overviewShortcuts'); + if (!holder) return; + const groups = Model.workFirst({hostList: visibleHosts(hosts), sessions: filtered() ? [] : sessions(), history: win.ConnectionHistory?.getHistory?.() || []}); + const signature = JSON.stringify([groups, win.i18n?.getLanguage?.()]); + if (state.shortcutSignature !== signature) { + state.shortcutSignature = signature; + holder.replaceChildren(); + Object.entries(groups).forEach(([kind, list]) => { + if (!list.length) return; + const group = node('section', 'od-shortcut-group'); + group.append(node('h3', '', wf(kind, {resume:'Resume sessions', favorites:'Favorites', recent:'Recent hosts', saved:'Saved hosts'}[kind]))); + list.forEach(host => { + const action = button('od-shortcut', host.name, () => connect(host), host.name); + action.dataset.key = `shortcut:${host.id}`; + action.append(node('small', '', host.destination)); + group.append(action); + }); + holder.append(group); + }); + if (!holder.childElementCount) holder.append(button('btn btn-secondary', null, createHost, t('profiles.newHost', 'New host'))); + } + const select = $('overviewSetupHost'); + const chosen = select.value; + const options = JSON.stringify([hosts.map(host => [host.id, host.name]), win.i18n?.getLanguage?.()]); + if (options !== state.setupOptions) { + state.setupOptions = options; + select.replaceChildren(); + if (!hosts.length) select.append(node('option', '', wf('noHost', 'Create a saved host to begin'))); + hosts.forEach(host => { const option = node('option', '', host.name); option.value = host.id; select.append(option); }); + if (hosts.some(host => host.id === chosen)) select.value = chosen; + select.disabled = !hosts.length; + } + const host = hosts.find(item => item.id === select.value); + const progress = Model.setupProgress(host, fileSessions); + if (!state.setupInitialized && win.ProfileManager?.profilesLoaded === true) { + state.setupInitialized = true; + if (!hosts.length) $('overviewFirstConnection').open = true; + } + const setupSignature = JSON.stringify([host?.id, progress, host?.status, win.i18n?.getLanguage?.()]); + if (setupSignature === state.setupSignature) return; + state.setupSignature = setupSignature; + const list = $('overviewSetupSteps'); list.replaceChildren(); + const names = {host:'Save host and username', auth:'Authenticate to the host', trust:'Verify the SSH host key', terminal:'Open a terminal', files:'Open Files for this session'}; + const nextStep = Object.keys(progress).find(key => !progress[key]); + Object.entries(progress).forEach(([key, done]) => { + const row = node('li', 'od-setup-step'); row.dataset.step = key; row.dataset.complete = String(done); + row.append(icon(done ? 'check_circle' : 'radio_button_unchecked'), node('span', '', wf(key, names[key]))); + const label = done ? wf('verified', 'Observed') : wf('pending', 'Pending'); + row.append(node('small', '', label)); + if (key === nextStep) { + const action = () => { + if (!host) return createHost(); + if (key === 'host' || (key === 'auth' && ['key_missing', 'password_changed'].includes(host.status?.auth))) return edit(host.id); + connect(host); + if (key === 'files' && host.sessionId) { + win.workspaceLayoutController?.openContext?.('files', 'user'); + win.sessionWorkspace?.openSftpPanel?.(); + } + }; + row.append(button('od-link', null, action, !host ? t('profiles.newHost', 'New host') + : key === 'host' || (key === 'auth' && ['key_missing', 'password_changed'].includes(host.status?.auth)) ? t('common.edit', 'Edit') + : key === 'files' && host.sessionId ? wf('openFiles', 'Open Files') : t('connection.actionConnect', 'Connect'))); + } + list.append(row); + }); + } + function renderHero(hosts, attentionItems) { const name = view.dataset.username || ''; $('overviewTitle').textContent = tw(Model.greetingKey(new Date().getHours()), @@ -164,7 +234,7 @@ disconnected: t('overview.stale', 'Connection to WebSSH interrupted. Displayed data may be out of date.'), attention: line.count === 1 ? tw('statusOneIssue', '1 item needs attention') : tw('statusIssues', '{count} items need attention', {count: line.count}), empty: tw('statusEmpty', 'Ready for your first host'), - ready: tw('statusReady', 'Everything is ready'), + ready: wf('configReady', 'Saved configuration is ready'), }[line.key]; $('overviewStatusButton').disabled = line.key !== 'attention'; } @@ -175,7 +245,7 @@ // Readiness arrives separately; never show a provisional count. const known = loaded && (metrics.hosts.loaded || !total); kpi('odKpiHosts', { - label: tw('kpiHosts', 'Hosts ready'), iconName: 'dns', + label: wf('configTitle', 'Configuration readiness'), iconName: 'dns', value: known ? ready : '–', suffix: known ? ` / ${total}` : '', sub: !known ? tw('loading', 'Loading…') : !total ? tw('kpiHostsNone', 'No saved hosts yet') : needsAttention > 0 ? tw('kpiHostsAttention', '{count} need attention', {count: needsAttention}) : tw('kpiHostsAll', 'All hosts ready'), @@ -218,7 +288,7 @@ } const result = metrics.reachability; kpi('odKpiReach', { - label: tw('kpiReach', 'Reachable'), iconName: 'sensors', + label: wf('tcpTitle', 'TCP reachability'), iconName: 'sensors', value: current === 'off' ? tw('kpiOff', 'Off') : result ? result.reachable : '–', suffix: result && current !== 'off' ? ` / ${result.checked}` : '', sub: state.reachError || (current === 'off' ? tw('reachOff', 'Reachability checks are off') @@ -380,7 +450,7 @@ } function stateLabel(value) { return { - ready: tw('stateReady', 'Ready'), action: tw('stateAction', 'Needs action'), broken: tw('stateBroken', 'Route broken'), + ready: wf('configReady', 'Saved configuration is ready'), action: tw('stateAction', 'Needs action'), broken: tw('stateBroken', 'Route broken'), unreachable: tw('stateUnreachable', 'Unreachable'), live: tw('stateLive', 'Session open'), unknown: tw('loading', 'Loading…'), }[value] || value; } @@ -487,7 +557,12 @@ connectButton.dataset.key = `connect:${host.id}`; connectButton.append(icon(host.sessionId ? 'east' : 'terminal'), node('span', '', primary)); bottom.append(badge, editButton, connectButton); - item.append(top, meta, bottom); + const context = node('p', 'od-host-context'); + context.textContent = wf('configHint', 'Configuration checks do not test login or permissions.'); + if (state.readinessAt) context.append(node('small', '', wf('configChecked', 'Configuration checked {time}', {time: new Date(state.readinessAt).toLocaleString(win.i18n?.getLanguage?.() || undefined)}))); + if (host.reach && state.checkedAt) context.append(node('small', '', wf('tcpChecked', 'TCP {state} · {time}', {state: wf(host.reach.state, host.reach.state), time: new Date(state.checkedAt).toLocaleString(win.i18n?.getLanguage?.() || undefined)}))); + if (host.reach) context.append(node('small', '', wf('tcpHint', 'TCP checks do not authenticate. For jump routes only the jump host is checked.'))); + item.append(top, meta, bottom, context); return item; } function renderHosts(hosts) { @@ -501,7 +576,7 @@ const signature = JSON.stringify([loaded, win.i18n?.getLanguage?.() || '', visible.map(host => [host.id, host.state, host.name, host.destination, host.favorite, host.group, host.route.kind, host.route.name, host.sessionId, reasonFor(host), win.HostPlatform?.resolve?.(host.profile)?.id || '', win.HostPlatform?.services?.(host.profile) || [], - Model.maintenance(host.profile)])]); + Model.maintenance(host.profile)]), state.readinessAt, state.checkedAt]); if (signature === state.hostSignature) return; state.hostSignature = signature; const focusedKey = hostList.contains(doc.activeElement) ? doc.activeElement.dataset.key : null; @@ -765,6 +840,7 @@ state.attention = allAttention; const metrics = Model.metrics({hostList: hosts, sessions: sessions(), runs: runInfo.runs || [], schedules: runInfo.schedules || [], reachability: state.reachability}); + renderWorkFirst(hosts); renderHero(hosts, attentionItems); renderKpis(hosts, metrics, runInfo); renderLegend(hosts); @@ -805,6 +881,7 @@ try { const data = await request('/api/overview/status'); state.readiness = Array.isArray(data.hosts) ? data.hosts : []; + state.readinessAt = Number(data.generated_at) * 1000 || Date.now(); } catch { // Readiness is optional context; hosts still render without it. } @@ -1052,6 +1129,11 @@ $('odAttention')?.addEventListener('animationend', event => { if (event.animationName === 'od-panel-flash') event.currentTarget.classList.remove('od-panel-flash'); }); + $('overviewSetupHost')?.addEventListener('change', schedule); + win.socket?.on?.('directory_listing', data => { + const id = typeof data?.source_id === 'string' && data.source_id.startsWith('sftp-session:') ? data.source_id.slice(13) : null; + if (id && Array.isArray(data.files) && sessionManager?.getSession?.(id)?.connected) { fileSessions.add(id); schedule(); } + }); ['session-workspace-change', 'languageChanged', 'automation-runs-updated'].forEach(name => win.addEventListener(name, schedule)); ['profile-list-change', 'jump-host-list-change', 'host-access-change'].forEach(name => win.addEventListener(name, () => { schedule(); queueReadiness(); })); // The first automatic check needs the saved hosts, which may load after init. diff --git a/static/js/session-command-launcher.js b/static/js/session-command-launcher.js index 1d8be129..4c4d2a2a 100644 --- a/static/js/session-command-launcher.js +++ b/static/js/session-command-launcher.js @@ -57,6 +57,10 @@ command: base, parameters, type: 'command', + safety: command?.isSystem === true && command?.safety + && command.safety.command === base && command.safety.parameters === parameters + && ['read-only', 'changes-system', 'deletes-data', 'network-load'].includes(command.safety.impact) + ? command.safety : null, isSystem: command?.isSystem === true, id: command?.id || '', name: command?.name || base || '', @@ -590,6 +594,19 @@ details.append(name); if (this.activeSection === 'favorites') details.append(badge); if (entry.description) details.append(description); + const impact = document.createElement('span'); + impact.className = 'command-impact-label'; + const updateImpact = current => { + impact.textContent = this.t(`commandSafety.${current.safety?.impact || 'unknown'}`, 'Impact not classified'); + }; + updateImpact(entry); + details.append(impact); + if (entry.safety) { + const scope = document.createElement('p'); + scope.className = 'command-template-hint'; + scope.textContent = this.t('commandSafety.scope', 'Impact describes this exact template, not a safety guarantee. Changed arguments, interactive actions and sudo can change the effect.'); + details.append(scope); + } if (entry.preview || parameterized) details.append(preview); if (entry.isSystem && entry.parameters) { const notice = document.createElement('p'); @@ -696,6 +713,7 @@ const updatePreview = () => { const updated = commandEntry({ ...savedEntry, parameters: input.value }); preview.textContent = insertTextFor(updated, this.useSudo); + updateImpact(updated); currentAvailable = canInsert && updated.available; insert.disabled = inserting || !currentAvailable; }; diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index e4f7d21d..8dee6e9b 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -6010,6 +6010,7 @@ class SFTPFileManager extends window.FileDirectoryRequests {
${this.escapeHtml(t.filename)}
${reason ? `
${reason}
` : ''} + ${t.status === 'error' && window.CustomerWorkflows ? `
${this.escapeHtml(this.t('customer.nextSteps', 'What can I do?'))}

${this.escapeHtml(this.t('customer.' + window.CustomerWorkflows.adviceKey(t.errorCode), 'Inspect the destination before repeating this operation.'))}

${this.escapeHtml([t.sourcePath, t.targetPath].filter(Boolean).join(' → '))}

` : ''} ${retryHint ? `
${this.escapeHtml(retryHint)}
` : ''} ${t.status === 'active' ? `
diff --git a/static/js/work-first-i18n.js b/static/js/work-first-i18n.js new file mode 100644 index 00000000..4be79c4a --- /dev/null +++ b/static/js/work-first-i18n.js @@ -0,0 +1,205 @@ +/* Task-oriented overview translations, registered before its controller. */ +(() => { + 'use strict'; + const dictionaries = { + "en": { + "workFirst.saved": "Saved hosts", + "workFirst.title": "Continue working", + "workFirst.startPage": "Choose your start page", + "workFirst.resume": "Resume sessions", + "workFirst.favorites": "Favorites", + "workFirst.recent": "Recent hosts", + "workFirst.setup": "Your first connection, step by step", + "workFirst.setupHint": "Progress reflects your selected host and this browser’s live session. Nothing connects or trusts a key automatically.", + "workFirst.selectHost": "Host to set up", + "workFirst.noHost": "Create a saved host to begin", + "workFirst.host": "Save host and username", + "workFirst.auth": "Authenticate to the host", + "workFirst.trust": "Verify the SSH host key", + "workFirst.terminal": "Open a terminal", + "workFirst.files": "Open Files for this session", + "workFirst.openFiles": "Open Files", + "workFirst.verified": "Observed", + "workFirst.pending": "Pending", + "workFirst.automationHint": "A successful interactive connection does not approve a workflow or schedule. Automation needs its own credentials, host trust and approval.", + "workFirst.configReady": "Saved configuration is ready", + "workFirst.configTitle": "Configuration readiness", + "workFirst.configHint": "Configuration checks do not test login or permissions.", + "workFirst.configChecked": "Configuration checked {time}", + "workFirst.tcpTitle": "TCP reachability", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "TCP checks do not authenticate. For jump routes only the jump host is checked.", + "workFirst.reachable": "reachable", + "workFirst.unreachable": "unreachable", + "workFirst.unresolved": "unresolved", + "workFirst.blocked": "blocked by network policy", + "workFirst.unchecked": "not checked" + }, + "de": { + "workFirst.saved": "Gespeicherte Hosts", + "workFirst.title": "Weiterarbeiten", + "workFirst.startPage": "Startseite auswählen", + "workFirst.resume": "Sitzungen fortsetzen", + "workFirst.favorites": "Favoriten", + "workFirst.recent": "Zuletzt verwendete Hosts", + "workFirst.setup": "Deine erste Verbindung, Schritt für Schritt", + "workFirst.setupHint": "Der Fortschritt zeigt den gewählten Host und die aktive Sitzung dieses Browsers. Es wird weder automatisch verbunden noch einem Schlüssel vertraut.", + "workFirst.selectHost": "Einzurichtender Host", + "workFirst.noHost": "Zum Start einen Host speichern", + "workFirst.host": "Host und Benutzername speichern", + "workFirst.auth": "Am Host authentifizieren", + "workFirst.trust": "SSH-Hostschlüssel prüfen", + "workFirst.terminal": "Terminal öffnen", + "workFirst.files": "Dateien für diese Sitzung öffnen", + "workFirst.openFiles": "Dateien öffnen", + "workFirst.verified": "Bestätigt", + "workFirst.pending": "Ausstehend", + "workFirst.automationHint": "Eine erfolgreiche interaktive Verbindung gibt keine Workflows oder Zeitpläne frei. Automatisierung benötigt eigene Zugangsdaten, Hostvertrauen und Freigaben.", + "workFirst.configReady": "Gespeicherte Konfiguration bereit", + "workFirst.configTitle": "Konfigurationsbereitschaft", + "workFirst.configHint": "Konfigurationsprüfungen testen weder Anmeldung noch Berechtigungen.", + "workFirst.configChecked": "Konfiguration geprüft {time}", + "workFirst.tcpTitle": "TCP-Erreichbarkeit", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "TCP-Prüfungen authentifizieren nicht. Bei Jump-Routen wird nur der Jump Host geprüft.", + "workFirst.reachable": "erreichbar", + "workFirst.unreachable": "nicht erreichbar", + "workFirst.unresolved": "nicht auflösbar", + "workFirst.blocked": "durch Netzwerkrichtlinie blockiert", + "workFirst.unchecked": "nicht geprüft" + }, + "fr": { + "workFirst.saved": "Hôtes enregistrés", + "workFirst.title": "Reprendre le travail", + "workFirst.startPage": "Choisir la page de démarrage", + "workFirst.resume": "Reprendre les sessions", + "workFirst.favorites": "Favoris", + "workFirst.recent": "Hôtes récents", + "workFirst.setup": "Votre première connexion, étape par étape", + "workFirst.setupHint": "La progression reflète l’hôte choisi et la session active de ce navigateur. Aucune connexion ni approbation de clé automatique.", + "workFirst.selectHost": "Hôte à configurer", + "workFirst.noHost": "Enregistrez un hôte pour commencer", + "workFirst.host": "Enregistrer l’hôte et l’utilisateur", + "workFirst.auth": "S’authentifier sur l’hôte", + "workFirst.trust": "Vérifier la clé SSH de l’hôte", + "workFirst.terminal": "Ouvrir un terminal", + "workFirst.files": "Ouvrir les fichiers de cette session", + "workFirst.openFiles": "Ouvrir les fichiers", + "workFirst.verified": "Constaté", + "workFirst.pending": "En attente", + "workFirst.automationHint": "Une connexion interactive réussie n’approuve ni workflow ni planification. L’automatisation nécessite ses propres identifiants, confiance des hôtes et approbation.", + "workFirst.configReady": "Configuration enregistrée prête", + "workFirst.configTitle": "État de la configuration", + "workFirst.configHint": "Les contrôles de configuration ne testent ni connexion ni permissions.", + "workFirst.configChecked": "Configuration vérifiée {time}", + "workFirst.tcpTitle": "Accessibilité TCP", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "Les contrôles TCP ne s’authentifient pas. Pour un relais, seul le bastion est vérifié.", + "workFirst.reachable": "accessible", + "workFirst.unreachable": "inaccessible", + "workFirst.unresolved": "non résolu", + "workFirst.blocked": "bloqué par la politique réseau", + "workFirst.unchecked": "non vérifié" + }, + "es": { + "workFirst.saved": "Hosts guardados", + "workFirst.title": "Continuar trabajando", + "workFirst.startPage": "Elegir página de inicio", + "workFirst.resume": "Retomar sesiones", + "workFirst.favorites": "Favoritos", + "workFirst.recent": "Hosts recientes", + "workFirst.setup": "Tu primera conexión, paso a paso", + "workFirst.setupHint": "El progreso refleja el host elegido y la sesión activa del navegador. No se conecta ni confía en claves automáticamente.", + "workFirst.selectHost": "Host que configurar", + "workFirst.noHost": "Guarda un host para empezar", + "workFirst.host": "Guardar host y usuario", + "workFirst.auth": "Autenticarse en el host", + "workFirst.trust": "Verificar la clave SSH del host", + "workFirst.terminal": "Abrir un terminal", + "workFirst.files": "Abrir archivos de esta sesión", + "workFirst.openFiles": "Abrir archivos", + "workFirst.verified": "Observado", + "workFirst.pending": "Pendiente", + "workFirst.automationHint": "Una conexión interactiva correcta no aprueba flujos ni programaciones. La automatización requiere sus propias credenciales, confianza y aprobación.", + "workFirst.configReady": "Configuración guardada lista", + "workFirst.configTitle": "Estado de configuración", + "workFirst.configHint": "La revisión de configuración no prueba acceso ni permisos.", + "workFirst.configChecked": "Configuración revisada {time}", + "workFirst.tcpTitle": "Accesibilidad TCP", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "Las pruebas TCP no autentican. En rutas con salto solo se comprueba el bastión.", + "workFirst.reachable": "accesible", + "workFirst.unreachable": "inaccesible", + "workFirst.unresolved": "sin resolver", + "workFirst.blocked": "bloqueado por política de red", + "workFirst.unchecked": "sin comprobar" + }, + "vi": { + "workFirst.saved": "Máy chủ đã lưu", + "workFirst.title": "Tiếp tục làm việc", + "workFirst.startPage": "Chọn trang bắt đầu", + "workFirst.resume": "Tiếp tục phiên", + "workFirst.favorites": "Yêu thích", + "workFirst.recent": "Máy chủ gần đây", + "workFirst.setup": "Kết nối đầu tiên, từng bước", + "workFirst.setupHint": "Tiến độ phản ánh máy chủ đã chọn và phiên đang hoạt động của trình duyệt. Không tự động kết nối hoặc tin cậy khóa.", + "workFirst.selectHost": "Máy chủ cần thiết lập", + "workFirst.noHost": "Lưu máy chủ để bắt đầu", + "workFirst.host": "Lưu máy chủ và tên người dùng", + "workFirst.auth": "Xác thực với máy chủ", + "workFirst.trust": "Xác minh khóa SSH máy chủ", + "workFirst.terminal": "Mở terminal", + "workFirst.files": "Mở tệp cho phiên này", + "workFirst.openFiles": "Mở tệp", + "workFirst.verified": "Đã xác nhận", + "workFirst.pending": "Đang chờ", + "workFirst.automationHint": "Kết nối tương tác thành công không phê duyệt quy trình hay lịch chạy. Tự động hóa cần thông tin xác thực, tin cậy máy chủ và phê duyệt riêng.", + "workFirst.configReady": "Cấu hình đã lưu sẵn sàng", + "workFirst.configTitle": "Trạng thái cấu hình", + "workFirst.configHint": "Kiểm tra cấu hình không kiểm tra đăng nhập hay quyền.", + "workFirst.configChecked": "Đã kiểm tra cấu hình {time}", + "workFirst.tcpTitle": "Khả năng truy cập TCP", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "Kiểm tra TCP không xác thực. Tuyến qua máy trung gian chỉ kiểm tra máy trung gian.", + "workFirst.reachable": "truy cập được", + "workFirst.unreachable": "không truy cập được", + "workFirst.unresolved": "không phân giải được", + "workFirst.blocked": "bị chính sách mạng chặn", + "workFirst.unchecked": "chưa kiểm tra" + }, + "zh": { + "workFirst.saved": "已保存主机", + "workFirst.title": "继续工作", + "workFirst.startPage": "选择起始页", + "workFirst.resume": "继续会话", + "workFirst.favorites": "收藏", + "workFirst.recent": "最近主机", + "workFirst.setup": "逐步建立首次连接", + "workFirst.setupHint": "进度反映所选主机及此浏览器的活动会话。不会自动连接或信任密钥。", + "workFirst.selectHost": "要设置的主机", + "workFirst.noHost": "保存主机以开始", + "workFirst.host": "保存主机及用户名", + "workFirst.auth": "向主机验证身份", + "workFirst.trust": "验证 SSH 主机密钥", + "workFirst.terminal": "打开终端", + "workFirst.files": "打开此会话的文件", + "workFirst.openFiles": "打开文件", + "workFirst.verified": "已观察到", + "workFirst.pending": "待完成", + "workFirst.automationHint": "交互连接成功不会批准工作流或计划。自动化需要自己的凭据、主机信任和批准。", + "workFirst.configReady": "已保存配置就绪", + "workFirst.configTitle": "配置就绪状态", + "workFirst.configHint": "配置检查不验证登录或权限。", + "workFirst.configChecked": "配置检查时间 {time}", + "workFirst.tcpTitle": "TCP 可达性", + "workFirst.tcpChecked": "TCP {state} · {time}", + "workFirst.tcpHint": "TCP 检查不验证身份。跳板路由只检查跳板主机。", + "workFirst.reachable": "可达", + "workFirst.unreachable": "不可达", + "workFirst.unresolved": "无法解析", + "workFirst.blocked": "被网络策略阻止", + "workFirst.unchecked": "未检查" + } +}; + Object.entries(dictionaries).forEach(([language, dictionary]) => window.i18n.addTranslations(language, dictionary)); +})(); diff --git a/templates/_automation_workspace.html b/templates/_automation_workspace.html index 1f73220b..02a6b684 100644 --- a/templates/_automation_workspace.html +++ b/templates/_automation_workspace.html @@ -20,28 +20,28 @@

Enable automation for this installation

One workflow. The hosts you choose.

Combine SSH commands into reusable steps. Run checks or maintenance across selected hosts now, or let a schedule run them after logout.

  1. Define and review the commands.
  2. Choose the hosts that should receive them.
  3. Run once or approve a recurring schedule.

    Creates an editable draft. No hosts are selected and nothing runs automatically.

    -
    +
    -
    +

    -

    New runbook

    -
    +

    New workflow

    +

    Steps

    Add from library

    -
    +

    Each step uses a separate shell. Use complete commands; shell state is not shared. Never put passwords or tokens in commands.

    -
    +
    +
    diff --git a/templates/_overview_workspace.html b/templates/_overview_workspace.html index f662942e..8d9404ee 100644 --- a/templates/_overview_workspace.html +++ b/templates/_overview_workspace.html @@ -25,7 +25,20 @@

    Overview

    - + +
    +

    Continue working

    + Choose your start page
    +
    +
    + Your first connection, step by step +

    Progress reflects your selected host and this browser's live session. Nothing connects or trusts a key automatically.

    + + +
      +

      A successful interactive connection does not approve a workflow or schedule. Automation needs its own credentials, host trust and approval.

      +
      +
      - + diff --git a/templates/_overview_workspace.html b/templates/_overview_workspace.html index 8d9404ee..ef31145d 100644 --- a/templates/_overview_workspace.html +++ b/templates/_overview_workspace.html @@ -31,10 +31,10 @@

      Overview

      Choose your start page
      - Your first connection, step by step + First connection

      Progress reflects your selected host and this browser's live session. Nothing connects or trusts a key automatically.

      - - +
      +

        A successful interactive connection does not approve a workflow or schedule. Automation needs its own credentials, host trust and approval.

        diff --git a/templates/index.html b/templates/index.html index 27b81a7c..6471e9e1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -142,8 +142,8 @@ - - + + {% if support_links_enabled %} @@ -183,7 +183,7 @@ - + diff --git a/tests/e2e/automation-followthrough.spec.js b/tests/e2e/automation-followthrough.spec.js index 6467f61a..47e66e23 100644 --- a/tests/e2e/automation-followthrough.spec.js +++ b/tests/e2e/automation-followthrough.spec.js @@ -27,22 +27,19 @@ test('source search preserves selected source and draft, without execution',asyn }); -test('account alert opt-in is visible and unavailable delivery is explained on desktop and mobile',async({page},info)=>{ - await login(page,'e2e_admin',{workspace:false});await page.goto('/automation'); +test('result guidance is available on demand without external notification requests',async({page})=>{ + await login(page,'e2e_admin',{workspace:false}); + const notificationRequests=[]; + page.on('request',request=>{if(request.url().includes('/api/automation/notifications'))notificationRequests.push(request.url());}); + await page.goto('/automation'); await page.locator('[data-auto-tab="runs"]').click(); - await expect(page.locator('#workflowNotifications')).not.toBeChecked(); - await expect(page.locator('#workflowNotificationStatus')).toContainText('unavailable'); - const saved=page.waitForResponse(response=>response.url().endsWith('/api/automation/notifications')&&response.request().method()==='POST'); - await page.locator('#workflowNotifications').check();await saved; - await expect(page.locator('#workflowNotifications')).toBeChecked(); - await expect(page.getByText('Only the latest 50 runs', {exact:false})).toBeVisible(); - await expect(page.getByText('Step output appears after the step completes.',{exact:false})).toBeVisible(); - for (const width of [390,1440]) { - await page.setViewportSize({width,height:900}); - await page.locator('#workflowNotifications').scrollIntoViewIfNeeded(); - await expect(page.locator('#workflowNotifications')).toBeVisible(); - expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); - await page.screenshot({path:info.outputPath('workflow-alerts-'+width+'.png')}); - } - await page.locator('#workflowNotifications').uncheck(); + await expect(page.locator('#workflowNotifications')).toHaveCount(0); + const help=page.locator('.automation-results-help'); + await expect(help.locator('[data-auto-i18n="retentionExplanation"]')).toBeHidden(); + await help.locator('summary').focus();await page.keyboard.press('Enter'); + await expect(help).toContainText('Only the latest 50 runs'); + await expect(help.locator('[data-auto-i18n="stepOutputExplanation"]')).toBeVisible(); + await page.locator('#refreshRuns').click(); + await expect(help).toHaveAttribute('open',''); + expect(notificationRequests).toEqual([]); }); diff --git a/tests/e2e/automation-results-refinement.spec.js b/tests/e2e/automation-results-refinement.spec.js new file mode 100644 index 00000000..31e02a39 --- /dev/null +++ b/tests/e2e/automation-results-refinement.spec.js @@ -0,0 +1,51 @@ +const {test, expect} = require('playwright/test'); +const {login, assertNoExternalRequests} = require('./helpers'); +const path = require('node:path'); +const fs = require('node:fs'); +const run = (id, status) => ({id, name: status === 'failed' ? 'Check backup storage' : 'Inspect system health', status, + created_at:'2026-11-14T08:30:00Z', finished_at:'2026-11-14T08:30:03Z', targets:[{ + name:'lab-server', host:'lab-server.example', username:'operator', status, + steps:[{name:status === 'failed' ? 'Check free space' : 'System uptime', status, exit_code:status === 'failed' ? 1 : 0, duration:3, + reason:status === 'failed' ? 'nonzero_exit' : '', stdout:status === 'failed' ? '/backup: 98% used\n' : 'up 14 days, load average: 0.12, 0.08, 0.05\n', stderr:status === 'failed' ? 'Available storage is below the configured threshold.\n' : ''}]}]}); +test.afterEach(async ({page}) => assertNoExternalRequests(page)); +for (const width of [1440,390]) { + test(`results hierarchy, filters and guidance at ${width}px`, async ({page}) => { + await page.setViewportSize({width,height:1000}); + await login(page,'e2e_admin',{workspace:false}); + let runs = [run('failed-fixture','failed'),run('success-fixture','succeeded')]; + await page.route('**/api/automation/runs',route=>route.fulfill({json:{runs}})); + await page.goto('/automation'); + await page.locator('[data-auto-tab="runs"]').click(); + await expect(page.locator('.automation-run')).toHaveCount(2); + await expect(page.locator('#workflowNotifications')).toHaveCount(0); + await expect(page.locator('.automation-results-help')).not.toHaveAttribute('open',''); + await page.locator('.automation-run-diagnostic > summary').first().click(); + await expect(page.locator('.automation-run-output').first()).toContainText('Available storage is below'); + await expect(page.locator('.automation-run').first().getByRole('link',{name:'Export JSON'})).toHaveAttribute('href',/failed-fixture\/export\?format=json$/); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + const folder=path.join(process.cwd(),'plans','results-refinement'); fs.mkdirSync(folder,{recursive:true}); + await page.screenshot({path:path.join(folder,`results-detail-${width}.png`),fullPage:true}); + if (width < 600) { + await page.locator('.automation-run').first().locator(':scope > summary').click(); + await page.locator('.automation-main').evaluate(element => { element.scrollTop = 0; }); + await page.evaluate(() => window.scrollTo(0, 0)); + } + await page.screenshot({path:path.join(folder,`results-populated-${width}.png`),fullPage:true}); + await page.locator('#runStatusFilter').selectOption('succeeded'); + await expect(page.locator('.automation-run:visible')).toHaveCount(1); + await expect(page.locator('.automation-run:visible')).toContainText('Inspect system health'); + await page.locator('#runSearch').fill('missing fixture'); + await expect(page.locator('#runFilterEmpty')).toBeVisible(); + await page.locator('#runSearch').fill(''); + await page.locator('#runStatusFilter').selectOption(''); + await page.locator('.automation-results-help > summary').click(); + await expect(page.locator('[data-auto-i18n="retentionExplanation"]')).toBeVisible(); + await page.locator('.automation-results-help > summary').click(); + runs=[]; await page.locator('#refreshRuns').click(); + await expect(page.getByRole('heading',{name:'No results yet'})).toBeVisible(); + await expect(page.locator('#clearRuns')).toBeDisabled(); + await page.screenshot({path:path.join(folder,`results-empty-${width}.png`),fullPage:true}); + await page.getByRole('button',{name:'Choose a workflow',exact:true}).click(); + await expect(page.locator('#auto-library')).toBeVisible(); + }); +} diff --git a/tests/e2e/commands-experience.spec.js b/tests/e2e/commands-experience.spec.js index 35b7c3e6..1a4f991d 100644 --- a/tests/e2e/commands-experience.spec.js +++ b/tests/e2e/commands-experience.spec.js @@ -70,8 +70,8 @@ test('system command details open a personal copy without running a session comm await expect(details).toBeVisible(); expect((await details.boundingBox()).width).toBeCloseTo(420, 0); await expect(details.locator('.command-source-label')).toHaveText('System · fixed template'); - await expect(details.locator('.command-detail-field pre').first()).toHaveText(command.command); - await expect(details.locator('.command-detail-field pre').nth(2)).toHaveText('df -h'); + await expect(details.locator('.command-detail-field pre').first()).toHaveText('df -h'); + await expect(details.locator('.command-detail-field pre').nth(2)).toHaveText('-h'); await expect(details.locator('input, textarea, [contenteditable="true"]')).toHaveCount(0); await expect(details.locator('.command-execution-hint')).toContainText('Run commands from the command tool in a session.'); await expect(details.getByRole('button', {name: 'Edit', exact: true})).toHaveCount(0); @@ -262,3 +262,32 @@ for (const width of [360, 390]) for (const language of ['en', 'de']) { await expect(page.locator('#commandSearchInput')).toHaveValue('apt upgrade'); }); } + + +test('command inspector keeps the exact command and impact visible with keyboard accessible guidance', async ({page}) => { + await login(page); + await page.evaluate(() => window.CommandLibrary.openLibrary()); + await expect(page.locator('.command-row').first()).toBeVisible(); + await page.evaluate(() => { + window.socket.off('commands_list'); + window.CommandLibrary.commands = [{id: 'refinement-fixture', name: 'Upgrade installed packages', command: 'sudo apt', parameters: 'upgrade', description: 'Upgrade installed packages using the configured package repositories.', os: ['linux'], isSystem: true, safety: {impact: 'changes-system', tool: 'apt', command: 'sudo apt', parameters: 'upgrade'}}]; + window.CommandLibrary.selectCommand('refinement-fixture'); + }); + const details = page.locator('#commandLibraryDetails'); + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 1000}); + await expect(details.locator('.command-detail-field pre').first()).toBeInViewport(); + await expect(details.locator('.command-impact-label')).toHaveText('Changes system or data'); + await expect(details.locator('.command-impact-label')).toBeInViewport(); + await expect(details.locator('.command-detail-guidance')).not.toHaveAttribute('open', ''); + await page.screenshot({path: test.info().outputPath(`command-inspector-${width}.png`)}); + const summary = details.locator('summary'); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(details.locator('.command-detail-guidance')).toHaveAttribute('open', ''); + await expect(details.locator('.command-template-hint').filter({hasText: 'not a safety guarantee'})).toBeVisible(); + await page.screenshot({path: test.info().outputPath(`command-inspector-expanded-${width}.png`)}); + await summary.click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } +}); diff --git a/tests/e2e/customer-workflows.spec.js b/tests/e2e/customer-workflows.spec.js index f1817922..e36d9d48 100644 --- a/tests/e2e/customer-workflows.spec.js +++ b/tests/e2e/customer-workflows.spec.js @@ -33,6 +33,8 @@ test('recovery guidance names omitted credentials and current owned hosts on mob await page.evaluate(()=>document.querySelector('[data-recovery-check]').click()); const dialog=page.getByRole('dialog',{name:'Recovery check',exact:true}); await expect(dialog).toBeVisible(); + await expect(dialog.locator('.customer-disclosure')).not.toHaveAttribute('open',''); + await dialog.getByText('Backup coverage',{exact:true}).click(); await expect(dialog.getByText(/Portable backups exclude/)).toBeVisible(); await expect(dialog.getByText('Unauthorized Tailscale',{exact:true})).toBeVisible(); await expect(dialog.getByText('Password review',{exact:true})).toHaveCount(0); diff --git a/tests/e2e/presets-recovery-refinement.spec.js b/tests/e2e/presets-recovery-refinement.spec.js new file mode 100644 index 00000000..872e9ac1 --- /dev/null +++ b/tests/e2e/presets-recovery-refinement.spec.js @@ -0,0 +1,66 @@ +const {test, expect}=require('playwright/test'); +const {login, installSshConnectTrap, assertNoExternalRequests}=require('./helpers'); +const path=require('node:path'); +const fs=require('node:fs'); +test.afterEach(async({page})=>assertNoExternalRequests(page)); +for(const width of [1440,390]) { + test(`presets and recovery remain explicit and usable at ${width}px`,async({page})=>{ + await page.setViewportSize({width,height:900}); + await login(page); + await installSshConnectTrap(page); + let presets=[]; + await page.route('**/api/account/workspace-presets',route=>{if(route.request().method()==='PUT')presets=route.request().postDataJSON().presets;return route.fulfill({json:{presets}});}); + const folder=path.join(process.cwd(),'plans','presets-recovery-refinement');fs.mkdirSync(folder,{recursive:true}); + if(width<600){await page.locator('#mobileMoreBtn').click();await page.locator('#accountBtnHeader').click();} + const entry=width<600 ? page.locator('#accountDropdownHeader [data-workspace-presets]') : page.locator('#workspacePresetsBtn'); + await expect(entry).toBeVisible(); + await expect(entry).toHaveAccessibleName('Workspace presets'); + const entryBounds=await entry.boundingBox(); + expect(entryBounds.x).toBeGreaterThanOrEqual(0);expect(entryBounds.x+entryBounds.width).toBeLessThanOrEqual(width); + await page.screenshot({path:path.join(folder,`workspace-entry-${width}.png`),fullPage:true}); + await entry.click(); + let dialog=page.getByRole('dialog'); + await expect(dialog.getByText('No presets yet. Save a layout and host selection below.')).toBeVisible(); + await dialog.getByLabel('Preset name',{exact:true}).fill('Daily operations'); + await dialog.getByLabel('Panes',{exact:true}).selectOption('2'); + await dialog.getByLabel('Password review',{exact:true}).check(); + await dialog.getByRole('button',{name:'Save preset',exact:true}).click(); + await expect(dialog.getByRole('status')).toHaveText('Preset saved.'); + await expect(dialog.getByRole('button',{name:'Connect Password review',exact:true})).toBeHidden(); + await dialog.getByRole('button',{name:'Apply layout and choose connections',exact:true}).click(); + await expect(dialog.getByRole('button',{name:'Connect Password review',exact:true})).toBeVisible(); + expect(await page.evaluate(()=>window.__sshConnectAttempts.length)).toBe(0); + expect(await dialog.evaluate(node=>node.scrollWidth<=node.clientWidth)).toBe(true); + await page.screenshot({path:path.join(folder,`presets-${width}.png`),fullPage:true}); + await dialog.getByLabel('Preset name',{exact:true}).fill('Too many hosts'); + await dialog.getByLabel('Panes',{exact:true}).selectOption('1'); + await dialog.getByLabel('Ops first',{exact:true}).check(); + await dialog.locator('.customer-dialog-body').evaluate(node=>{node.scrollTop=node.scrollHeight;}); + await dialog.getByRole('button',{name:'Save preset',exact:true}).focus(); + await page.keyboard.press('Enter'); + const error=dialog.getByRole('alert'); + await expect(error).toHaveText('Choose no more hosts than the number of panes.'); + await expect(error).toBeFocused(); + const bounds=await error.boundingBox();const bodyBounds=await dialog.locator('.customer-dialog-body').boundingBox(); + expect(bounds.y).toBeGreaterThanOrEqual(bodyBounds.y); + expect(bounds.y+bounds.height).toBeLessThanOrEqual(bodyBounds.y+bodyBounds.height+1); + await dialog.getByRole('button',{name:'Close',exact:true}).click(); + await page.route('**/api/account/recovery-check',route=>route.fulfill({json:{restored_at:'2026-11-14T08:00:00Z',needs_review:true,hosts:[{id:'synthetic-lab',name:'Lab server',credential:'key_required',route:'missing_jump',host_key:'review_required'}],schedules:[{name:'Inspect system health',enabled:false}]}})); + await page.evaluate(()=>document.querySelector('[data-recovery-check]').click()); + dialog=page.getByRole('dialog'); + await expect(dialog.locator('.customer-checklist > li')).toHaveCount(4); + await expect(dialog.getByText('SSH key required', {exact:false})).toBeVisible(); + await expect(dialog.locator('.customer-disclosure')).not.toHaveAttribute('open',''); + const ack=dialog.getByRole('button',{name:'Mark recovery steps reviewed'}); + await expect(ack).toBeVisible(); + await expect(ack).toHaveAttribute('aria-describedby','recoveryReviewHint'); + expect(await dialog.evaluate(node=>node.scrollWidth<=node.clientWidth)).toBe(true); + await page.screenshot({path:path.join(folder,`recovery-${width}.png`),fullPage:true}); + await dialog.getByText('Backup coverage',{exact:true}).click(); + await expect(dialog.getByText(/Portable backups exclude/)).toBeVisible(); + await page.screenshot({path:path.join(folder,`recovery-details-${width}.png`),fullPage:true}); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(()=>window.__sshConnectAttempts.length)).toBe(0); + }); +} diff --git a/tests/e2e/work-first-setup.spec.js b/tests/e2e/work-first-setup.spec.js index 499c5a47..e020bd62 100644 --- a/tests/e2e/work-first-setup.spec.js +++ b/tests/e2e/work-first-setup.spec.js @@ -1,3 +1,4 @@ +const path = require('node:path'); const {test, expect} = require('playwright/test'); const {login, installSshConnectTrap, sshAttempts, assertNoExternalRequests} = require('./helpers'); @@ -20,10 +21,13 @@ for (const width of [390, 1440]) { await expect(first.getByRole('button', {name:'Favorite task host',exact:true})).toBeVisible(); await expect(first.getByRole('button', {name:'Recent task host',exact:true})).toBeVisible(); expect(await first.boundingBox().then(box => box.y)).toBeLessThan(await page.locator('#odBoard').boundingBox().then(box => box.y)); + await expect(page.locator('#overviewSetupCount')).toHaveText('1 / 5'); + await page.screenshot({path:path.join(process.env.WEBSSH_E2E_OUTPUT_DIR || 'test-results', `home-${width}-collapsed.png`)}); await first.locator('summary').click(); await page.locator('#overviewSetupHost').selectOption('wf-favorite'); await expect(page.locator('[data-step="host"]')).toHaveAttribute('data-complete','true'); for (const step of ['auth','trust','terminal','files']) await expect(page.locator(`[data-step="${step}"]`)).toHaveAttribute('data-complete','false'); + await page.screenshot({path:path.join(process.env.WEBSSH_E2E_OUTPUT_DIR || 'test-results', `home-${width}-guide.png`)}); // Merely opening the guide, changing its target, or searching never connects. await page.locator('#overviewSetupHost').selectOption('wf-recent'); await page.locator('#overviewSearch').fill('Recent task host'); @@ -101,3 +105,40 @@ test('mobile saved-host fallback offers existing destinations before metrics and await expect(link).toBeFocused(); expect(await link.evaluate(el=>getComputedStyle(el).outlineStyle)).not.toBe('none'); }); + + +test('host context keeps disclosure and keyboard focus after profile updates', async ({page}) => { + await login(page, 'e2e_admin', {workspace:false}); + await page.evaluate(() => { + ProfileManager.setProfiles([{id:'context-host', name:'Context host', host:'192.0.2.31', username:'operator'}]); + }); + const summary = page.locator('[data-key="context:context-host"]'); + const details = page.locator('.od-host-context[data-host-id="context-host"]'); + await summary.click(); + await expect(details).toHaveAttribute('open', ''); + await summary.focus(); + await page.evaluate(() => { + ProfileManager.setProfiles([{id:'context-host', name:'Renamed context host', host:'192.0.2.31', username:'operator'}]); + }); + await expect(page.locator('#overviewHostList')).toContainText('Renamed context host'); + await expect(details).toHaveAttribute('open', ''); + await expect(summary).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(details).not.toHaveAttribute('open'); + await page.evaluate(() => { + ProfileManager.setProfiles([{id:'context-host', name:'Updated context host', host:'192.0.2.31', username:'operator'}]); + }); + await expect(page.locator('#overviewHostList')).toContainText('Updated context host'); + await expect(details).not.toHaveAttribute('open'); + await expect(summary).toBeFocused(); +}); + +test('mobile setup exposes completion status to assistive technology', async ({page}) => { + await page.setViewportSize({width:390, height:900}); + await login(page, 'e2e_admin', {workspace:false}); + await page.evaluate(() => ProfileManager.setProfiles([{id:'accessible-host', name:'Accessible host', host:'192.0.2.32', username:'operator'}])); + await page.locator('#overviewFirstConnection summary').click(); + await expect(page.locator('#overviewSetupSteps [data-step="host"]')).toHaveAttribute('data-complete', 'true'); + expect(await page.locator('#overviewSetupSteps [data-step="host"]').ariaSnapshot()).toContain('Observed'); + expect(await page.locator('#overviewSetupSteps [data-step="auth"]').ariaSnapshot()).toContain('Pending'); +}); diff --git a/tests/js/management-inspector.test.js b/tests/js/management-inspector.test.js index 87cf5b9a..78153e0f 100644 --- a/tests/js/management-inspector.test.js +++ b/tests/js/management-inspector.test.js @@ -44,9 +44,13 @@ test('system details preserve literal remote text and offer own-copy without exe const details = document.getElementById('commandLibraryDetails'); assert.ok(panel.classList.contains('has-command-details')); assert.equal(details.children[0].children[0].textContent, ''); - assert.equal(details.children[1].textContent, 'System · fixed template'); - assert.equal(details.children.filter(child => child.className === 'command-detail-field')[0].children[1].textContent, 'df'); - assert.equal(details.children.filter(child => child.className === 'command-detail-field')[1].children[1].textContent, '-h'); + assert.equal(details.children.find(child => child.className === 'command-detail-metadata').children[1].textContent, 'System · fixed template'); + assert.equal(details.children.filter(child => child.className === 'command-detail-field')[0].children[1].textContent, 'df -h'); + const guidance = details.children.find(child => child.className === 'command-detail-guidance'); + assert.equal(guidance.tag, 'details'); + assert.equal(guidance.open, undefined); + assert.equal(guidance.children.find(child => child.tag === 'summary').textContent, 'Usage and template details'); + assert.equal(guidance.children.filter(child => child.className === 'command-detail-field')[1].children[1].textContent, '-h'); const actions = details.children.find(child => child.className === 'management-inspector-actions'); assert.deepEqual(actions.children.map(child => child.textContent).filter(Boolean), ['Add to my commands']); actions.children[0].onclick(); @@ -63,7 +67,7 @@ test('personal command details retain edit and delete actions and a catalog refr library.deleteCommand = id => calls.push(['delete', id]); library.selectCommand('personal'); let details = document.getElementById('commandLibraryDetails'); - assert.equal(details.children[1].textContent, 'Personal'); + assert.equal(details.children.find(child => child.className === 'command-detail-metadata').children[1].textContent, 'Personal'); const actions = details.children.find(child => child.className === 'management-inspector-actions'); actions.children[0].onclick(); actions.children[1].onclick(); @@ -71,8 +75,8 @@ test('personal command details retain edit and delete actions and a catalog refr library.commands[1].description = 'Updated safely'; library.renderCommandDetails(); details = document.getElementById('commandLibraryDetails'); - assert.equal(details.children.filter(child => child.className === 'command-detail-field')[2].children[1].textContent, 'echo safe'); - assert.equal(details.children.filter(child => child.className === 'command-detail-field')[3].children[1].textContent, 'Updated safely'); + assert.equal(details.children.filter(child => child.className === 'command-detail-field')[0].children[1].textContent, 'echo safe'); + assert.equal(details.children.find(child => child.className === 'command-detail-description').textContent, 'Updated safely'); }); test('the compact catalog loads subsequent chunks from its scrolling wrapper', () => { diff --git a/tests/test_automation_followthrough.py b/tests/test_automation_followthrough.py index 7dbc1edc..421791fb 100644 --- a/tests/test_automation_followthrough.py +++ b/tests/test_automation_followthrough.py @@ -1,16 +1,13 @@ -import pytest from tests.test_automation_service import context from tests.test_automation_routes import enabled from tests.test_automation_schedules import scheduling -def test_notification_opt_in_owner_and_no_url(app, client, context, enabled): - response = client.post('/api/automation/notifications', json={'enabled': True, 'url': 'https://attacker.invalid'}) - assert response.status_code == 400 - response = client.post('/api/automation/notifications', json={'enabled': True}) +def test_external_notification_route_and_settings_are_absent(app, client, context, enabled): + response = client.get('/api/automation/state') assert response.status_code == 200 - assert response.json['notifications']['enabled'] is True - assert response.json['notifications']['available'] is False + assert 'notifications' not in response.json + assert client.post('/api/automation/notifications', json={'enabled': True}).status_code == 404 def test_export_is_owner_only_and_csv_safe(app, client, context, enabled): @@ -24,121 +21,21 @@ def test_export_is_owner_only_and_csv_safe(app, client, context, enabled): assert client.get('/api/automation/runs/missing/export').status_code == 404 assert client.get('/api/automation/runs/retained/export?format=exe').status_code == 400 - -def test_notifications_never_send_without_config_and_opt_in(app, context, monkeypatch): - from app import automation_notifications as notifications - calls = [] - monkeypatch.setattr(notifications, '_post', lambda *args: calls.append(args)) - uid = context[0] - with app.app_context(): - notifications.notify(uid, 'failed') - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = 'https://notify.invalid/hook' - notifications.notify(uid, 'unknown') - assert calls == [] - from app.automation_store import notification_preferences - notification_preferences(uid, True) - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = '' - notifications.notify(uid, 'failed') - assert calls == [] - assert notifications.status(uid)['delivery'] == 'unavailable' - - -def test_notification_payload_is_fixed_and_failure_is_separate(app, context, monkeypatch): - from app import automation_notifications as notifications - from app.automation_store import notification_preferences - uid = context[0] - calls = [] - import threading - monkeypatch.setattr(app.extensions['runtime_lifecycle'], 'start_job', lambda name, target, **kwargs: target(threading.Event())) - with app.app_context(): - notification_preferences(uid, True) - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = 'https://notify.invalid/hook' - monkeypatch.setattr(notifications, '_post', lambda url, payload: calls.append((url, payload))) - notifications.notify(uid, 'failed') - assert calls[0][1] == {'event': 'workflow_attention', 'status': 'failed'} - def broken(*args): - raise RuntimeError('secret endpoint diagnostics') - monkeypatch.setattr(notifications, '_post', broken) - notifications.notify(uid, 'unknown') - assert notifications.status(uid)['delivery'] == 'failed' - assert 'secret' not in str(notifications.status(uid)) - - -@pytest.mark.parametrize('url', ['http://notify.invalid', 'https://u:p@notify.invalid', 'https://notify.invalid/#fragment', 'file:///etc/passwd']) -def test_invalid_notification_endpoints_unavailable(app, context, url): - from app.automation_notifications import status - with app.app_context(): - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = url - assert status(context[0])['available'] is False - -def test_exports_and_preferences_are_fenced_to_authenticated_account(app, client, context, enabled): +def test_exports_are_fenced_to_authenticated_account(app, client, context, enabled): from app.auth import register_user - from app.automation_store import notification_preferences - user, error = register_user('notification_other', 'other-password123') + user, error = register_user('export_other', 'other-password123') assert error is None - notification_preferences(user.id, True) record = dict(id='private-run', name='Private', created_at='2026-01-01', status='failed', targets=[dict(id='p',name='Private target',host='private.invalid',port=22,username='u',group=None,status='failed',reason=None,steps=[dict(name='step',status='failed',exit_code=1,duration=1)])]) app.extensions['automation']._persist(user.id, record) assert client.get('/api/automation/runs/private-run/export').status_code == 404 - response = client.post('/api/automation/notifications', json={'enabled': False}) - assert response.status_code == 200 - assert notification_preferences(user.id)['enabled'] is True - assert notification_preferences(context[0])['enabled'] is False - app.config['WTF_CSRF_ENABLED'] = True - assert client.post('/api/automation/notifications', json={'enabled': True}).status_code == 400 - - -def test_delivery_runs_off_scheduler_and_rechecks_opt_in(app, context, monkeypatch): - import threading - from app import automation_notifications as notifications - from app.automation_store import notification_preferences - uid = context[0] - jobs, calls = [], [] - monkeypatch.setattr(app.extensions['runtime_lifecycle'], 'start_job', lambda name, target, **kwargs: jobs.append(target)) - monkeypatch.setattr(notifications, '_post', lambda *args: calls.append(args)) - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = 'https://notify.invalid/hook' - notification_preferences(uid, True) - notifications.notify(uid, 'unknown') - assert notifications.status(uid)['delivery'] == 'pending' - assert calls == [] and len(jobs) == 1 - notification_preferences(uid, False) - jobs[0](threading.Event()) - assert calls == [] - assert notifications.status(uid)['delivery'] == 'failed' - -def test_webhook_transport_rejects_redirect_without_proxy_or_response_body(app, monkeypatch): - from app import automation_notifications as notifications - captured = [] - class Connection: - def __init__(self, host, port, **kwargs): - captured.append((host, port, kwargs)) - def request(self, method, path, **kwargs): - captured.append((method, path, kwargs)) - def getresponse(self): - return type('Response', (), {'status': 302})() - def close(self): - captured.append('closed') - monkeypatch.setenv('HTTPS_PROXY', 'http://attacker.invalid') - monkeypatch.setattr(notifications.http.client, 'HTTPSConnection', Connection) - with pytest.raises(ValueError): - notifications._post('https://controlled.internal:8443/alerts', {'event': 'workflow_attention', 'status': 'failed'}) - assert captured[0][0:2] == ('controlled.internal', 8443) - assert captured[0][2]['timeout'] == 3 - assert captured[0][2]['context'].check_hostname - assert captured[1][0:2] == ('POST', '/alerts') - assert captured[-1] == 'closed' and len(captured) == 3 - - -def test_scheduler_unknown_and_reapproval_notifications_are_transition_only(app, scheduling, monkeypatch): +def test_scheduler_unknown_and_reapproval_states_persist(app, scheduling): from datetime import datetime, timezone from app.automation_schedule import AutomationSchedule from app.models import db from tests.test_automation_schedules import activate - service, uid, *_ = scheduling + service, *_ = scheduling now = datetime(2026, 11, 3, 12, 0, tzinfo=timezone.utc) - calls = [] - monkeypatch.setattr('app.automation_notifications.notify', lambda owner, event: calls.append((owner, event))) schedule = activate(scheduling, now) row = db.session.get(AutomationSchedule, schedule['id']) row.claim_id = 'stale-claim' @@ -146,7 +43,9 @@ def test_scheduler_unknown_and_reapproval_notifications_are_transition_only(app, db.session.commit() service.tick(now=now) service.tick(now=now) - assert calls == [(uid, 'unknown')] + assert row.last_status == 'unknown' + assert row.enabled is False + assert row.claim_id is None row.enabled = True row.claim_id = None row.last_status = 'waiting' @@ -155,74 +54,19 @@ def test_scheduler_unknown_and_reapproval_notifications_are_transition_only(app, db.session.commit() service.tick(now=now) service.tick(now=now) - assert calls == [(uid, 'unknown'), (uid, 'needs_review')] - - - -def test_orphaned_notification_attempt_is_unconfirmed_after_restart(app, context): - from app.automation_store import notification_preferences - from app.automation_notifications import status - notification_preferences(context[0], True, delivery='pending') - assert status(context[0])['delivery'] == 'failed' - - -def test_transport_admission_has_no_unbounded_thread_queue(app, monkeypatch): - from app import automation_notifications as notifications - calls = [] - monkeypatch.setattr(notifications, '_post', lambda *args: calls.append(args)) - assert notifications._network_slot.acquire(blocking=False) - try: - with pytest.raises(TimeoutError): - notifications._deliver('https://notify.invalid', {'event': 'workflow_attention', 'status': 'failed'}) - finally: - notifications._network_slot.release() - assert calls == [] - -@pytest.mark.parametrize('revocation', ['lock', 'generation', 'epoch', 'disable', 'policy']) -def test_queued_delivery_rechecks_account_and_automation_authority(app, context, monkeypatch, revocation): - import threading - from app import automation_notifications as notifications - from app.automation_store import notification_preferences - from app.models import User, db - from app.app_settings import set_automation_enabled - uid = context[0] - jobs, calls = [], [] - app.config['AUTOMATION_NOTIFICATION_WEBHOOK_URL'] = 'https://notify.invalid/hook' - notification_preferences(uid, True) - monkeypatch.setattr(app.extensions['runtime_lifecycle'], 'start_job', lambda name, target, **kwargs: jobs.append(target)) - monkeypatch.setattr(notifications, '_post', lambda *args: calls.append(args)) - notifications.notify(uid, 'unknown') - assert len(jobs) == 1 - if revocation == 'lock': - db.session.get(User, uid).is_locked = True - db.session.commit() - elif revocation == 'generation': - user = db.session.get(User, uid) - user.auth_generation = int(user.auth_generation or 0) + 1 - db.session.commit() - elif revocation == 'epoch': - from app.session_epoch import rotate_epoch - rotate_epoch() - else: - set_automation_enabled(False) - if revocation == 'policy': - set_automation_enabled(True) - jobs[0](threading.Event()) - assert calls == [] - assert notifications.status(uid)['delivery'] == 'failed' - + assert row.last_status == 'needs_review' + assert row.enabled is False -def test_manual_orphan_persists_unknown_once_and_never_replays_notification(app, context, monkeypatch): +def test_manual_orphan_persists_unknown_across_reads_and_restart(app, context): from app.automation_service import AutomationService service = AutomationService(app) uid = context[0] record = dict(id='orphan-run', name='Interrupted', created_at='2026-01-01', status='running', targets=[dict(id='p',name='target',host='host.invalid',port=22,username='u',group=None,status='running',reason=None,steps=[dict(name='step',status='running',exit_code=None,duration=0)])]) service._persist(uid, record) - calls = [] - monkeypatch.setattr('app.automation_notifications.notify', lambda owner, event: calls.append((owner, event))) assert service.history(uid)[0]['status'] == 'unknown' assert service.history(uid)[0]['status'] == 'unknown' - assert calls == [(uid, 'unknown')] restarted = AutomationService(app) assert restarted.history(uid)[0]['status'] == 'unknown' - assert calls == [(uid, 'unknown')] + target = restarted.history(uid)[0]['targets'][0] + assert target['status'] == 'unknown' + assert target['steps'][0]['status'] == 'unknown' From 48fb5e597592320d21d6a0e34f8b58859b14e8c9 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 07:24:18 +0100 Subject: [PATCH 4/5] Fix overview scrolling and maintenance notice dismissal --- app/user_settings.py | 2 +- static/css/overview-route-map.css | 3 +- static/css/overview-workspace.css | 3 +- static/js/overview-workspace.js | 22 +++- tests/e2e/host-platform-colors.spec.js | 2 + .../overview-maintenance-dismissal.spec.js | 48 +++++++ tests/e2e/overview-wheel-scroll.spec.js | 122 ++++++++++++++++++ tests/e2e/product-data-truth.spec.js | 1 + tests/e2e/work-first-setup.spec.js | 2 +- tests/test_overview_routes.py | 31 +++++ 10 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/overview-maintenance-dismissal.spec.js create mode 100644 tests/e2e/overview-wheel-scroll.spec.js diff --git a/app/user_settings.py b/app/user_settings.py index da388641..ac3cc099 100644 --- a/app/user_settings.py +++ b/app/user_settings.py @@ -19,7 +19,7 @@ OVERVIEW_PANEL_WIDTHS = range(3, 13) OVERVIEW_PANEL_HEIGHTS = range(8, 161) OVERVIEW_DISMISSED_LIMIT = 100 -_DISMISSED_KINDS = frozenset({'host-keys', 'jump-missing', 'unreachable', 'credentials', 'run-failed', 'schedule-review'}) +_DISMISSED_KINDS = frozenset({'host-keys', 'jump-missing', 'unreachable', 'credentials', 'reboot', 'security-updates', 'run-failed', 'schedule-review'}) def valid_overview_layout(value): diff --git a/static/css/overview-route-map.css b/static/css/overview-route-map.css index b050970a..16fd5a91 100644 --- a/static/css/overview-route-map.css +++ b/static/css/overview-route-map.css @@ -85,7 +85,8 @@ #overviewRouteMap [data-map-node].is-moving { z-index:5; outline:2px solid var(--ws2-accent); } #overviewRouteMap [data-map-node].is-moving .ssh-node-drag { cursor:grabbing; } -#overviewRouteMap .ssh-route-host-list { max-height:280px; overflow:auto; overscroll-behavior:contain; scrollbar-width:thin; } +#overviewRouteMap .ssh-route-host-list { max-height:280px; overflow:auto; overscroll-behavior:auto; scrollbar-width:thin; } +.ssh-map-dialog #overviewRouteMap .ssh-route-host-list { overscroll-behavior:contain; } #overviewRouteMap .ssh-route-more { margin:0 0 8px; } /* Dashboard: route summaries stay readable; host operations live in the expanded map. */ diff --git a/static/css/overview-workspace.css b/static/css/overview-workspace.css index 2b9b5f11..1af3a5a9 100644 --- a/static/css/overview-workspace.css +++ b/static/css/overview-workspace.css @@ -265,7 +265,8 @@ body.webssh-ui[data-theme="glass"]:not([data-custom-background]) .od-scene { bac .od-board > [data-panel] { grid-column: span var(--od-span, 12); margin-bottom: var(--od-gap); display: flex; flex-direction: column; } .od-board > [data-panel][data-user-hidden="true"] { display: none; } .od-board > [data-panel][data-fixed-height="true"] { height: var(--od-height); } -.od-board > [data-panel][data-fixed-height="true"] > .od-panel-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +/* Fixed panels scroll their own overflow, then pass the wheel to the overview. */ +.od-board > [data-panel][data-fixed-height="true"] > .od-panel-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: auto; scrollbar-gutter: stable; } .od-board > [data-panel][data-fixed-height="true"] .od-topology-svg { height: 100%; max-height: none; } .od-board > [data-panel][data-fixed-height="true"] .od-topology-canvas { height: 100%; } .od-panel-body { min-width: 0; } diff --git a/static/js/overview-workspace.js b/static/js/overview-workspace.js index 628ade33..7473ba31 100644 --- a/static/js/overview-workspace.js +++ b/static/js/overview-workspace.js @@ -561,7 +561,7 @@ connectButton.append(icon(host.sessionId ? 'east' : 'terminal'), node('span', '', primary)); bottom.append(badge, editButton, connectButton); const context = node('details', 'od-host-context'); - context.dataset.hostId = host.id; + context.dataset.contextHostId = host.id; context.open = openHostContexts.has(host.id); const summary = node('summary', '', wf('configTitle', 'Configuration readiness')); summary.dataset.key = `context:${host.id}`; @@ -590,8 +590,8 @@ const focusedKey = hostList.contains(doc.activeElement) ? doc.activeElement.dataset.key : null; hostList.setAttribute('aria-busy', String(!loaded)); hostList.querySelectorAll('.od-host-context').forEach(context => { - if (context.open) openHostContexts.add(context.dataset.hostId); - else openHostContexts.delete(context.dataset.hostId); + if (context.open) openHostContexts.add(context.dataset.contextHostId); + else openHostContexts.delete(context.dataset.contextHostId); }); hostList.replaceChildren(); if (!loaded) { @@ -1142,6 +1142,22 @@ if (event.animationName === 'od-panel-flash') event.currentTarget.classList.remove('od-panel-flash'); }); $('overviewSetupHost')?.addEventListener('change', schedule); + view.addEventListener('wheel', event => { + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || !event.deltaY || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return; + const body = event.target.closest?.('[data-panel][data-fixed-height="true"] > .od-panel-body'); + if (!body || !view.contains(body)) return; + const canScroll = element => event.deltaY < 0 ? element.scrollTop > 0 + : element.scrollTop + element.clientHeight < element.scrollHeight - 1; + // Keep nested controls and overflowing lists native until their edge. + for (let element = event.target; element && body.contains(element); element = element.parentElement) { + if (['auto', 'scroll'].includes(win.getComputedStyle(element).overflowY) && canScroll(element)) return; + } + if (!canScroll(view)) return; + // Some browsers keep the wheel latched to a panel after it reaches its edge. + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? view.clientHeight : 1; + event.preventDefault(); + view.scrollTop += event.deltaY * unit; + }, {passive: false}); win.socket?.on?.('directory_listing', data => { const id = typeof data?.source_id === 'string' && data.source_id.startsWith('sftp-session:') ? data.source_id.slice(13) : null; if (id && Array.isArray(data.files) && sessionManager?.getSession?.(id)?.connected) { fileSessions.add(id); schedule(); } diff --git a/tests/e2e/host-platform-colors.spec.js b/tests/e2e/host-platform-colors.spec.js index 0131d96d..10139a69 100644 --- a/tests/e2e/host-platform-colors.spec.js +++ b/tests/e2e/host-platform-colors.spec.js @@ -6,6 +6,8 @@ const {login}=require('./helpers'); test('Hosts management preserves platform colors in the actual host rows',async({page},info)=>{ await login(page,'e2e_admin',{workspace:false}); await page.evaluate(()=>ProfileManager.openManagement()); + // Finish the management refresh before installing the rendering fixture. + await page.evaluate(() => new Promise(resolve => window.socket.emit('list_profiles', resolve))); await page.evaluate(()=>{const profile=ProfileManager.profiles[0];ProfileManager.setProfiles(['ubuntu','docker','proxmox','debian'].map(platform=>({...profile,id:platform,name:platform,platform_icon:platform})));}); const icons=page.locator('#profileManagementList .profile-row-platform');await expect(icons).toHaveCount(4); for(const theme of ['glass','werkbank','retro','solar','paper','noir','arctic-ice','rose-gold','cyberpunk-neon','emerald-matrix','obsidian']){ diff --git a/tests/e2e/overview-maintenance-dismissal.spec.js b/tests/e2e/overview-maintenance-dismissal.spec.js new file mode 100644 index 00000000..aa8149a9 --- /dev/null +++ b/tests/e2e/overview-maintenance-dismissal.spec.js @@ -0,0 +1,48 @@ +const {test,expect}=require('playwright/test'); +const {login,installSshConnectTrap,assertNoExternalRequests}=require('./helpers'); +async function preferences(page,dismissed) { + const response=await page.request.put('/api/overview/preferences',{data:{dismissed},headers:{'X-CSRFToken':await page.locator('meta[name="csrf-token"]').getAttribute('content')}}); + expect(response.ok()).toBe(true); +} +async function maintenance(page) { + await expect.poll(()=>page.evaluate(()=>window.ProfileManager?.profilesLoaded===true)).toBe(true); + return page.evaluate(()=>{ + const profile=window.ProfileManager.profiles[0]; + profile.name='Node'; + profile.detected_maintenance={reboot_required:true,updates:3,security_updates:2,checked_at:Date.now()/1000}; + window.dispatchEvent(new Event('profile-list-change')); + return profile.id; + }); +} +test.afterEach(async({page})=>{ + await preferences(page,[]); + await assertNoExternalRequests(page); +}); +for(const width of [1440,390])test(`maintenance dismissal persists via the real API at ${width}px`,async({page})=>{ + await page.setViewportSize({width,height:900}); + await login(page,'e2e_admin',{workspace:false}); + const previous=['run-failed:existing','host-keys:existing']; + await preferences(page,previous); + await page.reload(); + const hostId=await maintenance(page); + await installSshConnectTrap(page); + const expected=[...previous]; + for(const kind of ['reboot','security-updates']){ + const key=`${kind}:${hostId}`; + const dismiss=page.locator(`#odAttention [data-key="dismiss:${key}"]`); + await expect(dismiss).toBeVisible(); + const saved=page.waitForResponse(response=>response.url().endsWith('/api/overview/preferences')&&response.request().method()==='PUT'); + await dismiss.click(); + const response=await saved;expect(response.status()).toBe(200); + expected.push(key);expect((await response.json()).dismissed).toEqual(expected); + await expect(dismiss).toHaveCount(0); + await expect(page.locator('#odAttentionError')).toBeHidden(); + } + expect(await page.evaluate(()=>window.__sshConnectAttempts.length)).toBe(0); + await page.reload(); + await maintenance(page); + for(const kind of ['reboot','security-updates'])await expect(page.locator(`#odAttention [data-key="dismiss:${kind}:${hostId}"]`)).toHaveCount(0); + expect(await page.locator('#overviewPreferences').evaluate(node=>JSON.parse(node.textContent).dismissed)).toEqual(expected); + await page.locator('#odAttentionRestore').click(); + for(const kind of ['reboot','security-updates'])await expect(page.locator(`#odAttention [data-key="dismiss:${kind}:${hostId}"]`)).toBeVisible(); +}); diff --git a/tests/e2e/overview-wheel-scroll.spec.js b/tests/e2e/overview-wheel-scroll.spec.js new file mode 100644 index 00000000..b36ac5d8 --- /dev/null +++ b/tests/e2e/overview-wheel-scroll.spec.js @@ -0,0 +1,122 @@ +const {test, expect} = require('playwright/test'); +const {login} = require('./helpers'); + +async function setPanels(page, panels) { + const token = await page.locator('meta[name="csrf-token"]').getAttribute('content'); + expect((await page.request.put('/api/overview/preferences', {data:{panels}, headers:{'X-CSRFToken':token}})).ok()).toBe(true); +} + +test.beforeEach(async ({page}) => { + await page.setViewportSize({width:1440, height:900}); + await page.emulateMedia({reducedMotion:'reduce'}); + await page.route('**/api/overview/status', route => route.fulfill({json:{generated_at:1, hosts:[]}})); + await login(page, 'e2e_admin', {workspace:false}); + await setPanels(page, [ + {id:'attention', w:12, h:40, hidden:false}, + {id:'topology', w:12, h:30, hidden:false}, + {id:'hosts', w:12, h:30, hidden:false}, + {id:'activity', w:12, h:100, hidden:false}, + ]); + await page.reload(); + await page.waitForFunction(() => ProfileManager.profilesLoaded && JumpHostManager.loaded); + await page.evaluate(() => { + JumpHostManager.setJumpHosts([{id:'wheel-gateway', name:'Wheel gateway'}]); + ProfileManager.setProfiles(Array.from({length:30}, (_, i) => ({id:`wheel-${i}`, name:`Wheel host ${i}`, host:`192.0.2.${i+1}`, username:'operator', jump_host_id:i%2 ? 'wheel-gateway' : null}))); + }); + await expect(page.locator('#overviewHostList .od-host')).toHaveCount(30); +}); +test.afterEach(async ({page}) => { await setPanels(page, null); }); + +async function positionPanel(page, selector) { + await page.locator(selector).evaluate(el => { + const scroller = document.getElementById('connectionOverview'); + scroller.scrollTop += el.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 100; + }); + const box = await page.locator(selector).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(80, box.height / 2)); +} +const scrollTop = page => page.locator('#connectionOverview').evaluate(el => el.scrollTop); + +test('wheel over a fixed-height panel without overflow scrolls the overview', async ({page}) => { + const selector = '#odAttention > .od-panel-body'; + await positionPanel(page, selector); + expect(await page.locator(selector).evaluate(el => el.scrollHeight <= el.clientHeight)).toBe(true); + const before = await scrollTop(page); + await page.mouse.wheel(0, 220); + await expect.poll(() => scrollTop(page)).toBeGreaterThan(before + 100); +}); + +test('overflowing fixed-height hosts retain internal scrolling', async ({page}) => { + const selector = '[data-panel="hosts"] > .od-panel-body'; + const body = page.locator(selector); + await positionPanel(page, selector); + const before = await scrollTop(page); + expect(await body.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true); + await page.mouse.wheel(0, 160); + await expect.poll(() => body.evaluate(el => el.scrollTop)).toBeGreaterThan(40); + expect(Math.abs(await scrollTop(page) - before)).toBeLessThan(2); +}); + +test('fixed-height hosts chain to the overview when already at their scroll edge', async ({page}) => { + const selector = '[data-panel="hosts"] > .od-panel-body'; + const body = page.locator(selector); + await positionPanel(page, selector); + for (let attempt = 0; attempt < 20; attempt += 1) { + const position = await body.evaluate(el => ({top:el.scrollTop, end:el.scrollHeight - el.clientHeight})); + if (position.end - position.top < 2) break; + await page.mouse.wheel(0, 300); + await expect.poll(() => body.evaluate(el => el.scrollTop)).toBeGreaterThan(position.top); + } + await expect.poll(() => body.evaluate(el => Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop))).toBeLessThan(2); + // Leave and re-enter after the completed internal scroll gesture. + await page.mouse.move(5, 50); + await positionPanel(page, selector); + const atEdge = await scrollTop(page); + await page.mouse.wheel(0, 240); + await expect.poll(() => scrollTop(page)).toBeGreaterThan(atEdge + 100); +}); + +test('inline routes allow page scrolling while the expanded map retains wheel zoom', async ({page}) => { + await positionPanel(page, '#odTopology'); + const before = await scrollTop(page); + await page.mouse.wheel(0, 220); + await expect.poll(() => scrollTop(page)).toBeGreaterThan(before + 100); + await expect(page.locator('dialog.ssh-map-dialog')).toBeHidden(); + await page.locator('#overviewOpenMap').click(); + const viewport = page.locator('#overviewRouteMap .ssh-route-viewport'); + const output = page.locator('#overviewRouteMap output'); + const zoom = await output.innerText(); + const box = await viewport.boundingBox(); + await page.mouse.move(box.x + 10, box.y + 10); + await page.mouse.wheel(0, -120); + await expect(output).not.toHaveText(zoom); +}); + + +test('wheel at the top of a fixed panel scrolls the overview upward', async ({page}) => { + const selector = '[data-panel="hosts"] > .od-panel-body'; + await positionPanel(page, selector); + expect(await page.locator(selector).evaluate(el => el.scrollTop)).toBe(0); + const before = await scrollTop(page); + await page.mouse.wheel(0, -220); + await expect.poll(() => scrollTop(page)).toBeLessThan(before - 100); +}); + +test('panel edge forwarding respects zoom modifiers and normalizes wheel units', async ({page}) => { + const selector = '[data-panel="hosts"] > .od-panel-body'; + await positionPanel(page, selector); + const result = await page.locator(selector).evaluate(body => { + const overview = document.getElementById('connectionOverview'); + const emit = options => { + const before = overview.scrollTop; + const event = new WheelEvent('wheel', {bubbles:true, cancelable:true, deltaY:-1, ...options}); + body.dispatchEvent(event); + return {canceled:event.defaultPrevented, moved:before-overview.scrollTop}; + }; + return {ctrl:emit({ctrlKey:true}), meta:emit({metaKey:true}), line:emit({deltaMode:1}), page:emit({deltaMode:2}), height:overview.clientHeight}; + }); + expect(result.ctrl).toEqual({canceled:false, moved:0}); + expect(result.meta).toEqual({canceled:false, moved:0}); + expect(result.line).toEqual({canceled:true, moved:16}); + expect(result.page).toEqual({canceled:true, moved:result.height}); +}); diff --git a/tests/e2e/product-data-truth.spec.js b/tests/e2e/product-data-truth.spec.js index 5ec66468..ee31905e 100644 --- a/tests/e2e/product-data-truth.spec.js +++ b/tests/e2e/product-data-truth.spec.js @@ -3,6 +3,7 @@ const {login,installSshConnectTrap,sshAttempts}=require('./helpers'); test('system command arguments are explicitly library defaults, not discovered host values',async({page})=>{ await login(page);await installSshConnectTrap(page); await page.evaluate(()=>CommandLibrary.openLibrary()); + await page.evaluate(() => new Promise(resolve => window.socket.emit('list_commands', {os_filter:null}, resolve))); await expect.poll(()=>page.evaluate(()=>CommandLibrary.commands.some(c=>c.id==='net-022'))).toBe(true); await page.evaluate(()=>CommandLibrary.selectCommand('net-022')); await expect(page.locator('#commandLibraryDetails .command-template-hint').filter({hasText:'not values detected'})).toBeVisible(); diff --git a/tests/e2e/work-first-setup.spec.js b/tests/e2e/work-first-setup.spec.js index e020bd62..7ee57d8a 100644 --- a/tests/e2e/work-first-setup.spec.js +++ b/tests/e2e/work-first-setup.spec.js @@ -113,7 +113,7 @@ test('host context keeps disclosure and keyboard focus after profile updates', a ProfileManager.setProfiles([{id:'context-host', name:'Context host', host:'192.0.2.31', username:'operator'}]); }); const summary = page.locator('[data-key="context:context-host"]'); - const details = page.locator('.od-host-context[data-host-id="context-host"]'); + const details = page.locator('.od-host-context[data-context-host-id="context-host"]'); await summary.click(); await expect(details).toHaveAttribute('open', ''); await summary.focus(); diff --git a/tests/test_overview_routes.py b/tests/test_overview_routes.py index b9bbf2ee..3a6b7295 100644 --- a/tests/test_overview_routes.py +++ b/tests/test_overview_routes.py @@ -222,6 +222,37 @@ def test_overview_preferences_round_trip_and_render(app, client, context): assert client.put('/api/overview/preferences', json={'panels': None}).json['panels'] is None +@pytest.mark.parametrize('kind', ['reboot', 'security-updates']) +def test_maintenance_attention_dismissal_persists_with_existing_items(app, client, context, kind): + from app.user_settings import get_user_settings + previous = ['run-failed:r1', 'host-keys:a1b2'] + assert client.put('/api/overview/preferences', json={'dismissed': previous}).status_code == 200 + expected = [*previous, f'{kind}:node-1'] + response = client.put('/api/overview/preferences', json={'dismissed': expected}) + assert response.status_code == 200 + assert response.json['dismissed'] == expected + assert get_user_settings(context[0])['overview_dismissed'] == expected + from app.auth import register_user + other, error = register_user('dismissal_other', 'password123') + assert error is None + assert get_user_settings(other.id)['overview_dismissed'] == [] + # A fresh page embeds the persisted choices; rendering must not filter them out. + assert f'{kind}:node-1' in client.get('/').data.decode() + assert client.put('/api/overview/preferences', json={'dismissed': previous}).json['dismissed'] == previous + + +@pytest.mark.parametrize('kind', ['reboot', 'security-updates']) +@pytest.mark.parametrize('fingerprint', ['', '